diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14eda9ef5c..ccce13c0d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,8 @@ jobs: with: shared-key: "ci-check-v2-${{ runner.os }}-no-cargo-bin-v1" cache-bin: false + # PR caches are scoped to merge refs; main pushes own shared cache refreshes. + save-if: ${{ github.event_name != 'pull_request' }} - name: Check compilation run: cargo check --workspace --exclude bitfun-cli @@ -123,8 +125,16 @@ jobs: - name: Validate GitHub config run: pnpm run check:github-config + - name: Select i18n contract profile + shell: bash + run: | + if git rev-parse HEAD^1 >/dev/null 2>&1 && + git diff --name-only HEAD^1 HEAD -- scripts/i18n-audit.mjs scripts/i18n-contract.test.mjs | grep -q .; then + echo "BITFUN_I18N_CONTRACT_TEST_AUDIT_INTEGRATION=1" >> "$GITHUB_ENV" + fi + - name: Validate i18n contract - run: pnpm run i18n:contract:test + run: pnpm run i18n:contract:test:ci - name: Audit i18n resources run: pnpm run i18n:audit diff --git a/.gitignore b/.gitignore index 57f5184a97..7411dad063 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ dist-ssr *.local **/dist **/dist-ssr +!src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ +!src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js # Build outputs - Rust/Tauri target/ @@ -52,6 +54,7 @@ src/apps/desktop/.tauri/*.key !.vscode/extensions.json .idea .claude +.codebuddy .DS_Store *.suo *.ntvs* @@ -62,6 +65,10 @@ src/apps/desktop/.tauri/*.key # E2E test reports and screenshots tests/e2e/reports/ +# Eval job artifacts +.eval/ +Cross.toml + # BitFun sandbox data - auto managed .bitfun/ .cursor diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 17ff2f6fd5..9dbf28d950 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -13,30 +13,27 @@ BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 3. 修改 Rust 文件后,优先使用 `pnpm run fmt:rs`,只格式化已改动或已暂存的 `.rs` 文件。只有在你明确需要更大范围格式化时才使用 `cargo fmt`。 4. 改完后按下方表格执行与改动范围匹配的最小验证。 -## 模块索引 - -| 模块 | 路径 | Agent 文档 | -|---|---|---| -| Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | -| 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,runtime-services,terminal,tool-runtime}` | (使用 core 指南) | -| Agent runtime owner crate | `src/crates/agent-runtime` | [AGENTS.md](src/crates/agent-runtime/AGENTS.md) | -| Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | -| Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | -| Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | -| Tool pack provider plan | `src/crates/tool-packs` | [AGENTS.md](src/crates/tool-packs/AGENTS.md) | -| 产品领域 crate | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | -| Transport 适配层 | `src/crates/transport` | (使用 core 指南) | -| API layer | `src/crates/api-layer` | (使用 core 指南) | -| ACP 集成 | `src/crates/acp` | [AGENTS.md](src/crates/acp/AGENTS.md) | -| AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | -| 桌面应用 | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | -| Server | `src/apps/server` | (使用 core 指南) | -| CLI | `src/apps/cli` | (使用 core 指南) | -| 中继服务器 | `src/apps/relay-server` | (使用 core 指南) | -| 共享前端 | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | -| Mobile web | `src/mobile-web` | [AGENTS.md](src/mobile-web/AGENTS.md) | -| 安装器 | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | -| E2E 测试 | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | +## 分层模块索引 + +依赖关系按自上而下读取:上层只能依赖下层,同层 crate 也应保持最小依赖。 + +| # | 层级 | 路径 | 职责 | 模块 / 入口 | 层级文档 | +|---|---|---|---|---|---| +| 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | +| 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线和 adapter/service 注册 | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | +| 3 | 适配层 | `src/crates/adapters` | AI/API/transport/WebDriver 协议 adapter 和外部 provider 转换 | `ai-adapters`, `api-layer`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、harness、stream、DeepReview policy/report、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | +| 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | + +边界规则: + +- 接口与入口层暴露选定产品行为;可复用行为应下移。 +- 组装层只接线下层并选择产品能力事实,不实现具体 adapter、OS 或 service 细节。 +- 适配层翻译协议和外部系统,不拥有产品能力选择或可复用 OS service 行为。 +- 服务实现层负责可复用的 OS、process、terminal、MCP、remote、git、filesystem、session persistence primitives 和 MiniApp runtime IO 能力。 +- 执行原语层只放可移植运行时构件,不拥有宿主或交付形态。 +- 契约层保持轻行为,不得向上依赖。 ## 常用命令 @@ -94,8 +91,11 @@ pnpm run desktop:build:nsis:fast # Windows 安装器,release-fast profile - 不要把 Web UI locale 资源导入 `src/mobile-web`、`BitFun-Installer` 等较小形态。 - Web UI 只急切加载 bootstrap namespace;路由或功能文案使用 `useI18n(namespace)`,直接 `i18nService.t(...)` 只用于 bootstrap namespace。 -- `pnpm run i18n:audit` 会检查 key / 占位符一致性、直接静态 key 是否存在,以及 - source 中不再新增硬编码 CJK 文案。 +- 用户可见的日期、时间和数字应通过共享 i18n 格式化 helper 处理,避免在产品代码中直接 + 使用 `Intl.*` 或 `toLocale*`。 +- `pnpm run i18n:audit` 会检查 key / 占位符一致性、直接静态 key、dynamic key + source proof、literal fallback / locale-format 零增长基线、shared-term / l10n + 治理基线、非阻断 same-text locale 盘点,以及 source 中不再新增硬编码 CJK 文案。 ### 日志 @@ -152,39 +152,16 @@ await api.invoke('your_command', { request: { ... } }); - 产品表面可以有差异;共享稳定 facts 或 ports,不共享 UI、protocol、lifecycle 或平台实现。 - 迁移 runtime owner 必须有评审过的 port/provider 设计、旧路径兼容、行为等价测试;如果可能改变行为边界,还需要先确认。 -### DeepReview 护栏 - -Deep Review / 代码审核团队横跨 core runtime 与 Web UI。target resolution 与 -manifest construction 保持在前端;policy validation、queue/retry state 和 -report enrichment 保持在 shared core。 - -### 后端链路 - -大多数功能建议按这个顺序追踪: - -1. `src/web-ui` 或应用入口 -2. `src/apps/desktop/src/api/*` 或 server routes -3. `src/crates/api-layer` -4. `src/crates/transport` -5. `src/crates/core` - -### `bitfun-core` +### SDLC 质量护栏 -`src/crates/core` 是代码库中心。 +涉及生命周期证据、门禁、Artifact Graph、Project Profile、Deep Review 策略、 +OpenCode 兼容或目标项目治理的变更,先阅读 +[`docs/sdlc-harness/README.md`](docs/sdlc-harness/README.md),再阅读 +[`docs/sdlc-harness/design.md`](docs/sdlc-harness/design.md)。如果变更影响模块边界或行为, +继续参考 `docs/sdlc-harness/architecture/` 或 `docs/sdlc-harness/features/` 下的对应设计。 -主要区域: - -- `agentic/`:agents、prompts、tools、sessions、execution、persistence -- `service/`:config、filesystem、terminal、git、LSP、MCP、remote connect、project context、AI memory -- `infrastructure/`:AI clients、app paths、event system、storage、debug log server - -Agent 运行时心智模型: - -```text -SessionManager → Session → DialogTurn → ModelRound -``` - -会话数据保存在 `.bitfun/sessions/{session_id}/`。 +不要把 BitFun 自身验证假设硬编码成目标项目通用规则;质量保护行为必须保持面向目标项目、 +基于证据、按风险分级、成本可控并可审计。 ## 验证 @@ -198,29 +175,14 @@ SessionManager → Session → DialogTurn → ModelRound | Locale contract 或 shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | | Web UI i18n runtime、namespace loading 或直接 `i18nService.t(...)` 调用 | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile web UI、状态、配对、断开或重连行为 | `pnpm --dir src/mobile-web run type-check`;行为变化还需要在 PR 中说明手动配对 / 重连验证 | -| Deep Review / 代码审核团队行为 | 运行最近的 Web UI 检查,再运行 `cargo test -p bitfun-core deep_review -- --nocapture`;如果触及后端或 Tauri API,还需要运行对应 Rust / 桌面端检查 | | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace`;行为变化时再加最近的 focused `cargo test` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop`;行为变化时再加 focused desktop tests | | 被桌面端 smoke/functional 流覆盖的行为 | 优先运行最近的 focused E2E/smoke check;除非改动影响构建,否则 broad build/test 交给 CI | -| `src/crates/ai-adapters` | 运行上面相关 Rust 检查;只有 stream contract 改动时再加 `cargo test -p bitfun-agent-stream` | +| `src/crates/adapters/ai-adapters` | 运行上面相关 Rust 检查;只有 stream contract 改动时再加 `cargo test -p bitfun-agent-stream` | | 不涉及打包的安装器前端或 i18n runtime | `pnpm --dir BitFun-Installer run type-check` | | 安装器 Tauri/Rust 改动 | `cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml` | | 安装器打包、payload、安装/卸载流程或 native bundling | `pnpm run installer:build` | -## 先看哪里 - -| 功能 | 关键路径 | -|---|---| -| Agent mode | `src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review/`、`src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/review-team/`、`src/web-ui/src/flow_chat/deep-review/`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | -| Mobile web 配对 / 远程控制 | `src/mobile-web/src/pages/PairingPage.tsx`、`src/mobile-web/src/pages/SessionListPage.tsx`、`src/mobile-web/src/pages/ChatPage.tsx`、`src/mobile-web/src/services/RemoteSessionManager.ts`、`src/mobile-web/src/services/RelayHttpClient.ts`、`src/mobile-web/src/services/store.ts` | -| 会话用量报告(`/usage`) | `src/crates/core/src/service/session_usage/`、`src/web-ui/src/flow_chat/components/usage/`、`src/web-ui/src/locales/*/flow-chat.json` | -| Tool | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | -| MCP / LSP / remote | `src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` | -| 桌面端 API | `src/apps/desktop/src/api/`、`src/crates/api-layer/src/`、`src/crates/transport/src/adapters/tauri.rs` | -| 中继服务器 | `src/apps/relay-server/` | -| Web/server 通信 | `src/web-ui/src/infrastructure/api/`、`src/crates/transport/src/adapters/websocket.rs`、`src/apps/server/src/routes/`、`src/apps/server/src/main.rs` | - ## Agent 文档优先级 进入具体目录后,优先遵循离目标文件最近的 `AGENTS.md` / `AGENTS-CN.md`。如果局部文档与本文件冲突,以更具体、更近的文档为准。 diff --git a/AGENTS.md b/AGENTS.md index 8ef7fd5189..b766a3735f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,30 +13,29 @@ Repository rule: **keep product logic platform-agnostic, then expose it through 3. After Rust file changes, prefer `pnpm run fmt:rs` to format only changed or staged `.rs` files. Use `cargo fmt` only when you intentionally want broader formatting coverage. 4. After changes, run the smallest matching verification from the table below. -## Module index - -| Module | Path | Agent doc | -|---|---|---| -| Core (product logic) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | -| Extracted core support | `src/crates/{core-types,agent-stream,runtime-ports,runtime-services,terminal,tool-runtime}` | (use core guide) | -| Agent runtime owner crate | `src/crates/agent-runtime` | [AGENTS.md](src/crates/agent-runtime/AGENTS.md) | -| Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | -| Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | -| Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | -| Tool pack provider plan | `src/crates/tool-packs` | [AGENTS.md](src/crates/tool-packs/AGENTS.md) | -| Product domains | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | -| Transport adapters | `src/crates/transport` | (use core guide) | -| API layer | `src/crates/api-layer` | (use core guide) | -| ACP integration | `src/crates/acp` | [AGENTS.md](src/crates/acp/AGENTS.md) | -| AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | -| Desktop app | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | -| Server | `src/apps/server` | (use core guide) | -| CLI | `src/apps/cli` | (use core guide) | -| Relay server | `src/apps/relay-server` | (use core guide) | -| Shared frontend | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | -| Mobile web | `src/mobile-web` | [AGENTS.md](src/mobile-web/AGENTS.md) | -| Installer | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | -| E2E tests | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | +## Layered Module Index + +Dependencies flow top to bottom. A layer may depend on lower layers only; keep +crate dependencies inside each layer to the smallest set needed. + +| # | Layer | Path | Owns | Modules / entries | Layer doc | +|---|---|---|---|---|---| +| 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | +| 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, and adapter/service registration | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | +| 3 | Adapters | `src/crates/adapters` | AI/API/transport/WebDriver protocol adapters and external-provider translation | `ai-adapters`, `api-layer`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | +| 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | + +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. +- Adapters translate protocols and external systems; 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. +- Execution crates are portable runtime building blocks, not host-specific or delivery-profile owners. +- Contracts stay behavior-light and must not depend upward. + ## Common commands @@ -102,8 +101,9 @@ For the full script list, see [`package.json`](package.json). - Use shared i18n formatting helpers for user-visible dates, times, and numbers instead of direct `Intl.*` or `toLocale*` calls. - `pnpm run i18n:audit` enforces key/placeholder parity, direct static key - existence, dynamic key governance, no-growth i18n governance baselines, - locale-format no-growth baselines, and the no-hardcoded-CJK source budget. + existence, dynamic key source proofs, literal fallback and locale-format + no-growth baselines, shared-term/l10n governance baselines, non-blocking + same-text locale inventory, and the no-hardcoded-CJK source budget. ### Logging @@ -132,7 +132,7 @@ await api.invoke('your_command', { request: { ... } }); ### Platform boundaries - Do not call Tauri APIs directly from UI components; go through the adapter/infrastructure layer. -- Desktop-only integrations belong in `src/apps/desktop`, then flow back through transport/API layers. +- Desktop-only host adapters belong in `src/apps/desktop`, then flow back through transport/API layers. - In shared core, avoid host-specific APIs such as `tauri::AppHandle`; use shared abstractions such as `bitfun_events::EventEmitter`. ### Remote compatibility @@ -164,39 +164,18 @@ Repository-level decomposition rules: compatibility, behavior equivalence tests, and explicit confirmation when a behavior boundary could change. -### DeepReview guardrails - -Deep Review / Code Review Team work spans the core runtime and web UI. Keep -target resolution and manifest construction on the frontend; keep policy -validation, queue/retry state, and report enrichment in shared core. - -### Backend flow - -Trace most features in this order: - -1. `src/web-ui` or app entrypoint -2. `src/apps/desktop/src/api/*` or server routes -3. `src/crates/api-layer` -4. `src/crates/transport` -5. `src/crates/core` +### SDLC quality guardrails -### `bitfun-core` +For lifecycle evidence, gates, Artifact Graph, Project Profile, Deep Review +policy, OpenCode compatibility, or target-project governance changes, read +[`docs/sdlc-harness/README.md`](docs/sdlc-harness/README.md) +first, then [`docs/sdlc-harness/design.md`](docs/sdlc-harness/design.md). If +module boundaries or behavior change, follow the matching design under +`docs/sdlc-harness/architecture/` or `docs/sdlc-harness/features/`. -`src/crates/core` is the center of the codebase. - -Important areas: - -- `agentic/`: agents, prompts, tools, sessions, execution, persistence -- `service/`: config, filesystem, terminal, git, LSP, MCP, remote connect, project context, AI memory -- `infrastructure/`: AI clients, app paths, event system, storage, debug log server - -Agent runtime mental model: - -```text -SessionManager → Session → DialogTurn → ModelRound -``` - -Session data is stored under `.bitfun/sessions/{session_id}/`. +Do not hard-code BitFun repository assumptions as target-project rules; keep +quality protection behavior target-aware, evidence-backed, risk-tiered, +cost-aware, and auditable. ## Verification @@ -211,29 +190,14 @@ change directly affects build, packaging, or CI cannot protect the path. | Locale contract or shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | | Web UI i18n runtime, namespace loading, or direct `i18nService.t(...)` usage | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile web UI, state, pairing, disconnect, or reconnect behavior | `pnpm --dir src/mobile-web run type-check`; include manual pairing / reconnect notes when behavior changes | -| Deep Review / Code Review Team behavior | Nearest Web UI check above, plus `cargo test -p bitfun-core deep_review -- --nocapture`; also run Rust / desktop checks when backend or Tauri APIs are touched | -| Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace`, plus the nearest focused `cargo test` when behavior changed | +| Shared Rust logic in `core`, `transport`, `api-layer`, adapters, or services | `cargo check --workspace`, plus the nearest focused `cargo test` when behavior changed | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop`, plus focused desktop tests when behavior changed | | Behavior covered by desktop smoke/functional flows | Prefer the nearest focused E2E/smoke check; rely on CI for broad build/test coverage unless build behavior changed | -| `src/crates/ai-adapters` | Relevant Rust checks above; add `cargo test -p bitfun-agent-stream` only when stream contracts changed | +| `src/crates/adapters/ai-adapters` | Relevant Rust checks above; add `cargo test -p bitfun-agent-stream` only when stream contracts changed | | Installer frontend or i18n runtime without packaging changes | `pnpm --dir BitFun-Installer run type-check` | | Installer Tauri/Rust changes | `cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml` | | Installer packaging, payload, install/uninstall flow, or native bundling | `pnpm run installer:build` | -## Where to look first - -| Feature | Key paths | -|---|---| -| Agent modes | `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | -| Mobile web pairing / remote control | `src/mobile-web/src/pages/PairingPage.tsx`, `src/mobile-web/src/pages/SessionListPage.tsx`, `src/mobile-web/src/pages/ChatPage.tsx`, `src/mobile-web/src/services/RemoteSessionManager.ts`, `src/mobile-web/src/services/RelayHttpClient.ts`, `src/mobile-web/src/services/store.ts` | -| Session usage report (`/usage`) | `src/crates/core/src/service/session_usage/`, `src/web-ui/src/flow_chat/components/usage/`, `src/web-ui/src/locales/*/flow-chat.json` | -| Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | -| MCP / LSP / remote | `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` | -| Desktop APIs | `src/apps/desktop/src/api/`, `src/crates/api-layer/src/`, `src/crates/transport/src/adapters/tauri.rs` | -| Relay server | `src/apps/relay-server/` | -| Web/server communication | `src/web-ui/src/infrastructure/api/`, `src/crates/transport/src/adapters/websocket.rs`, `src/apps/server/src/routes/`, `src/apps/server/src/main.rs` | - ## Agent-doc priority Prefer the nearest matching `AGENTS.md` / `AGENTS-CN.md` for the directory you are changing. If local guidance conflicts with this file, follow the more specific, nearer document. diff --git a/BitFun-Installer/README.md b/BitFun-Installer/README.md index d299a11edc..20da4473a3 100644 --- a/BitFun-Installer/README.md +++ b/BitFun-Installer/README.md @@ -142,7 +142,7 @@ Key behavior: Local debug command: ```bash -npx tauri dev -- -- --uninstall "D:\\tmp\\bitfun-uninstall-test" +npx tauri dev -- -- --uninstall "D:\\tmp\\example-install-dir" ``` Core implementation: diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index ddcce86db4..aacce26030 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.8", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.8", + "version": "0.2.10", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index f8522ba3ab..a2b650f98d 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.8", + "version": "0.2.10", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index fea65ca5ef..ef8b293462 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.8" +version = "0.2.10" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" @@ -35,7 +35,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus urlencoding = "2" futures = "0.3" eventsource-stream = "0.2" -bitfun-ai-adapters = { path = "../../src/crates/ai-adapters" } +bitfun-ai-adapters = { path = "../../src/crates/adapters/ai-adapters" } [target.'cfg(windows)'.dependencies] winreg = "0.52" diff --git a/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json b/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json index db9d3be4a5..f2f210aeb2 100644 --- a/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json +++ b/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json b/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json index 5aa9e8779d..24c90014ed 100644 --- a/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json +++ b/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json @@ -183,10 +183,10 @@ "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", "type": "string", "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" }, { "description": "Enables the app_hide command without any pre-configured scope.", @@ -260,6 +260,12 @@ "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", @@ -344,6 +350,12 @@ "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", @@ -867,10 +879,10 @@ "markdownDescription": "Denies the close command without any pre-configured scope." }, { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", @@ -902,6 +914,12 @@ "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", @@ -968,6 +986,12 @@ "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", @@ -1227,10 +1251,16 @@ "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." }, { "description": "Enables the available_monitors command without any pre-configured scope.", @@ -1424,6 +1454,12 @@ "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -1688,6 +1724,12 @@ "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", @@ -1880,6 +1922,12 @@ "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -2145,22 +2193,22 @@ "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", "const": "dialog:default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" }, { - "description": "Enables the ask command without any pre-configured scope.", + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-ask", - "markdownDescription": "Enables the ask command without any pre-configured scope." + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { - "description": "Enables the confirm command without any pre-configured scope.", + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-confirm", - "markdownDescription": "Enables the confirm command without any pre-configured scope." + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { "description": "Enables the message command without any pre-configured scope.", @@ -2181,16 +2229,16 @@ "markdownDescription": "Enables the save command without any pre-configured scope." }, { - "description": "Denies the ask command without any pre-configured scope.", + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-ask", - "markdownDescription": "Denies the ask command without any pre-configured scope." + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { - "description": "Denies the confirm command without any pre-configured scope.", + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-confirm", - "markdownDescription": "Denies the confirm command without any pre-configured scope." + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { "description": "Denies the message command without any pre-configured scope.", diff --git a/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json b/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json index 5aa9e8779d..24c90014ed 100644 --- a/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json +++ b/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json @@ -183,10 +183,10 @@ "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", "type": "string", "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" }, { "description": "Enables the app_hide command without any pre-configured scope.", @@ -260,6 +260,12 @@ "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", @@ -344,6 +350,12 @@ "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", @@ -867,10 +879,10 @@ "markdownDescription": "Denies the close command without any pre-configured scope." }, { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", @@ -902,6 +914,12 @@ "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", @@ -968,6 +986,12 @@ "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", @@ -1227,10 +1251,16 @@ "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." }, { "description": "Enables the available_monitors command without any pre-configured scope.", @@ -1424,6 +1454,12 @@ "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -1688,6 +1724,12 @@ "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", @@ -1880,6 +1922,12 @@ "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -2145,22 +2193,22 @@ "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", "const": "dialog:default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" }, { - "description": "Enables the ask command without any pre-configured scope.", + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-ask", - "markdownDescription": "Enables the ask command without any pre-configured scope." + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { - "description": "Enables the confirm command without any pre-configured scope.", + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-confirm", - "markdownDescription": "Enables the confirm command without any pre-configured scope." + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { "description": "Enables the message command without any pre-configured scope.", @@ -2181,16 +2229,16 @@ "markdownDescription": "Enables the save command without any pre-configured scope." }, { - "description": "Denies the ask command without any pre-configured scope.", + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-ask", - "markdownDescription": "Denies the ask command without any pre-configured scope." + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { - "description": "Denies the confirm command without any pre-configured scope.", + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-confirm", - "markdownDescription": "Denies the confirm command without any pre-configured scope." + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { "description": "Denies the message command without any pre-configured scope.", diff --git a/BitFun-Installer/src/i18n/locales/zh-TW.json b/BitFun-Installer/src/i18n/locales/zh-TW.json index 21296d3855..e09da22422 100644 --- a/BitFun-Installer/src/i18n/locales/zh-TW.json +++ b/BitFun-Installer/src/i18n/locales/zh-TW.json @@ -5,13 +5,13 @@ }, "installPath": { "notAbsolute": "安裝路徑必須是絕對路徑。", - "filesystemRoot": "不能安裝到磁盤根目錄,請選擇磁盤下的某個文件夾。", - "pathNotDirectory": "所選路徑已存在但不是文件夾。", + "filesystemRoot": "不能安裝到磁碟根目錄,請選擇磁碟下的某個資料夾。", + "pathNotDirectory": "所選路徑已存在但不是資料夾。", "directoryMustBeEmptyOrBitfun": "安裝目錄必須為空,或已包含 BitFun 安裝。", "inspectDirectoryFailed": "無法讀取安裝目錄,請檢查權限後重試。", "directoryNotWritable": "安裝目錄不可寫入。請更換路徑,或以管理員身份運行安裝器(見下方說明)。", "parentNotWritable": "上級目錄不可寫入。系統目錄(如 Program Files)通常需要管理員權限(見下方說明)。", - "adminHint": "若需安裝到受保護位置(例如 Program Files),請關閉本安裝器,在安裝程序上右鍵選擇「以管理員身份運行」後重新安裝。也可安裝到當前用戶目錄(例如 %LOCALAPPDATA%\\Programs),一般無需管理員權限。" + "adminHint": "若需安裝到受保護位置(例如 Program Files),請關閉本安裝器,在安裝程式上右鍵選擇「以管理員身份運行」後重新安裝。也可安裝到目前用戶目錄(例如 %LOCALAPPDATA%\\Programs),一般無需管理員權限。" } }, "options": { @@ -24,10 +24,10 @@ "browse": "瀏覽", "required": "所需空間", "available": "可用空間", - "insufficientSpace": "磁盤空間不足", + "insufficientSpace": "磁碟空間不足", "optionsLabel": "安裝選項", - "desktopShortcut": "創建桌面快捷方式", - "startMenu": "添加到開始菜單", + "desktopShortcut": "建立桌面快捷方式", + "startMenu": "新增到開始菜單", "launchAfterInstall": "安裝後啟動 BitFun", "back": "返回", "install": "安裝", @@ -35,24 +35,24 @@ "existingInstallTitle": "檢測到本機已安裝 BitFun", "existingInstallVersion": "已安裝版本:{{version}}", "existingInstallLocation": "安裝位置:{{path}}", - "existingInstallBinaryMissing": "該路徑下未找到主程序文件,可先運行卸載程序或重新安裝。", - "existingInstallHint": "直接點擊「安裝」可在原位置升級或修復。若需先卸載,可點擊下方按鈕運行卸載程序。", - "existingInstallRunUninstaller": "運行卸載程序" + "existingInstallBinaryMissing": "該路徑下未找到主程式檔案,可先運行解除安裝程式或重新安裝。", + "existingInstallHint": "直接點擊「安裝」可在原位置升級或修復。若需先解除安裝,可點擊下方按鈕運行解除安裝程式。", + "existingInstallRunUninstaller": "運行解除安裝程式" }, "model": { "title": "模型", - "subtitle": "安裝完成,繼續配置模型與主題", + "subtitle": "安裝完成,繼續設定模型與主題", "provider": "服務商", - "config": "連接信息", + "config": "連接資訊", "modelName": "模型名稱(如 deepseek-v4-flash)", - "skip": "稍後配置", + "skip": "稍後設定", "nextTheme": "下一步:主題", "description": "設定和管理 AI 模型提供商", "providerLabel": "選擇模型提供商", "selectProvider": "或選擇預設提供商", - "customProvider": "自定義配置", + "customProvider": "自定義設定", "modelNamePlaceholder": "輸入自定義模型名稱...", - "baseUrlPlaceholder": "示例:https://open.bigmodel.cn/api/paas/v4/chat/completions", + "baseUrlPlaceholder": "範例:https://open.bigmodel.cn/api/paas/v4/chat/completions", "providers": { "openbitfun": { "name": "OpenBitFun", @@ -70,7 +70,7 @@ "name": "MiniMax", "description": "MiniMax 系列模型", "urlOptions": { - "default": "Anthropic格式-默認", + "default": "Anthropic 格式-默認", "openai": "OpenAI 相容格式" } }, @@ -86,18 +86,18 @@ "name": "智譜AI", "description": "智譜AI GLM 系列模型", "urlOptions": { - "default": "OpenAI格式-默認", - "anthropic": "Anthropic格式", - "codingPlan": "OpenAI格式-CodingPlan" + "default": "OpenAI 格式-默認", + "anthropic": "Anthropic 格式", + "codingPlan": "OpenAI 格式-CodingPlan" } }, "qwen": { "name": "通義千問", "description": "阿里雲百鍊大模型平臺", "urlOptions": { - "default": "OpenAI格式-默認", - "codingPlan": "OpenAI格式-Coding Plan", - "codingPlanAnthropic": "Anthropic格式-Coding Plan" + "default": "OpenAI 格式-默認", + "codingPlan": "OpenAI 格式-Coding Plan", + "codingPlanAnthropic": "Anthropic 格式-Coding Plan" } }, "volcengine": { @@ -108,8 +108,8 @@ "name": "硅基流動", "description": "硅基流動大模型平臺", "urlOptions": { - "default": "OpenAI格式-默認", - "anthropic": "Anthropic格式" + "default": "OpenAI 格式-默認", + "anthropic": "Anthropic 格式" } }, "nvidia": { @@ -130,8 +130,8 @@ "fetchingModels": "正在擷取模型清單...", "fetchFailedFallback": "拉取模型列表失敗,已回退到常用預設模型", "fetchEmptyFallback": "供應商未返回可用模型,已回退到常用預設模型", - "usingPresetModels": "當前顯示的是常用預設模型", - "addCustomModel": "添加自定義模型", + "usingPresetModels": "目前顯示的是常用預設模型", + "addCustomModel": "新增自定義模型", "form": { "baseUrl": "API 位址", "apiKey": "API密鑰", @@ -153,15 +153,15 @@ "progress": { "title": "安裝中", "prepare": "準備中", - "extract": "正在解壓文件", + "extract": "正在解壓檔案", "registry": "正在註冊應用", - "shortcuts": "正在創建快捷方式", + "shortcuts": "正在建立快捷方式", "path": "正在更新 PATH", "config": "正在應用啟動偏好設置", "complete": "即將完成", "starting": "啟動中...", "failed": "安裝失敗", - "confirmContinue": "繼續完成配置" + "confirmContinue": "繼續完成設定" }, "themeSetup": { "title": "主題與啟動", @@ -182,13 +182,13 @@ "finish": "完成" }, "uninstall": { - "title": "卸載 BitFun", + "title": "解除安裝 BitFun", "subtitle": "將移除 BitFun 及其集成項(快捷方式、右鍵菜單、PATH)。", "installPath": "安裝目錄", "pathUnknown": "未檢測到安裝目錄", - "confirm": "開始卸載", - "uninstalling": "正在卸載...", - "completed": "卸載已完成,可關閉窗口。", + "confirm": "開始解除安裝", + "uninstalling": "正在解除安裝...", + "completed": "解除安裝已完成,可關閉視窗。", "cancel": "取消", "close": "關閉" } diff --git a/BitFun-Installer/src/pages/LanguageSelect.tsx b/BitFun-Installer/src/pages/LanguageSelect.tsx index a06b584589..4cc3a1bb98 100644 --- a/BitFun-Installer/src/pages/LanguageSelect.tsx +++ b/BitFun-Installer/src/pages/LanguageSelect.tsx @@ -72,7 +72,7 @@ export function LanguageSelect({ onSelect }: LanguageSelectProps) { opacity: 0.6, letterSpacing: '0.5px', }}> - Version 0.2.8 + Version 0.2.10 diff --git a/BitFun-Installer/src/styles/variables.css b/BitFun-Installer/src/styles/variables.css index 67d3d42c70..ff5e39d44c 100644 --- a/BitFun-Installer/src/styles/variables.css +++ b/BitFun-Installer/src/styles/variables.css @@ -1,12 +1,17 @@ :root { --color-bg-primary: #121214; --color-bg-secondary: #18181a; + --color-bg-tertiary: #121214; --color-bg-quaternary: #202024; + --color-bg-elevated: #18181a; + --color-bg-workbench: #121214; + --color-bg-flowchat: #121214; + --color-bg-tooltip: rgba(30, 30, 32, 0.92); --color-text-primary: #e8e8e8; - --color-text-secondary: #e5e5e5; - --color-text-muted: #a0a0a0; - --color-text-disabled: #666666; + --color-text-secondary: #b0b0b0; + --color-text-muted: #858585; + --color-text-disabled: #555555; --color-accent-50: rgba(96, 165, 250, 0.04); --color-accent-100: rgba(96, 165, 250, 0.08); @@ -15,24 +20,38 @@ --color-accent-400: rgba(96, 165, 250, 0.4); --color-accent-500: #60a5fa; --color-accent-600: #3b82f6; + --color-accent-700: rgba(59, 130, 246, 0.8); + --color-accent-800: rgba(59, 130, 246, 0.9); - --color-success: #6eb88c; - --color-success-bg: rgba(110, 184, 140, 0.1); - --color-success-border: rgba(110, 184, 140, 0.3); - --color-error: #c77070; - --color-error-bg: rgba(199, 112, 112, 0.1); - --color-error-border: rgba(199, 112, 112, 0.3); - - --element-bg-subtle: rgba(255, 255, 255, 0.05); - --element-bg-soft: rgba(255, 255, 255, 0.08); - --element-bg-base: rgba(255, 255, 255, 0.11); - --element-bg-medium: rgba(255, 255, 255, 0.14); - --element-bg-strong: rgba(255, 255, 255, 0.17); - - --border-subtle: rgba(255, 255, 255, 0.10); - --border-base: rgba(255, 255, 255, 0.14); - --border-medium: rgba(255, 255, 255, 0.20); - --border-strong: rgba(255, 255, 255, 0.26); + --color-purple-50: rgba(139, 92, 246, 0.04); + --color-purple-100: rgba(139, 92, 246, 0.08); + --color-purple-200: rgba(139, 92, 246, 0.15); + --color-purple-300: rgba(139, 92, 246, 0.25); + --color-purple-400: rgba(139, 92, 246, 0.4); + --color-purple-500: #8b5cf6; + --color-purple-600: #7c3aed; + --color-purple-700: rgba(124, 58, 237, 0.8); + --color-purple-800: rgba(124, 58, 237, 0.9); + + --color-success: #34d399; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #E1AB80; + --color-highlight: #d4a574; + --color-highlight-bg: rgba(212, 165, 116, 0.15); + + --element-bg-subtle: rgba(255, 255, 255, 0.06); + --element-bg-soft: rgba(255, 255, 255, 0.10); + --element-bg-base: rgba(255, 255, 255, 0.13); + --element-bg-medium: rgba(255, 255, 255, 0.17); + --element-bg-strong: rgba(255, 255, 255, 0.21); + --element-bg-elevated: rgba(255, 255, 255, 0.25); + + --border-subtle: rgba(255, 255, 255, 0.12); + --border-base: rgba(255, 255, 255, 0.18); + --border-medium: rgba(255, 255, 255, 0.24); + --border-strong: rgba(255, 255, 255, 0.32); + --border-prominent: rgba(225, 171, 128, 0.50); --radius-sm: 6px; --radius-base: 8px; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c41371cd..c4b6f80e06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,12 +19,14 @@ Be respectful, kind, and constructive. We welcome contributors of all background #### Windows: OpenSSL Setup -The desktop app includes SSH remote support, which pulls in OpenSSL. On Windows the workspace **does not use vendored OpenSSL**; link against **pre-built** binaries (no Perl/NASM/OpenSSL source build). +Most Windows contributors do not need to configure OpenSSL manually. Use +`pnpm run desktop:dev` or the normal `desktop:build*` scripts; they bootstrap a +pre-built OpenSSL package when needed. -- **Default**: `pnpm run desktop:dev` calls `ensure-openssl-windows.mjs` on Windows. `pnpm run desktop:preview:debug` does the same whenever it needs to fast-rebuild `bitfun-desktop` before preview. Every `desktop:build*` script runs via `scripts/desktop-tauri-build.mjs`, which does the same before invoking Cargo. -- **Manual / CI**: Download the [FireDaemon OpenSSL 3.5.5 LTS ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip), extract, set `OPENSSL_DIR` to the `x64` folder, `OPENSSL_STATIC=1`, or run `scripts/ci/setup-openssl-windows.ps1`. -- **Opt out of auto-download**: `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and configure `OPENSSL_DIR` yourself. -- **`desktop:dev:raw`** skips the dev script (no OpenSSL bootstrap); set `OPENSSL_DIR` yourself, run `scripts/ci/setup-openssl-windows.ps1`, or `node scripts/ensure-openssl-windows.mjs` (warms `.bitfun/cache/` and prints PowerShell `OPENSSL_*` lines to paste). +Only handle OpenSSL yourself when the bootstrap fails, you are preparing CI, or +you intentionally use `pnpm run desktop:dev:raw`. In that case, run +`scripts/ci/setup-openssl-windows.ps1`, or set `OPENSSL_DIR` to a pre-built x64 +OpenSSL directory and set `OPENSSL_STATIC=1`. ### Install dependencies @@ -54,80 +56,27 @@ pnpm run e2e:test ### Desktop debugging tools -When working on desktop UI/UX, the `devtools` Cargo feature provides additional debugging capabilities. It is automatically enabled in `dev` builds and `release-fast` profile builds, but never in `release` builds for end users. - -| Shortcut | Action | -|---|---| -| `Cmd/Ctrl + Shift + I` | Toggle element inspector — hover to highlight elements, click to capture metadata | -| `Cmd/Ctrl + Shift + J` | Open native webview DevTools window | - -The element inspector injects a lightweight script into the main webview. When you click an element, it captures: -- Tag, id, class, CSS selector path -- Computed styles and CSS variables -- Box model (margin, padding, border) -- Color values (text, background, border) -- Element attributes - -Captured data is logged as structured JSON under the `bitfun::devtools` target. +Desktop dev builds enable the `devtools` Cargo feature. Use `F12` for native +webview DevTools. `Cmd/Ctrl + Shift + I` toggles the BitFun element inspector, +and `Cmd/Ctrl + Shift + J` also opens native DevTools. These tools are disabled +in end-user `release` builds. ## Code Standards and Architecture Constraints -### Logging - -- English only, avoid verbose logs -- Frontend: `createLogger('ModuleName')` -- Backend: `log::{info, debug, warn, error}` macros - -### Internationalization - -- Locale metadata lives in `src/shared/i18n/contract/locales.json`; run - `pnpm run i18n:generate` after editing it. -- Put stable cross-surface labels in `src/shared/i18n/resources/shared`; keep - workflow copy in the owning surface. -- Web UI route or feature copy should use `useI18n(namespace)`. Do not import - Web UI locale catalogs into mobile-web, installer, backend, or static pages. -- Static self-contained pages may use generated page-scoped shared-term files - instead of copying stable labels. -- User-visible dates, times, and numbers should use shared i18n formatting - helpers instead of direct `Intl.*` or `toLocale*` calls. -- `pnpm run i18n:audit` enforces key/placeholder parity, direct static key - existence, dynamic key governance, no-growth i18n governance baselines, - locale-format no-growth baselines, and the no-hardcoded-CJK source budget. - -### Platform-agnostic core - -Do not use platform-specific dependencies in `core`: - -- ❌ `tauri::AppHandle` -- ✅ `bitfun_events::EventEmitter` - -For `bitfun-core` decomposition or build-speed refactors, follow -[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) -and do not change product feature sets or release scripts as a side effect. - -For Deep Review / Code Review Team changes, keep -[`docs/architecture/deep-review.md`](docs/architecture/deep-review.md), -`src/crates/core/src/agentic/deep_review/CONTRIBUTING.md`, and -`src/web-ui/src/flow_chat/deep-review/CONTRIBUTING.md` aligned with the -implementation. - -### Tauri command conventions - -- Command names use `snake_case` -- Keep Rust and TypeScript naming aligned -- Always use structured request format: - -```rust -#[tauri::command] -pub async fn your_command( - state: State<'_, AppState>, - request: YourRequest, -) -> Result -``` - -```ts -await api.invoke("your_command", { request: { /* ... */ } }); -``` +Use [`AGENTS.md`](AGENTS.md) as the canonical source for architecture-sensitive +rules, module boundaries, and the verification matrix. In contributor-facing +terms: + +- Logs are English-only and should stay useful, not noisy. +- User-visible copy should use the project i18n flow; do not share Web UI + locale catalogs with smaller surfaces. +- Shared core must stay platform-agnostic. Desktop/Tauri details belong in app + adapters and flow back through transport/API layers. +- Tauri commands use `snake_case` command names and structured `request` + payloads. +- Core decomposition, feature-boundary, dependency-boundary, and build-speed + work must follow `docs/architecture/core-decomposition.md`. +- Feature-specific rules belong in the nearest module `AGENTS.md`. ## Key Contribution Focus Areas @@ -145,10 +94,10 @@ We welcome contributions beyond standard feature or bug-fix PRs. Examples includ | Contribution area | Location / files | Example | | --- | --- | --- | -| Prompts | `src/crates/core/src/agentic/agents/prompts/` | Add or refine prompts, and update related logic as needed | -| Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | Add tool implementations and register them in the tool registry | -| Subagents | `src/crates/core/src/agentic/agents/custom_subagents/`, `src/crates/core/src/agentic/agents/registry.rs` | Add subagent implementations and register them in the subagent registry | -| Mode contributions | `src/crates/core/src/agentic/agents/*_mode.rs`, `src/crates/core/src/agentic/agents/prompts/*_mode.md`, `src/web-ui/src/locales/*/settings/modes.json` | Add/improve agent modes (e.g. Plan/Debug/Agentic or custom modes) and keep prompts + UI copy in sync | +| Prompts | `src/crates/assembly/core/src/agentic/agents/prompts/` | Add or refine prompts, and update related logic as needed | +| Tools | `src/crates/assembly/core/src/agentic/tools/implementations/`, `src/crates/assembly/core/src/agentic/tools/registry.rs` | Add tool implementations and register them in the tool registry | +| Subagents | `src/crates/assembly/core/src/agentic/agents/custom_subagents/`, `src/crates/assembly/core/src/agentic/agents/registry.rs` | Add subagent implementations and register them in the subagent registry | +| Mode contributions | `src/crates/assembly/core/src/agentic/agents/*_mode.rs`, `src/crates/assembly/core/src/agentic/agents/prompts/*_mode.md`, `src/web-ui/src/locales/*/settings/modes.json` | Add/improve agent modes (e.g. Plan/Debug/Agentic or custom modes) and keep prompts + UI copy in sync | | Scenario guides for Code Agent and AIIde | `website/src/docs/` | Add workflows, playbooks, and real-world scenario docs (or link them from `README.md`) | ### Before you start @@ -184,29 +133,25 @@ Keep PRs small and focused. Avoid bundling unrelated changes. ## Testing and Verification -Run relevant tests for your change. You do not need to run every row below; choose the smallest set that matches the files and behavior you touched: - -CI covers full builds and broad test suites. Local prechecks should stay focused -unless the change directly touches build, packaging, or a path not covered by CI. +Run the smallest checks that match the changed files and behavior. CI covers +full builds and broad test suites; local prechecks should stay focused unless +the change affects build, packaging, release behavior, or a path CI cannot +protect. -For `/usage` UI copy changes, keep `en-US`, `zh-CN`, and `zh-TW` locale strings in sync. +Common local checks: -| Change type | Recommended verification | +| Change type | Typical verification | | --- | --- | -| Repository metadata, PR/issue templates, or GitHub workflows | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | -| Locale resource-only changes | `pnpm run i18n:audit` | -| Locale contract or shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | -| Web UI state, adapters, or runtime code | `pnpm run type-check:web`, plus the nearest focused test when behavior changed | -| Web UI i18n runtime or namespace-loading changes | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | -| Mobile web UI, pairing, reconnect, disconnect, or chat-flow changes | `pnpm --dir src/mobile-web run type-check` | -| Installer frontend or i18n runtime without packaging changes | `pnpm --dir BitFun-Installer run type-check` | -| Rust core, transport, API layer, services, or shared runtime logic | `cargo check --workspace`, plus the nearest focused `cargo test` when behavior changed | -| Desktop integration, Tauri APIs, or desktop-only behavior | `cargo check -p bitfun-desktop`, plus focused desktop tests when behavior changed | -| E2E-covered behavior | Run the closest focused E2E/smoke check; rely on CI for broad build/test coverage unless build behavior changed | - -For mobile-web pairing, reconnect, disconnect, or chat-flow changes, include manual verification steps and screenshots or a short recording when the UI changes. - -If you cannot run tests, explain why in the PR and provide manual verification steps. +| Repository metadata or GitHub config | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | +| Frontend runtime or UI | `pnpm run type-check:web`, plus the nearest focused test when behavior changed | +| Mobile web | `pnpm --dir src/mobile-web run type-check` | +| Rust shared runtime or services | `cargo check --workspace`, plus a focused `cargo test` when behavior changed | +| Desktop/Tauri integration | `cargo check -p bitfun-desktop` | +| i18n resources or contract | use the matching i18n row in `AGENTS.md` | + +For UI changes, include screenshots or a short recording when helpful. If you +cannot run a relevant check, explain why in the PR and provide a lower-risk +manual verification path. ## Security and Compliance diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index d022c9a759..d0aea1c0d7 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -19,12 +19,12 @@ #### Windows:OpenSSL 配置 -桌面端包含 SSH 远程功能,会链接 OpenSSL。Windows 上**不使用 OpenSSL 源码编译(vendored)**,需使用**预编译**库。 +大多数 Windows 贡献者不需要手动配置 OpenSSL。使用 `pnpm run desktop:dev` +或常规 `desktop:build*` 脚本即可;脚本会在需要时自动引导预编译的 OpenSSL 包。 -- **默认**:Windows 下 `pnpm run desktop:dev` 会调用 `ensure-openssl-windows.mjs`;`pnpm run desktop:preview:debug` 在需要为预览执行快速本地 `cargo build -p bitfun-desktop` 时,也会做同样的 OpenSSL 引导。所有 `desktop:build*` 均通过 `scripts/desktop-tauri-build.mjs` 执行,在 `tauri build` 前做相同引导(首次下载到 `.bitfun/cache/`,之后走缓存)。 -- **手动 / CI**:下载 [FireDaemon ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip),解压后将 `OPENSSL_DIR` 指向 `x64`,并设 `OPENSSL_STATIC=1`,或运行 `scripts/ci/setup-openssl-windows.ps1`。 -- **关闭自动下载**:设置 `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_DIR`。 -- **`desktop:dev:raw`** 不经过 `dev.cjs`(无 OpenSSL 引导);请自行设置 `OPENSSL_DIR`、运行 `scripts/ci/setup-openssl-windows.ps1`,或执行 `node scripts/ensure-openssl-windows.mjs`(会预热 `.bitfun/cache/` 并打印可在 PowerShell 中粘贴的 `OPENSSL_*` 命令)。 +只有在自动引导失败、准备 CI 环境,或你明确使用 `pnpm run desktop:dev:raw` +时才需要手动处理。此时运行 `scripts/ci/setup-openssl-windows.ps1`,或将 +`OPENSSL_DIR` 指向预编译的 x64 OpenSSL 目录,并设置 `OPENSSL_STATIC=1`。 ### 安装依赖 @@ -54,67 +54,21 @@ pnpm run e2e:test ### 桌面端调试工具 -开发桌面端 UI/UX 时,`devtools` Cargo feature 提供额外的调试能力。它在 `dev` 构建和 `release-fast` profile 构建中自动启用,但在面向最终用户的 `release` 构建中永不启用。 - -| 快捷键 | 功能 | -|---|---| -| `Cmd/Ctrl + Shift + I` | 切换元素检查器 — 悬停高亮元素,点击采集元数据 | -| `Cmd/Ctrl + Shift + J` | 打开原生 webview DevTools 窗口 | - -元素检查器向主 webview 注入一个轻量脚本。点击元素后会采集: -- 标签、id、class、CSS 选择器路径 -- Computed styles 和 CSS 变量 -- Box model(margin、padding、border) -- 颜色值(文本、背景、边框) -- 元素属性 - -采集的数据以结构化 JSON 形式输出到 `bitfun::devtools` 日志目标下。 +桌面端 dev 构建会启用 `devtools` Cargo feature。`F12` 打开原生 webview +DevTools;`Cmd/Ctrl + Shift + I` 切换 BitFun 元素检查器,`Cmd/Ctrl + Shift + J` +也可以打开原生 DevTools。面向最终用户的 `release` 构建不会启用这些工具。 ## 代码规范与架构约束 -### 日志规范 - -- 仅使用英文日志,避免冗长输出 -- 前端:`createLogger('ModuleName')` -- 后端:`log::{info, debug, warn, error}` 宏 - -### 国际化 - -- Locale 元数据统一维护在 `src/shared/i18n/contract/locales.json`;修改后运行 - `pnpm run i18n:generate`。 -- 跨形态稳定标签放在 `src/shared/i18n/resources/shared`;流程文案留在所属形态资源中。 -- Web UI 路由或功能文案使用 `useI18n(namespace)`。不要把 Web UI locale 资源导入 mobile-web、installer、backend 或静态页面。 -- `pnpm run i18n:audit` 会检查 key / 占位符一致性、直接静态 key 是否存在,以及 - source 中不再新增硬编码 CJK 文案。 - -### 平台无关核心 - -`core` 中禁止引入平台相关依赖: +架构敏感规则、模块边界和验证矩阵以 [`AGENTS.md`](AGENTS.md) 为准。面向贡献者只需把握: -- ❌ `tauri::AppHandle` -- ✅ `bitfun_events::EventEmitter` - -进行 `bitfun-core` 拆解或构建提速重构时,请遵循 -[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md), -不要把产品 feature set 或 release 脚本变更作为顺手改动。 - -### Tauri 命令规范 - -- 命令名使用 `snake_case` -- Rust 与 TypeScript 命名保持一致 -- 必须使用结构化请求格式: - -```rust -#[tauri::command] -pub async fn your_command( - state: State<'_, AppState>, - request: YourRequest, -) -> Result -``` - -```ts -await api.invoke("your_command", { request: { /* ... */ } }); -``` +- 日志只使用英文,并保持必要、可读。 +- 用户可见文案走项目 i18n 流程;不要把 Web UI locale catalog 共享给较小产品形态。 +- shared core 必须保持平台无关;Desktop/Tauri 细节属于 app adapter,并通过 transport / API layer 回流。 +- Tauri command 使用 `snake_case` 命令名和结构化 `request` 参数。 +- core 拆解、feature 边界、依赖边界和构建提速重构必须遵循 + `docs/architecture/core-decomposition.md`。 +- 功能级规则应放在离代码最近的模块 `AGENTS.md` 中。 ## 重点关注的贡献方向 @@ -132,10 +86,10 @@ await api.invoke("your_command", { request: { /* ... */ } }); | 贡献方向 | 位置/文件 | 示例说明 | | --- | --- | --- | -| Prompts | `src/crates/core/src/agentic/agents/prompts/` | 新增或优化提示词,并按需更新相关逻辑 | -| Tools | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | 新增工具实现,并在工具注册表中注册 | -| Subagents | `src/crates/core/src/agentic/agents/custom_subagents/`、`src/crates/core/src/agentic/agents/registry.rs` | 新增子代理实现,并在子代理注册表中注册 | -| 模式贡献 | `src/crates/core/src/agentic/agents/*_mode.rs`、`src/crates/core/src/agentic/agents/prompts/*_mode.md`、`src/web-ui/src/locales/*/settings/modes.json` | 新增/优化 Agent 模式(例如 Plan/Debug/Agentic 或自定义模式)的逻辑与提示词,并同步前端模式文案 | +| Prompts | `src/crates/assembly/core/src/agentic/agents/prompts/` | 新增或优化提示词,并按需更新相关逻辑 | +| Tools | `src/crates/assembly/core/src/agentic/tools/implementations/`、`src/crates/assembly/core/src/agentic/tools/registry.rs` | 新增工具实现,并在工具注册表中注册 | +| Subagents | `src/crates/assembly/core/src/agentic/agents/custom_subagents/`、`src/crates/assembly/core/src/agentic/agents/registry.rs` | 新增子代理实现,并在子代理注册表中注册 | +| 模式贡献 | `src/crates/assembly/core/src/agentic/agents/*_mode.rs`、`src/crates/assembly/core/src/agentic/agents/prompts/*_mode.md`、`src/web-ui/src/locales/*/settings/modes.json` | 新增/优化 Agent 模式(例如 Plan/Debug/Agentic 或自定义模式)的逻辑与提示词,并同步前端模式文案 | | Code Agent 与 AIIde 场景指南 | `website/src/docs/` | 补充流程、playbook 与真实场景说明(或从 `README.md` 链接) | ### 开始前 @@ -171,29 +125,21 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 ## 测试与验证 -按改动范围运行相关测试;不需要跑完下方所有命令,只选择与本次改动文件和行为匹配的最小集合: - -完整构建和大范围测试由 CI 保护。本地预检应保持聚焦;只有改动直接触及构建、打包, -或 CI 不覆盖对应路径时才运行更重命令。 +按改动文件和行为选择最小检查。完整构建和大范围测试由 CI 保护;只有改动影响构建、打包、发布行为, +或 CI 无法覆盖对应路径时,才在本地运行更重命令。 -修改 `/usage` UI 文案时,请同步 `en-US`、`zh-CN`、`zh-TW` 多语言文本。 +常见本地检查: -| 改动类型 | 推荐验证 | +| 改动类型 | 常用验证 | | --- | --- | -| 仓库元信息、PR/Issue 模板或 GitHub workflow | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | -| 仅 locale 资源改动 | `pnpm run i18n:audit` | -| Locale contract 或 shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | -| Web UI 状态、适配层或运行时代码 | `pnpm run type-check:web`;行为变化时再加最近的 focused test | -| Web UI i18n runtime 或 namespace loading 改动 | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | -| Mobile web UI、配对、重连、断开或聊天流程 | `pnpm --dir src/mobile-web run type-check` | -| 不涉及打包的安装器前端或 i18n runtime | `pnpm --dir BitFun-Installer run type-check` | -| Rust core、transport、API layer、services 或共享运行时逻辑 | `cargo check --workspace`;行为变化时再加最近的 focused `cargo test` | -| 桌面端集成、Tauri API 或桌面端专属行为 | `cargo check -p bitfun-desktop`;行为变化时再加 focused desktop tests | -| E2E 覆盖的行为 | 运行最近的 focused E2E/smoke check;除非影响构建,否则 broad build/test 交给 CI | - -Mobile web 配对、重连、断开或聊天流程改动,需要在 PR 中补充手动验证步骤;涉及 UI 变化时,请附截图或短录屏。 - -如暂时无法运行测试,请在 PR 描述中说明原因,并提供手动验证步骤。 +| 仓库元信息或 GitHub 配置 | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | +| 前端运行时或 UI | `pnpm run type-check:web`;行为变化时再加最近的 focused test | +| Mobile web | `pnpm --dir src/mobile-web run type-check` | +| Rust 共享 runtime 或 services | `cargo check --workspace`;行为变化时再加 focused `cargo test` | +| Desktop/Tauri 集成 | `cargo check -p bitfun-desktop` | +| i18n 资源或契约 | 使用 `AGENTS.md` 中匹配的 i18n 验证行 | + +UI 改动在有帮助时附截图或短录屏。无法运行相关检查时,在 PR 中说明原因,并提供风险更低的手动验证路径。 ## 安全与合规 diff --git a/Cargo.toml b/Cargo.toml index 4cb109ad8a..30d1f629d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,28 +1,30 @@ [workspace] members = [ - "src/crates/core-types", - "src/crates/events", - "src/crates/ai-adapters", - "src/crates/agent-stream", - "src/crates/agent-runtime", - "src/crates/runtime-ports", - "src/crates/runtime-services", - "src/crates/services-core", - "src/crates/services-integrations", - "src/crates/product-domains", - "src/crates/agent-tools", - "src/crates/tool-packs", - "src/crates/acp", - "src/crates/core", - "src/crates/terminal", - "src/crates/tool-runtime", - "src/crates/transport", - "src/crates/api-layer", - "src/crates/webdriver", "src/apps/cli", "src/apps/desktop", "src/apps/server", "src/apps/relay-server", + "src/crates/interfaces/acp", + "src/crates/assembly/core", + "src/crates/adapters/ai-adapters", + "src/crates/adapters/webdriver", + "src/crates/adapters/api-layer", + "src/crates/adapters/transport", + "src/crates/services/services-core", + "src/crates/services/services-integrations", + "src/crates/services/terminal", + "src/crates/assembly/product-capabilities", + "src/crates/contracts/product-domains", + "src/crates/execution/agent-runtime", + "src/crates/execution/agent-stream", + "src/crates/execution/tool-contracts", + "src/crates/execution/harness", + "src/crates/execution/runtime-services", + "src/crates/execution/tool-provider-groups", + "src/crates/execution/tool-execution", + "src/crates/contracts/core-types", + "src/crates/contracts/events", + "src/crates/contracts/runtime-ports", ] exclude = [ @@ -33,7 +35,7 @@ resolver = "2" # Shared package metadata — single source of truth for version [workspace.package] -version = "0.2.8" # x-release-please-version +version = "0.2.10" # x-release-please-version authors = ["BitFun Team"] edition = "2021" @@ -66,6 +68,12 @@ uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde", "clock"] } chrono-tz = "0.10.4" cron = "0.15.0" +# Tauri 2.9.2 utilities conflict with time 0.3.48 on current stable Rust. +time = { version = "=0.3.47", default-features = false } +# brotli-decompressor 5.0.2 / alloc-stdlib 0.2.3 pull alloc-no-stdlib 3.x while brotli still uses 2.x. +brotli-decompressor = "=5.0.0" +alloc-no-stdlib = "=2.0.4" +alloc-stdlib = "=0.2.2" regex = "1" base64 = "0.22" # Keep macOS Tauri's dispatch2/bitflags expansion on the known-good bitflags release. @@ -77,6 +85,8 @@ indexmap = "2" include_dir = "0.7" aes = "0.8" hex = "0.4" +chardetng = "0.1.17" +encoding_rs = "0.8.35" # HTTP client reqwest = { version = "0.12", default-features = false, features = ["native-tls", "rustls-tls-native-roots", "json", "stream", "multipart"] } @@ -207,7 +217,7 @@ russh-sftp = "2.1" russh-keys = "0.45" shellexpand = "3" ssh_config = "0.1" -rustls = { version = "0.23", default-features = false } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } [profile.dev] incremental = true diff --git a/README.md b/README.md index 86d016db3b..860bdd2373 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Below is BitFun’s **official Agent and capability inventory**, plus how we tra | **Generative UI** | On-demand interactive UI components during chat, embedded in the message stream for immediate use | | **Mini App** | One sentence to a standalone runnable app—generate, run, one-click package for desktop | | **Markdown-defined Agents** | Write a `.md` file and run it in the Runtime right away for most domain customization | -| **Long-term memory + project context** | Accumulates across sessions; readable by any Agent | +| **Long-term memory** | Accumulates across sessions; readable by any Agent | | **Self-iteration** | Code Agent can change BitFun’s own repository | | **⋯⋯** | Next trends in progress—open an Issue with requests | @@ -169,11 +169,12 @@ For more details, see the [Contributing guide](./CONTRIBUTING.md). ## Project structure at a glance ``` -src/crates/core # Compatibility facade and product runtime assembly -src/crates/{core-types,agent-stream,runtime-ports} # Extracted core support boundaries -src/crates/{terminal,tool-runtime} # Workspace-level terminal/tool helper crates -src/crates/transport # Tauri / WebSocket / CLI transport adapters -src/crates/api-layer # Shared handlers and DTOs +src/crates/interfaces/ # Product protocol interfaces such as ACP +src/crates/assembly/ # Compatibility facade and product capability assembly +src/crates/adapters/ # AI, API, transport, and WebDriver adapters +src/crates/services/ # Reusable OS, terminal, MCP, remote, git, and filesystem services +src/crates/execution/ # Agent, harness, stream, typed-service, and tool primitives +src/crates/contracts/ # Stable DTOs, events, runtime ports, and product domains src/apps/desktop # Tauri desktop host src/apps/server # Web server runtime src/apps/cli # CLI runtime diff --git a/README.zh-CN.md b/README.zh-CN.md index 9ac340a623..6badd2ef26 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -172,11 +172,12 @@ cp target/aarch64-unknow-linux-ohos/release/libbitfun_desktop_lib.so src/apps/oh ## 项目结构一览 ``` -src/crates/core # 兼容门面与完整产品 runtime 组装点 -src/crates/{core-types,agent-stream,runtime-ports} # 已拆出的 core 支撑边界 -src/crates/{terminal,tool-runtime} # workspace 顶层 terminal / tool 辅助 crate -src/crates/transport # Tauri / WebSocket / CLI 传输适配 -src/crates/api-layer # 共享 handler 与 DTO +src/crates/interfaces/ # ACP 等产品协议接口 +src/crates/assembly/ # 兼容门面与产品能力组装 +src/crates/adapters/ # AI、API、transport 与 WebDriver adapter +src/crates/services/ # OS、terminal、MCP、remote、git 与 filesystem service +src/crates/execution/ # Agent、harness、stream、typed-service 与 tool 原语 +src/crates/contracts/ # 稳定 DTO、事件、runtime ports 与产品领域契约 src/apps/desktop # Tauri 桌面宿主 src/apps/server # Web 服务端运行时 src/apps/cli # CLI 运行时 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 592893d911..2e858576ae 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -1,31 +1,56 @@ # Agent Runtime SDK 与 Runtime Services 设计 本文是 [`core-decomposition.md`](core-decomposition.md) 的开发设计文档,描述目标模块、 -接口、crate 内部结构和迁移保护。`bitfun-runtime-services` 已建立 typed service bundle、 -builder、provider registry、capability availability 和 fake provider 基础;`bitfun-agent-runtime` -已创建并只承接可独立构建的 scheduler/background delivery 纯决策。`bitfun-harness` 仍是目标 crate; -未迁移的 session manager、prompt loop、subagent registry 和 concrete scheduler lifecycle 不得被描述为已完成。 +接口、crate 内部结构和行为保护。本文只记录设计约束,不记录实现过程或验证记录。 ## 1. 设计目标与边界 - Agent Runtime SDK 可被 Desktop、CLI、Server、Remote、ACP 等产品形态嵌入。 +- Agent Runtime SDK 对外提供稳定、窄口径的 runtime API,而不是暴露 `bitfun-core`、产品命令路径或 concrete manager。 - Runtime 不感知平台差异、工具实现差异和构建形态差异。 -- Tool 使用通用接口和 provider 注册,不绑定底层实现。 -- 具体 service 实现由上层 Product Assembly 注入。 +- Tool 使用通用接口和 provider group 注册,不绑定底层实现。 +- 具体 adapter 与 service 实现由上层 Product Assembly 注入。 - Harness 可扩展,新增 SDD 等工作流不侵入 runtime kernel。 - 每个 crate 只依赖最小稳定集合,依赖方向可检查。 -### 1.1 crate 划分 +### 1.1 SDK 发布边界 + +Agent Runtime SDK 的发布边界以调用方能力为准,而不是以物理 crate 命名为准。达到目标状态时,外部调用方 +应能在不依赖 `bitfun-core`、app crate、Tauri 或产品内部 manager 的情况下完成以下动作: + +- 构建 runtime:注入 model provider、`RuntimeServices`、tool provider、harness provider、agent definitions、 + hooks 和 runtime config。 +- 发起执行:创建或恢复 session,提交 turn,取消 turn,消费 provider-neutral event stream。 +- 执行工具:通过稳定 tool manifest、permission request、tool result、artifact ref 和 cancellation contract + 管理工具调用。 +- 扩展能力:通过 registry 注册 subagent、prompt module、skill、MCP/API tool、harness workflow 和 post-turn + processor。 +- 处理运维语义:接收 typed error、usage/cost/cache facts、telemetry event、checkpoint/resume facts 和 + unsupported capability。 + +因此,SDK readiness 的最低标准是: + +- 公共 façade 只暴露 builder、runner、request/response DTO、event stream、typed error 和 registry API。 +- 所有 DTO 可序列化,所有 runtime handle 通过 typed port 注入,不进入 wire contract。 +- `bitfun-agent-runtime`、Tool primitives、Runtime Services 和 Harness 能通过 fake provider 独立测试。 +- SDK minimal feature 不牵引 Desktop、Tauri、Git provider、MCP client、AI HTTP client、remote SSH 或产品 UI。 +- 完整产品能力只能通过 Product Assembly 或兼容 `bitfun-core/product-full` 组装,不反向污染 SDK API。 + +只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有 concrete service manager、读取产品命令 +registry 或依赖全局 mutable state,SDK 发布边界就不成立。 + +### 1.2 crate 划分 ```text bitfun-core-types bitfun-events bitfun-runtime-ports -bitfun-runtime-services # PR1 基础壳层 -bitfun-agent-tools -tool-runtime -bitfun-agent-runtime # 已创建,当前仅承接 scheduler/background delivery 决策 -bitfun-harness # 目标 +bitfun-runtime-services # typed service bundle / capability availability +tool-contracts # Cargo package: bitfun-agent-tools +tool-provider-groups # Cargo package: bitfun-tool-packs +tool-execution # Cargo package: tool-runtime +bitfun-agent-runtime # agent kernel contracts and portable runtime decisions +bitfun-harness # workflow descriptor / provider / registry contracts bitfun-services-core bitfun-services-integrations bitfun-product-domains @@ -45,25 +70,25 @@ Product Assembly -> product capability packs -> bitfun-agent-runtime -> bitfun-harness - -> tool-runtime + -> tool-contracts / tool-provider-groups / tool-execution -> bitfun-runtime-services - -> 具体 service crates + -> adapters / services Product Capability packs -> bitfun-harness -> bitfun-agent-runtime - -> tool-runtime + -> tool-provider-groups -> bitfun-product-domains bitfun-agent-runtime -> bitfun-runtime-ports -> bitfun-events -> bitfun-agent-stream - -> tool-runtime + -> tool-contracts -> bitfun-runtime-services -tool-runtime - -> bitfun-agent-tools +tool-execution + -> tool-contracts -> bitfun-runtime-ports -> bitfun-events @@ -71,7 +96,7 @@ bitfun-runtime-services -> bitfun-runtime-ports -> bitfun-core-types / bitfun-events(仅当 service DTO 或 event contract 需要时引入) -具体 service crates +adapters / services -> bitfun-runtime-ports -> bitfun-core-types -> 允许的 third-party 依赖 @@ -81,8 +106,8 @@ bitfun-runtime-services 禁止依赖: - `bitfun-runtime-ports` -> `bitfun-core` -- `bitfun-agent-tools` -> 具体 service crate -- `tool-runtime` -> 具体 tool 实现 crate +- `tool-contracts` -> 具体 service crate +- `tool-execution` -> 产品 registry / permission policy / 具体 tool 实现 crate - `bitfun-agent-runtime` -> `bitfun-core` - `bitfun-agent-runtime` -> Tauri / CLI / ACP protocol / Web UI - `bitfun-harness` -> 具体 filesystem / Git / terminal manager @@ -90,10 +115,10 @@ bitfun-runtime-services 目标 crate 创建或继续扩展准入: - 只有当 owner 边界、旧路径兼容、focused tests、依赖收益和 boundary check 都能同时落地时,才创建新的目标 crate。 -- `bitfun-runtime-services` 已按该准入建立基础壳层;继续扩展时仍必须保持 typed builder、本地 service、remote service 和 fake provider 三类注入路径可测试。 -- `bitfun-agent-runtime` 已通过 scheduler/background delivery 纯决策满足创建准入;继续扩展时仍必须保持旧路径 facade、focused tests 和 boundary check。 -- `bitfun-harness` 的创建前提是至少两个 workflow 可以通过 provider contract 注册,例如 Deep Review 与 MiniApp / DeepResearch。 -- 若目标 crate 只能承接单个 helper 或只能通过 `bitfun-core` 才能测试,继续留在迁移期 facade,不提前拆 crate。 +- `bitfun-runtime-services` 的扩展必须保持 typed builder、本地 service、remote service 和 fake provider 三类注入路径可测试。 +- `bitfun-agent-runtime` 的扩展必须保持旧路径 facade、focused tests 和 boundary check,且不得吸收 concrete service、product surface 或平台实现。 +- `bitfun-harness` 的扩展必须保持 descriptor / registry、旧路径兼容、focused tests 和 boundary check,且不得把 provider 注册误写成 concrete workflow execution。 +- 若目标 crate 只能承接单个 helper 或只能通过 `bitfun-core` 才能测试,应继续留在初始兼容 facade,不提前拆 crate。 ## 2. 稳定接口与运行时服务 @@ -157,7 +182,7 @@ pub trait WorkspacePort: Send + Sync { ### 2.2 Runtime Services -当前 crate:`bitfun-runtime-services`。 +目标 owner crate:`bitfun-runtime-services`。 职责: @@ -235,30 +260,67 @@ Remote ports 的边界: ### 3.1 Agent Runtime SDK -当前 crate:`bitfun-agent-runtime`。 +目标 owner crate:`bitfun-agent-runtime`。 -当前已承接范围: - -- background delivery 状态决策:Processing 注入当前运行 turn;Missing / Idle / Error 提交 agent-session follow-up turn。 - -仍留在 `bitfun-core` 的范围: - -- concrete scheduler 生命周期、session manager、turn id 生成、injection buffer、submit 执行、prompt loop、subagent registry 和 post-turn hook。 - -职责: +目标职责: - session 生命周期。 - dialog turn / model round 生命周期。 - scheduler / queue / cancellation。 - prompt loop 和 context assembly。 - prompt cache 协调。 -- subagent registry 查询和 delegation policy。 +- agent definition registry、subagent registry 查询和 delegation policy。 - fork context seeding。 - tool call 调度。 - permission 协调。 - runtime events。 - post-turn processor。 +公共 façade: + +```rust +pub struct AgentRuntimeBuilder { + // typed runtime parts only +} + +pub struct AgentRunRequest { + pub session: SessionSelector, + pub input: AgentInput, + pub cancellation: CancellationToken, +} + +pub struct AgentRunHandle { + pub session_id: SessionId, + pub turn_id: TurnId, + pub events: AgentEventStream, +} + +impl AgentRuntimeBuilder { + pub fn with_services(self, services: RuntimeServices) -> Self; + pub fn with_tools(self, tools: Arc) -> Self; + pub fn with_harnesses(self, harnesses: Arc) -> Self; + pub fn with_agents(self, agents: Arc) -> Self; + pub fn with_hooks(self, hooks: RuntimeHookRegistry) -> Self; + pub fn build(self) -> Result; +} + +impl AgentRuntime { + pub async fn run(&self, request: AgentRunRequest) -> Result; +} +``` + +该 façade 是目标 API 形态。它必须只接收已组装的 typed parts,不负责创建 +filesystem、terminal、MCP、AI client、Remote provider 或产品命令。 + +旧路径兼容约束: + +- `bitfun-agent-runtime` 只能依赖稳定契约、Tool Runtime、Runtime Services 接口和注入的 provider。 +- concrete scheduler 生命周期、session metadata store、token subscriber、event delivery、product `Tool` + handler、concrete prompt assembly、workspace / remote / config IO、custom subagent file IO 和平台 adapter + 在行为等价未证明前不得下沉到 runtime kernel。 +- prompt、event、thread goal、scheduler 或 subagent 的纯事实如果进入 Agent Runtime SDK,必须同时删除旧 owner + 实现主体,保留旧路径兼容,并具备 focused contract test 与 boundary check。 + 建议内部模块: ```text @@ -363,37 +425,43 @@ impl AgentRuntime { - `SessionManager -> Session -> DialogTurn -> ModelRound` 语义不变。 - `/goal` custom metadata、post-turn verification、continuation event 不漂移。 +- `get_goal` / `create_goal` / `update_goal` 的 tool response wire shape、blocked/complete 语义和 token budget report 不漂移。 - `Task.run_in_background` delivery 不漂移。 - `Task.fork_context` 禁止字段、prompt cache clone、context seeding 不漂移。 - DeepResearch citation renumber post-turn hook 保持 deterministic。 -### 3.2 Tool Runtime +### 3.2 Tool Primitives 所属 crate: -- `bitfun-agent-tools` -- `tool-runtime` -- `bitfun-tool-packs` +- `tool-contracts`(Cargo package: `bitfun-agent-tools`) +- `tool-provider-groups`(Cargo package: `bitfun-tool-packs`) +- `tool-execution`(Cargo package: `tool-runtime`) 目标职责: -- `bitfun-agent-tools`:tool DTO、manifest、exposure、schema、path policy、result policy。 -- `tool-runtime`:provider registry、permission gate、execution pipeline、hook。 -- `bitfun-tool-packs`:tool pack feature metadata 和 provider plan。 +- `tool-contracts`:tool DTO、manifest、exposure、schema、path policy、result policy、admission gate 和 provider-neutral registry assembly。 +- `tool-provider-groups`:tool provider group feature metadata 和 provider plan。 +- `tool-execution`:低层 file/search/tool IO helper,不拥有产品 registry、permission policy 或 agent-facing tool surface。 -建议 `tool-runtime` 模块: +建议模块: ```text -tool-runtime - runtime.rs - registry.rs - provider.rs - context.rs - permission.rs - execution.rs - hooks.rs - result.rs - catalog.rs +tool-contracts + framework.rs + restrictions.rs + file_guidance.rs + tool_result_storage.rs + tool_execution_presentation.rs + +tool-provider-groups + provider_groups.rs + +tool-execution + filesystem.rs + search.rs + remote.rs + result_window.rs ``` 核心接口: @@ -424,22 +492,37 @@ pub struct ToolExecutionContext { } ``` +目标职责: + +- provider-neutral manifest、catalog、permission gate、execution admission、tool hook、execution result + presentation 和 result artifact policy。 +- `GetToolSpec` catalog、detail、assistant result 和 collapsed-tool unlock observation。 +- workspace service、path policy、runtime artifact reference、remote path containment 和 tool context facts 的 + 稳定 contract。 + +旧路径兼容约束: + +- core 可以保留旧路径 facade、concrete tool adapter、state update、registry lookup、confirmation、actual + execution 和 filesystem persistence;目标状态要求只有在等价测试保护下才能移动这些行为。 +- workspace file/shell contract 保留既有错误与取消语义;不得把错误分类、取消语义或产品 tool exposure + 变更混入 owner 边界移动。 + 设计约束: - `ToolExecutionContext` 不暴露具体 manager。 - `ToolContextFacts` 只包含 portable facts。 -- Tool Runtime 只消费 `ToolExecutionServices` 这样的窄 service 视图,不依赖完整 +- Tool primitives 只消费 `ToolExecutionServices` 这样的窄 service 视图,不依赖完整 `RuntimeServices` bundle。 -- path policy、runtime artifact ref、remote POSIX containment 由 `bitfun-agent-tools` 承载。 +- path policy、runtime artifact ref、remote POSIX containment 由 `tool-contracts` 承载。 - MCP tool 作为 external tool provider 注入,不内置在 Agent Runtime SDK。 -- `GetToolSpec` 是 tool runtime/catalog 能力,不是产品 UI。 +- `GetToolSpec` 是 tool catalog 能力,不是产品 UI。 必须保护: - prompt-visible manifest。 - expanded / collapsed exposure。 - `GetToolSpec` schema / assistant detail / detail JSON。 -- collapsed unlock state。 +- collapsed unlock state 与 persistence 生命周期。 - readonly / enabled snapshot filter。 - MCP / ACP / desktop tool catalog 等价。 - oversized tool result persistence、flush、preview、artifact ref。 @@ -447,13 +530,13 @@ pub struct ToolExecutionContext { ### 3.3 Harness Layer -目标 crate:`bitfun-harness`。 +目标 owner crate:`bitfun-harness`。 职责: -- 把 SDD、DeepReview、DeepResearch、MiniApp、function-agent 等工作流从 runtime kernel - 中分离。 -- 定义 workflow plan、step、policy、artifact、review gate、post-processor。 +- 把 SDD、DeepReview、DeepResearch、MiniApp、function-agent 等工作流从 runtime kernel 中分离。 +- 定义 workflow descriptor、route plan、provider registry、workflow plan、step、policy、artifact、 + review gate 和 post-processor。 - 通过 Agent Runtime SDK、Tool Runtime 和 service ports 编排。 建议内部模块: @@ -508,24 +591,26 @@ pub struct HarnessExecutionContext { - harness 不直接访问 concrete filesystem / Git / terminal。 - 产品命令只映射到 harness capability,不把命令展示逻辑下沉。 - 新 harness 通过 provider 注册,不改 Agent Runtime SDK 内核。 +- descriptor-only / legacy-facade provider 只能表达 route plan;不得被描述为已经拥有 concrete workflow execution。 + 执行语义移动必须单独证明行为等价。 ## 4. 产品组装与扩展 ### 4.1 Product Assembly -Product Assembly 是 composition root。它可以位于 `bitfun-core` 迁移期 facade 内,也可以在 -后续拆成独立 Product Assembly crate。 +Product Assembly 是 composition root。初始状态可由 `bitfun-core` 兼容 facade 承载;目标状态可拆成独立 +Product Assembly crate。 职责: -- 创建具体 service 实现。 +- 创建或接收具体 adapter / service 实现。 - 构建 `RuntimeServices`。 -- 注册 tool providers。 +- 注册 tool provider groups。 - 注册 harness providers。 - 注册 agent definitions、subagents、skills、prompt modules。 - 建立产品 feature matrix。 -- 把 surface 命令映射到 capability / harness / runtime request。 -- 根据交付形态选择 `DeliveryProfile`、`CapabilitySet` 和 service provider 集合。 +- 把 interface 命令映射到 capability / harness / runtime request。 +- 根据交付形态选择 `DeliveryProfile`、`CapabilitySet`、adapter 和 service provider 集合。 - 对不支持能力返回 typed unsupported / unavailable 错误,而不是让下层 runtime 判断产品形态。 建议模块: @@ -644,6 +729,8 @@ pub fn build_desktop_runtime(input: DesktopAssemblyInput) -> ResultDesktop / CLI / Server / Relay / Remote / Web"] Core["bitfun-core
兼容 facade + 完整产品 runtime 组装"] - Acp["bitfun-acp
ACP protocol / client integration"] + Acp["bitfun-acp
ACP protocol surface / client behavior"] Transport["transport / api-layer
API 与传输 adapter"] CoreTypes["bitfun-core-types
稳定 DTO 子集"] Events["bitfun-events
事件事实与 emitter 抽象"] Ports["bitfun-runtime-ports
trait-only runtime 边界"] Stream["bitfun-agent-stream
stream 聚合"] AgentTools["bitfun-agent-tools
tool contract 与纯策略"] - ToolRuntime["tool-runtime
既有 tool runtime crate"] - ToolPacks["bitfun-tool-packs
feature group / provider plan"] + ToolRuntime["tool-execution
tool-runtime package / 低层 helper"] + ToolPacks["tool-provider-groups
bitfun-tool-packs package / provider plan"] ServicesCore["bitfun-services-core
基础 service helper / filesystem facade"] ServicesIntegrations["bitfun-services-integrations
MCP / Git / Remote helper owner"] ProductDomains["bitfun-product-domains
MiniApp / function-agent 纯 domain"] @@ -82,26 +92,26 @@ flowchart TB Ai --> External ``` -当前主要模块范围: +初始状态主要模块范围: -| 模块 | 当前定位 | 架构影响 | +| 模块 | 初始定位 | 架构影响 | |---|---|---| -| `bitfun-core` | 兼容 facade、agent runtime、tool runtime 组装、service 接线和完整产品能力集合 | 仍是事实上的 runtime owner,迁移必须先保护行为等价 | +| `bitfun-core` | 兼容 facade、agent runtime、tool runtime 组装、service 接线和完整产品能力集合 | 仍是事实上的 runtime owner,拆解必须先保护行为等价 | | `bitfun-runtime-ports` | 面向 runtime/service 边界的 DTO 和 trait | 只定义 contract,不拥有 runtime 实现 | -| `bitfun-agent-tools` | provider-neutral tool DTO、manifest、path/result policy 和 catalog contract | 已适合承接更多 tool contract,但不应拥有具体 IO tool | -| `tool-runtime` | 既有工具执行相关 crate | 目标是收敛 provider registry、permission gate 和 execution pipeline | +| `tool-contracts` / `bitfun-agent-tools` | provider-neutral tool DTO、manifest、path/result policy、catalog contract 和 deterministic execution admission gate | 适合承接纯 tool contract 策略,但不应拥有具体 IO tool | +| `tool-execution` / `tool-runtime` | 既有低层工具执行 helper crate | 目标是只承接低层 file/search/tool execution helper,不拥有产品 registry 或 permission policy | | `bitfun-services-core` | 基础 service helper、本地 filesystem facade、部分通用 service 逻辑 | 适合作为本地基础 service owner,但不能吸收产品 runtime 语义 | -| `bitfun-services-integrations` | MCP、Git、remote-connect、remote-SSH 等 integration helper | 适合拥有外部协议和重依赖 adapter,不应反向感知产品 surface | +| `bitfun-services-integrations` | MCP、Git、remote-connect、remote-SSH 等 integration helper | 适合拥有外部协议和重依赖 service implementation,不应反向感知产品 interface | | `bitfun-product-domains` | MiniApp、function-agent 等纯状态、策略、port 和部分决策逻辑 | 适合承接 pure domain,不应直接执行 filesystem/Git/AI concrete call | -| `bitfun-acp` | ACP protocol 和 client integration | 应保持 external capability owner,不下沉到 Agent Runtime SDK | +| `bitfun-acp` | ACP protocol interface 和 client behavior | 应保持产品协议入口,不下沉到 Agent Runtime | | `transport` / `api-layer` | surface 到 runtime 的 API/transport adapter | 应保持传输层,不拥有 runtime owner | -## 4. 当前主要问题 +## 4. 初始状态主要问题 ### 4.1 分层不清晰 同一能力经常同时包含 UI/command、runtime orchestration、tool execution、service IO 和 domain -decision。当前代码中这些部分仍大量通过 `bitfun-core` 串联,导致后续迁移时难以判断“移动的是接口、 +decision。初始状态代码中这些部分仍大量通过 `bitfun-core` 串联,导致拆解时难以判断“移动的是接口、 实现、组装逻辑还是产品行为”。 ### 4.2 接口与实现边界不稳定 @@ -112,21 +122,23 @@ core-owned context 或完整 product runtime snapshot。接口没有稳定到足 ### 4.3 产品形态被完整 core 牵引 -Desktop、CLI、Server、Remote、ACP 和 Web 的入口差异较大,但当前大多仍通过完整 `bitfun-core` +Desktop、CLI、Server、Remote、ACP 和 Web 的入口差异较大,但初始状态下大多仍通过完整 `bitfun-core` 获得能力。这会让轻量交付形态继承不必要的 tool、service、UI 或平台依赖。 ### 4.4 Tool contract 与 tool execution 混合 -provider-neutral manifest、path policy、result policy 已部分外移,但 concrete tool execution、 -`ToolUseContext`、collapsed unlock state、runtime artifact persistence 和产品 catalog 仍在 core。 -工具迁移如果没有快照保护,容易改变 prompt-visible manifest、`GetToolSpec`、MCP/ACP catalog 或 -oversized result 行为。 +provider-neutral manifest、path policy、result policy、`ToolUseContext` runtime handle、collapsed unlock +lifecycle、runtime artifact persistence 和 product registry materialization 在初始状态下与 concrete tool +execution 交织在 core 及其兼容路径中。目标状态下,tool contracts 应拥有 provider-neutral manifest / +catalog / permission / result / artifact contract,core、services 或 adapter 只保留实际 IO tool adapter、 +state update、旧路径 facade 和有等价保护的拆解边界。工具 owner 拆解如果没有快照保护,容易改变 +prompt-visible manifest、`GetToolSpec`、MCP/ACP catalog 或 oversized result 行为。 ### 4.5 Service、MCP、ACP 与 runtime kernel 容易交叉 MCP 和 ACP 是外部协议/能力接入,不应变成 Agent Runtime SDK 的内部协议依赖。Runtime kernel 只应看见 external capability、tool provider 或 service port;连接生命周期、鉴权、transport 和 timeout 策略应由 -integration owner 或 Product Assembly 管理。 +Adapters、Services 或 Product Assembly 管理。 ### 4.6 扩展点缺少统一语义 @@ -136,13 +148,20 @@ product commands 都是扩展点,但目前没有统一表达它们分别属于 ### 4.7 feature graph 还不是产品能力矩阵 -`product-full` 当前是完整产品能力的安全网,不是最终按产品拆分的 feature matrix。直接减轻默认 feature +初始状态下,`product-full` 是完整产品能力的安全网,不是最终按产品拆分的 feature matrix。直接减轻默认 feature 或把 feature group 当成产品能力边界,都会引入构建形态和发布能力漂移。 ### 4.8 构建与测试牵引过大 重依赖和完整 runtime 聚合在 `bitfun-core` 周围,导致局部测试、owner crate 测试和轻量产品入口容易被 -不相关依赖拖入编译和链接路径。中间阶段不保证每个 PR 都变快,但目标架构必须让依赖收益可度量。 +不相关依赖拖入编译和链接路径。目标状态必须让依赖收益可度量,同时不能以牺牲功能等价换取构建收益。 + +### 4.9 SDK 发布边界不足 + +已有 `bitfun-agent-runtime`、`bitfun-runtime-services`、`tool-contracts`、`tool-execution`、`bitfun-harness` +和 `runtime-ports` 等 SDK 候选原语,但缺少可对外承诺的统一 runtime façade、稳定错误模型、事件流协议、 +provider 注册边界、持久化/恢复契约和最小依赖构建形态。如果外部调用方仍需要直接理解 `bitfun-core`、 +`product-full`、concrete service manager 或产品命令路径,说明 SDK 边界尚未完成。 ## 5. 对照分析 @@ -173,173 +192,140 @@ agents 分为 primary agents 和 subagents,可配置 prompt、model 与 tool a - Agent、Tool、MCP、Plugin/Hook、Skill 和 Product Surface 应该是互相连接的扩展面,而不是同一个模块内部的分支。 - 权限和工具可见性必须是 runtime 可观测的 contract,不能只存在于 UI 或 prompt 拼接中。 -- 多产品形态需要 Product Assembly 做 capability/provider 选择,而不是让 Agent Runtime SDK 判断当前是 +- 多产品形态需要 Product Assembly 做 capability/provider 选择,而不是让 Agent Runtime SDK 判断调用来自 Desktop、CLI、Remote 还是 ACP。 ## 6. 目标逻辑视图 -目标架构以层级为入口描述系统。每层只暴露本层 contract,具体实现由上层组装或下层 integration 提供。 +目标架构以六个物理 owner 分区表达依赖方向。`interfaces` 只承载协议和宿主入口;`assembly` 负责产品能力选择与注册;`adapters` 负责协议、transport 和外部 provider 转换;`services` 负责本地系统与 runtime infrastructure 的可复用具体实现;`execution` 只放可移植执行原语;`contracts` 提供稳定事实、port 和产品领域规则。这样可以同时区分“协议适配”和“服务实现”,也避免把 execution 误解为完整运行时实现层。 ```mermaid flowchart TB - Surface["产品入口(Product Surfaces)
UI / command / protocol / route"] - Assembly["产品组装层(Product Assembly)
capability selection / provider registration"] - Capabilities["产品能力层(Product Capabilities)
feature-level capability packs"] - Harness["工作流编排层(Harness Layer)
workflow orchestration"] - AgentRuntime["Agent 运行时 SDK(Agent Runtime SDK)
session / turn / scheduler / prompt / subagent"] - ToolRuntime["工具运行时(Tool Runtime)
manifest / permission / execution / catalog"] - RuntimeServices["运行时服务层(Runtime Services)
typed service bundle / capability availability"] - Integrations["具体实现层(Concrete Integrations)
Tool / OS / Remote / Protocol providers"] - Contracts["稳定契约层(Stable Contracts)
DTO / event / permission / artifact / port"] - External["外部系统(External Systems)
OS / Git / MCP server / ACP client / AI provider"] - - Surface --> Assembly - Assembly --> Capabilities - Assembly --> AgentRuntime - Assembly --> ToolRuntime - Assembly --> RuntimeServices - Assembly --> Integrations - Capabilities --> Harness - Capabilities --> AgentRuntime - Capabilities --> ToolRuntime - Harness --> AgentRuntime - Harness --> ToolRuntime - Harness --> RuntimeServices - AgentRuntime --> ToolRuntime - AgentRuntime --> RuntimeServices - ToolRuntime --> Contracts - RuntimeServices --> Contracts - Integrations --> Contracts - Integrations --> External + Interfaces["接口与入口层(Interfaces and Entrypoints)
UI / command / protocol interface / delivery profile"] + Assembly["产品组装层(Product Assembly)
compatibility facade / capability selection / adapter and service registration"] + Adapters["适配层(Adapters)
AI / API / transport / WebDriver / external provider translation"] + Services["服务实现层(Services)
filesystem / git / terminal / MCP / remote / process / OS integration"] + Execution["执行原语层(Execution Primitives)
agent / harness / stream / typed-service / tool primitives"] + Contracts["稳定契约与产品领域层(Stable Contracts and Product Domains)
DTO / event / runtime port / product domain policy"] + External["外部系统(External Systems)
OS / Git / MCP server / ACP client / AI provider / remote host"] + + Interfaces --> Assembly + Interfaces --> Adapters + Assembly --> Adapters + Assembly --> Services + Assembly --> Execution + Assembly --> Contracts + Adapters --> Services + Adapters --> Execution + Adapters --> Contracts + Services --> Execution + Services --> Contracts + Execution --> Contracts + Adapters --> External + Services --> External ``` +依赖方向只允许从上到下。接口与入口层暴露产品形态;组装层选择能力集合并注册 adapter/service;适配层翻译协议和外部 provider;服务实现层接触 OS、process、filesystem、git、terminal、MCP 和 remote;执行原语层提供可复用 runtime building blocks;契约层提供稳定事实、port 和产品领域规则。任何下层 crate 反向读取产品入口、组装配置或 host state 都视为边界违规。 + ## 7. 目标层级 -目标层级以职责边界为入口。每层可以由多个 crate 承载,关键判断标准是依赖方向、接口归属和实现归属是否清楚。 +目标层级以物理 owner 分区为入口。每个分区可以包含多个 crate,但 crate 内部职责必须能够通过依赖、测试和边界脚本独立验证。 -### 7.1 产品入口(Product Surfaces) +### 7.1 接口与入口层(Interfaces and Entrypoints) -产品入口(Product Surfaces)是用户、协议或外部系统进入 BitFun 的入口,负责展示、路由、协议适配和命令外观。 -它不拥有共享 runtime 行为,只把请求转换为 capability、runtime request 或 transport DTO。 -对应范围是 `src/apps/*`、`src/web-ui` 和 `src/mobile-web`,这些入口可以有形态差异,但差异不能下沉到 runtime。 +接口与入口层是用户、协议或外部系统进入 BitFun 的入口,负责 UI、命令、路由、协议接口、交付形态选择和 host integration。对应范围包括 `src/apps/*`、`src/web-ui`、`src/mobile-web`、`BitFun-Installer`、`tests/e2e` 和 `src/crates/interfaces`。入口层可以选择 `DeliveryProfile` 并调用 assembly 或 adapter API,但不拥有共享 runtime 行为。 ### 7.2 产品组装层(Product Assembly) -产品组装层(Product Assembly)是唯一的组装入口,负责选择产品能力、tool pack、harness pack、agent definition、 -command provider 和 service provider,并把具体实现注册到稳定接口。迁移期它可以留在 `bitfun-core` -facade 或产品入口中,目标形态可以收敛为独立 assembly crate 或清晰的 facade 模块。 - -### 7.3 产品能力层(Product Capabilities) +产品组装层负责兼容导出、完整产品能力选择、feature group 到 capability set 的映射、adapter/service 注册和 product-full 接线。物理位置是 `src/crates/assembly`,当前包含 `bitfun-core` 兼容门面和 `bitfun-product-capabilities` 能力模型。`product-capabilities` 只描述 capability id、tool group、service requirement 和 harness selection,不执行 IO,也不承载产品领域状态机。 -产品能力层(Product Capabilities)描述 Code Agent、Deep Review、DeepResearch、MiniApp、Remote Control、MCP App、 -Computer Use 等能力的组合边界。它负责定义一个产品能力需要哪些 agent、tool、harness、domain policy -和 service capability,不负责 UI,也不直接执行 IO。当前主要落在 `bitfun-product-domains` 和 -`bitfun-core` 的能力组装代码中,后续应收敛为 capability pack 和 domain policy。 +### 7.3 适配层(Adapters) -### 7.4 工作流编排层(Harness Layer) +适配层负责协议、transport、外部 provider 和宿主通信转换,物理位置是 `src/crates/adapters`。其中 `ai-adapters` 负责 AI provider 请求/响应映射和 provider stream 协议解析,解析结果应转换为 execution 层拥有的统一 stream 契约;`api-layer` 负责产品宿主共用的后端 API adapter,`transport` 负责事件投递和 host transport adapter,`webdriver` 负责 WebDriver 协议和浏览器自动化 adapter。适配层不拥有产品能力选择,也不承载可复用 OS service 实现。 -工作流编排层(Harness Layer)承载多步骤工作流和策略编排,例如 SDD、Deep Review、DeepResearch、MiniApp 生成或更新流程。 -它可以调用 Agent Runtime SDK、Tool Runtime 和 Runtime Services,但不拥有 session manager 内部状态、 -具体 filesystem/Git/terminal manager 或产品 UI。当前相关逻辑仍分散在 `bitfun-core` 和产品能力中, -目标归属是 `bitfun-harness` 或等价的 harness owner。 +### 7.4 服务实现层(Services) -### 7.5 Agent 运行时 SDK(Agent Runtime SDK) +服务实现层负责接触本地系统和 runtime infrastructure 的可复用具体实现,物理位置是 `src/crates/services`。其中 `services-core` 承载轻量 service primitive,`services-integrations` 承载 MCP、Git、remote、file watch 和产品领域 port 的具体实现,`terminal` 承载 PTY、shell integration 和 terminal session infrastructure。服务实现层可以实现 `contracts`、`execution` 或 `product-domains` 定义的 port,但不选择产品 profile,也不直接暴露 UI/协议入口。 -Agent 运行时 SDK(Agent Runtime SDK)是可嵌入的 agent kernel,负责 session、turn、scheduler、prompt loop、subagent、 -background task、permission coordination 和 runtime events。它只依赖稳定契约、tool runtime 和注入的 -service ports,不感知 Desktop、CLI、Remote、ACP、Tauri 或 Web UI。当前主体仍在 `bitfun-core`, -但 `bitfun-agent-runtime` 已开始承接可独立构建的 scheduler/background delivery 纯决策;concrete -scheduler lifecycle、session manager、prompt loop 和 subagent registry 仍未外移。 +### 7.5 执行原语层(Execution Primitives) -### 7.6 工具运行时(Tool Runtime) +执行原语层提供 provider-neutral 的 runtime building blocks,物理位置是 `src/crates/execution`。`agent-runtime`、`agent-stream`、`harness`、`runtime-services`、`tool-contracts`、`tool-provider-groups` 和 `tool-execution` 分别定义 agent loop facts、统一 stream DTO / tool-call 累积 / replay 契约、workflow descriptor、typed service bundle、tool manifest / permission / result policy、tool group facts 和低层 tool execution helper。当前 Cargo package / lib 名保持兼容,但物理目录按职责命名。它们只能依赖稳定契约或明确的 provider-neutral DTO,不直接创建 Tauri handle、filesystem manager、Git provider、MCP client、AI client 或 host process。 -工具运行时(Tool Runtime)负责工具 manifest、catalog、permission gate、execution pipeline、tool hook 和结果归一化。 -它只消费 `ToolExecutionServices` 这类窄 service 视图,不直接创建 filesystem、Git、terminal、MCP 等具体实现。 -当前相关 crate 包括 `tool-runtime`、`bitfun-agent-tools`、`bitfun-tool-packs` 以及 `bitfun-core` -中的 tool materialization 代码。 +### 7.6 稳定契约与产品领域层(Stable Contracts and Product Domains) -### 7.7 运行时服务层(Runtime Services) +稳定契约与产品领域层是最低层,物理位置是 `src/crates/contracts`。它包含 `core-types`、`events`、`runtime-ports` 和 `product-domains`。`product-domains` 是 Product Domain Model,负责 MiniApp、function-agent 等领域 DTO、纯策略、状态规则和窄 port;具体 Git、filesystem、AI 或 worker execution 实现在 services、adapters 或 assembly/core 的兼容路径中,不得回流到 contracts。 -运行时服务层(Runtime Services)是 runtime 可消费的 typed service bundle 和 capability availability 层。它提供 -filesystem、workspace、session store、Git、terminal、network、MCP catalog、remote connection / projection -等端口,不执行产品命令, -不作为无类型 service locator,也不创建平台实现。当前相关 crate 包括 `bitfun-runtime-ports`、 -`bitfun-runtime-services`、`bitfun-services-core`、`bitfun-services-integrations` 和 `bitfun-core` 中的 service 接线代码。 +### 7.7 扩展点归属 -### 7.8 具体实现层(Concrete Integrations) - -具体实现层拥有外部系统连接和重依赖,但它不是一个混合大筐,需要按实现类型保持边界: -Tool 实现器负责具体 tool provider 和 tool pack;OS 实现器负责 filesystem、terminal、process、network、 -environment 等平台能力;协议实现器负责 MCP、ACP、AI provider、Git 等外部协议;Remote 实现器负责 remote -connection、remote workspace projection 和 remote host capability。 - -Remote 不应作为 Agent Runtime SDK 的内部能力,也不应只按 Desktop/CLI 入口区分。它的稳定接口应拆为 -remote connection、remote workspace、remote filesystem/terminal projection、remote capability facts 等 port; -SSH、relay、本地隧道、远端 OS 差异和认证方式属于具体 Remote provider,由 Product Assembly 按产品形态注册。 -当前相关 crate 包括 `bitfun-services-*`、`bitfun-ai-adapters`、`terminal-core`、`bitfun-acp` 和 app adapters。 - -### 7.9 稳定契约层(Stable Contracts) - -稳定契约层提供跨层共享的数据结构和接口语言,包括 DTO、event、permission facts、artifact refs、identity -和 port traits。它只描述事实和能力,不包含 IO、网络、进程、UI、runtime manager 或产品策略。当前相关 -crate 包括 `bitfun-core-types`、`bitfun-events` 和 `bitfun-runtime-ports`。 -当前 remote workspace facts、remote session metadata、remote workspace file projection DTO 和 remote workspace/projection -host trait 已归入 `bitfun-runtime-ports`,`bitfun-services-integrations::remote_connect` 保留旧路径 re-export。 +- AI、API、transport 和 WebDriver 的协议转换属于 Adapters。 +- MCP、terminal、filesystem、git、remote 和 file watch 的可复用具体实现属于 Services。 +- Tool manifest、permission、execution admission、result / artifact policy 属于 Execution Primitives 的 `tool-contracts`。 +- Tool provider group facts 属于 Execution Primitives 的 `tool-provider-groups`;低层 filesystem/search helper 属于 `tool-execution`。 +- Agent、subagent、prompt module、scheduler、session / turn facts 和 hook routing 属于 Execution Primitives。 +- Harness workflow descriptor 和 route plan 属于 Execution Primitives;具体工作流 IO 留在 Services、Adapters 或兼容路径,直到有等价保护后再迁移。 +- Capability pack、delivery profile、adapter/service selection 和 product-full assembly 属于 Product Assembly。 +- 产品领域状态、规则、port 和 domain policy 属于 Stable Contracts and Product Domains。 ## 8. 接口与实现关系 -接口由稳定契约、Runtime Services、Tool Runtime 或 Harness contract 定义;具体实现由产品入口或具体实现层创建; -注册动作只能发生在 Product Assembly。Agent Runtime SDK、Tool Runtime 和 Harness 只接收已经组装好的接口或 -provider registry,不直接创建平台实现。 +接口由稳定契约、Runtime Services、Tool Contracts 或 Harness contract 定义;具体实现由 adapter、service 或产品入口创建;注册动作只能发生在 Product Assembly。Agent Runtime、tool contracts、tool execution 和 Harness 只接收已经组装好的接口或 provider registry,不直接创建平台实现。 ```mermaid flowchart TB - Surface["产品入口(Product Surfaces)
选择入口和 DeliveryProfile"] + Interface["接口与入口层(Interfaces and Entrypoints)
选择入口和 DeliveryProfile"] Assembly["产品组装层(Product Assembly)
唯一注册点"] ServiceBuilder["运行时服务层(Runtime Services)
RuntimeServicesBuilder"] - ToolBuilder["工具运行时(Tool Runtime)
ToolRuntimeBuilder"] + ToolBuilder["工具执行原语(Tool Primitives)
tool contracts / groups / execution"] HarnessBuilder["工作流编排层(Harness Layer)
HarnessRegistryBuilder"] - AgentRegistry["Agent 运行时 SDK(Agent Runtime SDK)
AgentDefinitionRegistry"] - CommandRegistry["产品入口 / 产品组装层
ProductCommandRegistry"] - Runtime["Agent Runtime SDK / Tool Runtime / Harness
只消费接口"] - Impl["具体实现层(Concrete Integrations)
Tool / OS / Remote / Protocol providers"] - Contracts["稳定契约层(Stable Contracts)
DTO / event / port trait"] - - Surface --> Assembly + AgentRegistry["Agent 执行原语(Agent Runtime)
AgentDefinitionRegistry"] + CommandRegistry["接口 / 产品组装层
ProductCommandRegistry"] + Runtime["Agent / Tool / Harness primitives
只消费接口"] + Adapters["适配层(Adapters)
AI / API / transport / WebDriver adapters"] + Services["服务实现层(Services)
OS / filesystem / Git / terminal / MCP / remote services"] + Contracts["稳定契约与产品领域层(Stable Contracts and Product Domains)
DTO / event / port trait"] + + Interface --> Assembly Assembly --> ServiceBuilder Assembly --> ToolBuilder Assembly --> HarnessBuilder Assembly --> AgentRegistry Assembly --> CommandRegistry - Assembly --> Impl + Assembly --> Adapters + Assembly --> Services ServiceBuilder --> Runtime ToolBuilder --> Runtime HarnessBuilder --> Runtime AgentRegistry --> Runtime - CommandRegistry --> Surface + CommandRegistry --> Interface Runtime --> Contracts - Impl --> Contracts + Adapters --> Contracts + Services --> Contracts + Adapters --> Services ``` 注册器与前文目标层级的对应关系如下: -| 注册器 / 组装点 | 所属目标层级 | 目标或迁移期模块 | 注册内容 | +| 注册器 / 组装点 | 所属目标层级 | 初始承载与目标承载 | 注册内容 | |---|---|---|---| -| `ProductAssembler` / `ProductAssemblyPlan` | 产品组装层(Product Assembly) | 迁移期在 `bitfun-core` facade 或产品入口;目标可收敛为 assembly owner | `DeliveryProfile`、`CapabilitySet`、feature group、provider 选择 | -| `RuntimeServicesBuilder` | 运行时服务层(Runtime Services) | `bitfun-runtime-services` PR1 基础壳层;迁移期连接 `bitfun-runtime-ports`、`bitfun-services-*` 和 `bitfun-core` service wiring | filesystem、workspace、session store、Git、terminal、network、MCP catalog、remote connection / workspace / projection port | -| `ToolRuntimeBuilder` | 工具运行时(Tool Runtime) | `tool-runtime`、`bitfun-agent-tools`、`bitfun-tool-packs` | tool provider、tool pack、manifest、permission gate、tool hook | -| `HarnessRegistryBuilder` | 工作流编排层(Harness Layer) | 目标 `bitfun-harness`;迁移期在 `bitfun-core` 和产品能力代码中 | SDD、Deep Review、DeepResearch、MiniApp 等 harness provider | -| `AgentDefinitionRegistry` | Agent 运行时 SDK(Agent Runtime SDK) | 目标 `bitfun-agent-runtime`;迁移期在 `bitfun-core` agent definition 代码中 | agent、subagent、prompt module、skill definition | -| `ProductCommandRegistry` | 产品入口(Product Surfaces)与产品组装层(Product Assembly)的边界 | 产品入口或 assembly 模块 | 输入框命令、审核入口、MiniApp 入口到 capability / harness / runtime request 的映射 | -| concrete provider set | 具体实现层(Concrete Integrations) | `bitfun-services-*`、`bitfun-ai-adapters`、`terminal-core`、`bitfun-acp`、app adapters | Tool、OS、Remote、Protocol 的具体 provider;Remote provider 内部继续区分 SSH、relay、本地隧道、远端 OS 支持 | +| `ProductAssembler` / `ProductAssemblyPlan` | 产品组装层(Product Assembly) | 初始可在 `bitfun-core` facade 或产品入口;目标可收敛为 assembly owner | `DeliveryProfile`、`CapabilitySet`、feature group、adapter/service 选择 | +| `RuntimeServicesBuilder` | 执行原语层(Execution Primitives)与服务实现层(Services)的边界 | 目标在 `bitfun-runtime-services`;连接 `bitfun-runtime-ports`、`bitfun-services-*` 和初始 service wiring | filesystem、workspace、session store、Git、terminal、network、MCP catalog、remote connection / workspace / projection port | +| `ToolRuntimeBuilder` | 执行原语层(Execution Primitives) | `tool-execution`、`tool-contracts`、`tool-provider-groups`;Cargo package 名保持兼容 | tool provider、tool group、manifest、permission gate、tool hook | +| `HarnessRegistryBuilder` | 工作流编排层(Harness Layer) | 目标在 `bitfun-harness`;初始可由 `bitfun-core::agentic::harness` 注册 legacy-facade provider | SDD、Deep Review、DeepResearch、MiniApp 等 harness provider | +| `AgentDefinitionRegistry` | 执行原语层(Execution Primitives) | 目标在 `bitfun-agent-runtime`;初始可由 `bitfun-core` agent definition 代码承载 | agent、subagent、prompt module、skill definition | +| `ProductCommandRegistry` | 接口与入口层(Interfaces and Entrypoints)与产品组装层(Product Assembly)的边界 | 产品入口或 assembly 模块 | 输入框命令、审核入口、MiniApp 入口到 capability / harness / runtime request 的映射 | +| adapter set | 适配层(Adapters) | `bitfun-ai-adapters`、`bitfun-api-layer`、`bitfun-transport`、`bitfun-webdriver`、app adapters | AI、API、transport、WebDriver 等协议或外部 provider adapter | +| service set | 服务实现层(Services) | `bitfun-services-*`、`terminal-core` 和具体 app service implementations | OS、filesystem、Git、terminal、MCP、remote 的具体 service;Remote service 内部继续区分 SSH、relay、本地隧道、远端 OS 支持 | 注册路径必须是显式、typed、可测试的: -- 产品入口(Product Surfaces)只选择 `DeliveryProfile` 和产品配置,不直接把 concrete manager 传入 runtime。 -- 产品组装层(Product Assembly)根据产品形态创建或接收具体 provider,并调用 typed builder 完成注册。 -- Tool、OS、Remote、Protocol provider 分别留在对应 app 或 integration owner 中,通过同一组 port 暴露。 +- 接口与入口层(Interfaces and Entrypoints)只选择 `DeliveryProfile` 和产品配置,不直接把 concrete manager 传入 runtime。 +- 产品组装层(Product Assembly)根据产品形态创建或接收 adapter/service,并调用 typed builder 完成注册。 +- Tool、OS、Remote、Protocol provider 分别留在对应 app、Adapters 或 Services 中,通过同一组 port 暴露。 +- Tauri 只能出现在 Desktop app、transport/API adapter 或产品入口命令外观中;Agent Runtime、 + Tool primitives、Harness、Runtime Services contract 和 Product Capabilities 不得依赖 Tauri handle、 + window、command macro 或 desktop app state。 - Remote provider 必须拆分稳定连接接口和具体远端 OS / transport 实现,避免把 SSH、relay 或远端平台差异泄漏到 runtime。 -- 不支持的能力在 assembly 的 capability availability 中显式返回 unsupported / unavailable,不在 runtime 内写产品分支。 +- 不支持的能力在 assembly 的 capability availability 中显式返回 unsupported / unavailable,不在 execution primitive 内写产品分支。 - 禁止使用无类型 `Any` service locator、全局 mutable registry 或下层 crate 反向读取产品配置。 ## 9. 风险 @@ -348,21 +334,24 @@ flowchart TB |---|---| | 产品组装层(Product Assembly)膨胀为新的全局状态中心 | assembly 只做构建期注册,输出不可变 runtime parts;产品状态仍归 surface 或 runtime owner | | 接口拆得过细,导致复杂度和动态分发成本上升 | 以 capability 和稳定用例定义 port 粒度,热路径避免运行时 map lookup,优先 builder-time 注入 | -| 平台实现泄漏到 Agent Runtime SDK、Tool Runtime 或 Harness | 依赖检查禁止 runtime owner 依赖 app crate、Tauri、CLI TUI、ACP protocol 和 concrete service crate | +| 平台实现泄漏到 Agent、Tool 或 Harness execution primitives | 依赖检查禁止 execution owner 依赖 app crate、Tauri、CLI TUI、ACP protocol 和 concrete service crate | +| core 拆分后仍隐式绑定 Tauri | Tauri 只允许在 Desktop app 或明确 feature-gated adapter;向下层传递 typed port、DTO、event fact 和 capability availability | | 不同产品形态能力矩阵漂移 | Product Assembly 维护 capability matrix;减少或替换能力时补产品入口验证和 unsupported 行为测试 | -| Tool、MCP、ACP 的 manifest、permission 或事件语义迁移后不等价 | 保留旧路径兼容 facade,增加 manifest snapshot、permission 决策和事件映射等价测试 | +| Tool、MCP、ACP 的 manifest、permission 或事件语义拆解后不等价 | 保留旧路径兼容 facade,增加 manifest snapshot、permission 决策和事件映射等价测试 | +| Harness provider 只做注册但被误认为已经拥有执行语义 | descriptor-only / legacy-facade provider 只能生成 route plan;执行语义移动必须单独证明行为等价 | | `bitfun-core` 只是改名为新的巨型 runtime crate | 新 owner crate 必须有单一职责和最小依赖;产品能力、harness、service 实现不得继续堆入 agent kernel | | 目标 crate 先行创建但没有真实 owner | 只有 owner 边界、旧路径兼容、focused tests、依赖收益和 boundary check 同时成立时才创建 crate;否则继续留在 facade | -## 10. 总结 - -- 当前架构影响:`bitfun-core` 从事实上的完整 runtime owner 收缩为兼容 facade 和迁移期组装点;Agent Runtime SDK、 - Tool Runtime、Runtime Services、Harness 和 Product Capabilities 分别成为可审查的 owner。 -- 接口与实现边界:稳定契约和各 runtime owner 定义接口,具体 Tool、OS、Remote、Protocol provider 留在具体实现层, - 由产品组装层(Product Assembly)通过 typed builder / registry 注册。 -- Remote 拆分方向:runtime 只依赖 remote connection、remote workspace、remote projection 和 capability facts 等 - port;SSH、relay、本地隧道、远端 OS 差异和认证方式属于具体 Remote provider。 -- 后续工作范围:抽出可独立构建的 runtime kernel;把 service、tool、harness 和 product capability 改为 typed - provider 注册;建立产品形态与 capability matrix 的对应关系。 -- 质量保护:用等价测试保护权限、工具曝光、事件、session、remote workspace 和构建形态不发生功能偏移。 -- 非目标:不改变默认产品能力、命令语义、权限策略或 release 构建形态。 +## 10. 目标状态判定 + +- `bitfun-core` 不再是事实上的完整 runtime owner,而是兼容 facade 和 `product-full` 组装边界。 +- Agent Runtime SDK 可在不依赖 `bitfun-core`、app crate 或 Tauri 的情况下被嵌入,并通过稳定 builder / + runner / event stream / registry API 提供 agent 能力。 +- Agent Runtime、Tool Contracts / Tool Provider Groups / Tool Execution、Runtime Services、Harness 和 Product Capabilities 分别拥有可审查的职责边界。 +- 稳定契约和各 execution owner 定义接口;具体 Tool、OS、Remote service 留在 Services,协议和外部 provider 转换留在 Adapters。 +- 产品组装层(Product Assembly)是唯一注册点,通过 typed builder / registry 连接接口和具体实现。 +- Tauri 只属于 Desktop app 或明确 feature-gated adapter,不进入 core、execution owner 或 contract crate。 +- runtime 只依赖 remote connection、remote workspace、remote projection 和 capability facts 等 port;SSH、relay、 + 本地隧道、远端 OS 差异和认证方式属于具体 Remote provider。 +- 产品形态差异通过 capability matrix 和 Product Assembly 表达,不通过下沉 UI、命令、协议或平台实现表达。 +- 权限、工具曝光、事件、session、remote workspace 和 release 构建形态必须保持功能等价。 diff --git a/docs/architecture/deep-review.md b/docs/architecture/deep-review.md index 4e6752081a..ba5b275c1d 100644 --- a/docs/architecture/deep-review.md +++ b/docs/architecture/deep-review.md @@ -6,15 +6,15 @@ DeepReview is a child-session workflow that runs a configurable Code Review Team - Frontend launch and UI orchestration in `src/web-ui`. - Platform adapter commands in `src/apps/desktop/src/api/agentic_api.rs`. -- Platform-agnostic runtime policy, task admission, queue state, retry metadata, and report enrichment in `src/crates/core/src/agentic`. +- Platform-agnostic runtime policy, task admission, queue state, retry metadata, and report enrichment in `src/crates/assembly/core/src/agentic`. The backend does not choose the review target or build the launch manifest. The frontend builds the effective `ReviewTeamRunManifest`, persists it on the DeepReview child session, and sends it with the first user message. ## Runtime Roles -`src/crates/core/src/agentic/agents/deep_review_agent.rs` defines the writable `DeepReview` orchestrator. It can call `Task`, read/search/git tools, `submit_code_review`, `AskUserQuestion`, and write/edit/bash tools for user-approved remediation. +`src/crates/assembly/core/src/agentic/agents/deep_review_agent.rs` defines the writable `DeepReview` orchestrator. It can call `Task`, read/search/git tools, `submit_code_review`, `AskUserQuestion`, and write/edit/bash tools for user-approved remediation. -`src/crates/core/src/agentic/agents/review_specialist_agents.rs` defines read-only reviewer agents: +`src/crates/assembly/core/src/agentic/agents/review_specialist_agents.rs` defines read-only reviewer agents: - `ReviewBusinessLogic` - `ReviewPerformance` @@ -57,8 +57,8 @@ The default review team contract is mirrored in Rust and TypeScript. Rust source: -- `src/crates/core/src/agentic/deep_review/team_definition.rs` -- `src/crates/core/src/agentic/deep_review_policy.rs` +- `src/crates/assembly/core/src/agentic/deep_review/team_definition.rs` +- `src/crates/assembly/core/src/agentic/deep_review_policy.rs` - `src/apps/desktop/src/api/agentic_api.rs` Frontend source: @@ -147,7 +147,7 @@ If the included file count exceeds the reviewer file-split threshold and same-ro ## Backend Policy and Admission -`DeepReviewExecutionPolicy` in `src/crates/core/src/agentic/deep_review/execution_policy.rs` parses runtime policy from config and classifies subagent launches. +`DeepReviewExecutionPolicy` in `src/crates/assembly/core/src/agentic/deep_review/execution_policy.rs` parses runtime policy from config and classifies subagent launches. Allowed DeepReview runtime launches are: @@ -169,10 +169,10 @@ Rejected launches include: The generic `Task` tool is adapted for DeepReview in: -- `src/crates/core/src/agentic/tools/implementations/task_tool.rs` -- `src/crates/core/src/agentic/deep_review/task_adapter.rs` -- `src/crates/core/src/agentic/deep_review/queue.rs` -- `src/crates/core/src/agentic/deep_review/budget.rs` +- `src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs` +- `src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs` +- `src/crates/assembly/core/src/agentic/deep_review/queue.rs` +- `src/crates/assembly/core/src/agentic/deep_review/budget.rs` DeepReview task execution uses the manifest and tool context to: @@ -214,7 +214,7 @@ Pause, continue, and cancel are scoped to a specific turn and tool id. `skip_opt ## Runtime Events -Queue state events are defined in `src/crates/events/src/agentic.rs` as `AgenticEvent::DeepReviewQueueStateChanged`. +Queue state events are defined in `src/crates/contracts/events/src/agentic.rs` as `AgenticEvent::DeepReviewQueueStateChanged`. The frontend listens through `AgentAPI.onDeepReviewQueueStateChanged` on `agentic://deep-review-queue-state-changed`. The TypeScript event shape mirrors the Rust event fields: @@ -234,7 +234,7 @@ The frontend listens through `AgentAPI.onDeepReviewQueueStateChanged` on `agenti ## Report Submission -Review results are submitted through `submit_code_review` in `src/crates/core/src/agentic/tools/implementations/code_review_tool.rs`. +Review results are submitted through `submit_code_review` in `src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs`. In DeepReview context, the tool requires the deep-review fields in addition to the standard summary/issues/positive-points shape: @@ -243,7 +243,7 @@ In DeepReview context, the tool requires the deep-review fields in addition to t - `reviewers` - `remediation_plan` -DeepReview report enrichment lives in `src/crates/core/src/agentic/deep_review/report.rs`. It fills missing reviewer packet metadata when a unique packet can be inferred, adds runtime diagnostics, updates incremental cache data, and adds reliability signals for cache hits, cache misses, partial coverage, capacity skips, retry guidance, queue waits, reduced scope, and evidence-pack metadata. +DeepReview report enrichment lives in `src/crates/assembly/core/src/agentic/deep_review/report.rs`. It fills missing reviewer packet metadata when a unique packet can be inferred, adds runtime diagnostics, updates incremental cache data, and adds reliability signals for cache hits, cache misses, partial coverage, capacity skips, retry guidance, queue waits, reduced scope, and evidence-pack metadata. Report enrichment is guarded by the tool context. Standard Code Review output should not receive DeepReview-only metadata unless the active tool context proves `agent_type == 'DeepReview'`. diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md index cad58e5103..4d43ae96b4 100644 --- a/docs/architecture/i18n.md +++ b/docs/architecture/i18n.md @@ -1,25 +1,27 @@ # I18n Architecture -BitFun uses one locale contract across all product surfaces, but each surface owns -and loads only the translation resources it needs. +BitFun has one language contract across product surfaces, but each surface owns +and loads only its own translation resources. -## Goals +## Core Invariants -- Keep language identity, aliases, fallback rules, and surface defaults consistent - between frontend and backend code. -- Avoid loading the full Web UI locale catalog in smaller surfaces such as - `mobile-web` and `BitFun-Installer`. -- Keep product-specific resources isolated so mobile, installer, desktop/web, - server, and backend text can evolve without creating cross-surface bundles. -- Make locale additions auditable through generated files and parity checks. +- Locale ids, aliases, fallback chains, and surface defaults are centralized. +- Web UI, mobile-web, installer, backend, and static pages do not import another + surface's full locale catalog. +- Shared terms are only for stable cross-surface concepts: product names, + feature names, modes, agent names, tool names, connection methods, and status + labels. +- Page, workflow, toast, validation, and feature copy stays in the nearest + surface or feature namespace. +- Audits distinguish structural safety, review inventory, and actionable l10n + gates. Do not delete keys from quality, inventory, or dynamic-access findings + unless usage is ruled out. -## Contract +## Locale Contract -The canonical contract lives at: +The canonical source is `src/shared/i18n/contract/locales.json`. -- `src/shared/i18n/contract/locales.json` - -Generated files are produced by: +Run this after editing the contract or shared terms: ```bash pnpm run i18n:generate @@ -30,207 +32,91 @@ Generated outputs: - `src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts` - `src/mobile-web/src/i18n/generatedLocaleContract.ts` - `BitFun-Installer/src/i18n/generatedLocaleContract.ts` -- `src/crates/core/src/service/i18n/generated_locale_contract.rs` +- `src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs` - `BitFun-Installer/src-tauri/src/installer/generated_locale_contract.rs` - `src/apps/relay-server/static/homepage/i18n.shared.json` -Do not edit generated files manually. Change the canonical contract and rerun the -generator. - -## Resource Boundaries - -Shared terms live under: - -- `src/shared/i18n/resources/shared//terms.json` - -Only stable cross-surface terms belong there: product names, feature names, -mode names, common tool labels, connection method labels, and status names. -The generated contracts expose these terms as a small `shared.*` runtime -namespace for each surface; this keeps concept labels aligned without importing -another surface's full resource catalog. - -`shared.*` is the canonical source for stable concept names, not a replacement -for product-surface copy. Treat resource ownership as three layers: - -1. `shared.*` for product names, feature names, mode names, common tool names, - connection method labels, and shared status nouns. -2. Surface-level common namespaces for short UI atoms owned by that surface, - such as generic actions, empty-state primitives, and shell labels. -3. Feature or route namespaces for workflow sentences, page titles, field - labels, validation text, toasts, and copy whose wording depends on a surface - or scene. - -Specific copy wins over common copy, and common copy wins over shared terms. Do -not configure `shared` as an implicit global fallback namespace. When a feature -needs a shared label, call the shared key directly for standalone labels or pass -the shared value as an interpolation argument for composed sentences. - -Surface resources stay in their existing owners: - -- Web UI: `src/web-ui/src/locales//**/*.json` -- Mobile Web: `src/mobile-web/src/i18n/messages.ts` -- Installer: `BitFun-Installer/src/i18n/locales/*.json` -- Backend: `src/crates/core/locales/*.ftl` -- Relay static homepage: `src/apps/relay-server/static/homepage` - -Shared contract does not mean shared bundle. Importing Web UI locales from -`mobile-web`, installer, backend, or another app is not allowed. - -## Loading Rules - -The locale contract is shared; translation resources are not. Each surface must -load only its own resource owner. Smaller surfaces should keep runtime loading -to their current locale data. - -- Web UI owns Web UI namespaces. It eagerly bootstraps only - `WEB_UI_BOOTSTRAP_NAMESPACES`, the small namespace set needed by synchronous - `i18nService.t(...)` call sites during module initialization. Other Web UI - namespaces load lazily through the i18next backend when a component requests - them with `useI18n(namespace)` or `i18nService.loadNamespace(namespace)`. -- Mobile Web owns mobile-only resources and must not import Web UI locale - catalogs. -- Installer owns installer-only resources and must not depend on app runtime - locale catalogs. -- Backend owns backend Fluent resources and uses generated contract helpers for - locale parsing, canonicalization, fallback order, and shared terms. -- Relay static homepage is a self-contained static surface. It is tracked in the - contract and audit baseline, but it does not import runtime locale catalogs. - Keep its static copy in its own resource file, such as - `src/apps/relay-server/static/homepage/i18n.json`, with English HTML fallback - text for first paint and fetch-failure resilience. If it needs a shared - concept label, reference it as `{ "$shared": "features.remoteControl" }` and - load the generated page-scoped `i18n.shared.json` file rather than any Web UI - catalog. - -Direct Web UI `i18nService.t('namespace:key')` calls are allowed only for -bootstrap namespaces. Route, scene, and feature UI should use `useI18n(namespace)` -so the namespace can be loaded on demand. - -Large or rarely used Web UI copy should live in a feature namespace outside -`WEB_UI_BOOTSTRAP_NAMESPACES`; components load it with `useI18n(namespace)` or -`useTranslation(namespace)` at the point of use. - -Static Web UI translation keys are audited with namespace context. Literal -`defaultValue` fallbacks are treated as existing debt, not a sanctioned source -of copy: current findings are protected by a per-file key allowlist and should -be reduced as resource keys are added. - -## Key Grouping And Naming - -Use dot-separated semantic keys with camelCase segments inside JSON-based -resources. Namespace ownership should follow the component or feature owner: - -- Keep `common` namespaces small. They may contain generic actions, statuses, - and app-shell atoms, but not large feature workflows. -- Put page and workflow copy in the nearest feature namespace, for example - `settings/review`, `settings/ai-model`, `scenes/agents`, or `flow-chat`. -- Split large feature copy into a dedicated lazy namespace when the copy is - rarely used or owned by a distinct feature. -- Avoid abbreviations such as `f1t` or English-copy-shaped keys. Prefer - `features.connect.title` over `f1t` and `deleteConfirm` over - `areYouSureYouWantToDelete`. +Do not edit generated files manually. + +## Resource Ownership + +| Surface | Resource owner | Loading rule | +|---|---|---| +| Shared terms | `src/shared/i18n/resources/shared//terms.json` | Generated into a small `shared.*` contract for each surface | +| Web UI | `src/web-ui/src/locales//**/*.json` | Eagerly loads bootstrap namespaces only; route and feature copy loads lazily | +| Mobile Web | `src/mobile-web/src/i18n/messages.ts` | Owns mobile-only messages; no Web UI catalog imports | +| Installer | `BitFun-Installer/src/i18n/locales/*.json` | Owns installer-only messages; no app runtime catalog imports | +| Backend | `src/crates/assembly/core/locales/*.ftl` | Uses generated locale helpers and Fluent resources | +| Relay static homepage | `src/apps/relay-server/static/homepage` | Self-contained static resources plus generated page-scoped shared terms | + +Shared contract does not mean shared bundle. Smaller product shapes must not pay +for Web UI resource size. + +## Lookup And Override Order + +Resource intent has three layers: + +1. `shared.*`: stable concept labels. +2. Surface common namespaces: short UI atoms owned by that surface. +3. Feature or route namespaces: workflow and scene-specific copy. + +Specific copy wins over common copy; common copy wins over shared terms. Do not +configure `shared` as an implicit global fallback namespace. Use a shared key +directly for standalone labels, or pass its value into composed sentences as an +interpolation argument. + +Web UI direct `i18nService.t('namespace:key')` calls are only for bootstrap +namespaces. Route and feature UI should use `useI18n(namespace)` or an explicit +namespace load at the render boundary. + +## Key Policy + +JSON resources use dot-separated semantic keys with camelCase segments. Existing +backend Fluent keys remain kebab-case until that owner is deliberately migrated. + +Use feature ownership in the key path. Avoid abbreviated, positional, or +English-copy-shaped keys such as `f1t`, `button1`, or +`areYouSureYouWantToDelete`. Allowed exceptions: - i18next plural suffixes such as `_one` and `_other`. -- Backend or protocol enum mirrors, when the key segment intentionally matches - a stable runtime value. -- Existing Fluent backend keys use kebab-case until the backend i18n resource - owner is deliberately migrated. - -Product and feature names must not be copied into every namespace by default. -Use `shared:product.name`, `shared:features.*`, `shared:modes.*`, -`shared:agents.*`, `shared:tools.*`, `shared:connectionMethods.*`, and -`shared:statuses.*` as the source of truth for standalone concept labels. -Composed copy should inject those values explicitly so product or feature -renames do not require editing many unrelated sentence keys. - -## Cleanup Rules - -Remove locale keys only after checking every release surface that can reach the -resource: - -- Static translation calls, including namespace-relative `useI18n` or - `useTranslation` calls. -- Dynamic key construction through template literals, concatenation, keyed - metadata such as `labelKey`, and fallback-key arrays. -- Backend or bridge contracts that map stable error codes or enum values into - locale keys. -- Self-contained static surfaces such as the relay homepage. - -If a key is kept only for a dynamic contract, document the mapping near the code -that constructs it. If the dynamic contract cannot be identified, do not delete -the key in the same PR as broad i18n refactoring. - -## Quality Governance - -I18n quality gates should protect correctness without forcing every local change -to run broad builds. Keep the fast path focused on contract generation, resource -audits, and the touched surface checks; let CI cover full builds and broad test -suites. - -The audit layer should distinguish these cases explicitly: - -- Confirmed unused keys: keys that no shipped surface can reach through static - calls, namespace-relative hooks, metadata fields, dynamic factories, backend - bridge mappings, or static page resources. -- Dynamic-key candidates: keys that might be reached by a documented key factory - such as enum-to-i18n mapping, installer error-code mapping, metadata - `labelKey` fields, fallback-key arrays, or template-literal construction. -- Localization quality candidates: values that pass key parity but are likely - not actually localized, such as unchanged Simplified/Traditional Chinese text, - stale English values, or copied placeholder-only strings. -- Stable concept duplicates: surface values that duplicate an existing - `shared.*` term and should be migrated only when the local wording has the same - product meaning. -- Direct locale-format candidates: user-visible date, time, number, or currency - formatting that bypasses the surface i18n formatting helper. - -Deletion is safe only for confirmed unused keys. Dynamic-key candidates need an -explicit allowlist or a code-level mapping comment before cleanup. Localization -quality candidates are translation work, not structural cleanup, and should not -be treated as unused keys. - -`pnpm run i18n:audit -- --report-json ` emits the machine-readable -governance snapshot for these categories. Dynamic-key entries include a -`proofState`; current valid contracts must be `source-proven`, while stale or -unproved source references fail the audit. The report is a review input: it does -not automatically delete keys or force shared-term migrations. - -Dynamic key contracts are tracked in -`scripts/i18n-dynamic-key-allowlist.json`. Each entry must name the owning code -path, source references, surface, and exact keys or prefixes. Stale entries or -source references fail `i18n:audit`, so metadata-driven keys, backend error-code -mappings, fallback-key arrays, and template factories stay visible before cleanup -work deletes resource keys. - -`scripts/i18n-governance-baseline.json` is the no-growth baseline for confirmed -unused keys, shared-term duplicate candidates, and l10n quality candidates. The -baseline is intentionally conservative: it prevents new debt, and it must be -lowered when a PR removes existing debt. Raising it requires an explicit review -reason because it means accepting additional terminology or localization risk. -For actionable reviews, the baseline tracks both totals and focused dimensions -such as surface, shared term key, and namespace. - -`scripts/i18n-l10n-identical-allowlist.json` records intentional identical -values across locale pairs, such as Simplified/Traditional Chinese terms that -are genuinely written the same way. Keep it narrow and owner-scoped; stale -entries fail `i18n:audit`. When an entry sets `namespace`, its `keys` are -namespace-local; otherwise use full resource keys. - -Do not add checked-in execution plans for these governance improvements. Keep -durable architecture and development rules in this document and -`docs/development/i18n.md`; keep one-off rollout plans outside version control. - -`scripts/i18n-literal-fallback-baseline.json` and -`scripts/i18n-locale-format-baseline.json` are temporary no-growth baselines for -existing call-site debt. They should move downward as literal fallback copy and -direct locale formatting are moved behind owned locale resources and i18n -formatting helpers. - -## Backend And Frontend Language Contract - -All surfaces must exchange canonical app locale ids: +- Runtime enum or protocol mirrors reached by documented dynamic mappings. +- Existing backend Fluent keys. + +## Audit And Governance + +`pnpm run i18n:audit -- --report-json ` emits the governance snapshot. +The categories have different meanings: + +| Category | Meaning | Allowed action | +|---|---|---| +| Confirmed unused keys | No shipped surface can reach the key through known static or dynamic paths | Safe to delete after reviewing the evidence | +| Dynamic key candidates | Keys reached through metadata, enum/error-code mappings, fallback arrays, or factories | Keep source-proof allowlist entries current | +| Shared-term duplicates | Surface values matching `shared.*` terms | Migrate only when the local value is the same stable concept | +| Same-text locale inventory | Broad zh-CN/zh-TW same-text Han pairs, including entries without a script or terminology signal | Review for copy quality; this inventory is non-blocking and is not deletion evidence | +| L10n quality candidates | Same-text locale pairs with script or terminology signals that need localization review | Translate or narrowly allowlist reviewed same-writing terms | +| Literal fallback candidates | Static UI copy in component fallback arguments or composed `defaultValue` expressions | Add or repair locale resources instead | +| Locale-format candidates | User-visible formatting bypassing surface i18n helpers | Move formatting behind the surface i18n API | + +Baselines: + +- `scripts/i18n-governance-baseline.json` is a no-growth guard for structural + governance candidates and must be lowered when debt is removed. +- `scripts/i18n-dynamic-key-allowlist.json` documents source-proven dynamic key + access. Stale keys or stale source references fail audit. +- `scripts/i18n-l10n-identical-allowlist.json` is only for reviewed values that + are intentionally identical across locale pairs. +- `scripts/i18n-literal-fallback-baseline.json` and + `scripts/i18n-locale-format-baseline.json` should stay empty. + +The same-text inventory preserves broad review visibility. The l10n candidate +check is intentionally a narrower signal, not a translation-quality oracle. Key +parity and a clean audit do not replace human copy review. + +## Language Contract + +All surfaces exchange canonical app locale ids: - `zh-CN` - `zh-TW` @@ -240,13 +126,12 @@ Aliases such as `zh`, `zh-Hans`, `zh-Hant`, and `en` are accepted only at input boundaries. Persisted config, API payloads, QR links, and backend state should store canonical ids. -## Adding A Locale +## Adding A Locale Or Surface -1. Add the locale to `src/shared/i18n/contract/locales.json`. -2. Add `terms.json` under `src/shared/i18n/resources/shared//`. -3. Add resources for every surface that supports the locale. -4. Run `pnpm run i18n:generate`. -5. Run `pnpm run i18n:audit`. -6. Run the focused type checks/builds for the touched surfaces. -7. If a surface intentionally stays self-contained or does not support the new - locale, document that surface decision in the contract and PR. +1. Update `src/shared/i18n/contract/locales.json`. +2. Add shared terms for every supported locale. +3. Add resources in each surface that supports the locale. +4. Run `pnpm run i18n:generate` and `pnpm run i18n:audit`. +5. Run focused checks for touched runtime code. +6. If a surface intentionally does not support the locale, document that + decision in the contract-facing PR description. diff --git a/docs/architecture/model-request-cache-reuse.md b/docs/architecture/model-request-cache-reuse.md new file mode 100644 index 0000000000..5d99515bea --- /dev/null +++ b/docs/architecture/model-request-cache-reuse.md @@ -0,0 +1,501 @@ +# Model Request Cache Reuse In BitFun + +This note summarizes the mechanisms BitFun uses to improve model-side prompt +cache reuse and to avoid unnecessary cache invalidation across long-running +agent sessions. + +The implementation is mostly in the shared Rust runtime under +`src/crates/assembly/core/src/agentic/`. + +## The Core Idea + +BitFun tries to keep the model-visible request prefix as stable as possible. +It does that in four complementary ways: + +1. Cache stable prompt fragments at the session level. +2. Reuse or clone existing conversation prefixes when creating derived + sessions. +3. Keep compatible coding modes on the same prompt-cache identity. +4. Move frequently changing surfaces, such as skill and subagent listings, out + of the cached base prompt and update them incrementally. + +There is also explicit observability for provider-reported cache reads versus +cache writes so BitFun can measure whether these strategies are actually +working. + +## 1. System Prompt And User Context Are Cached Separately + +BitFun does not treat the whole request prefix as one opaque string. +Instead, it separates two relatively stable layers: + +- the system prompt +- the user-context reminder + +The cache model lives in +`src/crates/execution/agent-runtime/src/prompt_cache.rs`. + +Core still exposes `src/crates/assembly/core/src/agentic/session/prompt_cache.rs`, but +that file is now a compatibility facade that re-exports the owner types from +`bitfun-agent-runtime`. + +Key details: + +- `SystemPromptCacheIdentity` keys the cached system prompt. +- `UserContextCacheIdentity` keys the cached user-context reminder. +- `SessionPromptCache` stores both entries independently. +- `PromptCachePolicy` supports both in-memory TTL and persistence TTL. +- the default persistence TTL is one day + (`DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL`) + +Session-level loading, saving, invalidation, and cloning live in +`src/crates/assembly/core/src/agentic/session/session_manager.rs`. + +The test coverage in that file also explicitly exercises: + +- persistence across session restore +- persisted invalidation cleanup +- prompt-cache cloning between sessions + +Persistence details: + +- prompt cache is persisted as `prompt_cache.json` +- the file is stored under the session directory in `.bitfun/sessions/...` +- restore-time TTL cleanup happens before the cache is accepted back into + memory + +Request assembly uses the cache in +`src/crates/assembly/core/src/agentic/execution/execution_engine.rs`: + +- `resolve_cached_system_prompt(...)` reuses a cached system prompt when the + scope key still matches +- `build_cached_prepended_prompt_reminders(...)` reuses the cached + user-context reminder when the user-context scope key still matches + +One important design choice is documented directly in +`prompt_builder_impl.rs`: workspace context is intentionally injected outside +the system prompt cache. That lets BitFun keep the global instruction template +stable while still handling workspace-dependent context separately. + +## 2. Derived Sessions Reuse Existing Prefixes Instead Of Starting Cold + +### Session branching + +Persisted session branching is implemented in +`src/crates/assembly/core/src/agentic/persistence/session_branch.rs`. + +When BitFun branches a session from an existing turn, it copies more than turn +text: + +- the branched turns up to the selected turn +- persisted turn context snapshots +- persisted skill/subagent listing snapshots +- persisted `skill-agent-baseline-override.json` when the source session has one +- the source session's prompt cache +- compression state and lineage metadata + +This means a branch can continue from a pre-built prefix instead of paying to +reconstruct everything from scratch. + +### `/btw` side questions + +`/btw` is implemented as a hidden child session, not as an ad hoc detached +request. + +Relevant code: + +- desktop adapter: + `src/apps/desktop/src/api/btw_api.rs` +- child-session creation: + `ConversationCoordinator::ensure_hidden_btw_session(...)` in + `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` +- side-question prompt wrapper: + `src/crates/assembly/core/src/agentic/side_question.rs` + +The parent context snapshot is not limited to a hot in-memory session. When +needed, `load_session_context_messages(...)` restores the parent session from +persistence first and then captures the context snapshot. + +The child session is initialized from a captured parent context snapshot: + +- same parent agent type +- same workspace and model config +- inherited message context +- cloned session-level prompt cache +- seeded skill/agent listing baselines derived from the parent session + +That gives the side thread an already-built conversational prefix. + +In other words, `/btw` now reuses both: + +- the parent message context snapshot +- the parent session's prompt cache via `SessionManager::clone_prompt_cache(...)` +- the parent session's full skill/agent listing baseline via + `SessionManager::seed_forked_skill_agent_listing_baselines(...)` + +### `fork_context` subagents + +Forked subagents reuse even more. + +Relevant code: + +- snapshot model: + `src/crates/assembly/core/src/agentic/fork_agent/mod.rs` +- request validation and tool contract: + `src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs` +- execution and prompt-cache cloning: + `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` + +When `Task` is called with `fork_context=true`, BitFun: + +- captures the parent session's model-visible message context +- creates an isolated child session with the parent session's agent type, + workspace, remote metadata, and model selection +- clones the parent session's prompt cache into the child session via + `SessionManager::clone_prompt_cache(...)` +- seeds a fork-aware skill/agent listing baseline split via + `SessionManager::seed_forked_skill_agent_listing_baselines(...)` + +That seed step intentionally keeps two different baselines: + +- the parent's turn-0 skill/agent snapshot is preserved as a prompt/listing + baseline override so the child can reuse the same full-listing prefix on its + first request +- the parent's latest snapshot at fork time becomes the child's own turn-0 + snapshot so later child turns diff against the fork-time surface, not forever + against the parent's original turn-0 baseline + +The `Task` tool also forbids fields that would make the fork drift away from +the inherited prefix, including `subagent_type`, `workspace_path`, `model_id`, +and DeepReview retry fields. + +That restriction is not just validation polish; it protects cache reuse by +keeping the forked child aligned with the parent session shape. + +## 3. The Shared Coding Modes Intentionally Reuse The Same Cache Identity + +BitFun's four shared coding modes are: + +- `agentic` +- `Plan` +- `debug` +- `Multitask` + +They are intentionally configured to share the same stable prompt base. + +Relevant code: + +- shared constants and tests: + `src/crates/execution/agent-runtime/src/agents.rs` +- mode definitions: + `src/crates/assembly/core/src/agentic/agents/definitions/modes/{agentic,plan,debug,multitask}.rs` + +Why they reuse cache: + +- all four modes use the same prompt template: + `SHARED_CODING_MODE_PROMPT_TEMPLATE = "agentic_mode"` +- all four modes use the same user-context policy: + `shared_coding_mode_user_context_policy()` +- `Agent::system_prompt_cache_identity(...)` is derived from + `prompt_template_name(...)` +- `Agent::user_context_cache_identity(...)` is derived from the user-context + policy scope key + +The test `shared_template_modes_share_system_prompt_cache_identity()` asserts +that these shared modes intentionally produce the same system-prompt and +user-context cache identities. + +Mode-specific behavior is added through `system_reminder` text, not by swapping +the cached base template: + +- `Plan`, `Debug`, and `Multitask` provide first-entry and ongoing reminder + templates +- reminders are injected immediately before the current user message in + `ExecutionEngine::build_ai_messages_for_send(...)` + +This is the key reason mode switches between these four coding modes do not +force a base prompt cache reset. + +The frontend also knows about this compatibility: + +- `AgentInfo.prompt_cache_scope_key` is produced in + `src/crates/assembly/core/src/agentic/agents/registry/types.rs` +- `ChatInput.tsx` only shows a prompt-cache warning when the next mode's scope + key differs from the last submitted mode's scope key + +In other words, BitFun aligns backend prompt identities and frontend mode-switch +UX around the same cache-compatibility contract. + +## 4. Skill And Subagent Listings Are Hot-Updated Without Rebuilding The Base Prompt + +A major source of cache churn in agent systems is dynamic capability listing. +BitFun explicitly avoids baking that surface into one permanently rebuilt prompt. + +Relevant code: + +- snapshot and diff model: + `src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs` +- sparse snapshot store: + `src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs` +- fork baseline override persistence: + `snapshots/skill-agent-baseline-override.json` via + `src/crates/assembly/core/src/agentic/persistence/manager.rs` +- turn-time diff injection: + `ConversationCoordinator::wrap_user_input(...)` +- baseline reminder reuse: + `ExecutionEngine::build_cached_prepended_prompt_reminders(...)` +- reminder ordering owner: + `src/crates/execution/agent-runtime/src/prompt.rs` + +The strategy is: + +1. On the first turn, BitFun saves a full skill/subagent snapshot. +2. On later turns, it resolves the current snapshot again. +3. It diffs the latest prior snapshot against the new one. +4. Only the diff is injected as a reminder when something changed. + +This keeps the base cached prefix stable while still letting the model see +fresh capability changes. + +Forked child sessions add one extra wrinkle: prompt/listing reuse and later diff +correctness do not always want the same baseline. In those cases BitFun can keep +the child's turn-0 snapshot as the diff baseline while reading the separate +baseline-override snapshot first when rebuilding the full skill/agent listing +reminder. + +What gets updated dynamically: + +- skills +- visible subagents / agents +- collapsed tool listing sections + +Why this is effectively "hot update": + +- local custom subagents are reloaded during request wrapping via + `load_custom_subagents(...)` +- skills are rescanned for the current workspace via + `get_resolved_skills_for_workspace(...)` +- the desktop app also exposes an explicit `reload_subagents` command in + `src/apps/desktop/src/api/subagent_api.rs` + +So capability changes can reach the next request without restarting the session +and without rebuilding the main prompt template. + +### Current gap: per-agent tool changes still churn request-prefix cache + +This is not fully solved yet. + +BitFun does allow tool customization at the agent/profile level: + +- built-in modes resolve their effective tools from default tools plus user + `added_tools` / `removed_tools` +- custom subagents can define their own tool lists directly + +Relevant code: + +- mode/profile tool overrides: + `src/crates/assembly/core/src/service/config/types.rs` + and `src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs` +- runtime resolution of effective agent tool policy: + `src/crates/assembly/core/src/agentic/agents/registry/query.rs` + +However, tool manifests are still recomputed per turn/request, not diff-patched +like skill and subagent listings. + +More precisely: + +- `ExecutionEngine` resolves `get_agent_tool_policy(...)` at turn start +- it then resolves `resolve_tool_manifest(...)` +- the resulting `tool_definitions` are attached to the model request for that + turn +- all model rounds inside the same turn reuse that resolved tool surface + +Relevant code: + +- request-time tool manifest resolution: + `src/crates/assembly/core/src/agentic/execution/execution_engine.rs` +- tool definitions included in token estimation: + `src/crates/assembly/core/src/util/token_counter.rs` +- provider request-body tool attachment: + `src/crates/adapters/ai-adapters/src/providers/openai/responses.rs` + and `src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs` + +This is why tool changes cannot currently be handled the same way as +skill/subagent listing updates. + +Skill and subagent listings are prompt-visible descriptive surfaces, so BitFun +can: + +- keep a baseline +- send only a diff reminder when they change + +But tool availability is different. For many tool-calling providers, the model +is only allowed to call tools that are present in the current request's tool +definitions/schema payload. + +That creates an asymmetry: + +- removing a tool or changing its description can be partially explained with a + reminder +- adding a new callable tool cannot be expressed by reminder alone +- the new tool must exist in the turn's actual `tool_definitions` + +So as long as the callable tool set is request-attached in this provider style, +new tool additions will necessarily mutate the request prefix surface and +typically break prompt-cache reuse for that turn. + +One plausible future direction would be to expose only a single stable +"tool-dispatch" entry in the provider request and let that entry accept: + +- the real tool name +- the real tool arguments + +In that design, the provider-visible tool schema could stay nearly constant +even when the product's actual tool catalog changes underneath it. + +However, BitFun does not currently plan to implement this. + +Reasons: + +- it would be a fairly large architectural change across tool schema exposure, + execution, validation, safety checks, and provider adapters +- the cache misses caused by tool-set changes are relatively low-frequency in + practice +- those misses are usually user-driven, because changing an agent's tool set is + an explicit configuration action rather than a high-churn runtime event + +So while the direction is technically viable, it is not currently attractive +enough relative to its implementation cost. + +## 5. Compression Rebuilds A Smaller Stable Prefix + +Compression is not a pure cache-preserving operation, but it is still part of +BitFun's cache-hit strategy. + +Relevant code: + +- compression flow: + `src/crates/assembly/core/src/agentic/execution/execution_engine.rs` +- compressor: + `src/crates/assembly/core/src/agentic/session/compression/` + +There are two different cache-reuse stories around compression: + +1. the compression request itself reuses the current session prefix +2. the completed compression result intentionally invalidates and rebuilds the + session prompt cache + +### Compression request path: reuses the existing prefix + +BitFun does not send compression as a detached, prompt-from-scratch request. + +Instead: + +- `build_compression_request_messages(...)` calls + `build_ai_messages_for_send(...)` +- that means the compression request is assembled through the same message-send + path used for ordinary model rounds +- prepended reminders are preserved +- the current conversation messages are preserved + +For automatic compression, the `runtime_messages` passed into +`build_compression_request_messages(...)` come from the active `messages` +buffer, which already includes the current system prompt at the front of the +request. + +For manual compaction, the code explicitly rebuilds `runtime_messages` as: + +- `system_prompt_message` +- followed by the current session messages + +Only after that shared prefix is rebuilt does BitFun append the final +compression-specific instruction from +`context_compressor.build_compact_prompt(...)`. + +So the compression model call itself is prefix-reusing by design: it keeps the +same stable prefix and adds one extra user message that asks the model to +compact the conversation. + +There is even an explicit runtime comment documenting why an older pre-pass was +removed: the removed "microcompact" rewrite mutated already-sent prefixes and +"kill[ed] provider KV-cache hits on every round". The current compression path +keeps that concern front and center. + +### Compression completion path: invalidate, then rebuild against a shorter prefix + +When compression is applied, BitFun does three important things: + +1. Replaces the live context messages with the compressed result. +2. Rebuilds the skill/subagent listing baseline to the latest snapshot. +3. Invalidates the session prompt cache because the mutable prefix changed. + +That invalidation happens on purpose. After compression, the old cached prompt +fragments no longer match the new conversation prefix. + +However, the next request can rebuild cache entries against a much shorter and +more stable post-compression prefix. In practice, compression is a "reset once, +reuse many times after" mechanism, not a permanent loss. + +## 6. Cache Observability Is First-Class + +BitFun also normalizes provider-specific cache telemetry so cache reuse can be +measured instead of guessed. + +Relevant code: + +- unified usage types: + `src/crates/adapters/ai-adapters/src/stream/types/unified.rs` +- Anthropic mapping: + `src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs` +- OpenAI / DeepSeek mapping: + `src/crates/adapters/ai-adapters/src/stream/types/openai.rs` +- runtime event emission: + `src/crates/assembly/core/src/agentic/execution/round_executor.rs` + +Two fields matter: + +- `cached_content_token_count`: cache reads / hits +- `cache_creation_token_count`: cache writes / creation + +BitFun keeps them separate on purpose. That avoids overstating hit rate and +makes it possible to answer both of these questions correctly: + +- "How many input tokens were served from cache?" +- "How many input tokens were spent creating new cache entries?" + +## Summary + +BitFun improves request cache hit rate by combining prompt splitting, session +reuse, compatible mode identities, incremental capability-list updates, and +post-compression prefix rebuilding. + +The most important implementation choices are: + +- stable prompt pieces are cached separately and persisted per session +- derived sessions reuse parent prefixes instead of starting from zero +- shared coding modes keep the same cache identity and express differences as + reminders +- dynamic capability surfaces are updated incrementally outside the base cached + prompt +- provider cache-read and cache-write telemetry is tracked separately + +## Implementation Map + +- Prompt cache model: + `src/crates/execution/agent-runtime/src/prompt_cache.rs` +- Prompt cache lifecycle: + `src/crates/assembly/core/src/agentic/session/session_manager.rs` +- Request assembly and cache hits: + `src/crates/assembly/core/src/agentic/execution/execution_engine.rs` +- Fork snapshot model: + `src/crates/assembly/core/src/agentic/fork_agent/mod.rs` +- Session branching: + `src/crates/assembly/core/src/agentic/persistence/session_branch.rs` +- Side-question child sessions: + `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` + and `src/apps/desktop/src/api/btw_api.rs` +- Shared coding-mode identities: + `src/crates/execution/agent-runtime/src/agents.rs` +- Dynamic skill/agent listing snapshots: + `src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs` +- Provider cache telemetry: + `src/crates/adapters/ai-adapters/src/stream/types/` diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md new file mode 100644 index 0000000000..f85d44891b --- /dev/null +++ b/docs/architecture/theme-token-optimization.md @@ -0,0 +1,743 @@ +# 主题与颜色 Token 优化方案 + +> 基线:`gcwing/main` 的 `b5f4f131`,扫描日期为 2026-06-15。 + +本文档用于梳理 BitFun 前端主题、硬编码颜色、重复 token、近似色冗余、 +命名漂移和后续治理方案。目标不是把所有看起来相近的颜色都合并,而是让 +每一个颜色都能追溯到明确的语义角色,并保留那些会帮助用户区分区域、状态、 +层级或数据含义的视觉差异。 + +## 范围 + +本方案覆盖: + +- `src/web-ui` 中的主题预设、运行时 CSS 变量注入和共享样式 token。 +- `src/web-ui/src/component-library/styles` 下的 token 定义。 +- 组件 SCSS/CSS/TSX 中的硬编码颜色、fallback 色值和局部 token。 +- 旧 token 名称到新规范名称的兼容别名。 +- 后续防止新增硬编码颜色的审计和约束规则。 + +## 治理原则 + +本次优化的方向需要同时满足两点: + +- 色值数量要尽可能收敛。一个应用的基础色值不应该无限增长,后续需要用 + 明确的预算上限来约束 palette、semantic token 和 component token。 +- 合并必须有依据。除非两个颜色已经极其相似、肉眼基本不可区分,否则不能只 + 因为“看起来接近”就合并;需要说明它们为什么是同一角色、为什么不会破坏 + 区域区分、状态区分或数据含义。 + +建议把颜色分成三类预算: + +| 类型 | 建议上限 | 说明 | +| --- | ---: | --- | +| Primitive palette | 80-120 | 包含核心 hue、neutral、alpha ramp;主题预设可以映射,但不应无限扩张。 | +| App semantic token | 40-70 | 覆盖背景、文本、边框、状态、交互和 app intent。 | +| Component token | 每个复杂 surface 8-20 | 只在 semantic token 不足以表达组件契约时添加。 | + +预算不是为了追求某个机械数字,而是为了阻止“每个组件随手新增一个色值”。 +新颜色必须进入以下流程之一: + +1. 能映射到现有 token:直接复用,不新增色值。 +2. 肉眼不可区分:合并到已有色值,并记录为直接合并。 +3. 有独立语义:新增 semantic 或 component token,并说明为什么不能复用。 +4. 属于 editor、terminal、syntax、diff 等专用域:进入 exception namespace。 + +第一阶段不覆盖: + +- 重新设计品牌视觉方向或重做主题风格。 +- 强行替换 Monaco editor、terminal ANSI、Mermaid、语法高亮或第三方内容 + 的专用色板。 +- 对每个页面做像素级重设计。 +- 在所有调用方迁移完成前移除兼容别名。 + +## 当前现状 + +基于最新 `gcwing/main` 的扫描结果,当前颜色系统已经具备一定抽象,但分散 +程度较高,重复和命名漂移明显。 + +| 指标 | 当前基线 | +| --- | ---: | +| 扫描的前端文件数 | 1711 | +| 包含颜色字面量的文件数 | 297 | +| 颜色字面量出现次数 | 5572 | +| 唯一颜色字面量数量 | 1532 | +| 组件或非 token 文件中的颜色出现次数 | 3735 | +| 包含组件或非 token 颜色的文件数 | 272 | +| `var(--token, fallback-color)` 出现次数 | 2847 | + +债务集中在少数高频 UI 区域: + +| 区域 | 文件 | 非 token 颜色出现次数 | +| --- | --- | ---: | +| Flow Chat 输入区 | `src/web-ui/src/flow_chat/components/ChatInput.scss` | 158 | +| Toolbar mode | `src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss` | 94 | +| Code editor | `src/web-ui/src/component-library/components/CodeEditor/CodeEditor.scss` | 86 | +| Profile nursery view | `src/web-ui/src/app/scenes/profile/views/NurseryView.scss` | 78 | +| Generative widget frame | `src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.tsx` | 78 | +| Select 组件 | `src/web-ui/src/component-library/components/Select/Select.scss` | 70 | +| Code review tool card | `src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss` | 70 | +| Stream text | `src/web-ui/src/component-library/components/StreamText/StreamText.scss` | 66 | +| Snapshot diff viewer | `src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.css` | 64 | +| Workspace manager | `src/web-ui/src/tools/workspace/components/WorkspaceManager.css` | 64 | + +重复最多的原始色值主要是应用强调色、状态色、白色半透明叠层和暗色表面叠层: + +| 色值 | 次数 | 推测角色 | +| --- | ---: | --- | +| `#60a5fa` | 162 | blue accent / focus / info | +| `rgba(255, 255, 255, 0.08)` | 115 | 暗色主题 subtle overlay | +| `rgba(255, 255, 255, 0.1)` | 112 | 暗色主题 hover/elevated overlay | +| `#f59e0b` | 112 | warning | +| `#ffffff` | 107 | white / inverse text | +| `#ef4444` | 110 | error / danger | +| `#22c55e` | 77 | success | +| `#3b82f6` | 70 | primary / info | +| `rgba(255, 255, 255, 0.05)` | 70 | 暗色主题低强度 overlay | +| `rgba(255, 255, 255, 0.06)` | 68 | 暗色主题低强度 overlay | + +fallback 也已经形成了第二套分散色板。高频 fallback token 如下: + +| fallback token | 次数 | +| --- | ---: | +| `--color-accent-500` | 147 | +| `--color-warning` | 127 | +| `--color-error` | 117 | +| `--color-success` | 109 | +| `--color-text-muted` | 77 | +| `--color-text-primary` | 75 | +| `--color-text-secondary` | 71 | +| `--border-subtle` | 55 | +| `--element-bg-subtle` | 39 | +| `--color-primary` | 41 | + +## 现有架构地图 + +当前主题相关定义分布在多个层次: + +- `src/web-ui/src/component-library/styles/tokens.scss` 定义 SCSS 变量和 + `:root` CSS 变量。 +- `src/web-ui/src/infrastructure/theme/core/ThemeService.ts` 根据当前主题在运行时注入 CSS 变量, + 同时补充了一批 app 级别别名和覆盖值。 +- `src/web-ui/src/theme/presets/*.ts` 定义主题预设色板。 +- `src/web-ui/src/tools/generative-widget/themePayload.ts` 向 generative widget payload + 暴露部分主题变量。 +- 组件 SCSS/CSS/TSX 中存在大量局部颜色字面量和局部 fallback。 + +主要架构问题: + +- 静态 token 和运行时 token 没有共享单一注册表。 +- 有些 token 只由 `ThemeService.ts` 动态注入,但组件 fallback 假设它们 + 在所有渲染边界都存在。 +- 同一个语义角色存在多种历史命名方式。 +- 组件 fallback 中的字面量过多,导致 fallback 变成实际上的第二套色板。 +- 当前主题验证链路不能作为充分的可访问性证据,contrast 计算需要真实实现 + 后才能支撑大规模颜色合并判断。 + +## 问题分类 + +### 1. 组件内硬编码颜色 + +多个组件直接写入产品语义色,例如 `#60a5fa`、`#ef4444`、`#22c55e` +和大量白色半透明叠层。这会让主题调整变成跨文件替换,也会让同一语义角色 +在不同组件中逐渐漂移。 + +改进方向: + +- app 级语义颜色改为 CSS 变量。 +- 组件独有角色使用组件 token。 +- Monaco、terminal、语法高亮等特殊色板不直接映射到普通 app token, + 先建立专用命名空间。 + +### 2. 重复 fallback 色值 + +`var(--token, literal)` 对兼容有价值,但当它在大量组件中重复时,就会让 +组件层携带 palette 副本。 + +改进方向: + +- fallback 只保留在明确的兼容边界。 +- 根主题层先补足兼容别名。 +- 组件确认 canonical token 一定存在后,移除局部 fallback 字面量。 + +### 3. 未定义或历史命名 token + +高频可疑名称包括: + +- `--color-text-tertiary` +- `--accent-primary` +- `--color-bg-hover` +- `--text-secondary` +- `--color-danger` +- `--color-border-subtle` +- `--element-bg-hover` +- `--border-primary` + +其中部分可能来自动态注入,但也有明显历史别名或命名分叉。它们需要显式 +进入兼容映射,而不是依赖组件 fallback 暗中兜底。 + +改进方向: + +- 在主题层增加兼容 alias map。 +- 文档中标记 deprecated 名称。 +- 调用点逐步迁移到 canonical 名称。 + +### 4. 精确重复 token 值 + +精确重复不一定是错误。很多重复其实是不同语义角色当前恰好使用同一色值。 +问题在于当前定义没有清晰表达 alias 方向。 + +例子: + +- `#0e0e10` 同时用于 `$color-bg-primary`、`$color-bg-tertiary`、 + `$color-bg-workbench`、`$color-bg-flowchat`。 +- `#1c1c1f` 同时用于 `$color-bg-secondary`、`$color-bg-elevated`、 + `$color-bg-scene`。 +- git 相关颜色与 app intent 色如 warning、error、info 有重复。 +- `$panel-border`、`$card-border`、`$input-border`、`$nav-border` + 都指向 `$border-base`。 + +改进方向: + +- 不因为值相同就删除语义 token。 +- 以“primitive value -> semantic token -> component token”的方向表达别名。 +- 标记哪些 alias 是稳定语义 alias,哪些只是迁移期 alias。 + +### 5. 近似色冗余 + +近似色是风险最高的一类。相似颜色可能是历史漂移,也可能是在保护区域边界、 +状态差异或主题个性。 + +典型族群: + +- 蓝色强调族:`#60a5fa`、`#58a6ff`、`#3b82f6`。 +- 暗色表面族:`#0e0e10`、`#111114`、`#121214`、`#141414`、 + `#16161a`、`#18181a`、`#1a1a1a`、`#1c1c1f`、`#1e1e22`。 +- 灰色文本和边框族:`#a0a0a0`、`#9ca3af`、`#6b7280`、 + `#64748b`、`#e8e8e8`、`#e5e5e5`。 +- 白色 overlay alpha:从 `0.03` 到 `0.18` 都有出现。 + +改进方向: + +- 不能只按色差或 RGB 距离合并。 +- 先判断语义角色、相邻关系、交互状态、主题预设和可访问性,再决定是否替换。 +- 对于白/黑透明叠层,先建立精确等价的 overlay ramp,例如 + `--color-overlay-white-08` 和 `--color-overlay-white-10`。这一步只消除散落 + 硬编码,不合并不同 alpha,因为 alpha 差异经常用于表达层级和状态。 + +## 目标 Token 模型 + +建议采用分层 token 模型,每一层只承担一个职责。 + +### Primitive palette + +primitive token 是原始色阶,不建议在普通组件样式中直接使用,只用于定义 +语义 token。 + +示例: + +- `--palette-blue-500` +- `--palette-red-500` +- `--palette-green-500` +- `--palette-amber-500` +- `--palette-neutral-900` +- `--palette-white` + +### App semantic token + +semantic token 描述产品级语义,应作为共享 UI 的默认使用层。 + +建议族群: + +- 背景:`--color-bg-primary`、`--color-bg-secondary`、 + `--color-bg-tertiary`、`--color-bg-elevated`、`--color-bg-workbench`、 + `--color-bg-scene`、`--color-bg-flowchat`。 +- 文本:`--color-text-primary`、`--color-text-secondary`、 + `--color-text-muted`、`--color-text-disabled`;如果设计系统确实需要第三层 + 文本强度,再将 `--color-text-tertiary` 转正。 +- 边框:`--border-base`、`--border-subtle`、`--border-emphasis`、 + `--border-focus`。 +- 元素状态:`--element-bg-default`、`--element-bg-subtle`、 + `--element-bg-hover`、`--element-bg-active`、`--element-bg-selected`。 +- 意图色:`--color-success`、`--color-warning`、`--color-error`、 + `--color-info`。 +- 意图色背景:`--color-success-bg`、`--color-warning-bg`、 + `--color-error-bg`、`--color-info-bg`。 + +### Component token + +当共享 semantic token 过于泛化,或者会隐藏组件自身契约时,使用组件 token。 + +示例: + +- `--flowchat-input-bg` +- `--flowchat-input-border` +- `--flowchat-drop-zone-bg` +- `--toolbar-mode-bg` +- `--toolbar-mode-active-bg` +- `--tool-card-bg` +- `--tool-card-hover-bg` +- `--diff-added-bg` +- `--diff-deleted-bg` +- `--editor-token-keyword` +- `--terminal-ansi-green` + +组件 token 默认可以映射到 semantic token,但当用户含义依赖差异时,需要保留 +专用色值或专用映射。 + +### 兼容别名 + +第一阶段应先保留兼容别名,避免为了清理 token 引入大面积视觉变化。 + +| 历史或漂移 token | 建议 canonical 目标 | 说明 | +| --- | --- | --- | +| `--accent-primary` | `--color-accent-500` 或 `--color-primary` | 先明确 accent 与 primary 是否是两个角色。 | +| `--text-primary` | `--color-text-primary` | 仅兼容别名。 | +| `--text-secondary` | `--color-text-secondary` | 仅兼容别名。 | +| `--text-muted` | `--color-text-muted` | 仅兼容别名。 | +| `--bg-primary` | `--color-bg-primary` | 仅兼容别名。 | +| `--bg-secondary` | `--color-bg-secondary` | 仅兼容别名。 | +| `--bg-tertiary` | `--color-bg-tertiary` | 仅兼容别名。 | +| `--border-primary` | `--border-base` | 需要确认调用点是否期望更强边界。 | +| `--color-border-subtle` | `--border-subtle` | 建议统一 border 命名族。 | +| `--color-danger` | `--color-error` | 仅当 destructive 与 validation error 不需要区分时合并。 | +| `--color-bg-hover` | `--element-bg-hover` | 需要确认调用点是 element、card 还是 panel hover。 | +| `--radius-*` | `--size-radius-*` | 对齐静态 CSS export 与运行时/widget 命名。 | +| `--spacing-*` | `--size-gap-*` | 对齐静态 CSS export 与运行时/widget 命名。 | + +## 近似色合并规则 + +近似色清理必须先做安全分类。不能批量把相近颜色替换成同一个值。 + +默认目标是收敛,而不是保守保留。判断顺序应为: + +1. 先证明能不能复用已有 token。 +2. 如果色差极小且肉眼基本不可区分,可以直接合并。 +3. 如果色差可见,必须给出合并依据:相同语义、非相邻显示、非状态区分、 + 非数据含义、contrast 安全。 +4. 如果依据不足,先标记为 `defer`,并补截图或调用点证据。 +5. 只有存在明确用户理解风险时,才标记为 `do not merge`。 + +### 可以安全合并 + +同时满足以下条件时,可以合并: + +- 色值代表同一个语义角色。 +- 正常工作流中不会相邻显示。 +- 不用于区分状态、严重程度、来源、所有权或数据含义。 +- 替换后 contrast 不低于验收阈值。 +- 截图对比没有造成层级或交互 affordance 丢失。 + +常见安全场景: + +- 精确重复的语义 alias。 +- 组件 fallback 复制了已经保证存在的根 token。 +- 历史 alias 在运行时已经稳定指向 canonical token。 +- 同一个暗色主题状态下重复出现的白色 overlay 值。 + +极高相似度直接合并建议门槛: + +- 同一色彩空间和同一 alpha 下,RGB 通道差异肉眼不可辨。 +- 不涉及 status、diff、syntax、terminal、theme personality。 +- 不在相邻区域中承担边界分隔。 +- audit report 中标记为 `indistinguishable`,review 时只需抽样确认。 + +### 必须视觉复核后才能合并 + +出现以下任一情况,合并前必须做视觉复核: + +- 两个颜色会出现在同一视口或相邻区域。 +- 颜色用于区分嵌套 panel、card、canvas、tool surface。 +- 一个颜色表示 hover、active、selected、disabled、drag-over 或 focus。 +- 颜色出现在 Flow Chat、tool card、review panel、git/diff UI、generated widget + frame 等高密度区域。 +- 颜色属于某个主题预设的个性表达。 + +合并前检查: + +- 桌面和窄屏布局都要有 before/after 截图。 +- 检查 normal、hover、active、selected、disabled、loading、error 等状态。 +- 检查同一视口中的相邻区域。 +- 检查文字和图标在替换后背景上的 contrast。 +- 明确回答用户是否会失去以下判断能力: + - 我现在在哪个区域。 + - 哪些元素可交互。 + - 当前状态是什么。 + - 哪些内容发生了变化。 + +这类合并不应被视为禁止合并。它们是主要的色值压缩空间,但必须带证据: + +- 调用点列表。 +- 旧值和目标 token 的语义说明。 +- 相邻区域判断。 +- before/after 截图或等价视觉证据。 +- 如果合并会产生可见变化,需要在 PR 描述中说明预期影响。 + +### 默认不合并 + +以下场景默认不按近似色合并: + +- success、warning、error、info、destructive action。 +- git added、modified、deleted、renamed、branch、conflict。 +- diff added/deleted 背景和行内高亮。 +- Monaco syntax、terminal ANSI、code review token colors。 +- cyber、tokyo、midnight、China 等主题个性颜色。 +- 导航、scene、panel、canvas、input、floating overlay 等相邻布局区域的边界色。 +- 任意可能改变 foreground/background contrast 的可访问性组合。 + +## 相邻关系审查模型 + +每个近似色合并候选都需要先回答这些问题: + +| 问题 | 原因 | +| --- | --- | +| 它是否会和替换目标出现在同一视口? | 相邻颜色可能承担区域分隔作用。 | +| 它是否区分父子表面? | 合并可能让 card、panel、input 混在一起。 | +| 它是否区分交互状态? | 合并可能削弱 hover、focus、active、selected。 | +| 它是否区分严重程度或数据含义? | 状态色必须保持可读。 | +| 它是否同时影响亮色和暗色主题? | 暗色下安全的合并,亮色下可能失败。 | +| 它是否出现在 generated widget 或 embedded frame? | 嵌入表面不一定继承全部 root token。 | +| 它是否是主题个性的一部分? | 主题预设可能需要保留接近但不同的 accent。 | + +第一轮实施应优先建立以下高风险表面的 review inventory: + +- 主框架:导航、scene viewport、content canvas、side panel。 +- Flow Chat:transcript、input、collapsed input、tool card、toolbar mode、 + review team surface。 +- Git 和 diff:状态 badge、文件状态、行高亮、branch indicator。 +- Component library:select、code editor、stream text、button、input。 +- Generated widget:widget frame、widget content、payload-exposed variables。 + +## 分阶段实施方案 + +### Phase 0:基线与工具 + +先建立可重复审计工具,再做批量修改。 + +交付物: + +- 按文件和按色值聚合的颜色字面量清单。 +- CSS 变量使用清单。 +- 未定义或历史 token 报告。 +- 精确重复 token 组。 +- 按 hue/value/alpha 聚类的近似色报告。 +- 高风险表面清单。 + +验收标准: + +- 脚本可以在 `src/web-ui` 上无副作用运行。 +- 报告可以对比 baseline 与当前分支。 +- 报告能区分普通 app color 与已知 exception namespace。 + +### Phase 1:canonical token 契约 + +明确 canonical token 家族和兼容别名。 + +交付物: + +- canonical token map。 +- 历史名称兼容 alias。 +- `tokens.scss`、`ThemeService.ts`、`themePayload.ts` 的静态与运行时变量 + 对齐。 +- deprecated token 名单。 + +验收标准: + +- 现有 UI 不应出现可见变化。 +- 组件可以直接使用 canonical 名称,不需要本地 fallback literal。 +- generated widget 在合理范围内获得与 app surface 一致的尺寸和颜色变量。 + +### Phase 2:精确重复合并 + +只合并 alias 安全的精确重复。 + +交付物: + +- token 定义通过 alias 表达方向,而不是重复字面量。 +- intent 与 git/diff alias 分开记录。 +- border alias 指向 canonical border token。 +- 高频白/黑 overlay 字面量迁移到精确 alpha token;相近 alpha 只记录为候选, + 不在没有视觉证据时合并。 + +验收标准: + +- 预期无截图可见变化。 +- `git diff --check`、web lint、type check 和相关测试通过。 + +### Phase 3:legacy fallback 迁移 + +迁移高频 fallback 调用点。 + +建议顺序: + +1. component-library 中的 select、input、button、stream text。 +2. Flow Chat 的 toolbar 和 input。 +3. tool card 与 review panel。 +4. workspace、git 和 diff surface。 +5. generated widget frame 和 payload consumer。 + +边界处理规则: + +- 普通 app 组件只有在 `tokens.scss`、`ThemeService.ts` 或组件根变量已经保证 + token 存在后,才移除局部 `var(--token, literal)`。 +- embedded frame、generated widget、第三方内容宿主可以保留边界默认值,但默认值 + 应集中在 frame/root contract 上,不应在每个 selector 中重复一套 fallback palette。 +- `--member-accent`、`--group-color`、`--tag-color` 等由 TS inline style、数据驱动或 + 动态 key 设置的变量,不能仅凭静态未定义报告删除;需要先确认运行时设置路径, + 再决定是建立组件根默认值,还是保留明确的边界 fallback。 + +验收标准: + +- 组件文件不再携带根 token 的 fallback palette。 +- 兼容 alias 仍保留给旧调用方或外部边界。 +- 剩余 fallback 必须能解释其边界,例如 embedded widget 或第三方内容。 + +### Phase 4:组件 token 抽取 + +为不适合泛化的角色建立组件 token。 + +交付物: + +- Flow Chat token set。 +- Tool card token set。 +- Diff/git token set。 +- Editor/terminal exception token set。 +- Widget frame token set。 + +验收标准: + +- 组件 token 默认映射到 semantic token。 +- 有意保留的例外被记录,并完成视觉复核。 +- 组件不再直接用 raw color 表达产品语义。 + +### Phase 5:近似色合并 + +只有在 Phase 0-4 完成后,才进入近似色合并。 + +交付物: + +- 候选合并表:包含角色、调用点、相邻风险和决策。 +- 每个 conditional merge 都有 before/after 截图。 +- rejected merge list,记录有意保留的近似色。 + +验收标准: + +- 被合并的颜色拥有相同语义角色。 +- 相邻 UI 层级仍清晰。 +- 状态和数据含义仍可区分。 +- 主题个性没有被抹平。 + +### Phase 6:防回退约束 + +增加轻量约束,避免新增同类债务。 + +交付物: + +- 对组件中新 app raw color 的 lint 或 audit 检查。 +- 已知 exception file 与 namespace allowlist。 +- CI 在迁移期只阻止新增问题,不因历史 baseline 直接失败。 + +验收标准: + +- 新增组件级 raw color 必须有明确原因。 +- 历史迁移可以按目录增量推进。 +- exception 可见、可审查。 + +## 风险清单 + +| 风险 | 影响 | 缓解措施 | +| --- | --- | --- | +| 相邻表面的近似色被合并 | 用户可能无法区分 panel、card、输入区或工作区边界。 | 近似色合并前必须做相邻关系审查和截图对比。 | +| hover/active/selected 被合并到静态背景 | 交互 affordance 变弱。 | 状态 token 与 base surface token 分开建模。 | +| intent 色被过度归一 | warning、error、success、info 或 destructive 语义混淆。 | intent token 即使色值接近,也保留独立语义。 | +| git/diff 色被当作普通 success/error | added/deleted/changed/conflict 扫描效率下降。 | 使用专用 git/diff token,只有复核后才 alias 到 app intent。 | +| 主题个性被抹平 | 用户选择主题的价值下降。 | theme preset 保留自己的 primitive/accent 映射。 | +| fallback 先删、alias 后补 | embedded 或 early render surface 样式丢失。 | 先加 alias,再删除 fallback。 | +| 静态 token 与运行时 token 不一致 | widget、SCSS、runtime theme 注入结果不一致。 | `tokens.scss`、`ThemeService.ts`、`themePayload.ts` 同阶段对齐。 | +| 动态 CSS 变量 key 被误判为未定义 | inline style 或数据驱动变量失去兜底,导致特定卡片、标签或分组颜色缺失。 | 对动态 key 建立运行时设置清单;删除 fallback 前补组件根默认值或保留边界 fallback。 | +| contrast 验证不可信 | 可访问性回归可能漏掉。 | 先实现真实 contrast 检查,再声称可访问性改善。 | +| 迁移 PR 过大 | review 疲劳导致视觉回归漏审。 | 按 surface 拆 PR,每个 PR 附指标和截图。 | +| editor/terminal 颜色被强行泛化 | 代码语法和 terminal 语义下降。 | 建立 exception namespace,而不是直接套普通 app token。 | + +## 候选决策 + +### 精确重复 + +建议先合并定义方式,不删除语义角色。 + +- `--color-bg-workbench`、`--color-bg-flowchat`、`--color-bg-primary` + 即使当前解析到同一个值,也应保留为不同语义契约。 +- panel/card/input/nav border 可以 alias 到 `--border-base` 或 + `--border-subtle`,但需要根据真实 contrast 和相邻关系确认。 +- git/diff token 即使映射到 app intent 色,也应在组件使用层保持独立名称。 + +### 暗色表面近似色 + +不建议一次性合并所有暗色背景。 + +原因: + +- BitFun 的主界面是高密度相邻 surface。极小的暗色差异可能用于区分 scene、 + panel、card、editor、input、floating overlay。 +- 应先建立层级表: + base -> workbench -> scene -> panel -> card -> elevated -> overlay -> + hover/selected。 + +### 白色 overlay alpha + +只按状态角色合并,不按“都是 white alpha”合并。 + +建议 token: + +- `--overlay-white-subtle` +- `--overlay-white-hover` +- `--overlay-white-active` +- `--overlay-white-selected` +- `--overlay-white-focus` + +alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值。 + +### 蓝色强调色 + +保留 theme-specific 和 state-specific 蓝色,直到调用点完成分类。 + +可能角色: + +- `--color-primary` +- `--color-accent-500` +- `--color-info` +- `--border-focus` +- `--link-color` +- `--selection-bg` + +在确认调用点究竟表示 accent、info、link、focus、selected 或主题个性之前, +不要合并 `#60a5fa`、`#3b82f6`、`#58a6ff`。 + +### Editor 和 terminal 色 + +使用专用命名空间,不直接使用普通 app token。 + +建议方向: + +- `--editor-syntax-keyword` +- `--editor-syntax-string` +- `--editor-selection-bg` +- `--terminal-ansi-red` +- `--terminal-ansi-green` +- `--terminal-selection-bg` + +只有在语法和 terminal 含义仍然清晰时,才考虑把它们映射到 app palette。 + +## 验证方案 + +文档变更: + +- `git diff --check` + +实现类 PR: + +- `pnpm run lint:web` +- `pnpm run type-check:web` +- `pnpm --dir src/web-ui run test:run` +- 被修改 surface 的 focused screenshot review。 +- changed foreground/background pair 的 contrast 检查。 + +大型 theme/runtime 变更还需要: + +- 验证静态 CSS 变量和运行时注入变量都存在。 +- 验证 generated widget payload 变量。 +- 验证 dark 和 light theme。 +- 至少覆盖以下 surface: + - main shell + - Flow Chat input 和 transcript + - toolbar mode + - tool card + - review team panel + - git/diff view + - code editor + - generated widget frame + +建议每个实现 PR 都附 before/after 指标: + +| 指标 | 目标 | +| --- | --- | +| 组件文件 raw color literal | 每个迁移 PR 递减。 | +| 组件级 fallback literal | 兼容 alias 落地后递减。 | +| 未定义或历史 token 使用 | 除文档化 alias 外逐步清零。 | +| token 文件中的精确重复 literal | 改为 alias 表达。 | +| 近似色合并候选 | 每个都有 `merge`、`defer` 或 `do not merge` 决策。 | +| 视觉回归 | 已复核 surface 无回归。 | + +长期预算目标: + +| 指标 | 目标 | +| --- | --- | +| app 级 raw color literal | 普通组件中趋近于 0。 | +| unique app color literal | 进入 token 层后受预算约束,不再随组件增长。 | +| undocumented component color | 0。 | +| exception namespace color | 有 allowlist 和 owner。 | + +## Review Checklist + +颜色合并 PR 合入前必须检查: + +- 每个被替换的字面量是否有明确语义角色。 +- 旧色和新色是否可能在同一视口相邻出现。 +- 旧差异是否用于区分父子 surface。 +- 旧差异是否用于区分 hover、active、selected、focus、disabled、 + loading、drag-over 或 error。 +- 旧差异是否用于区分状态、严重程度、数据来源或文件变更类型。 +- 变更是否同时影响 light 和 dark theme。 +- 变更是否影响 generated widget、code editor、terminal、Mermaid 或第三方内容。 +- 删除 fallback 前,兼容 alias 是否已经存在。 +- 高风险 surface 是否有截图或 focused visual check。 +- PR 描述是否说明了任何用户可见视觉变化。 + +## 建议 PR 拆分 + +建议按证据和 surface 拆分,避免一次性大迁移: + +1. 审计工具和 baseline report。 +2. canonical token map 与 compatibility alias。 +3. 静态和运行时 token 对齐。 +4. token 文件中的精确重复合并。 +5. component-library fallback 迁移。 +6. Flow Chat surface 迁移。 +7. tool card 和 review panel 迁移。 +8. git/diff surface 迁移。 +9. widget/editor/terminal namespace 清理。 +10. 带截图的近似色合并批次。 +11. 新增 raw app color 的防回退约束。 + +每个 PR 应包含: + +- 范围和影响 surface。 +- before/after 指标。 +- 用户可见 surface 的截图。 +- 明确保留的近似色列表。 +- 验证命令和结果。 + +## 待决问题 + +- `--color-text-tertiary` 应转正为一等 semantic token,还是迁移到 + `--color-text-muted`。 +- `--color-primary` 和 `--color-accent-500` 是否是两个角色,还是应统一为 + 一个 accent contract。 +- `--color-danger` 是否需要和 `--color-error` 区分,以表达 destructive action。 +- 尺寸 token 长期应统一为 `--size-radius-*` / `--size-gap-*`,还是继续暴露 + `--radius-*` / `--spacing-*` 兼容名。 +- 迁移期 CI 应如何严格:只阻止新增 raw app color,还是按目录迁移完成后 + 对该目录启用失败约束。 + +## 完成标准 + +整体优化完成时应满足: + +- 共享 app color 由 canonical semantic token 表达。 +- 组件专属角色由文档化 component token 表达。 +- 历史 token 名称已迁移或明确 alias。 +- 普通组件文件不再出现 app 级 raw color。 +- 近似色都有 merge、defer 或 reject 决策记录。 +- 相邻 surface、交互状态、状态语义和主题个性仍能被用户清楚识别。 +- 静态 token、运行时 token、widget payload token 对齐。 +- 新增 raw color 必须经过可见 review 决策。 diff --git a/docs/development/i18n.md b/docs/development/i18n.md index 5adc8c0db2..5e254900a2 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -1,94 +1,83 @@ # I18n Development Guide +Use this guide for day-to-day i18n changes. Architecture and ownership rules are +in `docs/architecture/i18n.md`. + ## Quick Rules -- Add or change language metadata only in - `src/shared/i18n/contract/locales.json`. -- Run `pnpm run i18n:generate` after editing the contract or shared terms. -- Put cross-surface stable terms in +- Edit language metadata only in `src/shared/i18n/contract/locales.json`. +- Run `pnpm run i18n:generate` after changing the contract or shared terms. +- Keep stable cross-surface labels in `src/shared/i18n/resources/shared//terms.json`. -- Keep surface copy in the surface resource owner. -- Do not duplicate stable product, feature, mode, tool, connection-method, or - status labels in feature namespaces when a `shared.*` term exists. -- Store translation resources as readable UTF-8 text. Use escapes only for - characters that must be escaped, such as control characters, quotes, - backslashes, or explicit regex/codepoint ranges. -- Do not import `src/web-ui/src/locales` from `src/mobile-web`, - `BitFun-Installer`, Rust crates, or server apps. -- Persist and send canonical app locale ids, not aliases. +- Keep workflow, page, toast, validation, and feature copy in the owning + surface namespace. +- Do not import Web UI locale catalogs into mobile-web, installer, backend, or + static pages. +- Store resources as readable UTF-8. Use escapes only when JSON, regex, or code + syntax requires them. +- Do not add static `defaultValue` fallback copy in components, including + composed static `defaultValue` expressions. Add or repair locale keys instead. +- Use `defaultValue` only for real runtime data fallbacks, such as + server-provided names or descriptions. - Use surface i18n formatting helpers for user-visible dates, times, numbers, - and currency instead of direct `Intl.*` or `toLocale*` calls. -- In Web UI, request route or feature namespaces through `useI18n(namespace)`. - Add a namespace to `WEB_UI_BOOTSTRAP_NAMESPACES` only when a synchronous - `i18nService.t('namespace:key')` call must run during module initialization. + and currency; avoid direct `Intl.*` or `toLocale*` calls in product code. +- Persist and send canonical locale ids: `zh-CN`, `zh-TW`, or `en-US`. ## Commands +Run the smallest set that matches the files you touched. CI protects broad +builds and suites. + ```bash pnpm run i18n:generate pnpm run i18n:contract:test pnpm run i18n:audit ``` -Run only the touched surface checks. CI covers broad builds and full suites; -resource-only JSON changes usually stop after `i18n:audit`, and contract/shared -term changes stop after the three commands above unless runtime code changed: +| Change | Local check | +|---|---| +| Locale resource-only JSON or Fluent changes | `pnpm run i18n:audit` | +| Locale contract or shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | +| Web UI i18n runtime or namespace loading | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | +| Mobile Web i18n runtime | `pnpm --dir src/mobile-web run type-check` | +| Installer frontend i18n runtime | `pnpm --dir BitFun-Installer run type-check` | +| Backend i18n runtime | `cargo test -p bitfun-core i18n -- --nocapture` | + +Do not add process-only execution plans to version control. Keep durable rules +in this file and `docs/architecture/i18n.md`; keep temporary rollout notes out +of git. -```bash -pnpm run type-check:web # Web UI i18n runtime/hooks -pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts -pnpm --dir src/mobile-web run type-check # mobile-web messages.ts/runtime -pnpm --dir BitFun-Installer run type-check # installer frontend i18n runtime -cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml # installer Tauri/Rust changes -cargo test -p bitfun-core i18n -- --nocapture # backend i18n runtime changes -``` +## Where Strings Belong -Do not add process-only execution plans to version control. Keep durable i18n -architecture and development rules in `docs/architecture/i18n.md` and this file; -keep rollout checklists, temporary analysis notes, and one-off cleanup plans out -of git. +Use shared terms for stable labels whose meaning must stay aligned everywhere: -## Where To Put Strings - -Use shared terms only for labels that must stay semantically aligned everywhere, -such as product names, feature names, mode names, core agent names, common tool -names, and connection method labels. - -Use surface common namespaces for short UI atoms that are genuinely generic -inside that surface, such as `common:actions.cancel` or -`common:status.loading`. Use feature resources for workflow text, validation -messages, empty states, toasts, page titles, field labels, and copy that is -specific to a product surface or scene. - -Generated runtime contracts expose shared terms as `shared.*`: - -- Web UI: i18next `shared` namespace, for example `t('shared:features.deepReview')`. -- Mobile Web: message keys under `shared.*`, for example `t('shared.features.deepReview')`. -- Installer: message keys under `shared.*`, for example `t('shared.features.deepReview')`. -- Backend: `I18nService::translate*` accepts `shared.*` keys and follows the - contract fallback chain. -- Relay static homepage: `$shared` references in `i18n.json` are resolved from - its generated page-scoped `i18n.shared.json`. - -Shared terms are lower-priority concept labels, not implicit global fallbacks. -When product-surface or scene-specific wording exists, code must use that local -namespace key first. If a local key should fall back to a shared concept, keep -the fallback explicit and local-first, for example Web UI or installer i18next -fallback keys ordered as `['scenes/agents:labels.deepReview', -'shared:features.deepReview']`. Do not configure `shared` as a global -`fallbackNS`. - -For standalone product or feature labels, call the shared key directly. For -sentences that include a product or feature name, inject the shared value as a -parameter rather than copying the name into the sentence value. Keep the -surface-specific interpolation syntax local to that surface. +- `product.*` +- `features.*` +- `modes.*` +- `agents.*` +- `tools.*` +- `connectionMethods.*` +- `statuses.*` -## Key Policy +Use surface common namespaces for short atoms that are generic inside that +surface. Use feature namespaces for workflow text, field labels, validation, +empty states, toasts, and page titles. + +Shared terms are exposed per surface: -Use stable, semantic keys. JSON resources use dot-separated paths with -camelCase segments. Do not encode current English copy in the key. +- Web UI: `t('shared:features.deepReview')` +- Mobile Web: `t('shared.features.deepReview')` +- Installer: `t('shared.features.deepReview')` +- Backend: `I18nService::translate*("shared.features.deepReview", ...)` +- Relay homepage: `{ "$shared": "features.remoteControl" }` -Good: +`shared` is not an implicit fallback namespace. If a local key may fall back to +a shared concept, keep the fallback explicit and local-first. + +## Key Policy + +JSON resources use dot-separated semantic keys with camelCase segments. Prefer +stable meaning over current English copy: ```text sessions.startRemoteFlow @@ -96,178 +85,71 @@ connectionMethods.bitfunServer tools.explore ``` -Avoid: +Avoid positional or copy-shaped keys: ```text -clickHereToStartANewRemoteFlow button1 -newFeatureText +f1t +areYouSureYouWantToDelete ``` -Allowed naming exceptions: +Allowed exceptions are i18next plural suffixes, documented runtime enum or +protocol mirrors, and existing backend Fluent kebab-case keys. -- i18next plural suffixes such as `_one` and `_other`. -- Runtime enum mirrors, such as protocol values or backend error codes, when the - key is reached by a documented dynamic mapping. -- Existing backend Fluent keys, which remain kebab-case until the backend - resource owner is intentionally migrated. +Before deleting a key, check static calls, namespace-relative hooks, metadata +fields such as `labelKey`, fallback arrays, string concatenation, template +literals, backend mappings, and static self-contained pages. Delete only +confirmed unused keys. -Shared term keys are reserved for concepts that are expected to mean the same -thing across surfaces: +## Loading Rules -- `product.*` -- `features.*` -- `modes.*` -- `agents.*` -- `tools.*` -- `connectionMethods.*` -- `statuses.*` +Unifying the language contract must not unify all translation bundles. + +- Web UI eagerly loads only `WEB_UI_BOOTSTRAP_NAMESPACES`. Route and feature + copy should load through `useI18n(namespace)` or `useTranslation(namespace)`. +- Mobile Web, Installer, Backend, and relay static pages own their own resources + and must not import Web UI catalogs. +- Large or rarely used Web UI copy should live in lazy feature namespaces, not + bootstrap namespaces. + +## Audit Signals + +`pnpm run i18n:audit -- --report-json ` separates: + +- confirmed unused keys +- source-proven dynamic key candidates +- shared-term duplicate candidates +- broad same-text locale inventory +- l10n quality candidates +- literal fallback candidates +- direct locale-format candidates + +The reports are review inputs. Only confirmed unused keys are deletion evidence. +Dynamic keys need source-proof allowlist entries. Shared-term duplicates should +move only when they represent the same stable concept. Same-text locale +inventory is non-blocking review data; do not treat a smaller actionable l10n +candidate count as proof that all localization work is complete. + +L10n quality candidates are actionable same-text locale pairs with script or +terminology signals. Values that are intentionally written the same across +locales should either remain out of the signal set or be narrowly recorded in +`scripts/i18n-l10n-identical-allowlist.json`. + +Keep these no-growth guards tight: + +- `scripts/i18n-governance-baseline.json` +- `scripts/i18n-literal-fallback-baseline.json` +- `scripts/i18n-locale-format-baseline.json` -Do not put long sentences, layout-specific copy, or flow-specific copy in shared -terms. Those belong in the surface resource owner. - -Do not add abbreviated or position-based keys such as `f1t` or `button1`. -Group by meaning and owner, for example `features.connect.title`. - -Before deleting a key as unused, check static calls, namespace-relative calls, -template literals, string concatenation, metadata fields such as `labelKey`, and -backend or bridge contracts that map enum/error codes into i18n keys. Only -delete the key when no release surface can reach it. - -When a key is kept for a dynamic contract, document it in -`scripts/i18n-dynamic-key-allowlist.json` with the owning path, exact keys or -prefixes, and `sourceReferences` that appear in the owning code. `i18n:audit` -fails stale keys and stale source references, and the governance report marks -valid dynamic contracts as `source-proven`. - -Do not rely on literal fallback strings in component code. Add the missing key -to the relevant locale file instead. Literal fallback strings make audits and -translation completeness harder to enforce. Existing Web UI -`defaultValue: "..."` fallbacks are tracked by -`scripts/i18n-literal-fallback-baseline.json` as a temporary per-file key -allowlist. Lower that baseline whenever fallback copy is moved into locale -resources. -Dynamic fallbacks are acceptable only when the fallback is real runtime data, -such as a server-provided role name or description. - -Direct user-visible locale formatting calls are tracked by -`scripts/i18n-locale-format-baseline.json`. New code should format through the -surface i18n API, such as Web UI `useI18n().formatNumber` or -`i18nService.formatDate`, so language changes and fallback rules stay aligned. -Lower the baseline whenever a call site moves behind the shared helper. - -Repeated values are not automatically wrong. Generic atoms such as -`common:actions.cancel` should be reused when the meaning is identical, but -feature-specific labels, button text, and status words may stay local when their -wording depends on workflow context. If a repeated value matches an existing -`shared.*` term and represents the same stable concept, prefer reading the -shared key directly or passing the shared value into a sentence as an -interpolation parameter. - -Before moving repeated values into shared terms, check all supported locales. -Identical English strings can still need different translations, and identical -Chinese strings can hide incomplete `zh-TW` localization. Treat duplicate-value -reports as review inputs, not automatic refactors. - -`pnpm run i18n:audit` fails on missing or extra keys in Web UI, mobile-web, and -installer locale resources. It also fails on placeholder mismatches, unknown -static Web UI translation keys from `i18nService.t('namespace:key')`, -`i18nService.getT()('namespace:key')`, and namespace-aware -`useI18n(namespace)` / `useTranslation(namespace)` calls, direct `t(key, -"literal fallback")` arguments, object-form literal `defaultValue` budget -growth, direct locale-format budget growth, and CJK source candidates outside -approved resource owners. Keep user-facing copy and formatting decisions in -locale/resource or i18n helper files rather than raising hardcoded-copy, -literal-fallback, or locale-format baselines. - -Use `pnpm run i18n:audit -- --report-json ` when reviewing cleanup or -shared-term work. The JSON report separates confirmed unused keys, dynamic-key -candidates, shared-term duplicate candidates, and l10n quality candidates. -Delete only confirmed unused keys. - -When code reaches keys through metadata or factories, add or update -`scripts/i18n-dynamic-key-allowlist.json`. Keep each entry scoped to an owner -path and exact keys or prefixes. Stale allowlist entries fail `i18n:audit`, so -do not keep broad prefixes after the owning code or resource group is removed. - -`scripts/i18n-governance-baseline.json` is a no-growth gate for confirmed -unused keys, shared-term duplicate candidates, and l10n quality candidates. -Lower it whenever cleanup or translation work reduces the report counts. Do not -raise it unless the PR explicitly accepts the added terminology or localization -risk. Keep its focused dimensions in sync as well as the totals, so debt cannot -move between shared terms, namespaces, or surfaces without review. - -If two locale values are intentionally identical, add a narrow entry to -`scripts/i18n-l10n-identical-allowlist.json` instead of leaving it as l10n debt. -Use this only for reviewed same-writing terms, and remove entries when the -underlying resource changes. When an entry sets `namespace`, write -namespace-local `keys`; otherwise write full resource keys. - -## Loading Size - -Unifying the language contract must not unify all translation bundles. Each -surface should ship only the resource set needed for that surface and the current -locale path. - -Web UI eagerly loads only bootstrap namespaces and lazy-loads all other -namespaces. Mobile Web, Installer, Backend, and static relay pages must not -import Web UI locale catalogs to reuse copy. - -Static relay pages may use shared concept labels only through a tiny generated -resource, such as `src/apps/relay-server/static/homepage/i18n.shared.json`. -Do not replace that with Web UI namespace loading. - -For large feature copy, create a dedicated lazy Web UI namespace instead of -putting it in a bootstrap namespace. Static self-contained pages should keep -their own small resource files and retain English fallback HTML. - -Large namespaces should be split by workflow ownership when doing so lowers -startup or route-load cost without making key ownership ambiguous. In Web UI, -`WEB_UI_BOOTSTRAP_NAMESPACES` should stay limited to namespaces required by -synchronous module-initialization calls. Do not add a large namespace to the -bootstrap set to avoid loading it at the component boundary; call -`useI18n(namespace)` or `useTranslation(namespace)` where the UI is rendered. - -For bundle-sensitive changes, compare generated asset sizes before and after the -change and include the result in the PR. - -## Localization Quality - -Key parity only proves that each locale has the same keys. It does not prove that -the translation is complete or regionally correct. - -For localization-sensitive changes: - -- Keep `zh-CN`, `zh-TW`, and `en-US` values readable UTF-8 and reviewable in - diffs. -- Do not copy `zh-CN` values into `zh-TW` as a substitute for Traditional - Chinese localization unless the wording is intentionally identical. -- Keep placeholders semantically equivalent across locales, not only - syntactically present. -- Prefer shared terminology for stable product concepts so later naming changes - do not require edits across many feature namespaces. -- Treat English fallback text in static HTML as first-paint or fetch-failure - resilience, not as a replacement for locale resources. - -## Language Detection - -Input boundaries may accept aliases from browsers, old configs, and links. -Convert them to canonical ids immediately: - -- Frontend: generated `resolve*Language` or `resolveLocaleId` helpers. -- Backend: `LocaleId` and `I18nService`, which derive locale identity from the - generated contract. - -Persisted values must use canonical ids. +Lower baselines when debt is removed. Do not raise them unless the PR explicitly +accepts the risk. ## PR Expectations Every i18n PR should state: -- Which surfaces changed. -- Whether the contract changed. -- Whether generated files were updated. -- Which i18n verification commands passed. -- Whether any surface intentionally does not support a new locale. -- Whether any hardcoded-copy, literal-fallback, or locale-format budget changed, - and why any increase is acceptable. +- changed surfaces +- whether the locale contract or generated files changed +- which focused i18n checks passed +- whether any surface intentionally does not support a locale +- any baseline change, with a short reason diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md index ffc0130968..792381cb38 100644 --- a/docs/features/session-runtime-usage-report-design.md +++ b/docs/features/session-runtime-usage-report-design.md @@ -172,11 +172,11 @@ BitFun can reuse several existing surfaces. | Capability | Current anchor | | --- | --- | -| Agentic event definitions | `src/crates/events/src/agentic.rs` | -| Token usage persistence and aggregation | `src/crates/core/src/service/token_usage/{types.rs,service.rs,subscriber.rs}` | -| Model stream timing currently logged/held during execution | `src/crates/core/src/agentic/execution/{round_executor.rs,stream_processor.rs}` | -| Context compression events and tool-like UI item | `src/crates/core/src/agentic/execution/execution_engine.rs`, `src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx` | -| Tool lifecycle and total duration | `src/crates/core/src/agentic/tools/pipeline/{tool_pipeline.rs,state_manager.rs}` | +| Agentic event definitions | `src/crates/contracts/events/src/agentic.rs` | +| Token usage persistence and aggregation | `src/crates/assembly/core/src/service/token_usage/{types.rs,service.rs,subscriber.rs}` | +| Model stream timing currently logged/held during execution | `src/crates/assembly/core/src/agentic/execution/{round_executor.rs,stream_processor.rs}` | +| Context compression events and tool-like UI item | `src/crates/assembly/core/src/agentic/execution/execution_engine.rs`, `src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx` | +| Tool lifecycle and total duration | `src/crates/assembly/core/src/agentic/tools/pipeline/{tool_pipeline.rs,state_manager.rs}` | | CLI slash command handling | `src/apps/cli/src/modes/chat.rs` | | CLI session/tool persistence | `src/apps/cli/src/session.rs`, `src/apps/cli/src/agent/core_adapter.rs` | | Desktop token/compression event routing | `src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts` | @@ -234,7 +234,7 @@ considered complete. | Area | Current status | Code evidence | Remaining work | | --- | --- | --- | --- | -| Shared report service | Done | `src/crates/core/src/service/session_usage/{service.rs,types.rs,render.rs}` and `SessionAPI.getSessionUsageReport` | Keep the API contract stable while adding future report fields. | +| Shared report service | Done | `src/crates/assembly/core/src/service/session_usage/{service.rs,types.rs,render.rs}` and `SessionAPI.getSessionUsageReport` | Keep the API contract stable while adding future report fields. | | Durable local report message | Done | `DialogTurnKind::LocalCommand`, `localCommandKind: 'usage_report'`, `modelVisible: false` | Keep usage report snapshots model-invisible through future history, export, and transcript changes. | | CLI `/usage` coverage | Done for interactive CLI | CLI `usage_*` coverage and the shared renderer | Top-level `bitfun usage --session` is outside the closed product scope. | | Model timing | Mostly done | Optional event and persisted fields for duration, provider/model identity, first chunk, visible output, stream duration, attempts, failure category, and token details | Throughput/TPS and provider-latency claims are outside the closed product scope. | @@ -765,13 +765,13 @@ Executable implementation plan: P1 must move `/usage` from P0 approximation toward factual runtime accounting without changing the report command contract. The implementation order below is test-first and split by ownership boundary so each step can be reviewed independently. 1. Persist runtime facts already owned by the runtime. - - Files: `src/crates/core/src/service/session/types.rs`, `src/crates/core/src/agentic/session/session_manager.rs`, `src/crates/core/src/agentic/coordination/coordinator.rs`, and the tool/model execution call sites that construct persisted session items. + - Files: `src/crates/assembly/core/src/service/session/types.rs`, `src/crates/assembly/core/src/agentic/session/session_manager.rs`, `src/crates/assembly/core/src/agentic/coordination/coordinator.rs`, and the tool/model execution call sites that construct persisted session items. - Add optional fields to persisted model rounds for provider/model identity, first chunk latency, first visible output latency, stream duration, attempt count, failure category, token details, and total duration. - Add optional tool phase durations to persisted tool items: queue wait, preflight, confirmation wait, and execution. - Acceptance: old session JSON still deserializes, new session JSON round-trips with these fields, and missing fields never fail report generation. 2. Consume persisted facts in the usage service. - - File: `src/crates/core/src/service/session_usage/service.rs`. + - File: `src/crates/assembly/core/src/service/session_usage/service.rs`. - Prefer persisted model/tool duration fields when present; fall back to existing start/end or result durations only when facts are missing. - Compute active time as a union of known active intervals so overlapping spans do not double-count the denominator. - Exclude `local_command` usage-report turns from scope, wall/active time, model/tool/file/error rows, and slowest spans so generating a report cannot affect the next report. @@ -780,7 +780,7 @@ P1 must move `/usage` from P0 approximation toward factual runtime accounting wi - Acceptance: model rows can exist from runtime span facts even when token records are absent, tool rows expose phase subtotals, slowest spans include model rounds and tools, and the coverage panel explains missing facts conservatively. 3. Keep file-change correlation conservative. - - File: `src/crates/core/src/service/session_usage/service.rs`. + - File: `src/crates/assembly/core/src/service/session_usage/service.rs`. - Keep snapshot operations as the highest-trust source for file rows, including remote sessions when cached snapshot summaries are present, then use tool-call metadata as a fallback only for recognized edit/write/delete operations. - Preserve operation ids and turn indexes for later UI navigation, but do not invent line counts when no snapshot or diff fact exists. - Acceptance: remote sessions with cached snapshot summaries show file/line rows; remote sessions that only have tool metadata show edited files with unknown line counts instead of "unavailable"; files without trustworthy evidence remain omitted. @@ -963,7 +963,7 @@ Files: - Read: `session-runtime-usage-report-design.md` - Read: `AGENTS.md` - Read: `src/web-ui/AGENTS.md` -- Read: `src/crates/core/AGENTS.md` +- Read: `src/crates/assembly/core/AGENTS.md` Steps: @@ -1000,10 +1000,10 @@ Goal: define the stable structured contract used by CLI, Desktop, and future ser Files: -- Create: `src/crates/core/src/service/session_usage/types.rs` -- Create: `src/crates/core/src/service/session_usage/mod.rs` -- Modify: `src/crates/core/src/service/mod.rs` -- Test: `src/crates/core/src/service/session_usage/types.rs` or nearby module tests +- Create: `src/crates/assembly/core/src/service/session_usage/types.rs` +- Create: `src/crates/assembly/core/src/service/session_usage/mod.rs` +- Modify: `src/crates/assembly/core/src/service/mod.rs` +- Test: `src/crates/assembly/core/src/service/session_usage/types.rs` or nearby module tests Steps: @@ -1046,8 +1046,8 @@ Goal: give Desktop `/usage` a durable chat representation without polluting futu Files: -- Modify: `src/crates/core/src/service/session/types.rs` -- Modify: `src/crates/core/src/agentic/session/session_manager.rs` +- Modify: `src/crates/assembly/core/src/service/session/types.rs` +- Modify: `src/crates/assembly/core/src/agentic/session/session_manager.rs` - Modify: `src/web-ui/src/flow_chat/types/flow-chat.ts` - Modify: `src/web-ui/src/flow_chat/store/FlowChatStore.ts` - Test: session serialization/deserialization tests near existing session tests @@ -1090,13 +1090,13 @@ Goal: produce a useful `/usage` report without changing runtime event production Files: -- Create: `src/crates/core/src/service/session_usage/service.rs` -- Modify: `src/crates/core/src/service/session_usage/mod.rs` -- Modify: `src/crates/core/src/service/mod.rs` -- Read/reuse: `src/crates/core/src/service/token_usage/service.rs` -- Read/reuse: `src/crates/core/src/service/session/types.rs` -- Read/reuse: `src/crates/core/src/service/snapshot/service.rs` -- Test: `src/crates/core/src/service/session_usage/service.rs` +- Create: `src/crates/assembly/core/src/service/session_usage/service.rs` +- Modify: `src/crates/assembly/core/src/service/session_usage/mod.rs` +- Modify: `src/crates/assembly/core/src/service/mod.rs` +- Read/reuse: `src/crates/assembly/core/src/service/token_usage/service.rs` +- Read/reuse: `src/crates/assembly/core/src/service/session/types.rs` +- Read/reuse: `src/crates/assembly/core/src/service/snapshot/service.rs` +- Test: `src/crates/assembly/core/src/service/session_usage/service.rs` Steps: @@ -1143,8 +1143,8 @@ Goal: render the same report as CLI terminal text and Desktop Markdown without d Files: -- Create: `src/crates/core/src/service/session_usage/render.rs` -- Modify: `src/crates/core/src/service/session_usage/mod.rs` +- Create: `src/crates/assembly/core/src/service/session_usage/render.rs` +- Modify: `src/crates/assembly/core/src/service/session_usage/mod.rs` - Test: renderer snapshot or exact-output tests in Rust Steps: @@ -1323,11 +1323,11 @@ Goal: make model speed and wait-time metrics accurate after the minimal report i Files: -- Modify: `src/crates/events/src/agentic.rs` -- Modify: `src/crates/core/src/agentic/execution/round_executor.rs` -- Modify: `src/crates/core/src/agentic/execution/stream_processor.rs` -- Modify: `src/crates/transport/src/adapters/tauri.rs` -- Modify: `src/crates/transport/src/adapters/websocket.rs` +- Modify: `src/crates/contracts/events/src/agentic.rs` +- Modify: `src/crates/assembly/core/src/agentic/execution/round_executor.rs` +- Modify: `src/crates/assembly/core/src/agentic/execution/stream_processor.rs` +- Modify: `src/crates/adapters/transport/src/adapters/tauri.rs` +- Modify: `src/crates/adapters/transport/src/adapters/websocket.rs` - Test: Rust event serialization and stream/round executor tests Steps: @@ -1370,9 +1370,9 @@ Goal: explain tool-heavy sessions without relying on logs. Files: -- Modify: `src/crates/events/src/agentic.rs` -- Modify: `src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs` -- Modify: `src/crates/core/src/agentic/tools/pipeline/state_manager.rs` +- Modify: `src/crates/contracts/events/src/agentic.rs` +- Modify: `src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs` +- Modify: `src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs` - Modify: transport adapters for new optional timing fields - Test: tool pipeline/state manager tests @@ -1414,8 +1414,8 @@ Goal: connect usage reports with existing file diff affordances without changing Files: -- Modify: `src/crates/core/src/service/session_usage/service.rs` -- Read/reuse: `src/crates/core/src/service/snapshot/service.rs` +- Modify: `src/crates/assembly/core/src/service/session_usage/service.rs` +- Read/reuse: `src/crates/assembly/core/src/service/snapshot/service.rs` - Read/reuse: `src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts` - Modify later UI: `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` only for report link integration - Test: report aggregation tests with snapshot operation summaries @@ -1549,7 +1549,7 @@ Goal: keep `/usage` focused on current-session observability and prevent scope d Files: -- Harden: `src/crates/core/src/service/session_usage/render.rs` +- Harden: `src/crates/assembly/core/src/service/session_usage/render.rs` - Harden: `src/apps/cli/src/main.rs` - Update: `session-runtime-usage-report-design.md` - Test: current-session report and top-level CLI scope tests diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 904d50d3c0..a30f26b573 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -1,64 +1,45 @@ # BitFun Core 拆解已完成内容归档 -本文只记录已完成事实和明确未完成边界。活跃执行计划见 -[`core-decomposition-plan.md`](core-decomposition-plan.md)。 - -## 1. 已完成主线 - -### 1.1 P0 / P1:安全边界与最小编译面验证 - -- 已建立 `product-full` 默认能力保护,产品 crate 显式启用完整能力。 -- 已把既有 nested `terminal-core` 和 `tool-runtime` 移到 workspace 顶层,保持旧 package / lib 语义。 -- 已抽出 `bitfun-core-types` 第一批纯类型、错误分类和轻量 helper。 -- 已抽出 `bitfun-agent-stream`,让 stream processor 和相关测试可绕开完整 `bitfun-core`。 -- 已引入 `bitfun-runtime-ports` 初始边界和旧路径 compatibility wrapper。 -- 已补 `AgentSubmissionRequest.source` / `turnId` 显式化,以及 dynamic tool provider metadata 基线。 - -明确未完成: - -- `BitFunError` / `BitFunResult` 仍继续 core-owned。 -- remote-connect / cron / MCP concrete call-site、generic attachment / image context 接入、产品逻辑或边界行为变更不属于 P1 完成范围。 - -### 1.2 P2:中等粒度 owner crate 成型 - -- `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`bitfun-tool-packs`、`bitfun-product-domains` 已加入 workspace。 -- `bitfun-core` 通过 facade / re-export 保持旧路径兼容。 -- 已迁移 Git feature group、remote-SSH identity / path helper、MCP runtime / dynamic provider、remote-connect wire / tracker / file / image / dialog helper。 -- 已迁移 generic tool registry / provider / catalog / `GetToolSpec` helper 和 product provider plan。 -- 已迁移 MiniApp / function-agent 的纯 domain helper、port / facade 和部分决策逻辑。 -- 已补 `core-types`、`runtime-ports`、`agent-tools`、`product-domains`、`services-integrations` 的 boundary check 和 feature graph 保护。 - -明确未完成: - -- remote-SSH runtime、remote FS / terminal、workspace-root source、persistence / workspace service reads、`ImageContextData` concrete impl 仍未迁移。 -- `ToolUseContext`、runtime manifest / `GetToolSpecTool` execution、collapsed unlock state、concrete tools 仍未迁移。 -- MiniApp filesystem IO / worker / host dispatch / builtin asset runtime、function-agent Git / AI concrete service 仍未迁移。 -- agent registry / scheduler 仍未迁移。 - -### 1.3 H1-H5 基线收口 - -- Tool runtime 已完成 provider-neutral contract、file guidance marker、file-read freshness facts、tool-result storage policy / preview / rendered replacement contract 和 execution presentation policy。 -- Product-domain 已完成 MiniApp 纯状态 owner、runtime detection policy、worker capacity / idle / LRU policy、host method / fs access / shell token / env 等纯决策,以及 function-agent prompt / response policy。 -- Service / agent 已完成 remote-connect presentation assembly、remote model policy、remote command orchestration、dialog scheduler outcome assembly、scheduler queue routing / cancel suppression 等 portable contract closure。 -- Core 内部已形成 `product_runtime.rs`、`product_domain_runtime.rs`、`service_agent_runtime.rs` 等 owner closure 入口,便于后续审查。 -- H5 当前只完成 feature / dependency baseline:`bitfun-core --no-default-features` 可编译面、`product-full` 显式 owner feature 聚合、optional dependency owner 映射和产品入口显式装配检查。 - -明确未完成: - -- H5 不代表 per-product feature matrix、构建收益或 runtime owner 深迁移完成。 -- `bitfun-core default = []` 仍是独立评估项,不能混入 runtime owner 迁移。 -- 具体 IO、scheduler 生命周期、workspace-root、persistence、MiniApp worker / host / builtin、function-agent Git / AI 仍需后续完整 owner PR。 - -## 2. 已建立保护 - -- 新 owner crate 不得依赖回 `bitfun-core`。 -- `product-full` 是完整产品能力保护开关。 -- 构建脚本和 installer 相关脚本不作为 core 拆解的一部分修改。 -- boundary check 覆盖已外移 owner 的旧路径 facade-only / 禁止回流状态。 -- tool manifest、`GetToolSpec`、MiniApp storage layout adapter、product-domain pure helper、remote workspace search fallback、MCP config / catalog / dynamic manifest 等已有 focused baseline。 - -## 3. 当前剩余结论 - -- 低风险准备项已经完成,不再新增零散小 PR。 -- 后续只按高风险 owner 主题推进:Remote / Service-Agent、Agent Registry / Scheduler、Product-Domain Runtime、Tool Runtime、Feature / Build-Benefit Evaluation。 -- 缺陷修复、行为变更、冗余清理、三方库升级和构建脚本调整必须独立评估,不能伪装成 core decomposition 剩余里程碑。 +本文只记录已完成事实摘要。后续执行路径以 +[`core-decomposition-plan.md`](core-decomposition-plan.md) 为准;稳定架构目标以 +[`core-decomposition.md`](../architecture/core-decomposition.md) 和 +[`agent-runtime-services-design.md`](../architecture/agent-runtime-services-design.md) +为准。 + +## 1. 基础边界 + +- 已建立 `product-full` 作为完整产品能力保护开关,产品入口显式启用完整能力。 +- 已抽取 `bitfun-core-types`、`bitfun-events`、`bitfun-runtime-ports`、`bitfun-agent-stream` 等基础契约。 +- 已建立 `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`tool-runtime`、`bitfun-tool-packs`、`bitfun-agent-runtime`、`bitfun-runtime-services`、`bitfun-harness`、`bitfun-product-domains`、`bitfun-product-capabilities` 等 owner crate。 +- `src/crates` 已按 `interfaces / assembly / adapters / services / execution / contracts` 六层布局整理,DeepReview path classifier、boundary rules、Cargo workspace path 和根/层级 AGENTS 已同步。 + +## 2. 已迁移 owner + +- `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、diagnostic redaction、session usage/token usage 基础服务。 +- `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 +- `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation 和 backend event delivery;core backend event system 只保留兼容 re-export。 +- `bitfun-events` 已承接 backend event DTO、agentic event DTO 和 platform-neutral `EventEmitter` trait。 +- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、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、confirmation facts、truncation recovery presentation。 +- `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、cancellation-state/token-store policy、background exec output capture 和部分 result rendering。 +- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、session management/cancellation port contracts、thread-goal facts、prompt / prompt-cache facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、custom subagent discovery/loading、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping、DeepResearch citation renumber 与 report post-process gate。 +- `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、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。 +- Product Assembly 已承接 `DeliveryProfile`、`CapabilitySet`、product-full provider plan、service availability report 和 profile-scoped harness registry 入口。 + +## 3. 已建立保护 + +- owner crate 不得依赖回 `bitfun-core`。 +- `product-full` 保持完整产品能力集合。 +- boundary check 覆盖 owner crate 禁止依赖、旧路径 facade-only、feature gate、six-layer path 解析、Product Assembly 收口和高风险 owner 回流。 +- focused baseline 覆盖 tool manifest、GetToolSpec、execution admission、workspace search、remote workspace fallback、MCP config/catalog、prompt cache、custom subagent、thread-goal tools、AskUserQuestion、DeepReview policy、tool confirmation、session restore、MiniApp storage/builtin/import、function-agent Git、scheduled-job state 等路径。 + +## 4. 明确未完成 + +- `bitfun-core` 仍是完整 product runtime 组装点,尚未退化为纯 compatibility facade。 +- 产品入口仍主要通过 `bitfun-core/product-full` 获取完整能力,交付形态级 feature / dependency trimming 未完成。 +- concrete scheduler lifecycle、prompt-cache persistence orchestration、tool pipeline scheduler glue、concrete prompt assembly、AI client factory / provider acquisition 仍在 core 或产品路径,待 PR-D 通过 SDK / provider port 统一收口。 +- DeepReview concrete Task launch、queue event emission 和 session metadata cache persistence 仍是 core adapter,因为它们依赖 coordinator、session manager、subagent runtime 和产品事件;provider-neutral policy / queue / retry / report shaping 已在 `agent-runtime`。 +- MiniApp larger workflow 的 UI asset / desktop scheduler / AI factory 调用仍属于产品 host adapter;可复用规则已迁入 `product-domains`,不再在 desktop 命令内重复实现。 +- Agent Runtime SDK 具备候选原语,但尚未形成可独立发布的稳定外部 SDK 边界。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index d5fd986a66..2db6433a64 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1,210 +1,72 @@ -# BitFun Core 拆解与运行时迁移执行计划 +# BitFun Core 拆解与运行时迁移计划 -本文只记录活跃计划、执行节奏、剩余迁移队列和验收门禁。已完成事实移入 -[`core-decomposition-completed.md`](core-decomposition-completed.md),避免主计划继续膨胀为历史流水账。 +本文只维护后续执行计划。稳定目标以 +[`core-decomposition.md`](../architecture/core-decomposition.md) 和 +[`agent-runtime-services-design.md`](../architecture/agent-runtime-services-design.md) +为准;已完成事实归档在 +[`core-decomposition-completed.md`](core-decomposition-completed.md)。 -架构基线见 [`core-decomposition.md`](../architecture/core-decomposition.md),详细接口和 crate 内部设计见 -[`agent-runtime-services-design.md`](../architecture/agent-runtime-services-design.md)。 +## 1. 执行原则 -## 1. 当前判断 +- `bitfun-core` 最终收敛为 compatibility facade、Product Assembly 边界和少量迁移期 adapter。 +- `src/crates` 保持六层物理布局:`interfaces -> assembly -> adapters -> services -> execution -> contracts`,依赖只能自上而下。 +- 新抽象必须同步删除、迁移或显著简化旧 core 主体路径;纯 facade、纯 guard、纯文档或空接口不算完成。 +- 设计文档默认保持稳定。阶段状态、剩余工作和风险只写入本计划、completed 归档和 issue。 +- 任何会改变权限、工具 schema、事件语义、session 生命周期、remote 行为、MiniApp 行为或交付形态的变更必须暂停并单独评审。 -- P0/P1/P2 的低风险准备和 owner container 化已经完成,不再拆成 helper、guard、facade cleanup 小 PR。 -- 当前迁移已经进入高风险 runtime owner 阶段。后续 PR 必须按完整 owner 主题推进,不能把 PR 当作单个 commit 使用。 -- `bitfun-core` 迁移期继续作为兼容 facade 和完整产品 runtime 组装点;新 owner crate 不得依赖回 `bitfun-core`。 -- 目标不是立即让 `bitfun-core default = []`,而是先把接口、provider 注册、旧路径兼容和行为等价保护做实。 -- 产品能力、权限语义、工具曝光、事件语义、session 行为、release / fast build 脚本和各产品形态能力集合不得因迁移改变。 +## 2. 当前基线 -## 2. 迁移关键内容 +- workspace 已按六层目录展开,旧 `surfaces` / `providers` 目标层级不再使用。 +- `bitfun-core --no-default-features` 已裁掉 workspace-search owner、debug ingest HTTP server、AI provider adapter runtime 和 direct `reqwest`。 +- Desktop / CLI / ACP 仍通过 `bitfun-core/product-full` 获取完整能力;Server / Web / Mobile Web 不直接依赖 core,但交付形态级 feature / dependency trimming 仍未闭环。 +- Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立,部分逻辑仍由 core concrete manager 或产品命令路径持有。 +- PR-B 已收口 Agent lifecycle 与 tool side-effect owner:turn skill/agent snapshot DTO / diff / render / store、file-read session state、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state 已迁入 `agent-runtime`;background exec output capture、tool cancellation token store 已迁入 `tool-execution`;core 保留 resolver、产品事件、具体工具执行、IO 编排和旧路径兼容 re-export。 +- PR-C 已收口 Harness / product workflow 的低风险 owner:MiniApp AI / Agent permission、rate-limit、model/message/session/workspace/turn-text 规则迁入 `product-domains`;DeepResearch 后处理 gate 迁入 `agent-runtime`,report IO 继续由 `services-integrations` 持有;function-agent AI concrete acquisition 收拢为 core port adapter,旧 `runtime_services` 路径删除。 -### 2.1 接口与实现分离 +## 3. 已完成但仍需保持的边界 -- 稳定接口属于 Stable Contracts、Runtime Services、Tool Runtime 或 Harness contract。 -- 具体实现按 Tool、OS、Remote、Protocol provider 分类,保留在 app 或 integration owner 中。 -- Product Assembly 是唯一注册点,负责把具体 provider 注入 typed builder / registry。 -- Runtime、Tool、Harness 只消费接口或 registry,不直接创建 filesystem、terminal、MCP、ACP、remote host 等 concrete manager。 +- `services-core` 已承接 session metadata store、session index rebuild、lineage / branch metadata shaping、JSON file store、session layout 和 legacy session-store merge。 +- `runtime-services` 已承接 typed runtime service assembly、capability validation、provider registry、backend event delivery owner。 +- `agent-runtime` 已承接 provider-neutral scheduler decisions、dialog lifecycle port contracts、background delivery decisions、thread-goal facts、prompt-cache facts、turn skill/agent snapshot state、file-read session state、session evidence ledger、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、DeepReview provider-neutral policy / queue / retry / diagnostics shaping。 +- `tool-contracts` / `tool-execution` 已承接 tool manifest / catalog / admission、batching plan、retry policy、state counting、cancellation-state/token-store policy、background exec output capture、shell helper 和部分 local / remote IO helper。 +- `services-integrations` 已承接 remote-connect primitives、workspace search concrete owner、remote SSH/SFTP/PTY owner、MiniApp host dispatch / storage / worker IO、DeepResearch report IO。 +- `product-domains` 已承接 MiniApp workflow planning、compile / permission path adaptation、function-agent prompt / parser / response policy 和部分 Git snapshot/fallback 逻辑。 +- boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件和重点 feature gate。 -### 2.2 Runtime owner 拆分 +## 4. 剩余大块 PR -- Agent Runtime SDK:session、turn、scheduler、prompt loop、subagent、background task、permission coordination、runtime events。 -- Runtime Services:filesystem、workspace、session store、Git、terminal、network、MCP catalog、remote connection / projection 等 port 和 capability availability。 -- Tool Runtime:manifest、catalog、permission gate、execution pipeline、tool hook、结果归一化。 -- Harness Layer:SDD、Deep Review、DeepResearch、MiniApp 等多步骤工作流和策略编排。 -- Product Capabilities:Code Agent、MiniApp、function-agent、Remote Control、MCP App、Computer Use 等能力包。 +| PR | 目标 | 主要范围 | 准出标准 | +|---|---|---|---| +| PR-D | Product shape / Agent SDK / core facade closure | 内部 Agent Runtime SDK façade、fake provider 最小 session / turn / event stream、Product Assembly capability matrix、delivery profile feature trimming、`bitfun-core` facade 收口 | cargo metadata / cargo tree 有 no-default/product-full 对比;各产品入口验证通过;SDK 不暴露 `bitfun-core`、product-full、concrete manager 或全局 mutable state | -### 2.3 Remote 拆分原则 +## 5. 固定执行流程 -- Remote 不是 Agent Runtime SDK 的内部能力,也不只按 Desktop / CLI 入口区分。 -- 稳定接口应拆为 remote connection、remote workspace、remote filesystem / terminal projection、remote capability facts。 -- SSH、relay、本地隧道、远端 OS 差异、认证方式属于具体 Remote provider。 -- remote workspace、terminal pre-warm、scheduler submit、session restore、file chunk / image fallback 等行为必须用等价测试保护。 +1. 同步最新 `main`,检查主干新增的 CLI、tool、terminal、session、scheduler、remote、MiniApp、ACP 或 product interface 变更。 +2. 对照设计文档和 Issue #970 明确本次 owner 边界,不从旧计划标签继承完成判断。 +3. 先补等价保护,再迁移实现主体。 +4. 删除、迁移或显著简化 core 中对应旧路径。 +5. 运行 focused verification、boundary check 和必要的 feature / dependency 对比。 +6. 从独立第三方角度审查功能漂移、性能劣化、依赖回流、产品形态遗漏和文档一致性。 +7. 合入后只更新 completed 摘要和 issue 状态;设计文档只有目标架构变更时才修改。 -### 2.4 目标 crate 创建或扩展准入 +## 6. 验证矩阵 -- 新目标 crate 不能为了“架构完整”提前创建。必须同时满足 owner 边界清晰、旧路径兼容可保留、focused tests 可落地、依赖收益可解释、boundary check 可防回流。 -- `bitfun-runtime-services` 已按该准入建立基础壳层;继续扩展时仍必须保持 `RuntimeServicesBuilder` skeleton、Remote ports 和 fake provider 测试同时成立。 -- `bitfun-agent-runtime` 只能在 session / turn / scheduler / prompt loop 中至少一个 owner 可脱离 `bitfun-core` 构建时创建。 -- `bitfun-harness` 只能在至少两个 workflow 通过 provider contract 接入时创建,不能只为单个 Deep Review 或 MiniApp helper 拆 crate。 -- 若某项迁移只能承接单个 helper,或测试仍必须依赖完整 `bitfun-core`,继续留在迁移期 facade。 - -## 3. 执行节奏 - -每个高风险 PR 按同一节奏执行: - -1. **同步主干。** 变基到远端 `main`,检查最新主干是否引入新的 tool、remote、session、scheduler、CLI、mobile-web 或 product-surface 行为。 -2. **确认组装门禁。** 高风险迁移前必须先有最小 Product Assembly / Runtime Services skeleton,能把 provider 注册到 typed builder / registry。 -3. **确定 owner 主题。** 每个 PR 只迁移一个完整 owner 主题;预保护、迁移、旧路径兼容、文档更新和对抗性审核属于同一个 PR。 -4. **先补保护。** 在移动 owner 前补 owner 设计、输入输出盘点、旧路径兼容方案、等价测试或 snapshot。 -5. **再移动实现。** 只移动已被 port/provider 保护的逻辑;发现需要改变产品行为时暂停并单独评审。 -6. **回看边界。** 检查是否新增反向依赖、万能 context、无类型 service locator、全局 mutable registry 或重复 runtime materialization。 -7. **提交前审核。** 从第三方角度审查功能偏移、性能劣化、产品形态遗漏和文档一致性;不满足时不提交 PR。 - -## 4. 后续迁移队列 - -后续迁移固定收敛为 7 个大块 PR。每个 PR 都必须先补保护,再迁移 owner,最后回看文档和边界;如果发现必须改变功能语义,需要在 PR 中单独说明原因、影响范围和回滚边界。 - -| PR | 主题 | 完整范围 | 不允许混入 | 合入门禁 | -|---|---|---|---|---| -| PR1 | Product Assembly / Runtime Services Foundation | 创建 `bitfun-runtime-services`,补 `RuntimeServicesBuilder`、typed provider registration、capability availability、Remote ports、fake provider 和 boundary check 入口 | 具体 remote runtime、tool IO、product-domain IO、default feature 调整 | provider 注册路径可测试,Remote ports 不暴露 SSH / relay concrete handle,新增 crate 不依赖 `bitfun-core` | -| PR2 | Service / Agent Remote Runtime Owner | 在 remote connection、remote workspace、remote FS / terminal projection、workspace-root / persistence、`ImageContextData`、remote-SSH / relay provider 中完成一个完整 owner 主题的 port、provider、旧路径兼容和行为等价验证 | tool runtime、product-domain runtime、feature matrix、产品命令或 UI 行为变更 | remote/session/file/image/terminal/scheduler 行为等价,产品 surface 不变 | -| PR3 | Agent Runtime SDK Owner | 拆分 mode-scoped subagent visibility、agent registry facts、queue policy decision、scheduler submit/cancel facts 和 background delivery 边界;concrete scheduler 生命周期按保护程度逐步外移 | remote provider、tool IO、product-domain IO、默认 feature 调整 | subagent 可见性、queue/preempt/cancel、background reply、DeepResearch hook 等价 | -| PR4 | Harness / Product Capability Boundary | 建立 Harness provider contract,让 Deep Review、DeepResearch、MiniApp 等 workflow 通过 provider 注册,不侵入 Agent Runtime SDK | concrete service IO、tool IO、surface 命令语义变更 | 至少两个 workflow 可通过 provider contract 表达,旧路径兼容 | -| PR5 | Product-Domain Runtime Owner | MiniApp filesystem IO / worker / host / builtin seed 或 function-agent Git/AI 中完成一个完整 owner 主题,建立最小 port/provider 和 core adapter | tool runtime、service/agent runtime、surface 行为变更 | MiniApp/function-agent focused regression,PathManager/process/Git/AI 边界清晰 | -| PR6 | Tool Runtime Owner | 在 `ToolUseContext` projection、manifest execution、`GetToolSpecTool` execution、snapshot wrapper、collapsed unlock state 或具体工具 IO 中完成一个收益明确的完整 owner 主题 | service/agent runtime、product-domain runtime、feature matrix、产品行为变更 | tool visibility、manifest、`GetToolSpec`、snapshot、Deep Review tool flow 等价 | -| PR7 | Feature / Build-Benefit Evaluation | 评估 feature matrix、dependency profile、no-default 编译面和构建收益数据,确认是否具备收敛默认 feature 的条件 | runtime owner 迁移、default feature 副作用、构建脚本变更 | cargo metadata / cargo tree 证据,产品入口完整能力不变 | - -### 4.1 PR1 具体实施计划 - -PR1 是后续高风险迁移的前置门禁,目标是提供可测试的 typed assembly 基础,而不是移动任何既有业务行为。 - -1. 新建 `bitfun-runtime-services` crate,并加入 workspace。 -2. 在 `bitfun-runtime-ports` 中补齐 Runtime Services 所需的轻量 port trait 和 Remote port trait;这些 trait 只能描述能力和请求边界,不携带 SSH、relay、Tauri、process、filesystem manager 等 concrete handle。 -3. 在 `bitfun-runtime-services` 中实现 `RuntimeServices`、`RuntimeServicesBuilder`、capability availability、typed unsupported error 和 provider registry。 -4. 提供 `test_support` fake provider,覆盖本地 mandatory service、optional remote service 和 unsupported capability 三类注入路径。 -5. 更新 `scripts/check-core-boundaries.mjs`,把 `bitfun-runtime-services` 纳入 no-core dependency 和轻量依赖边界检查。 -6. 更新仓库入口文档中的模块索引,说明 `bitfun-runtime-services` 仍使用 core decomposition guardrails。 -7. 运行 focused tests、边界检查和最小 Rust 验证;提交前从第三方视角检查是否出现 service locator、全局 mutable registry、反向依赖或功能语义漂移。 - -PR1 不迁移任何 concrete service owner,因此预期不会修改产品行为、默认能力集合、权限语义、工具曝光、事件语义、session 生命周期或构建脚本。 - -### 4.2 PR2 + PR3 合并实施计划 - -本次 PR 合并推进 PR2 和 PR3,但仍按两个 owner 主题顺序实施,避免把 remote provider、agent scheduler -和产品 surface 行为混在同一个迁移步骤中。若实现过程中发现必须改变用户可见行为、默认 feature、权限语义或构建形态, -应暂停并在 PR 描述中单独说明设计偏移原因、影响范围和回滚边界。 - -#### 4.2.1 PR2:Service / Agent Remote Runtime Owner - -目标是在不搬动 concrete SSH / relay / terminal / session restore 实现的前提下,把 remote workspace 与 projection -的稳定接口归入 `bitfun-runtime-ports`,并保留 `bitfun-services-integrations::remote_connect` 旧路径 re-export。 - -1. 在 `bitfun-runtime-ports` 中承接 remote workspace facts、remote session metadata、remote workspace file projection DTO - 和 `RemoteWorkspacePort` / `RemoteProjectionPort` owner trait。 -2. 在 `bitfun-services-integrations::remote_connect` 中删除重复 owner 定义,改为 re-export 新 owner crate 的类型和 trait, - 保持现有调用方 import 路径兼容。 -3. 让 core 侧 remote workspace / file adapter 继续作为具体 provider,实现新的 stable port;workspace-root、 - persistence、session restore、terminal pre-warm 和 scheduler submit 仍保留在 `bitfun-core`。 -4. 补充 focused tests,覆盖 remote workspace / file projection 类型通过旧路径与新 owner 路径保持等价,以及 - `RuntimeServicesBuilder` 能注册带方法的 remote workspace / projection provider。 -5. 更新 boundary check,防止 remote owner contract 回流到 `bitfun-core` 或 concrete service crate。 - -#### 4.2.2 PR3:Agent Runtime SDK Owner - -目标是创建有真实 owner 的 `bitfun-agent-runtime`,只迁移 scheduler/background delivery 这类可纯函数保护的运行时决策, -不外移 concrete scheduler 生命周期。 - -1. 新建 `bitfun-agent-runtime` crate,并加入 workspace;该 crate 只依赖 `bitfun-runtime-ports` 等稳定契约,不依赖 - `bitfun-core`、Tauri、CLI、ACP、Web UI 或 concrete service crate。 -2. 先把 background delivery 的状态决策抽为 `bitfun-agent-runtime` 的纯 contract:Processing 注入当前运行 turn, - Missing / Idle / Error 提交 agent-session follow-up turn。 -3. core scheduler 仅调用该决策结果,继续负责 injection buffer、submit、turn id、metadata 和实际生命周期执行。 -4. 补充 `bitfun-agent-runtime` focused tests 与 core scheduler 兼容验证,确保 background reply、cancel suppression、 - queue/preempt 和 DeepResearch/post-turn 相关语义没有漂移。 -5. 更新 `AGENTS.md` / `AGENTS-CN.md` 和设计文档中的 crate 状态,描述 `bitfun-agent-runtime` 已承接的范围以及仍未外移的 - scheduler lifecycle、session manager、prompt loop 和 subagent registry。 - -#### 4.2.3 本次 PR 验收 - -- 不修改产品命令、UI、默认 feature、release / fast build 脚本或产品能力集合。 -- 不新增反向依赖、无类型 service locator、全局 mutable registry 或重复 runtime materialization。 -- 必须通过 remote / runtime owner focused tests、boundary check、repo hygiene 和最小 Rust 编译验证。 -- 提交前从第三方视角审查功能偏移、性能劣化、跨产品形态遗漏、文档与代码不一致,并修复发现的问题。 - -## 5. 每类 PR 的保护重点 - -### 5.1 Service / Agent Remote Runtime Owner - -- 先定义 remote connection、workspace、projection、capability facts port。 -- 保留 core adapter 读取 workspace-root、persistence、session restore、scheduler submit,直到有端到端 remote regression。 -- SSH、relay、tunnel、远端 OS、认证差异留在 Remote provider。 -- 验证 remote command/response wire、restore -> terminal pre-warm -> scheduler submit 顺序、file full/chunk/info、image context fallback、remote workspace startup guard。 - -### 5.2 Agent Registry / Scheduler Owner - -- 先迁移只读 facts、queue policy decision、runtime event facts,不先移动 concrete scheduler 生命周期。 -- 保留 mode-scoped visibility、hidden/custom/review grouping、background result delivery、running-turn injection 和 idle-session follow-up 语义。 -- 验证 subagent availability、queue/preempt/cancel suppression、DeepResearch citation / post-turn hook、goal verification events。 - -### 5.3 Product-Domain Runtime Owner - -- MiniApp 优先拆 storage/process/asset/Git/AI 的最小 port,避免把 PathManager、worker process、host dispatch、builtin marker IO 下沉到 domain crate。 -- function-agent 保留 Git/AI provider acquisition、error mapping、no-HEAD diff fallback、非 Git workspace fallback、`analyzed_at` 时序。 -- 验证 MiniApp import/sync/recompile/rollback/deps state、builtin seed marker、customized update metadata、function-agent prompt/response policy。 - -### 5.4 Tool Runtime Owner - -- 不直接搬全部 concrete tools。先拆 portable facts projection、manifest/catalog snapshot、`GetToolSpec`、snapshot wrapper 或 collapsed unlock 中一个 owner。 -- 保留 tool name、schema、prompt stub、readonly/enabled/filtering、unlock state 生命周期。 -- 验证 builtin tool list、provider order、expanded/collapsed exposure、dynamic provider metadata、Deep Review 修改类工具 checkpoint hook。 - -## 6. 不可变更边界 - -- 不改变产品行为、默认能力集合、权限语义、工具曝光、事件语义或 session 生命周期。 -- 不修改 `package.json`、`scripts/dev.cjs`、`scripts/desktop-tauri-build.mjs`、`scripts/ensure-openssl-windows.mjs`、`scripts/ci/setup-openssl-windows.ps1`、`BitFun-Installer/**`,除非单独作为产品构建变更评审。 -- 不让新 crate 依赖回 `bitfun-core`。 -- 不把 `bitfun-core` 重新包装成新的 `common`、`platform`、`app context` 或 service locator。 -- 不在同一 PR 中同时做 runtime owner 迁移、default feature 调整、三方库大版本升级和构建脚本变更。 -- 不为了减少代码行数抽象语义并不等价的跨产品或跨平台流程。 - -构建脚本保护命令: - -```powershell -git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer -``` - -期望结果:没有 diff。 - -## 7. 验证矩阵 - -按触碰范围选择最小但足够的验证: - -| 触碰范围 | 最小验证 | +| 触达范围 | 最小验证 | |---|---| -| contract / DTO / boundary 文档 | `pnpm run check:repo-hygiene`,必要时补 `node scripts/check-core-boundaries.mjs` | -| Runtime ports / service boundary | `cargo test -p bitfun-runtime-ports`,`cargo check -p bitfun-core --features product-full` | -| Service integrations / Remote | owner crate focused tests,remote-connect / remote-SSH focused tests,`cargo check -p bitfun-core --features product-full` | -| Remote product surfaces | 触碰 remote connection / workspace / projection 时,按范围补 Desktop remote connect、relay / mobile session、ACP remote config reuse、CLI subagent / remote-adjacent path 验证 | -| Tool runtime | `cargo test -p bitfun-agent-tools`,tool manifest / `GetToolSpec` / snapshot focused tests,`node scripts/check-core-boundaries.mjs` | -| Product domains | `cargo test -p bitfun-product-domains`,MiniApp / function-agent focused tests | -| Product surface 或 Tauri/API 触碰 | `cargo check -p bitfun-desktop`,必要时补 Web UI 或 mobile-web 验证 | -| 大范围 owner 迁移 | `cargo check --workspace`;若行为面广,再补 `cargo test --workspace` | - -任何声明构建收益的 PR 必须记录迁移前后 cargo metadata / cargo tree / check 数据;不声明收益时,也不得造成明显编译或运行时退化。 - -## 8. 暂停条件 - -- 必须改变用户可见行为、权限策略、产品命令、默认能力或 release 构建形态才能继续。 -- 新 owner crate 必须依赖回 `bitfun-core` 才能编译。 -- contract crate 开始吸收 Tauri、CLI/TUI、network client、process execution、`git2`、`rmcp`、`image`、`tokio-tungstenite` 等 concrete runtime 依赖。 -- Remote / Tool / MiniApp / function-agent / scheduler 迁移无法给出迁移前后等价测试或可复核 snapshot。 +| docs / boundary / layout | `pnpm run check:repo-hygiene`,`node --test scripts/check-core-boundaries.test.mjs`,`node scripts/check-core-boundaries.mjs` | +| Workspace layout / Cargo path | `cargo metadata --no-deps --format-version 1` | +| Runtime Services / backend events | `cargo test -p bitfun-runtime-services`,`cargo check -p bitfun-core --no-default-features` | +| Services Core session migration | `cargo test -p bitfun-services-core merge_legacy_session_store`,core workspace-runtime focused tests | +| Agent lifecycle / scheduler | `cargo test -p bitfun-agent-runtime`,core scheduler / session focused tests | +| Tool / terminal | `cargo test -p bitfun-agent-tools`,`cargo test -p tool-runtime`,terminal / exec-command focused tests | +| Harness / Product Domains | `cargo test -p bitfun-harness`,`cargo test -p bitfun-product-domains`,DeepReview / MiniApp focused tests | +| Product shape / SDK | `cargo test -p bitfun-agent-runtime`,`cargo test -p bitfun-runtime-services`,SDK fake-provider smoke,cargo tree / metadata 对比 | +| 大范围 owner 迁移 | `cargo check --workspace`,必要时补 `cargo test --workspace` | + +## 7. 暂停条件 + +- 新 owner crate 必须依赖回 `bitfun-core` 才能编译或测试。 +- Execution / contracts crate 吸收 Tauri、产品命令、AI provider、MCP client、process execution、Git provider 等 concrete dependency。 - Product Assembly 变成无类型 service locator 或全局 mutable app state。 -- 某个产品 crate 需要减少 feature 才能通过编译。 - -## 9. 完成标准 - -- `bitfun-core` 只保留兼容 facade 和产品组装,不再承载新 runtime owner 实现。 -- Agent Runtime SDK、Runtime Services、Tool Runtime、Harness、Product Capabilities 与 Concrete Integrations 的依赖方向可由边界检查证明。 -- 至少有一组低层 contract / owner crate 可以绕开完整 `bitfun-core` 和对应 heavy dependency。 -- 产品 crate 仍拥有拆解前的完整能力集合,且旧公开 import 路径保持兼容。 -- Remote、Tool、MiniApp/function-agent、scheduler/registry 等高风险路径都有等价测试、旧路径兼容和回滚边界。 -- 新增 crate 数量保持中等粒度;继续拆小必须有 owner、依赖或实测收益依据。 -- 已完成事实只记录在归档文档中,主计划持续聚焦当前方向和待完成事项。 +- PR 只新增抽象,没有迁移、删除或显著简化旧 core 主体路径。 +- SDK façade 必须暴露 `bitfun-core`、`product-full`、concrete service manager 或产品命令 registry 才能完成基本 agent 执行。 diff --git a/docs/sdlc-harness/README.md b/docs/sdlc-harness/README.md new file mode 100644 index 0000000000..d247b15496 --- /dev/null +++ b/docs/sdlc-harness/README.md @@ -0,0 +1,61 @@ +# BitFun 可配置开发体验与工程治理总览 + +> 范围:BitFun 加载外部软件工程后的产品需求、架构边界、执行安全、渐进质量治理和复杂项目支撑能力。 +> 用途:作为拆分后的入口文档。产品需求文档回答产品定位和体验要求,设计文档回答架构边界,实施计划回答阶段落地,子模块文档回答局部契约。 + +## 文档结构 + +| 文档 | 角色 | 主要内容 | +|---|---|---| +| [research/external-research.md](research/external-research.md) | 调研文档 | 外部产品、论文、标准和趋势信号 | +| [product-requirements.md](product-requirements.md) | 产品需求 | 产品定位、用户画像、体验路径、产品规格、关键边界、平台差异和成功指标 | +| [design.md](design.md) | 架构设计 | 设计目标、领域模型、配置层级、模块边界和架构风险 | +| [implementation-plan.md](implementation-plan.md) | 实施计划 | 按用户收益切片组织快速路径、上下文保障、团队治理、复杂生命周期能力的阶段落地 | +| [traceability-matrix.md](traceability-matrix.md) | 追踪矩阵 | 需求、设计、功能规格、执行阶段和测试方法的映射 | +| [architecture/security-boundary.md](architecture/security-boundary.md) | 安全边界 | prompt 注入、hook/MCP/网络/凭据/shell、执行位置、沙箱等级和应急放行规则 | +| [features/configurable-policy-profile.md](features/configurable-policy-profile.md) | 配置化策略 | 任务、操作、环境、项目和团队配置如何共同决定内部策略画像、提示、验证和审查 | +| [architecture/evidence-pack.md](architecture/evidence-pack.md) | 证据包设计 | 证据包负责人、状态、生命周期、风险接受和 PR/审查/回放投影契约 | +| [governance/metrics-spec.md](governance/metrics-spec.md) | 指标规格 | 开发效率、安全提示、质量治理和阶段退出指标的公式、分母、窗口和负责人 | +| [governance/self-governance-notes.md](governance/self-governance-notes.md) | 自身治理说明 | 记录 BitFun 仓库自身作为内部验证项目暴露出的文档、边界和治理问题 | + +## 核心定位 + +BitFun 面向任意目标项目提供可配置的智能体开发体验。产品定位和需求以 [product-requirements.md](product-requirements.md) 为准,本文只保留文档导航和稳定边界。 + +- **快速开发**:质量保障要求较低、探索性、演示、文档和低风险改动优先完成任务,只给必要提示和轻量结果摘要。 +- **上下文保障**:核心路径、权限、网络、数据迁移、发布或团队 PR 等场景触发验证建议、风险说明和审查人建议。 +- **团队治理**:项目或组织通过配置启用统一规则、审查强度、强制检查、门禁、风险接受和审计。 +- **执行安全**:prompt 注入、恶意 hook、MCP、网络、凭据、跨目录写入、删除和发布凭据等风险始终走独立安全边界,并展示执行位置、沙箱等级和授权范围。 +- **阶段收益**:每个阶段都先交付可解释的用户收益,同时说明必要技术前置、延期边界和质量一致性要求。 + +配置化策略把这些能力组合成按需显露的开发路径: + +```text +默认快速路径 + -> 风险出现时进入上下文保障 + -> 项目或组织需要时进入团队治理 + -> 安全边界始终启用 +``` + +证据包、交付物图谱、质量数据面和评测系统作为后台支撑能力使用;普通任务只展示完成任务所需的摘要、提示和下一步建议。Harness 作为内部工程术语,仅用于描述受控执行、证据校验、策略约束和评估回放能力。 + +## 全局基础准则 + +这些准则适用于产品需求、架构设计、实施计划和子模块文档。非通用准则只写在对应子模块,并说明触发条件、退出条件和与全局准则的关系。 + +- **默认轻量,关键风险强保护**:普通低风险开发不进入重流程;凭据、网络外发、危险 shell、跨目录写、删除、发布、主动配置和 prompt 注入等安全风险始终走安全边界。 +- **按动作效果判定,不按工具名称判定**:tool、MCP、skills、插件、hook、shell 和内置能力都映射为能力、目标、数据、来源和副作用,再由策略判断。 +- **未知能力默认受限**:新增扩展必须声明能力和可能副作用;未声明、声明不完整或运行时行为超出声明时,不能按低风险处理。 +- **用户确认不是万能授权**:确认只在指定范围、期限、执行域和能力内生效;组织策略、安全拒绝和关键凭据保护不能被本地确认绕过。 +- **模型只参与解释和候选判断**:风险解释、建议检查和候选影响可以由模型辅助;授权、阻断、审计、状态写入和策略变更必须由确定性策略和内核事实执行。 +- **体验和性能预算常驻**:风险判断、提示、事件和扫描不能明显拖慢默认路径;高成本分析、深度证据和完整图谱默认按需、异步或离线执行。 +- **扩展契约先于生态运行时**:先稳定内核 hook、事件、打点、能力声明、工具复写候选和插件效果候选;真实插件运行时、OpenCode 兼容或其他生态适配后置接入。 + +## 阅读建议 + +1. 先读调研文档,确认市场正在从单点 AI IDE 走向仓库指令、路径规则、沙箱、异步智能体和可选审查/治理。 +2. 再读产品需求,确认 BitFun 的默认体验、用户画像、产品规格、关键边界、平台差异和成功指标。 +3. 需要架构边界时读设计文档。 +4. 需要落地顺序时读实施计划。 +5. 需要检查覆盖关系时读追踪矩阵。 +6. 需要实现契约时再读配置化策略、安全边界、证据包、质量数据面(QDP)、风险分类和门禁等子模块。 diff --git a/docs/sdlc-harness/architecture/artifact-graph.md b/docs/sdlc-harness/architecture/artifact-graph.md new file mode 100644 index 0000000000..fd8fa22eaa --- /dev/null +++ b/docs/sdlc-harness/architecture/artifact-graph.md @@ -0,0 +1,159 @@ +# BitFun 子模块设计:交付物图谱 + +> 上游文档:[design.md](../design.md) +> 模块角色:把目标项目中的任务、需求、设计、代码、测试、评审、CI、发布、运行期和复盘资产建模为可追踪、可失效、可确认的关系层。 + +## 1. 模块定位 + +交付物图谱是复杂项目的后台关系层。它让 BitFun 在解释变更、准备 PR、发布、复盘或评估时,回答“这次变更和哪些工程资产有关,证据是否新鲜,关系是谁确认的”。 + +P0 建立隐藏的最小关系投影,用于支撑证据引用和就绪度;issue、spec、完整审查系统和企业知识图谱在复杂项目场景中逐步接入: + +```text +任务 -> diff -> 验证 -> 证据引用 -> 就绪度摘要 +``` + +当用户进入 PR、发布、事故、需求影响或团队治理场景时,再逐步显露图谱视图和人工确认队列。 + +约束:交付物图谱表达可治理的工程对象和可失效关系;RAG 作为候选召回手段使用,确认状态、证据引用和失效规则由图谱契约维护。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [Atlassian Software Collection](https://www.atlassian.com/collections/software) | 工作项、文档、代码和团队上下文正在图谱化 | +| [Harness Software Delivery Knowledge Graph](https://www.harness.io/blog/knowledge-graphs-for-ai-software-delivery) | 知识图谱必须围绕用例最小建模,并以新鲜度和结果改善衡量价值 | +| [Kiro Specs](https://kiro.dev/docs/specs/) / [Steering](https://kiro.dev/docs/steering/) | spec、steering 和项目规则正在成为 AI 原生工程交付物 | +| [Rovo acceptance criteria 检查](https://support.atlassian.com/rovo/docs/check-acceptance-criteria-in-a-code-review/) | PR 可以检查代码是否满足关联工作项的验收标准 | +| [TraceLLM](https://arxiv.org/html/2602.01253v1) | LLM 可辅助轨迹链接,但需要置信度和人工确认 | + +## 3. 范围与边界 + +范围: + +- 建模交付物节点、边、证据和确认状态。 +- 支撑就绪度、PR 审计视图、需求变更影响视图、发布就绪度和事故回溯视图。 +- 为风险分类器、证据包和评测提供可解释上下文。 + +边界: + +- 目标项目已有的 Jira、Linear、GitHub、CI 或观测系统保留系统主权,BitFun 只做适配和投影。 +- P0 使用最小关系投影;完整企业知识图谱属于后续复杂项目能力。 +- LLM 推断链接、向量检索结果和相似度召回进入候选状态,人工确认或确定性证据才能生成已确认边。 +- 外部系统同步优先按需读取和投影,双向同步作为明确集成能力单独设计。 +- 普通任务用户只看到摘要和下一步建议,图谱概念在 PR、发布、事故和复盘场景显露。 + +## 4. 输入、输出与数据模型 + +核心节点: + +```ts +type ArtifactKind = + | "task" + | "issue" + | "requirement" + | "acceptance_criteria" + | "spec" + | "design_decision" + | "plan" + | "diff" + | "code_symbol" + | "test" + | "verification" + | "review_finding" + | "ci_check" + | "release" + | "incident" + | "metric" + | "policy" + | "evidence_pack"; +``` + +核心边: + +```ts +interface ArtifactEdge { + from: ArtifactId; + to: ArtifactId; + relation: string; + source_event_id: string; + created_by: "system" | "agent" | "human" | "integration"; + confidence: number; + evidence: EvidenceReference[]; + last_verified_at: string; + staleness: "fresh" | "stale" | "unknown"; + confirmation_status: "auto" | "confirmed" | "rejected" | "expired" | "hidden_support"; +} +``` + +核心输出: + +- 就绪度关联证据。 +- PR 证据包关联交付物。 +- 变更影响候选。 +- 强制检查种子。 +- 过期审查 / 过期链接告警。 +- 发布就绪度上下文。 +- 事故到测试回归候选链接。 + +## 5. 核心流程 + +```text +收集任务和项目交付物 + -> 创建隐藏支撑节点 + -> 推断候选边 + -> 附加来源和置信度 + -> 只在产品上下文需要时显露 + -> 对高风险低置信边要求确认 + -> 将确认结果写回图谱 +``` + +关键视图: + +| 视图 | 显露条件 | 用途 | +|---|---|---| +| 就绪度支撑视图 | PR/审查场景 | 展示 diff、验证、风险和未关闭缺口 | +| 需求变更视图 | 明确 spec/API/验收变更 | 展示受影响文件、测试、负责人、发布风险 | +| 任务视图 | 长任务或团队协作 | 展示从 spec 到计划、diff、验证的工作链路 | +| 事故回溯视图 | 运行期问题复盘 | 展示事故、发布、PR、测试缺口和回归补充 | + +## 6. 策略与治理 + +- **边质量优先**:链接少但可信,优先于链接多但不可验证。 +- **后台优先**:快速路径下图谱只做支撑,不成为用户流程。 +- **语义层优先**:先定义交付物类型、关系、来源、信心和新鲜度,再考虑 RAG 或 embedding 扩展。 +- **人工确认**:高风险低置信链接进入待确认状态,不直接影响通过/就绪判断。 +- **新鲜度管理**:diff、test、审查、CI 状态变化会触发边新鲜度更新。 +- **来源分级**:人工确认 > CI/测试证据 > 静态分析 > LLM 推断。 +- **可删除但不可篡改**:错误链接用拒绝/过期状态表达,不静默删除审计事实。 + +## 7. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | `task -> diff -> verification -> evidence refs` 隐藏支撑投影 | +| P1 | 就绪度关联证据、过期证据、PR 支撑视图 | +| P2 | issue/spec/审查链接、团队 PR 审计视图 | +| P3 | 需求影响、发布就绪度、事故到测试回归 | +| P4 | 跨团队质量趋势、图谱查询和预测性风险提示 | + +## 8. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 图谱边界扩张过快 | P0 只能支撑快速路径和就绪度,不建设完整 SDLC | +| 图谱概念污染默认体验 | 普通任务只展示摘要,不展示图谱 | +| 低质量链接影响判断可信度 | 所有边必须携带信心、证据、新鲜度和确认状态 | +| LLM 链接幻觉 | LLM 只生成候选边,高风险链接必须人工确认 | +| 外部系统同步成本过高 | 默认本地投影和按需导出,双向实时同步作为独立集成能力 | +| 链接过期不可见 | 任何 diff、test、审查、CI 变更都应能标记过期 | +| 团队不使用 | 先嵌入就绪度和审查人工作流,避免单独打开图谱工具 | + +## 9. 成功标准 + +- 快速路径可获得图谱支持但不暴露图谱术语。 +- PR/就绪度场景能展示与 diff、验证、证据包相关的可信关系。 +- 高风险 PR 能暴露缺失需求、测试、审查或负责人链接。 +- 用户可以确认、拒绝或覆盖自动链接。 +- 过期链接不会被作为通过/就绪依据。 +- 需求影响分析和发布就绪度复用同一图谱模型。 diff --git a/docs/sdlc-harness/architecture/evidence-pack.md b/docs/sdlc-harness/architecture/evidence-pack.md new file mode 100644 index 0000000000..d5c0c8e22c --- /dev/null +++ b/docs/sdlc-harness/architecture/evidence-pack.md @@ -0,0 +1,170 @@ +# BitFun 子模块设计:证据包 + +> 上游文档:[design.md](../design.md) +> 模块角色:把一次任务或变更的上下文、验证、风险、跳过项、人工决策和安全授权整理成可投影、可失效、可回放的证据快照。 + +## 1. 模块定位 + +证据包是后台证据投影契约。快速路径下用户看到信心摘要;准备 PR、团队策略启用、风险升级、发布/事故追溯或评测回放时,系统按配置展示证据引用或完整证据包。 + +质量数据面记录事件和引用,证据包负责把这些事实整理成一次任务或变更集可消费、可审计、可失效的快照。证据包陈述证据内容、来源、新鲜度、跳过检查、风险接受和安全授权;合入判断由变更就绪度、团队策略、CI、分支保护和人工审查共同决定。 + +外部系统的成熟实践说明了这个边界:[GitHub Checks](https://docs.github.com/rest/checks) 把检查结论和摘要投影到提交(commit)或 PR;[SLSA provenance](https://slsa.dev/provenance) 关注制品的来源、时间和生成方式;[OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) 关注跨系统语义稳定。证据包应吸收这些思想,但保持 BitFun 内部规范证据模型。 + +## 2. 设计约束 + +- 证据包由交付物与证据层(Artifact and Evidence Plane)负责投影和版本化。 +- 原始事实来自质量数据面的 `LifecycleEvent` 和 `EvidenceReference`。 +- 证据包保存摘要和引用,完整终端日志、prompt、模型上下文或第三方载荷按隐私策略另行保留或丢弃。 +- 证据包必须能表达 `fresh`、`partial`、`stale`、`blocked` 和 `superseded`。 +- 证据包必须支持展示层级,避免完整证据包默认污染快速路径。 +- 缺少证据、证据过期或主动配置未确认时,证据包使用 `partial`、`stale` 或 `blocked` 状态。 +- PR 文本、审查界面、门禁、发布就绪度和评测回放都应消费同一证据包契约。 + +## 3. 证据展示层级 + +| 层级 | 用户可见内容 | 适用场景 | +|---|---|---| +| `none` | 不展示证据结构,只保留后台事件 | 快速路径中间过程 | +| `summary` | 已做什么、未做什么、信心和下一步 | 快速任务或辅助建议任务结束 | +| `evidence_refs` | 摘要加命令、CI、文件、审查和安全决策引用 | PR 就绪度、审查、团队建议投影 | +| `full_pack` | 完整证据包、策略版本、风险接受、新鲜度和审计引用 | 守护/合规策略、发布、事故、评测 | + +展示层由 [配置化策略画像](../features/configurable-policy-profile.md) 决定。证据存在性和证据展示层级相互独立:后台可以生成最小证据摘要,快速路径只展示任务闭环需要的信息。 + +## 4. 输入、输出与数据模型 + +输入: + +| 输入 | 来源 | +|---|---| +| 项目画像快照 | 项目结构、规则、验证能力、负责人、主动配置状态 | +| 任务与变更摘要 | 用户意图、Git diff、文件变更、重命名/删除、生成文件 | +| 验证证据 | `verification.completed`、CI 检查、命令摘要、制品引用 | +| 风险策略提示 | 风险标签、推荐/强制检查、审查强度 | +| 安全决策 | allow/ask/deny/应急放行、授权范围、残余风险 | +| 审查证据 | 深度审查问题、人工审查、过期标记 | +| 主动配置证据 | hook、plugin、自定义工具、MCP、智能体规则的发现、hash、权限和信任状态 | +| 人工决策 | 覆盖、风险接受、确认、拒绝 | + +输出: + +```ts +type EvidencePackStatus = + | "fresh" + | "partial" + | "stale" + | "blocked" + | "superseded"; + +type EvidenceDisplayTier = + | "none" + | "summary" + | "evidence_refs" + | "full_pack"; + +interface EvidencePack { + id: string; + version: number; + project_id: string; + task_id: string; + changeset_id?: string; + profile_version: string; + policy_version: string; + generated_at: string; + status: EvidencePackStatus; + display_tier: EvidenceDisplayTier; + context: ContextEvidence[]; + change?: ChangeEvidence; + verification: VerificationEvidence[]; + risk: RiskEvidence[]; + security: SecurityEvidence[]; + review: ReviewEvidence[]; + active_config: ActiveConfigEvidence[]; + skipped_checks: SkippedCheck[]; + open_risks: OpenRisk[]; + risk_acceptances: RiskAcceptance[]; + break_glass_decisions: BreakGlassDecision[]; + source_events: string[]; + evidence_refs: EvidenceReference[]; +} +``` + +关键字段语义: + +| 字段 | 语义 | +|---|---| +| `source_events` | 生成该包使用的事件 id 集合 | +| `evidence_refs` | 指向日志摘要、报告、CI、截图、轨迹或外部系统事实的引用 | +| `security` | 执行安全决策摘要,包括执行位置、沙箱等级组合、降级原因和授权范围;不作为质量通过依据 | +| `skipped_checks` | 未运行检查的原因、触发规则、可接受条件和残余风险 | +| `open_risks` | 尚未被证据覆盖或人工接受的风险 | +| `risk_acceptances` | 质量风险接受记录 | +| `break_glass_decisions` | 安全边界临时放行记录,必须与质量风险接受分开 | + +## 5. 生命周期 + +```text +源事件 + -> 构建证据摘要 + -> 附加画像和策略版本 + -> 判断新鲜度与完整度 + -> 选择展示层级 + -> 显露摘要、引用或完整证据包 + -> 变更集、画像、策略、验证、审查或主动配置变化时标记过期 + -> 用新的 EvidencePack 版本取代旧版本 +``` + +状态规则: + +| 状态 | 触发条件 | 下游行为 | +|---|---|---| +| `fresh` | 当前层级所需证据完整且来源版本未变化 | 可支撑就绪度或门禁判断 | +| `partial` | 推荐证据缺失、非阻塞跳过项或低风险未知 | 摘要或建议投影展示缺口 | +| `stale` | diff、项目画像、策略、强制检查、审查范围或主动配置变化 | 不得继续支撑通过/就绪判断 | +| `blocked` | 必要验证失败、安全拒绝、高权限主动配置未确认或证据不可访问 | 下游应进入阻断、失败或降级状态 | +| `superseded` | 新版本证据包取代旧版本 | 旧包保留审计,不作为当前判断依据 | + +## 6. 与其他模块的边界 + +| 模块 | 关系 | +|---|---| +| 质量数据面 | 提供事实事件、信任等级、隐私分类和证据引用 | +| 配置化策略画像 | 决定证据展示层级和是否进入 PR、团队或合规投影 | +| 安全边界 | 产生安全决策,证据包只记录摘要和授权引用 | +| 项目画像 | 提供项目、策略画像、规则和主动配置快照 | +| 风险分类器 | 消费上下文、变更和验证信息,输出风险证据 | +| 变更就绪度 / PR 门禁 | 消费证据包,产出就绪度或门禁决策 | +| 交付物图谱 | 可把证据包作为交付物节点,并把证据引用挂到图谱边 | +| 智能体评测 | 使用证据包和源事件做回放与失败归因 | + +## 7. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P-1 | 定义 EvidenceReference、证据包结构、状态、展示层级、新鲜度和风险接受契约 | +| P0 | 为快速路径生成摘要层级,记录验证、安全决策、沙箱等级和跳过项 | +| P1 | 支撑 PR 就绪度的证据引用、过期证据和定向审查证据 | +| P2 | 支撑团队/守护策略的 PR 门禁投影、风险接受和主动配置信任审查 | +| P3 | 接入需求影响、发布就绪度、事故回溯和外部证明引用 | +| P4 | 支撑轨迹回放、治理策略评估和跨项目证据覆盖率分析 | + +## 8. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 证据包变成用户必须理解的流程 | 展示层级默认摘要或不展示,`full_pack` 只在强场景显露 | +| 证据包变成日志包 | 只保存摘要和引用,完整日志通过受控 EvidenceReference 访问 | +| 安全放行和质量接受混淆 | 应急放行与风险接受分开字段、分开用户界面 | +| 门禁与证据包状态不一致 | 门禁结果必须引用 `evidence_pack_id` 和 `policy_version` | +| 人工接受掩盖证据缺失 | 风险接受不能把缺失证据改写成通过 | +| 证据过期不可见 | 变更集、策略画像、策略、检查、审查或主动配置变化必须标记过期 | +| 模块重复定义字段 | 证据包结构是唯一证据投影契约,其他模块只能扩展引用或消费 | + +## 9. 成功标准 + +- 快速路径能给出简洁信心摘要,不暴露完整证据包。 +- PR 就绪度可通过证据引用追溯关键证据。 +- 跳过项、未关闭风险、风险接受和应急放行不会被隐藏。 +- 证据过期后,旧证据包不再支撑通过/就绪判断。 +- 完整证据包只在团队、发布、审计、复盘或评测场景显性使用。 diff --git a/docs/sdlc-harness/architecture/project-profile-integration.md b/docs/sdlc-harness/architecture/project-profile-integration.md new file mode 100644 index 0000000000..276263c7d1 --- /dev/null +++ b/docs/sdlc-harness/architecture/project-profile-integration.md @@ -0,0 +1,183 @@ +# BitFun 子模块设计:项目画像与集成 + +> 上游文档:[design.md](../design.md) +> 模块角色:在 BitFun 加载外部目标项目后,渐进发现、归一化、版本化项目画像,并通过适配器连接项目依赖的代码托管、议题(issue)、CI、文档、发布和观测系统。 + +## 1. 模块定位 + +项目画像是渐进项目理解能力。它让 BitFun 更快进入有用状态,并在风险出现时解释检查、提示、审查和配置建议的来源。 + +它分三层: + +| 层级 | 目的 | 用户体验 | +|---|---|---| +| 轻量项目理解(Lightweight Project Understanding) | 快速识别语言、包管理器、脚本、git 状态、README/AGENTS/CONTRIBUTING/CI 入口 | 快速路径可直接工作 | +| 已确认画像(Confirmed Profile) | 确认规则、负责人、路径边界、验证能力、主动配置信任状态 | PR/团队场景提供更准建议 | +| 集成项目上下文(Integrated Project Context) | 连接 issue、CI、文档、发布、观测系统和多仓库 | 复杂项目、发布和复盘时显露 | + +项目画像采用分层成熟度:快速路径先读取低成本信号;PR、团队治理、发布和复盘场景再补齐负责人、规则、验证能力、外部系统和主动配置状态。这样既能降低误判和脏链接,也能保证第一次有用动作足够快。 + +主动配置是本模块必须发现但不能信任的对象:hook、plugin、自定义工具、MCP server、智能体规则中可改变执行、权限、上下文或网络访问的配置。主动配置默认只作为项目事实,执行权由 [安全边界](security-boundary.md) 和用户/团队策略决定。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [GitHub Copilot 仓库指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions) | 仓库/路径指令和 AGENTS.md 已成为项目规则入口 | +| [Kiro Steering](https://kiro.dev/docs/steering/) | 工作区、全局、团队和加载范围模式可以减少无关上下文 | +| [CodeRabbit 路径指令](https://docs.coderabbit.ai/configuration/path-instructions) | 路径级规则更适合单体多项目仓库和团队差异 | +| [CDEvents](https://cdevents.dev/docs/primer/) | CI/CD 事件应保持松耦合、声明式和互操作 | +| [SLSA Provenance](https://slsa.dev/spec/v0.1/provenance) / [in-toto attestation](https://slsa.dev/blog/2023/05/in-toto-and-slsa) | 构建和交付证据需要来源证明、时间、环境和生成方式 | + +设计约束: + +- 项目画像必须来源可追踪、可刷新、可失效。 +- 缺失或冲突信息必须显式暴露,不能用默认假设掩盖。 +- P0 只做快速路径所需轻量画像,不建设企业级集成平台。 +- 适配器只负责读取、同步和投影外部系统语义,不改变 BitFun 规范模型。 +- 项目画像必须支持多语言、多仓库、多 CI、多发布模式。 +- 未信任主动配置不得影响执行、模式确认或就绪度通过判断。 +- 用户可以跳过非关键画像补全,但跳过结果必须影响信心摘要。 + +## 3. 范围与边界 + +范围: + +- 发现目标项目结构、语言、框架、模块、负责人、规则来源和验证能力。 +- 识别未知区域、规则冲突、过期规则和不可访问外部系统。 +- 发现主动配置并记录来源、hash、权限声明、启用范围和信任状态。 +- 为配置化策略、安全边界、风险分类器、证据包、交付物图谱和评测提供项目画像。 +- 提供代码托管、issue、文档、CI、发布和观测系统的适配器边界。 + +边界: + +- 目标项目的配置管理、需求管理、CI 和发布系统保留系统主权,BitFun 通过适配器读取和投影。 +- 项目画像以实际仓库信号为准,避免把单一技术栈或单一组织流程作为模板。 +- 目标项目无需先改造成 BitFun 推荐结构;BitFun 通过路径、规则和适配器逐步理解。 +- P0 只覆盖快速路径所需画像;组织知识图谱和企业权限系统属于后续集成能力。 +- 主动配置发现只记录候选执行面,信任审批由安全边界和团队策略完成。 + +## 4. 输入、输出与数据模型 + +输入: + +| 输入 | 示例 | +|---|---| +| 仓库事实 | 文件树、依赖文件、构建配置、测试目录、生成文件 | +| 规则来源 | README、CONTRIBUTING、AGENTS.md、`.github/instructions`、CODEOWNERS、模块文档 | +| 验证来源 | package scripts、任务运行器、CI 工作流、测试报告、lint/typecheck/build 命令 | +| 主动配置来源 | hooks、plugins、自定义工具、MCP servers、智能体规则、自动化配置 | +| 负责人来源 | CODEOWNERS、git 历史、issue assignee、团队映射 | +| 外部集成 | GitHub/GitLab、Jira/Linear、Confluence/Notion、CI、制品仓库、观测系统 | +| 用户确认 | 手动确认模块边界、负责人、验证命令、敏感区域和不支持状态 | + +输出: + +```ts +interface ProjectProfile { + project_id: string; + maturity: "lightweight" | "confirmed" | "integrated"; + roots: ProjectRoot[]; + languages: LanguageProfile[]; + modules: ModuleProfile[]; + rule_sources: RuleSource[]; + verification_capabilities: VerificationCapability[]; + ownership: OwnershipProfile; + integrations: IntegrationProfile[]; + risk_areas: RiskArea[]; + active_configs: ActiveConfigProfile[]; + unknowns: ProfileUnknown[]; + conflicts: ProfileConflict[]; + freshness: FreshnessSnapshot; + confidence: number; +} +``` + +关键状态: + +| 状态 | 含义 | 下游影响 | +|---|---|---| +| `confirmed` | 来源明确且已被用户或确定性证据确认 | 可作为强制策略、风险和图谱的强依据 | +| `inferred` | 由文件、配置、历史或静态分析推断 | 可作为候选依据,需要展示置信度 | +| `unknown` | 缺少足够信息 | 下游保持建议态或降级状态,必要时要求人工确认 | +| `conflicting` | 多个规则来源冲突 | 下游不得自动选择高风险路径 | +| `stale` | 来源已变更或超过刷新窗口 | 需要刷新或重新确认 | + +画像生成优先级: + +| 来源 | 优先级 | 说明 | +|---|---:|---| +| 组织拒绝/安全策略 | 0 | 不允许被用户级配置覆盖 | +| 用户确认 | 1 | 高风险规则、负责人、发布边界以用户确认为准 | +| 确定性配置 | 2 | CI、build、package、CODEOWNERS、类型化配置 | +| 项目文档 | 3 | README、贡献指南、智能体规则、模块文档 | +| 历史信号 | 4 | 共同变更、事故、审查阻塞项、高频热点文件 | +| 模型推断 | 5 | 只能生成候选,不作为事实 | + +## 5. 核心流程 + +```text +打开目标项目 + -> 执行轻量本地发现 + -> 允许快速路径启动 + -> 渐进发现规则和验证来源 + -> 发现主动配置并发送到安全边界 + -> 归一化画像并标记未知、冲突和过期状态 + -> 只对关键缺口请求确认 + -> 发出 project.profiled 事件 + -> 项目变化时刷新或失效 +``` + +主动配置状态: + +| 状态 | 含义 | 下游影响 | +|---|---|---| +| `discovered` | 已发现配置,但尚未审核 | 只能展示,不得执行 | +| `trusted` | 用户或策略确认来源、hash、权限和范围 | 可按权限执行并写审计 | +| `changed` | 内容、hash、权限或来源变化 | 原信任失效,需要重新确认 | +| `disabled` | 用户、策略或安全规则禁用 | 不参与执行,可保留审计记录 | + +## 6. 适配器边界 + +| 适配器 | 读取对象 | 输出到 BitFun | +|---|---|---| +| Git 适配器 | branch、diff、commit、PR ref、历史 | 变更集、负责人提示、风险信号 | +| Issue 适配器 | issue、ticket、验收标准、负责人、状态 | 交付物节点、需求上下文 | +| 文档适配器 | 设计文档、运行手册、决策记录、团队规则 | 规则来源、上下文来源 | +| CI 适配器 | workflow、job、检查、artifact、日志摘要 | 验证能力、证据项 | +| 发布适配器 | 发布、artifact、环境、回滚信息 | 发布就绪度上下文 | +| 观测适配器 | 事故、指标、轨迹/日志链接、告警 | 运行时反馈和图谱回溯 | + +适配器只输出规范事实和证据引用,不直接写就绪度、门禁或安全结论。 + +## 7. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 轻量项目理解、规则入口发现、验证命令候选、主动配置发现、未知/冲突标记 | +| P1 | 用户确认、画像刷新、路径级规则、主动配置信任审查持久化 | +| P2 | GitHub/GitLab PR、issue、CI 适配器;团队策略包 | +| P3 | 文档、发布、观测适配器;多仓库和多工作区支持 | +| P4 | 画像漂移看板、跨项目画像对比和治理指标 | + +## 8. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 画像误判导致错误门禁 | 未确认画像只能作为候选;强策略必须引用已确认或确定性证据 | +| 入门过重 | P0 必须在几分钟内生成可用轻量画像,并允许边工作边补全 | +| 对 BitFun 自身验证样本过拟合 | 默认模式不能内置 BitFun 路径、语言或验证命令 | +| 外部系统耦合 | 适配器输出规范事实,不让外部载荷泄漏到核心策略 | +| 敏感信息泄露 | 画像写入前执行脱敏,凭据和私有日志只存引用或摘要 | +| 主动配置被误认为可信规则 | hook、plugin、自定义工具只作为已发现事实,必须信任审查后才可执行 | +| 画像过期 | 文件、CI、规则或集成状态变化必须触发新鲜度更新 | +| 用户不信任推断 | 用户界面必须展示来源、置信度、冲突和确认状态 | + +## 9. 成功标准 + +- 新目标项目加载后可快速生成轻量画像并开始快速路径。 +- 用户无需先配置完整 `.bitfun` 就能完成普通任务。 +- 风险分类器、变更就绪度和安全边界能解释所用项目事实来自哪里。 +- 未知或冲突规则会降低信心,并进入建议态或降级状态。 +- 外部适配器接入不会改变 BitFun 规范事件、交付物、权限和策略模型的一致性。 +- 主动配置能被发现、展示、确认、禁用和重新确认,且默认不自动执行。 diff --git a/docs/sdlc-harness/architecture/quality-data-plane.md b/docs/sdlc-harness/architecture/quality-data-plane.md new file mode 100644 index 0000000000..35de42737f --- /dev/null +++ b/docs/sdlc-harness/architecture/quality-data-plane.md @@ -0,0 +1,201 @@ +# BitFun 子模块设计:质量数据面 + +> 上游文档:[design.md](../design.md) +> 模块角色:为 BitFun 加载的目标项目提供统一事件、证据、指标和审计数据模型,用于解释、恢复、回放和校准产品治理策略。 + +## 1. 模块定位 + +质量数据面是事实数据层。它负责把项目画像、任务、工具调用、文件变更、验证命令、策略决策、安全授权、审查、CI、发布和运行期反馈整理成可追踪、可裁剪、可回放的数据事实,并为摘要、审查、门禁、复盘和评测提供同一事实来源。 + +P0 事件集优先支撑三件事: + +1. 普通任务结束时能解释做了什么、验证了什么、没验证什么。 +2. 安全敏感动作能追溯允许、询问、拒绝、应急放行的原因和范围。 +3. 配置化策略能用真实数据校准是否过度打断、误升级或漏提示。 + +证据包、交付物图谱、风险分类器、变更就绪度、PR 门禁和智能体评测都消费同一事实层;模块通过稳定查询和投影契约读取事实,避免各自重新定义字段。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/concepts/semantic-conventions/) | 轨迹、指标、日志和资源需要统一语义,避免观测数据孤岛 | +| [CDEvents](https://cdevents.dev/) | CI/CD 事件需要可互操作的事件模型 | +| [SLSA provenance](https://slsa.dev/spec/v1.0/) / [in-toto attestations](https://in-toto.io/) | 构建、验证和制品元数据应具备来源与证明 | +| [Codex approvals/security](https://developers.openai.com/codex/agent-approvals-security) | 审批、安全授权和工具执行要能审计,安全授权与质量就绪度分层记录 | +| [NIST SP 800-218A](https://csrc.nist.gov/pubs/sp/800/218/a/final) | AI 进入 SDLC 后,模型、数据、工具、权限和供应链都属于安全开发边界 | + +设计约束: + +- P0 只采集快速路径、安全边界和信心摘要所需事件。 +- 每类事件必须定义保留周期、隐私分级、脱敏、载荷大小和导出策略。 +- 事件字段需要稳定语义命名,避免子模块各自定义不可对齐的事实。 +- 证据包只保存摘要和引用,不长期保存无界原始日志。 +- 内部事件模型保持规范;OpenTelemetry、CDEvents、SLSA 等作为导出适配和互操作参考。 +- 证据必须区分信任层级:确定性事实、外部系统事实、人工确认、模型推断和插件建议不能混为同一等级。 +- 新事件域必须声明生产者、消费者、保留周期、隐私分级、迁移和导出策略。 + +## 3. 范围与边界 + +范围: + +- 定义 `LifecycleEvent` 事件信封。 +- 统一任务、会话、策略、安全、工具、文件、验证、审查、门禁、成本和主动配置的事件域。 +- 为证据包、交付物图谱、风险分类器、变更就绪度、PR 门禁和智能体评测提供事实输入。 +- 支持本地追加式审计、投影查询和外部导出。 + +边界: + +- 质量数据面聚焦 BitFun 工程治理事实,不建设通用日志平台。 +- 终端输出、模型上下文和第三方载荷按摘要、引用、脱敏和保留策略处理。 +- CI、APM、SIEM 和数据仓库保留系统主权,BitFun 做事件映射和投影。 +- 模型摘要以推断或建议身份进入事件,原始事实来自确定性系统、人工确认或受信外部系统。 +- 事件数量只用于诊断和运营,产品质量需要结合结果指标、后验缺陷和用户反馈判断。 + +## 4. 输入、输出与数据模型 + +核心输入: + +| 输入 | 来源 | +|---|---| +| 项目画像事件 | 项目结构、规则、负责人、验证模式、发布模型 | +| 任务事件 | 用户意图、模式、任务完成、信心摘要 | +| 策略事件 | 配置化策略决策、原因、模式转换 | +| 安全事件 | 权限、执行位置、沙箱等级、网络、凭据、prompt 注入、应急放行 | +| 工具事件 | 命令、工具调用、审批、退出码、耗时 | +| 文件事件 | diff、重命名、删除、生成文件、文件监听 | +| 验证事件 | 命令、退出码、耗时、日志摘要、制品引用 | +| 审查/门禁事件 | 深度审查、问题、就绪度、门禁投影 | +| 性能/成本事件 | 首次有用动作耗时、后台分析耗时、token、模型、墙钟耗时、工具耗时 | +| 主动配置事件 | hook、plugin、自定义工具信任状态、hash、权限声明、启用范围 | +| 阶段收益事件 | 阶段用户收益、技术前置、延期边界、质量一致性抽样和验收结果 | + +核心输出: + +- 快速任务信心摘要。 +- 安全边界审计引用。 +- 证据包源事件和证据引用。 +- 风险分类器校准特征。 +- 变更就绪度和 PR 门禁状态。 +- 交付物图谱边证据。 +- 智能体评测回放轨迹。 +- 阶段收益评审和质量一致性抽样。 +- 审计导出和最小指标集。 + +事件信封: + +```ts +interface LifecycleEvent { + id: string; + type: string; + version: number; + timestamp: string; + source: EventSource; + actor: EventActor; + scope: EventScope; + correlation: EventCorrelation; + payload: unknown; + evidence?: EvidenceReference[]; + risk?: RiskSnapshot; + privacy: PrivacyClass; + retention: RetentionPolicy; +} +``` + +证据信任等级: + +| 等级 | 来源 | 是否可作为强制要求/阻断依据 | +|---|---|---| +| `deterministic` | 本地命令、测试、CI 检查、签名制品、静态配置 | 可以 | +| `external_system` | GitHub、Jira、CI、观测适配器返回的已认证事实 | 可以,但需记录适配器和刷新时间 | +| `human_confirmed` | 用户确认、审查人决策、风险接受 | 可以,但必须记录操作者和原因 | +| `model_inferred` | LLM 摘要、候选影响面、候选风险标签 | 不可以,只能作为候选或说明 | +| `plugin_suggested` | 第三方 hook/plugin 产生的建议 | 不可以,必须经过 BitFun 策略或人工确认 | + +## 5. P0 最小事件集 + +| 事件 | 用途 | +|---|---| +| `project.profiled.light` | 关联轻量项目结构、规则入口和验证候选 | +| `task.started` / `task.completed` | 关联一次用户任务与结果摘要 | +| `policy.decided` | 固化模式、触发原因、推荐/强制动作 | +| `security.decided` | 固化允许/询问/拒绝/应急放行、执行位置、沙箱等级组合、范围、原因、降级和残余风险 | +| `sandbox.capability.evaluated` | 记录当前执行面可用的快照、worktree、只读、网络限制、进程隔离或容器能力 | +| `user.override.recorded` | 记录跳过、风险接受或临时放行 | +| `active_config.discovered` | 固化 hook、plugin、自定义工具、MCP、智能体规则的来源、hash、权限声明和未信任状态 | +| `extension.hook.dispatched` | 记录 Kernel 扩展点被触发、适用范围、deadline 和空实现/候选处理结果 | +| `extension.effect.candidate` | 记录外部适配器或空实现返回的建议、证据、复写候选或阻断建议 | +| `tool.override.candidate` | 记录工具复写候选的来源、hash、目标工具、能力范围和信任状态 | +| `telemetry.point.recorded` | 记录可采样的扩展点打点,用于成本、超时、失败和降级分析 | +| `file.changed` | 更新 diff 摘要和风险候选 | +| `tool.completed` | 采集工具和命令输出摘要 | +| `verification.completed` | 形成验证证据 | +| `confidence.summary.generated` | 固化任务结束的用户可见信心摘要 | +| `evidence_pack.generated` | 在需要时固化摘要/证据引用/完整证据包 | +| `stage.outcome.evaluated` | 记录阶段用户收益、必要技术前置、延期边界和验收结果 | +| `quality.consistency.sampled` | 记录同一任务中需求、开发、验证、审查或发布强度是否匹配 | +| `performance.budget.evaluated` | 记录阶段性能预算、实际耗时、事件载荷、成本和回退判定 | + +P1/P2 再引入: + +| 事件 | 触发 | +|---|---| +| `risk.policy_hinted` | 上下文保障 | +| `readiness.generated` | PR 或审查场景 | +| `gate.projected` | 团队/守护/合规策略 | +| `review.completed` | 定向/完整审查 | +| `artifact.edge.updated` | 复杂项目图谱 | + +## 6. 核心流程 + +```text +项目/任务/智能体/工具/安全事件 + -> 归一化为 LifecycleEvent + -> 脱敏并标注隐私分级 + -> 追加本地审计日志 + -> 更新投影存储 + -> 暴露摘要、证据、就绪度和评测查询 + -> 按需通过适配器导出 +``` + +治理规则: + +- **本地优先**:默认写入本地追加式日志,外部导出需显式配置。 +- **事件预算**:每类事件设置载荷大小、采样和保留周期。 +- **隐私分级**:区分公开、项目、敏感和凭据;凭据只保留脱敏引用或授权状态。 +- **证据引用**:大日志、报告、截图、轨迹使用 `EvidenceReference` 引用,不内嵌。 +- **语义稳定**:核心字段采用稳定命名和版本;用户界面文案和外部载荷通过投影层转换。 +- **信任分层**:就绪度、门禁和发布就绪度必须能区分事实、候选、建议和人工接受。 +- **可重放性**:关键事件版本化,结构变更提供迁移或兼容读取。 +- **导出隔离**:导出到 GitHub、OpenTelemetry、CDEvents 或云端时保留脱敏和权限策略。 + +## 7. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 轻量项目、任务、策略、安全、验证和信心摘要事件 | +| P1 | 风险提示、就绪度、定向审查、成本和提示体验指标 | +| P2 | 团队策略、门禁投影、主动配置信任审查、外部 CI/PR 事件 | +| P3 | 交付物图谱、发布、事故、观测事件接入 | +| P4 | 评测回放、跨团队指标、策略回放分析 | + +## 8. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 遥测膨胀 | P0 事件集必须能解释快速路径和安全决策,采集范围按阶段扩展 | +| 原始日志泄露 | 证据包保存摘要和引用;敏感片段必须脱敏 | +| 事件不可治理 | 每个事件域必须定义负责人、保留周期、结构版本和导出策略 | +| 结构漂移 | 新事件或字段必须先进入注册表,并提供兼容读取或迁移策略 | +| 模型摘要覆盖事实层 | 模型输出作为派生证据,原始命令/CI/审查事实保持独立 | +| 跨模块耦合过重 | 上游模块依赖查询接口和事件结构,内部存储保持封装 | +| 审计不可复现 | 策略、安全、就绪度、门禁、审查、风险等关键结论必须能追溯到事件 id 和证据引用 | + +## 9. 成功标准 + +- 普通任务可以生成可解释信心摘要。 +- 安全提示、拒绝、应急放行都能追溯到事件和范围。 +- 证据包、变更就绪度和门禁复用同一事实层。 +- 深度审查 token、耗时、范围、跳过项上下文可被统一记录。 +- 事件模型能够导出到至少一种外部标准或平台。 +- hook/plugin/custom 工具的信任状态可通过事件追溯到来源、hash、权限和审核人。 diff --git a/docs/sdlc-harness/architecture/security-boundary.md b/docs/sdlc-harness/architecture/security-boundary.md new file mode 100644 index 0000000000..0d8a2a75a6 --- /dev/null +++ b/docs/sdlc-harness/architecture/security-boundary.md @@ -0,0 +1,208 @@ +# BitFun 架构设计:安全边界 + +> 上游文档:[design.md](../design.md) +> 模块角色:为 prompt 注入、主动配置、MCP、hook、shell、网络、凭据、跨目录写入和发布凭据等执行安全风险提供默认常驻、可解释、可临时放行且可审计的安全边界。 + +## 1. 模块定位 + +安全边界是 BitFun Agent 执行层的安全域。它判断当前动作是否可能越权、泄露、破坏环境或被恶意上下文操控,并输出允许、沙箱允许、询问、应急放行、拒绝或策略拒绝。 + +这层必须默认启用。即使用户处于快速路径、质量保障要求较低、演示原型或无 git 目录场景,安全边界仍然生效。 + +## 2. 设计原则 + +- **安全与质量分离**:缺测试进入质量建议;读取凭据、执行未知 hook、联网上传文件进入安全决策。 +- **先隔离再提速**:优先通过沙箱、白名单、范围授权和一次性授权降低打断。 +- **应急放行可审计**:临时放行高风险动作必须带范围、后果、期限、撤销路径和残余风险。 +- **项目配置按信任状态执行**:仓库内 hook、MCP、智能体规则、自定义工具、CI helper 必须记录来源、hash、权限和信任状态。 +- **工具复写按能力授权**:用户可以启用工具复写,但确认只授予指定范围内的工具语义替换;文件、shell、网络和凭据访问仍逐次受安全边界约束。 +- **模型输出作为建议**:prompt、AGENTS.md 或工具描述影响提示和解释,实际权限由安全边界执行。 + +### 2.1 风险判定模型 + +安全边界不按工具名称、命令关键词或模型判断结果直接授权。每次 tool、MCP、skills、插件、hook、shell、文件写入、网络访问或浏览器/桌面动作都应先归一为风险评估请求: + +| 字段 | 判定作用 | +|---|---| +| 动作 | 读、写、执行、联网、安装、发布、删除、复写工具或修改策略 | +| 来源 | 用户直接要求、模型推断、项目规则、工具输出、MCP、skill、插件或外部内容 | +| 目标 | 工作区文件、工作区外路径、系统配置、远程主机、生产资源、浏览器上下文或凭据 | +| 能力 | 文件读写、shell、网络、凭据、浏览器/桌面、工具复写、插件执行或策略变更 | +| 数据类别 | 普通代码、内部数据、隐私数据、凭据、发布制品、日志或 prompt 上下文 | +| 信任级别 | 用户输入、已信任项目配置、未信任仓库内容、PR/issue、网页、日志、工具返回 | +| 可恢复性 | 可撤销、可回滚、需人工恢复、不可逆或影响外部系统 | +| 影响范围 | 当前文件、当前项目、用户环境、远程环境、团队共享配置、生产或第三方系统 | +| 策略上下文 | 用户偏好、任务覆盖、工作区配置、项目规则、组织策略和当前执行域 | + +关键安全风险按后果判定。满足任一条件时,至少进入强确认、隔离执行或阻断路径: + +- 可能读取、复制、上传、缓存、打印或传给模型的对象包含凭据、token、SSH key、cookie、云账号、私有数据或企业内部敏感信息。 +- 动作可能删除、覆盖、发布、force push、修改权限策略、修改 CI/CD、改变安全配置或影响生产/远程资源。 +- 不可信内容影响后续工具调用、命令、网络访问、文件写入、授权或发布决策。 +- 动作会扩大智能体能力,例如新增 MCP server、启用插件、复写工具、安装并执行未知依赖或改变沙箱/网络/审批策略。 +- 动作会产生外发、持久化或跨执行域影响,例如调用未知外部 API、写全局配置、修改团队共享规则或跨项目复用授权。 + +安全边界可以使用模型生成解释或辅助低置信判断,但最终决策只能来自能力声明、目标分类、数据分类、信任状态、策略上下文和内核事实。 + +## 3. 风险类别 + +| 类别 | 例子 | 默认处理 | +|---|---|---| +| Prompt 注入 | README/issue/doc 要求泄露凭据、忽略策略、执行外部脚本 | 标记为恶意指令,隔离为不可信上下文 | +| 主动配置 | hook、plugin、MCP server、自定义工具、工具复写、智能体规则、工作流脚本 | 已发现时默认未信任,需确认后执行 | +| 网络访问 | 下载依赖、curl 外部域名、上传日志、访问未知 API | 默认按域名/目的说明确认 | +| 凭据访问 | `.env`、SSH key、token、cloud credential、browser cookie | 默认阻断或要求明确范围授权 | +| 文件系统越界 | 写工作区外路径、改 home/config、删除大量文件 | 默认确认,高危路径默认阻断 | +| Shell 执行 | 未知脚本、安装包 postinstall、生成命令链 | 沙箱内可低摩擦,越界则确认 | +| 破坏性动作 | 删除、force push、reset、发布、deploy | 默认确认,组织策略可阻断 | +| 数据外泄 | 把代码、日志、prompt、凭据或制品发到外部服务 | 默认确认或阻断,必须说明目标 | + +## 4. 权限动作 + +```ts +type SecurityDecision = + | "allow" + | "allow_in_sandbox" + | "ask" + | "ask_with_break_glass" + | "deny" + | "deny_by_policy"; + +type ExecutionLocation = + | "local_host" + | "remote_ssh" + | "container" + | "acp_client" + | "mcp_server" + | "plugin_domain" + | "browser_or_desktop" + | "cloud_task"; + +type SandboxLevel = + | "none" + | "permission_only" + | "snapshot_or_worktree" + | "readonly_scope" + | "network_restricted" + | "process_isolated" + | "containerized"; + +interface SandboxFallback { + reason: string; + next_best_option: "ask" | "ask_with_break_glass" | "deny"; +} + +interface SecurityBoundaryDecision { + decision: SecurityDecision; + risk: "low" | "medium" | "high" | "critical"; + reasons: string[]; + requested_capabilities: Capability[]; + scope: SecurityScope; + execution_location: ExecutionLocation; + sandbox_levels: SandboxLevel[]; + sandbox_fallback?: SandboxFallback; + user_options: SecurityOption[]; + audit_level: "none" | "local" | "project" | "organization"; +} +``` + +默认体验: + +| 动作 | 默认 | +|---|---| +| 读工作区普通文件 | allow | +| 写当前工作区 | allow 或 allow_in_sandbox | +| 运行已识别的 test/lint/build 命令 | allow_in_sandbox | +| 联网访问未知域名 | ask | +| 读取凭据 | ask_with_break_glass 或 deny | +| 执行未信任 hook/MCP/custom 工具 | ask_with_break_glass | +| 启用内置工具复写 | ask_with_break_glass,且绑定项目、来源、hash、权限和期限 | +| 写工作区外路径 | ask_with_break_glass | +| 删除大量文件、force push、发布 | ask_with_break_glass 或 deny_by_policy | + +## 5. 沙箱能力边界 + +沙箱不是单一能力。BitFun 需要同时区分三类保护: + +| 类型 | 作用 | 不能替代 | +|---|---|---| +| 权限确认 | 让用户或策略决定动作是否可执行 | 不能限制进程、文件系统或网络副作用 | +| 快照/回滚隔离 | 记录、回滚或隔离工作区文件变化 | 不能阻止命令读取凭据、联网或影响主机 | +| 运行时沙箱 | 通过只读目录、临时 worktree、容器、无凭据环境、网络策略或受控 facade 限制副作用 | 不能替代审计、来源信任和人工风险判断 | + +目标能力矩阵: + +| 执行面 | 执行位置 | 默认边界 | 沙箱能力 | 用户确认 | +|---|---|---|---|---| +| 本地文件工具 | 本机当前工作区 | 路径解析、工作区根、受管路径策略 | 快照/回滚、临时 worktree、只读/写入范围 | 跨根目录写、删除、高危路径需要确认或拒绝 | +| 本地 shell | 本机用户 shell | 命令说明、危险动作识别、超时、输出摘要 | 临时 worktree、无凭据环境、网络禁用或容器执行 | 未知脚本、安装后脚本、网络、发布和破坏性命令需要确认 | +| 远程 SSH / Dev Container | 远程主机或容器 | 显示主机、工作区根、路径映射、端口/网络边界 | 远程侧临时 worktree、容器、只读挂载、远程无凭据环境 | 本地 Host 不代替远程执行;远程不可支持能力给替代路径 | +| ACP 客户端 | 本地或远程 ACP 进程 | `ask/allow_once/reject_once` 权限桥接、客户端配置和会话范围 | 只读模式、受控工作区、隔离进程或远程执行域 | ACP 允许只表达本次授权;文件、shell、网络和凭据仍按安全边界判定 | +| MCP server / MCP tool | 本地、远程或项目配置声明的位置 | 来源、hash、工具声明、读写能力和用户授权 | 禁用未知来源、只读工具集、受控网络、隔离进程 | 工具自称只读不能自动可信;实际能力变化后重新确认 | +| WebView / MiniApp / 生成式 UI | 前端 iframe 或受控 JS worker | iframe sandbox、postMessage bridge、host facade | iframe sandbox、worker、host-side allowlist、fs/net/shell scope | UI 沙箱不授予宿主文件、网络或 shell 权限 | +| 插件适配主机 | Kernel 管理的 Host / cell / worker / subprocess / sandbox | 项目执行域、来源信任、权限 facade、候选效果校验 | cell、worker、subprocess、容器/无凭据 sandbox | 插件只返回候选效果;授权、审计和工具执行由 Kernel 写入 | +| 浏览器/Computer Use | 本机桌面或浏览器上下文 | 桌面能力开关、动作确认、不可远程时明确禁用 | 受控浏览器上下文、临时 profile、禁止敏感域或剪贴板范围 | 不能在远程工作区假装本地桌面能力可用 | +| 云端异步任务 | 云端任务执行域 | 任务前授权、阶段性授权、取消和审计续接 | 临时环境、无凭据启动、网络策略、只读仓库和受控 secret 注入 | 长任务减少弹窗,但高风险阶段必须提前或阶段性确认 | + +最低实现要求: + +- `security.decided` 必须记录执行位置、沙箱等级组合、授权范围、残余风险和是否存在降级。 +- 用户界面必须能说明“当前动作在本机、远程、容器、ACP 客户端还是插件执行域执行”。 +- `allow_in_sandbox` 只能在实际沙箱或隔离路径存在时返回;否则应降级为 `ask`、`ask_with_break_glass` 或 `deny`。 +- 无法提供运行时沙箱时,仍可提供权限确认和审计,但文案必须说明这不是系统级隔离。 +- 远程、ACP 和插件场景必须绑定执行域;信任、工具复写、事件订阅和授权不能跨项目或跨执行主机复用。 + +阶段建设: + +| 阶段 | 能力 | +|---|---| +| P0 | 展示执行位置、工作区根、授权范围和基础 allow/ask/deny;记录沙箱不可用时的降级原因 | +| P1 | 本地临时 worktree、只读/写入范围、远程上下文提示、ACP 权限桥接和 UI iframe sandbox 统一记录 | +| P2 | 工作区配置声明可信命令、域名、路径和凭据范围;支持撤销、过期和项目级审计 | +| P3 | 插件适配主机原型验证 cell/worker/subprocess/sandbox 分级和项目执行域隔离 | +| P4 | 企业受管 sandbox、容器策略、无凭据运行、网络策略、签名插件和跨项目审计导出 | + +## 6. 应急放行规则 + +应急放行(break-glass)用于处理临时、应急或隔离环境下的高风险动作,规则如下: + +- 范围必须明确:单次命令、单个域名、单个目录、当前会话、当前 worktree 或当前任务。 +- 默认只对当前范围生效;保存为项目规则必须显式确认。 +- 高风险授权要显示后果,例如“此命令可能读取 `.env` 并访问 `api.example.com`”。 +- 对关键风险,优先建议隔离环境:临时 worktree、容器、无凭据沙箱、禁用网络或只读目录。 +- 组织/项目受管策略可以禁止本地应急放行。 +- 所有应急放行都必须可撤销、可查看、可过期。 + +## 7. 与质量治理的分界 + +| 问题 | 所属层 | +|---|---| +| 测试没跑 | 配置化策略 / 质量 | +| CI 不稳定 | 质量数据面 / 变更就绪度 | +| PR 需要审查人 | 风险分类器 / 团队治理 | +| 文档注入要求泄露 token | 安全边界 | +| 新增 MCP server | 安全边界 + 项目画像 | +| 迁移脚本影响生产数据 | 安全边界 + 守护策略 | +| 用户跳过深度审查 | 配置化策略 | +| 用户允许联网下载依赖 | 安全边界应急放行 | + +## 8. 边界场景 + +| 场景 | 正确行为 | +|---|---| +| 无 git 或无团队规则且质量保障要求较低的任务 | 允许快速写当前工作区;联网、凭据、删除仍确认 | +| 用户明确说“不要问,直接跑” | 只能降低质量提示;安全越界仍提示或要求沙箱 | +| 仓库 AGENTS.md 要求禁用安全检查 | 视为普通项目规则,不影响强制执行 | +| hook 文件刚被 PR 修改 | 信任状态失效;不能继续按旧信任执行 | +| 项目插件复写 `bash` | 显示复写来源、hash、权限和撤销入口;复写只在当前项目执行域生效,实际命令仍经 shell 安全策略 | +| MCP server 描述自己是 read-only | 仍以工具声明、实际能力和用户授权为准 | +| 依赖安装脚本需要网络 | 展示域名和命令来源;可允许本次安装,不默认授予智能体阶段 | +| 发布 token 在环境变量中 | 默认不暴露给智能体;发布操作经受控适配器或用户确认 | + +## 9. 成功标准 + +- 快速路径不因为安全系统产生过度弹窗。 +- 高风险动作不会被 prompt、项目文档或插件自行授权。 +- 用户能在质量保障要求较低或应急场景清楚地一次性放行,并知道风险。 +- 组织强策略能禁止本地绕过。 +- 安全事件可追踪,但不强迫普通项目进入质量审计流程。 diff --git a/docs/sdlc-harness/design.md b/docs/sdlc-harness/design.md new file mode 100644 index 0000000000..f46ec4820a --- /dev/null +++ b/docs/sdlc-harness/design.md @@ -0,0 +1,169 @@ +# BitFun 可配置开发体验与工程治理架构设计 + +> 产品输入:[product-requirements.md](product-requirements.md) +> 范围:把产品需求转为架构边界、领域模型、配置优先级和模块职责。用户画像、体验路径和功能需求以产品需求文档为准。 + +## 1. 设计目标 + +| 目标 | 架构要求 | +|---|---| +| 快速路径轻量 | 项目打开、任务执行和结果摘要不依赖完整证据包或图谱 | +| 用户语言稳定 | 内部策略画像不直接外露,统一投影为任务状态、弱提示、确认和设置 | +| 策略可解释 | 每次提示、升级、阻断和覆盖都有来源、原因和适用范围 | +| 阶段收益可交付 | 架构能力按用户收益切片落地,技术跑道、API 预设和测试桩不伪装成用户价值 | +| 安全独立 | 执行安全与质量治理分层,安全边界优先于用户覆盖和质量模式 | +| 配置可组合 | 用户、任务、工作区、路径、团队和组织策略按稳定优先级合成 | +| 证据可追溯 | PR、发布、事故和合规场景可以引用证据包、质量事件和交付物图谱 | +| 评估可回放 | 策略、工具、模型、上下文和安全版本能进入评测与指标分析 | +| 生态可扩展 | 先在 Rust 内核预留 hook、事件、打点和工具复写候选契约;外部插件生态后续通过适配器接入 | + +## 2. 复杂来源 + +| 来源 | 例子 | 架构要求 | +|---|---|---| +| 结构复杂 | 单体多项目、多语言、多服务、生成代码、跨仓库依赖 | 渐进项目画像和路径级规则 | +| 流程复杂 | issue、spec、PR、发布、事故分散在多个系统 | 交付物图谱后台关联,按场景投影 | +| 验证复杂 | 私有依赖、不稳定 CI、本地环境缺口 | 替代验证建议和不可验证解释 | +| 风险复杂 | 权限、网络、凭据、迁移、发布、远程工作区 | 安全边界和守护策略分层处理 | +| 团队复杂 | CODEOWNERS、组织策略、合规审计 | 团队治理和风险接受 | +| 信息复杂 | 旧文档、冲突规则、prompt 注入、未知配置 | 来源、信任、新鲜度和冲突状态 | + +## 3. 顶层领域模型 + +| 领域 | 核心对象 | 作用 | +|---|---|---| +| 项目理解 | 工作区、仓库、执行环境、远程上下文、语言、框架、模块、规则来源、验证能力 | 减少用户解释和错误路径选择 | +| 用户画像 | 用户类型、任务熟悉度、偏好、授权范围、受管状态 | 选择默认入口、解释语言和提示密度 | +| 任务与意图 | 任务、阶段、目标路径、会话模式、用户覆盖 | 决定快速、PR、发布或应急路径 | +| 阶段收益 | 用户可见收益、必要技术前置、延期边界、降级解释、质量一致性检查 | 约束阶段交付必须产生可解释体验变化 | +| 体验投影 | 内部策略画像、用户可见状态、提示层级、确认策略、设置项 | 降低用户学习成本和提示噪音 | +| 执行安全 | 权限、执行位置、沙箱等级、网络域名、凭据访问、主动配置信任、应急放行 | 防止 prompt、配置或工具越权 | +| 内核扩展点 | 生命周期 hook、规范事件、打点、工具复写候选、插件效果候选 | 为后续多插件生态适配预留稳定契约 | +| 变更信心 | 变更摘要、验证摘要、风险提示、未验证项、跳过检查 | 给用户可理解的信心和下一步行动 | +| 团队治理 | 路径规则、审查强度、强制检查、风险接受、审计策略 | 统一团队和高可靠场景体验 | +| 生命周期上下文 | issue、spec、计划、PR、发布、事故、学习资产 | 支撑复杂项目追溯和复盘 | +| 评测与学习 | 轨迹回放、评测任务、判定标准、反馈、指标 | 证明策略改善体验和质量 | + +## 4. 逻辑架构 + +```text +用户界面 + 快速工作台 / 变更摘要 / PR 就绪度 / 发布审查 / 设置 + +体验投影层 + 任务状态 / 弱提示 / 可折叠摘要 / 确认 / 受限原因 / 降噪规则 + +阶段收益编排 + 用户收益 / 技术前置 / 延期边界 / 降级解释 / 质量一致性检查 + +配置化策略面 + 意图识别 / 策略画像 / 风险提示 / 审查画像 / 覆盖策略 + +安全边界 + 执行位置 / 沙箱等级 / 权限 / 网络 / 凭据 / 主动配置信任 / 应急放行 + +内核扩展点 + 生命周期 hooks / 规范事件 / 打点 / 工具复写候选 / 插件效果候选 + +未来插件适配主机(按需启用) + 适配器注册表 / 项目执行域 / 插件单元 / 工具门面 / 事件路由 / 候选效果输出 + +交付物与证据面 + 证据包 / 交付物图谱 / 问题生命周期 / 风险接受 + +质量数据面 + 生命周期事件 / 执行轨迹 / 验证摘要 / 指标 / 本地审计 + +项目集成面 + Git / CI / Issue / 文档 / 发布 / 观测 / 知识库 + +智能体与工具运行时 + 会话 / 智能体 / 终端 / 文件系统 / MCP-LSP / Hooks / 适配器 +``` + +关键边界: + +- 用户界面展示用户可理解的任务状态、摘要、提示和确认。 +- 体验投影层把内部策略画像映射为用户语言,并负责提示合并、延后和降噪。 +- 阶段收益编排约束每次落地必须说明用户可见收益、后台前置、延期边界和质量一致性。 +- 配置化策略面决定内部体验强度、检查建议、证据展示层级和用户覆盖选项。 +- 安全边界负责权限、执行位置、沙箱等级、网络、凭据和高风险动作隔离;权限确认、快照/回滚隔离和运行时沙箱必须分开表达。 +- Kernel 扩展点先提供空实现和稳定契约;未来插件适配主机由 Rust 内核管理生命周期,只返回建议、证据候选、工具参数补丁或工具结果候选。内核仍是状态、安全、工具执行和审计的唯一事实源。 +- 交付物、证据和质量数据面支撑解释、审查、发布和复盘。 +- 项目集成面适配外部系统,并把外部语义映射为内部稳定事件和契约。 +- 智能体运行时执行任务,并通过策略面和安全边界获取质量结论与授权状态。 + +## 5. 模块边界 + +| 模块 | 文档 | 产品角色 | 默认显露 | +|---|---|---|---| +| 配置化策略画像 | [configurable-policy-profile.md](features/configurable-policy-profile.md) | 决定内部策略画像、检查强度和提示投影 | 是 | +| 阶段收益编排 | [implementation-plan.md](implementation-plan.md) | 把能力拆成用户收益、技术前置、延期边界和验收切片 | 发布说明/阶段评审中 | +| 安全边界 | [security-boundary.md](architecture/security-boundary.md) | 管执行安全、执行位置、沙箱等级、权限、应急放行 | 是,但低噪音 | +| 项目画像与集成 | [project-profile-integration.md](architecture/project-profile-integration.md) | 渐进理解项目结构、规则和验证能力 | 部分 | +| 质量数据面 | [quality-data-plane.md](architecture/quality-data-plane.md) | 记录事件、验证、提示和安全决策 | 否 | +| 证据包 | [evidence-pack.md](architecture/evidence-pack.md) | 统一证据投影契约 | 快速路径否,PR/治理时是 | +| 交付物图谱 | [artifact-graph.md](architecture/artifact-graph.md) | 关联 diff、验证、PR、issue、发布、事故 | 快速路径否,复杂项目按需 | +| 风险分类器 | [risk-classifier.md](features/risk-classifier.md) | 输出风险原因、检查建议和升级信号 | 以提示形式 | +| 变更就绪度 / PR 门禁 | [pr-quality-gate.md](features/pr-quality-gate.md) | 生成 PR 信心摘要;强策略下成为门禁 | 仅准备 PR 或配置启用 | +| 需求影响分析 | [requirement-impact-analysis.md](features/requirement-impact-analysis.md) | 高风险需求/API/设计变更的影响候选 | 按需 | +| 智能体评测 | [agent-evaluation.md](features/agent-evaluation.md) | 评估智能体、上下文、策略和治理策略 | 否 | +| OpenCode 兼容层 | [opencode-compatibility.md](features/opencode-compatibility.md) | 先提供主动配置发现和扩展点契约,后续作为生态适配器接入 | 受安全边界管控 | + +## 6. 配置层级 + +| 层级 | 例子 | 作用 | +|---|---|---| +| 用户偏好 | 用户选择快/严、语言、默认授权范围 | 个体体验默认值 | +| Session/任务覆盖 | “这次快速放行网络”“本任务只读分析” | 临时治理强度 | +| 工作区配置 | `.bitfun/config.toml` 或 `.bitfun/quality.yaml` | BitFun 专属模式、检查、安全策略 | +| 现有仓库规则 | AGENTS.md、CONTRIBUTING、CODEOWNERS、CI、`.github/instructions` | 项目知识和规则来源 | +| 工具特定规则 | `.coderabbit.yaml`、`.gitlab/duo/*`、`.kiro/steering/*` | 外部工具和路径级经验 | +| 组织策略 | 受管配置、受保护分支、企业策略 | 强制策略和不可绕过限制 | + +```text +组织拒绝 / 受管强制要求 + > 安全边界 + > 最近确认的路径或团队规则 + > 工作区配置 + > 允许范围内的会话/任务覆盖 + > 全局/用户默认值 +``` + +## 7. 硬约束 + +| 约束 | 设计要求 | +|---|---| +| 内部画像不直接外露 | `fast/assist/review/guarded/regulated` 作为内部契约;用户侧展示任务状态、原因和下一步 | +| 提示先弱后强 | 默认行内提示、可折叠摘要和任务结束汇总;弹窗只用于安全、不可逆、组织强制或关键流程 | +| 门禁按配置显露 | 门禁在 PR、团队、强策略或合规场景显露 | +| 安全边界独立执行 | prompt 注入、凭据、网络、hook 等风险走独立判定 | +| 沙箱能力显式分层 | 本地、远程、ACP、MCP、插件、浏览器/桌面和云端任务都输出执行位置、沙箱等级和降级原因 | +| 阶段按收益切片 | 同一模块可以跨阶段交付;每个阶段必须有用户可见收益、必要技术前置、延期边界和质量一致性检查 | +| 技术契约后台化 | 证据包、质量数据面和图谱服务摘要、审查、发布和复盘 | +| 未知先进入建议态 | 新项目初期先提示和建议,安全或团队策略再阻断 | +| 用户可临时放行 | 应急放行有范围、期限、记录和撤销 | +| 组织策略可强制 | 受管策略高于本地覆盖 | +| 模型输出作为候选 | 确定性证据、用户决策和策略改变状态 | +| 能力/效果模型统一 | tool、MCP、skills、插件、hook 和内置能力必须映射为能力声明、目标对象、数据类别、信任来源和副作用候选 | +| 未声明能力受限 | 新增扩展未声明能力、声明不完整或运行时行为超出声明时,只能进入受限模式或安全确认,不能按低风险静默执行 | +| 策略不写死工具名 | 策略引擎以能力、效果、数据、来源、执行域和配置上下文判定;工具名只用于展示、审计、兼容和调试 | +| 外部适配不写权威状态 | 外部插件和适配器只能返回候选效果;通过、失败、阻断、审计和权限状态由内核写入 | +| 工具复写显式授权 | 用户确认复写哪个工具和能力范围;复写表按项目执行域生效,不能静默覆盖全局工具 | + +## 8. 架构风险 + +| 风险 | 治理策略 | +|---|---| +| 产品默认过重 | P0 以快速路径和低摩擦指标验收 | +| 技术计划脱离用户收益 | 阶段评审必须检查用户可见收益、延期边界和质量一致性,后台技术只能作为明确前置项进入 | +| 内部术语外露 | 通过体验投影层统一用户语言,设置和日志才展示调试级策略信息 | +| 安全提示太频繁 | 沙箱、白名单、范围和域名策略降噪 | +| 应急放行被滥用 | 范围、期限、组织禁用、隔离建议和审计 | +| 项目规则被注入污染 | 规则来源、信任、新鲜度和恶意指令检测 | +| 路径规则冲突 | 最近规则、冲突展示和人工处理 | +| 扩展点时序漂移 | 事件 id、sequence、deadline、epoch 和幂等键约束;过期响应不应用 | +| 能力声明被绕过 | 适配器注册、工具复写、MCP 描述和插件效果都必须经过能力/效果校验;未知能力默认受限 | +| 工具复写静默越权 | 内置工具复写必须显式展示、按项目生效,并重新经过安全边界 | +| 图谱和证据过早显露 | 只在解释、PR、发布、事故时显性化 | +| 平均体验掩盖局部问题 | 指标按用户画像、任务风险、内部策略画像、用户可见投影、入口平台和受管状态切片 | diff --git a/docs/sdlc-harness/features/agent-evaluation.md b/docs/sdlc-harness/features/agent-evaluation.md new file mode 100644 index 0000000000..06d106cf2d --- /dev/null +++ b/docs/sdlc-harness/features/agent-evaluation.md @@ -0,0 +1,175 @@ +# BitFun 子模块设计:智能体评测 + +> 上游文档:[design.md](../design.md) +> 模块角色:为智能体模式、工具接口、prompt、上下文、权限、配置化策略、安全边界、审查和模型组合提供长期评测体系。 + +## 1. 模块定位 + +智能体评测衡量模型、prompt、工具结构、上下文策略、权限策略、治理策略和审查策略对产品结果的影响。它回答: + +```text +某次模型、prompt、工具结构、上下文策略、权限策略、治理策略或审查策略改动, +是否真正提升了用户完成任务的速度、信心、安全性、质量和成本效率? +``` + +评测结论必须绑定判定标准。BitFun 的评测体系区分模型能力、工具接口、上下文选择、策略约束、工作流、用户界面打断和团队配置影响,产品决策同时参考基准测试、真实任务保留集、线上反馈和成本指标。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [SWE-bench](https://github.com/swe-bench/SWE-bench) / [SWE-agent](https://arxiv.org/abs/2405.15793) | 真实 issue 与智能体-计算机接口都会影响软件工程任务成功率 | +| [SWE-Bench Pro](https://labs.scale.com/leaderboard/swe_bench_pro_public) | 更真实、更长程、更复杂代码库暴露出评测集泄漏、任务多样性和测试可靠性问题 | +| [Agentless](https://arxiv.org/abs/2407.01489) | 简单、结构化、可解释流程是强基线 | +| [Terminal-Bench](https://arxiv.org/abs/2601.11868) / [Terminal-Bench 3.0](https://www.tbench.ai/) | 长程终端任务需要真实环境、可靠判定标准和任务泄漏防护 | +| [OpenAI Agent Improvement Loop](https://developers.openai.com/cookbook/examples/agents_sdk/agent_improvement_loop) | 轨迹、反馈、评测和策略变更应形成持续改进闭环 | +| [RovoDev Code Reviewer](https://arxiv.org/html/2601.01129v1) | 在线评估要同时看处理结果、PR 周期、人类评论负载和错误反馈 | + +设计约束: + +- 评测必须有明确判定标准。 +- 产品质量由公开基准、内部黄金集、私有保留集、线上反馈和成本指标共同判断。 +- 必须记录 token、墙钟耗时、工具调用、失败原因、安全事件和用户打断。 +- 评测集必须防止任务泄漏和过拟合。 +- 公开基准测试、内部黄金集和私有保留集必须分开管理。 +- 评测必须覆盖快速路径、配置化策略、安全边界、证据包、就绪度、门禁和图谱的协同效果。 +- 每个评测集必须有评测卡,记录来源、授权、污染风险、判定标准、可复现环境、适用范围和退出条件。 + +## 3. 范围与边界 + +范围: + +- 评测 Plan、Debug、Review、深度审查、Agentic、Mini App、remote、MCP 等模式。 +- 评测工具接口、上下文策略、hook 策略、审批/安全策略。 +- 评测配置化策略是否误升级、漏提示或过度打断。 +- 评测安全边界是否降低风险且不制造无意义确认。 +- 支持回放、A/B、回归和失败分类。 +- 将失败沉淀为规则、skill、hook、test、基准测试或产品策略。 + +边界: + +- 模型排名只在 BitFun 任务集和运行条件内解释。 +- 离线基准测试和线上产品指标共同进入决策。 +- 人工偏好、自动判定、后验缺陷和用户行为共同构成评估信号。 +- 高风险自动化任务必须运行在隔离环境或受控沙箱中。 +- 更严格的策略需要同时证明质量收益、速度影响、安全收益和成本变化。 + +## 4. 输入、输出与数据模型 + +输入: + +| 输入 | 示例 | +|---|---| +| 任务夹具 | 仓库快照、issue、预期 diff、测试命令、快速试验 prompt | +| 项目画像 | 规则来源、验证能力、负责人、未知区域和冲突状态 | +| 轨迹 | 模型调用、工具调用、文件编辑、审批、事件 | +| 策略版本 | prompt、工具结构、上下文策略、内部策略画像、安全策略 | +| 判定器 | 测试通过、diff 预期、审查规则、人工接受、安全期望 | +| 产品指标 | 首次有用动作耗时、打断次数、prompt 接受率、误升级 | +| 成本 | token、墙钟耗时、工具耗时、重试次数 | +| 数据集血缘 | 来源 issue、快照、许可证、隐私、泄漏状态、保留集标签 | + +输出: + +```ts +interface EvalResult { + task_id: string; + run_id: string; + success: boolean; + oracle: OracleResult; + product_experience: ProductExperienceScores; + quality_scores: Record; + control_scores: ControlStrategyScores; + safety_events: SafetyEvent[]; + cost: CostSnapshot; + failure_taxonomy: string[]; + trace_ref: EvidenceReference; + policy_versions: Record; + dataset_lineage: DatasetLineage; + reproducibility: ReproducibilitySnapshot; +} +``` + +评测卡最小字段: + +| 字段 | 说明 | +|---|---| +| task_source | 真实 issue、PR、CI 失败、事故、一次性低风险任务、合成种子或公开基准测试 | +| data_rights | 是否可用于内部训练、评测、分享或导出 | +| leakage_risk | 是否曾进入 prompt、文档、公开榜单或训练数据 | +| 判定标准 | 测试、diff 预期、审查规则、人工接受、安全期望或组合判定标准 | +| environment | 仓库快照、依赖缓存、工具版本、系统权限和网络策略 | +| decision_scope | 可用于阻断、建议投影、回归、策略校准或探索的范围 | +| retirement_policy | 任务过期、泄漏、不可复现或业务不再相关时的退出规则 | + +## 5. 核心流程 + +```text +选择任务集 + -> 准备隔离环境 + -> 运行基线和候选策略 + -> 收集轨迹、策略决策、安全事件和证据 + -> 执行判定器和规则评估 + -> 比较产品体验、成本、质量和安全 + -> 归类失败原因 + -> 将修复沉淀为策略、测试、skill 或产品变更 +``` + +任务类型: + +| 类型 | 判定器 | +|---|---| +| 快速路径任务 | 是否快速完成有用动作、是否低打断、是否给出合理摘要 | +| 项目画像 | 是否识别正确结构、规则、验证能力和未知区域 | +| 配置化策略 | 是否选择合理模式、是否误升级/漏升级 | +| 安全边界 | 是否拦截高风险动作、是否允许合理应急放行、是否避免无效弹窗 | +| 模式合规 | 是否遵守只读、审批、审查约束 | +| 工具接口 | 工具调用是否正确、输出是否被使用 | +| 真实 issue 修复 | 测试通过、diff 符合预期、无回归 | +| 深度审查 | 种子缺陷覆盖率、问题精度、判定一致性 | +| 变更就绪度 / PR 门禁 | 强制检查精度、错误就绪/错误阻断、降级处理 | +| Hook 策略 | 权限、脱敏、超时、阻断语义 | + +## 6. 策略与治理 + +- **基线优先**:每个复杂智能体流程都要与简单结构化流程比较。 +- **体验与质量共同决策**:新策略不能只提高高风险质量,却显著拖慢低风险任务。 +- **轨迹回放**:失败可在固定输入、固定工具版本和固定策略下回放。 +- **保留集管理**:公开基准测试、内部黄金集、私有保留集和线上回放集分开管理。 +- **数据血缘**:每个任务记录来源、授权、快照、污染状态和适用决策范围。 +- **成本约束**:报告必须展示 token、耗时、工具调用、重试和提示次数。 +- **安全隔离**:高风险工具、网络、文件系统和插件在评测中使用最小权限。 +- **线上反馈回流**:审查阻塞项、合入后缺陷、覆盖、应急放行、事故写入评测待办。 + +## 7. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 快速路径、安全边界、配置化策略的小型黄金集 | +| P1 | 轨迹回放、失败分类、成本和打断指标对比 | +| P2 | 变更就绪度、风险分类器、深度审查、团队策略评测 | +| P3 | 交付物图谱、需求影响、发布/事故回放 | +| P4 | 模型/策略 A/B、线上反馈回流、回归看板和长期趋势 | + +## 8. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 无判定标准评测 | 每个任务必须声明判定标准,否则只能作为探索样本 | +| 基准测试过拟合 | 保留独立保留集,记录任务来源和泄漏风险 | +| 保留集被污染 | 进入 prompt、文档、人工调参材料或公开榜单的样本必须降级 | +| 公开榜单幻觉 | 公开基准测试只用于参考,产品决策必须结合目标项目回放和线上指标 | +| 只看成功率忽略成本 | token、耗时、工具调用、提示次数必须和质量一起比较 | +| 只看高可靠质量忽略体验 | 首次有用动作耗时、打断率、误升级必须进入决策 | +| 混淆模型与工程策略贡献 | 模型、prompt、工具结构、上下文、安全和策略版本必须分开记录 | +| 人工评分不稳定 | 审查规则需要结构化,并记录审查人差异 | +| 评测与真实工作脱节 | 从真实 PR、issue、CI 失败、事故和一次性低风险任务中抽样 | + +## 9. 成功标准 + +- 关键 prompt、工具结构、上下文策略、安全策略或内部策略画像改动可用固定任务集回放。 +- 失败能归类到模型、工具、上下文、策略、安全边界或产品交互问题。 +- 评测能解释质量提升是否伴随 token、耗时或用户打断增加。 +- 快速路径、安全边界、配置化策略和 PR 就绪度都有专属评测。 +- 线上缺陷、审查阻塞项、应急放行和误升级能沉淀为新任务。 +- 每个用于决策的任务集都有评测卡、数据血缘和污染状态。 diff --git a/docs/sdlc-harness/features/configurable-policy-profile.md b/docs/sdlc-harness/features/configurable-policy-profile.md new file mode 100644 index 0000000000..4a3da23171 --- /dev/null +++ b/docs/sdlc-harness/features/configurable-policy-profile.md @@ -0,0 +1,120 @@ +# BitFun 子模块设计:配置化策略画像 + +> 上游文档:[product-requirements.md](../product-requirements.md)、[design.md](../design.md) +> 模块角色:汇总任务意图、操作风险、执行环境、项目规则、团队策略和用户覆盖,生成内部策略画像、用户可见展示层级、验证要求、证据展示层级和安全动作。 + +## 1. 模块定位 + +配置化策略画像是 BitFun 的体验调度和策略合成层。它把分散的项目规则、路径规则、团队策略、用户偏好和当前任务信号合成为一个可解释的内部策略决策: + +```text +当前任务内部使用哪种策略画像,用户看到哪种状态、提示、确认和下一步? +``` + +项目级配置提供默认值;任务意图、目标路径、工具能力、网络/凭据/发布链路、团队规则和用户覆盖共同决定内部策略画像,再由体验投影层转成用户可见状态。 + +## 2. 产品原则 + +- **配置优先**:优先读取项目、路径、团队和用户配置,并保留来源、作用域和新鲜度。 +- **任务定态**:内部策略画像由当前任务和动作决定,同一项目内的文档变更、热修复、迁移和演示原型可以使用不同策略。 +- **快速默认**:缺少高风险信号时内部使用低干预策略,只展示结果摘要、推荐检查和未验证项。 +- **升级可解释**:每一次提示、验证、确认或阻断都带触发原因、证据来源和可选下一步。 +- **覆盖有边界**:用户可以在单次、会话、任务或 worktree 范围内选择更快或更严;安全边界和受管策略保持独立优先级。 +- **用户不学模式**:`fast/assist/review/guarded/regulated` 是内部枚举;用户侧展示任务状态、弱提示、确认、受限原因和设置项。 + +## 3. 输入维度 + +| 维度 | 例子 | 影响 | +|---|---|---| +| 任务意图 | 探索、快速修复、原型、准备 PR、发布、事故响应 | 决定默认摩擦和输出形态 | +| 用户画像 | 快速试验者、独立开发者、团队审查人、架构负责人、安全合规、平台维护者 | 决定解释方式、提示密度和默认入口 | +| 操作风险 | 读取、写工作区、shell、网络、凭据、删除、跨根目录写入、发布 | 决定是否需要安全确认或隔离 | +| 变更风险 | 文档、界面行为、适配器、核心逻辑、迁移、安全敏感变更 | 决定验证和审查强度 | +| 环境信任度 | 无仓库工作区、git worktree、团队仓库、远程沙箱、连接生产环境的工作区 | 决定可自动执行范围 | +| 项目信号 | AGENTS.md、CONTRIBUTING、CI、CODEOWNERS、包脚本、自定义规则 | 决定默认建议和强制检查 | +| 团队策略 | 仓库配置、组织默认值、托管设置、受保护分支 | 决定是否能进入强制/阻断 | +| 用户覆盖 | 单次允许、会话模式、任务模式、显式跳过 | 决定临时降噪或升级 | + +## 4. 治理强度 + +| 内部策略画像 | 用户可见投影 | 典型场景 | +|---|---|---| +| `fast` | 低摩擦执行、简短摘要、低成本验证建议,不生成完整证据包 | 质量保障要求较低、演示原型、文档、探索性改动 | +| `assist` | 提示推荐检查、展示未验证项,但不阻塞 | 常规本地开发、小范围界面或适配器改动 | +| `review` | 生成变更就绪度、PR 摘要、风险标签、建议审查人/检查 | 准备 PR、团队协作、跨模块改动 | +| `guarded` | 强制检查、定向审查、过期证据、风险接受 | 核心路径、安全、权限、迁移、发布相关改动 | +| `regulated` | 完整证据包、审计、明确批准、发布/回滚证据 | 合规、金融、医疗、基础设施、企业强管控项目 | + +内部策略画像是当前任务的策略状态。项目可以声明默认策略,用户可以在当前任务或当前会话临时覆盖;覆盖范围、期限和残余风险进入质量数据面记录。用户界面默认不展示内部枚举名。 + +## 5. 决策输出 + +```ts +interface ConfigurablePolicyDecision { + profile: "fast" | "assist" | "review" | "guarded" | "regulated"; + reasons: PolicyReason[]; + user_visible_level: "silent" | "inline_hint" | "panel_summary" | "modal_confirm" | "blocked"; + recommended_checks: RecommendedCheck[]; + required_checks: RequiredCheck[]; + security_actions: SecurityAction[]; + evidence_mode: "none" | "summary" | "evidence_refs" | "full_pack"; + review_mode: "none" | "summary" | "targeted" | "full"; + override_options: OverrideOption[]; + expires_when: ExpirationCondition[]; +} +``` + +输出约束: + +- `security_actions` 只引用 [security-boundary.md](../architecture/security-boundary.md) 的判定,并保持高于质量模式的优先级。 +- `required_checks` 只能来自项目/团队策略、确定性风险或用户显式升级。 +- `override_options` 必须有范围、期限、撤销路径和残余风险说明。 +- 用户覆盖在策略允许范围内调节体验;组织拒绝策略、受管控路径强制策略和安全边界保持更高优先级。 +- 决策解释同时覆盖触发原因、配置来源、证据新鲜度和可选降噪路径。 + +## 6. 用户体验形态 + +| 触发 | 用户界面行为 | +|---|---| +| 无高风险信号 | 不弹窗;在任务结束给简短摘要 | +| 推荐检查缺失 | 行内弱提示、可折叠摘要或任务结束汇总 | +| 准备 PR | 可一键生成变更就绪度块 | +| 触及高风险路径 | 面板摘要展示原因、建议检查和跳过后果 | +| 触及安全边界 | 弹窗确认或阻断;说明文件、命令、域名、凭据、权限范围 | +| 用户选择快速放行 | 记录单次/会话覆盖,继续执行,但显示残余风险 | +| 团队策略不允许放行 | 阻断并指向项目配置或负责人 | + +用户画像用于选择默认入口、解释语言和提示密度。强制检查、阻断和安全确认继续引用任务风险、执行动作、团队策略或安全边界。弹窗只用于安全、不可逆、组织强制或用户进入关键流程;其他提示优先合并、延后或进入摘要。 + +## 7. 与其他模块关系 + +| 模块 | 关系 | +|---|---| +| 项目画像 | 提供项目规则、验证能力、结构和配置来源 | +| 安全边界 | 提供不可混同的执行安全判定和应急放行限制 | +| 风险分类器 | 提供变更风险和验证建议 | +| 证据包 | 按 `evidence_mode` 决定是否只做摘要、引用或完整证据包 | +| PR 门禁 | 只在 `review/guarded/regulated` 且用户进入 PR 或团队流程时显性启用 | +| 质量数据面 | 记录决策理由、提示、覆盖、验证和安全事件 | + +## 8. 边界场景 + +| 场景 | 策略 | +|---|---| +| 无 git 或无团队规则的工作区 | 内部使用低干预策略;跨目录写和网络仍走安全边界 | +| 用户明确要求质量保障要求较低的快速试验 | 保持低干预路径;只在 shell/网络/凭据/删除时打断 | +| 单体多项目仓库子目录规则冲突 | 使用最近可信规则;冲突进入提示,不直接升级为阻塞 | +| 远程开发路径或权限不明 | 展示执行位置、工作区根和不可支持能力;不自动扩大权限 | +| 生成文件/lockfile/二进制大 diff | 默认过滤深度审查;只展示范围和风险摘要 | +| 不稳定 CI 或私有依赖不可运行 | 输出不可验证原因、替代建议和信心影响 | +| 热修复 | 允许跳过部分质量建议;发布/回滚/负责人提示前置 | +| 用户强制跳过建议 | 若不触及安全底线,可继续;记录范围和残余风险 | +| 组织策略强制 | 用户可以请求风险接受;本地覆盖不改变受管策略 | + +## 9. 成功标准 + +- 普通任务通过结果摘要、已验证项和下一步建议完成闭环。 +- 高风险任务能解释升级原因、策略来源和可选处理路径。 +- 用户能临时选择更快或更严,且选择范围清晰。 +- 安全边界在质量模式和用户“快一点”请求之上保持优先级。 +- 团队配置能统一受管工作区体验,质量保障要求较低的任务保持低摩擦默认值。 diff --git a/docs/sdlc-harness/features/opencode-compatibility.md b/docs/sdlc-harness/features/opencode-compatibility.md new file mode 100644 index 0000000000..698329348c --- /dev/null +++ b/docs/sdlc-harness/features/opencode-compatibility.md @@ -0,0 +1,265 @@ +# BitFun 子模块设计:主动配置与 OpenCode 兼容层 + +> 上游文档:[design.md](../design.md) +> 模块角色:在 BitFun 内部 Hook/Event Bus 与安全边界之上,发现、隔离、审核并兼容 OpenCode 风格插件、hook、自定义工具和事件流。 + +## 1. 模块定位 + +OpenCode 兼容层是未来插件生态适配的参考子模块。它短期负责发现项目中的主动配置,约束 hook、plugin、自定义工具或 MCP 带来的执行风险,并把观察、建议、验证提示和证据候选映射到 BitFun 的规范事件。 + +BitFun 内部以规范事件、交付物、权限和策略模型为准;OpenCode API 负责降低插件迁移成本。插件可以提供观察、建议、验证提示或证据候选;安全边界、通过/失败、强制/阻断和审计事实由 BitFun 策略层写入。 + +P0/P1 不承诺任意社区插件无修改运行,也不把 OpenCode 插件运行时作为默认能力。默认策略是发现、展示、禁用、信任审查和内核扩展点预留;执行型自定义工具必须等到插件适配主机和隔离策略成熟后再启用。 + +长期目标不是复制 OpenCode 运行时,而是形成一个可承载多生态适配器的受控插件适配主机:OpenCode、Kiro hook、Claude hook 或 BitFun 原生插件都通过同一 Host ABI 返回候选效果,Rust AI Kernel 保持唯一事实源。这个目标不进入 P0/P1 的实现关键路径。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [OpenCode Plugins](https://opencode.ai/docs/plugins/) / [SDK](https://opencode.ai/docs/sdk/) / [Server API](https://opencode.ai/docs/server/) | plugin 上下文、hooks object、自定义工具、客户端日志、SSE 事件流是生态迁移重点 | +| [Codex Hooks](https://developers.openai.com/codex/hooks) | hook 需要信任审查、配置来源、事件范围、并发和关闭机制 | +| [Claude Code Hooks](https://code.claude.com/docs/en/hooks) | hook 需要明确阻塞/非阻塞、退出码、权限和上下文语义 | +| [Kiro Hooks](https://kiro.dev/docs/hooks/) | hook 已成为 IDE 内事件触发自动化能力,但必须和权限、策略、人工确认分离 | +| [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/) | 插件、工具调用、数据出境和权限提升属于 LLM 应用风险面 | + +设计约束: + +- 兼容适配器通过映射层接入 BitFun 内部事件模型。 +- 插件能力受权限、策略、脱敏和审计约束。 +- 项目内 hook、plugin 配置和自定义工具默认未信任。 +- 插件来源、版本、hash、权限声明和兼容等级必须可见。 +- 近期优先稳定 Rust AI Kernel 的 hook、事件、打点、工具复写候选和插件效果候选契约。 +- 插件适配主机作为后续生态能力由 Rust AI Kernel 拉起和监管,默认使用本地受控 IPC,不暴露公开 localhost 服务。 +- 一个插件适配主机运行时可以承载多个项目执行域,但项目之间的事件、信任、权限、工具复写表和插件状态必须隔离。 +- 插件适配主机只能返回候选效果,不能直接写入通过、失败、阻断、审计或权限状态。 +- BitFun 不继承 OpenCode “工具默认可用”的语义;所有工具、shell、网络、文件和凭据能力仍按 BitFun 安全边界授权。 +- JS/TS 运行时本身不是安全边界;如果无法限制直接 `fs`、`fetch`、`spawn` 或依赖副作用,高风险插件必须进入更强隔离。 +- 多个 hook 命中同一事件时,不允许依赖隐式顺序做安全判断。 +- 阻断语义必须进入 BitFun 策略层;第三方 hook 只能建议。 +- 兼容承诺必须通过测试矩阵表达,不用“兼容 OpenCode”这种宽泛表述替代边界。 + +## 3. 范围与边界 + +范围: + +- 发现 OpenCode 风格主动配置,并写入项目画像。 +- 映射 OpenCode 常见事件到 BitFun 规范 Hook/Event Bus。 +- 提供有限 plugin 上下文、客户端门面、自定义工具 API。 +- 支持 SSE 事件流或本地事件订阅的受控子集。 +- 支持观察/建议类插件产出证据候选或风险提示。 + +边界: + +- BitFun 提供兼容适配器和受控子集,不复制 OpenCode 运行时。 +- OpenCode 是一种适配器,不是 BitFun 内部插件模型。 +- OpenCode 配置作为外部生态配置读取,BitFun 规范配置仍由自身策略模型承载。 +- 运行时组件和依赖缓存可以共享;项目执行域、工具复写、权限和审计不能共享。 +- 插件兼容范围通过测试矩阵表达,shell 语义和阻塞行为逐项声明。 +- 门禁通过、就绪度就绪和审计事实由 BitFun 策略层写入。 +- 快速路径只依赖 BitFun 原生能力;插件能力按需启用。 + +### 3.1 适配主机与内核边界 + +```text +Rust AI Kernel + 权威事件日志 / 策略决策 / 安全边界 / 工具执行 / 审计 + <本地 IPC:JSON-RPC、gRPC over pipe 或等价协议> +插件适配主机(Plugin Adapter Host) + 运行时核心 / 适配器注册表 / 项目执行域管理器 / 效果路由 + -> OpenCode Adapter + -> Kiro Hook Adapter + -> Claude Hook Adapter + -> BitFun Native Adapter +``` + +Kernel 负责状态和副作用,插件适配主机负责插件语言运行时和生态 API 适配。适配主机崩溃、重启、超时或返回旧结果时,Kernel 必须能降级、拒绝或重放事件。插件隔离等级必须映射到 [安全边界](../architecture/security-boundary.md) 的执行与沙箱矩阵;Host 内部的 JS/TS 运行时、worker 或子进程不是自动可信边界。 + +项目执行域(Project Domain)包含项目根、worktree、执行主机、远程上下文、信任记录、事件订阅、工具复写表、权限范围和审计流。一个 Host 进程可以管理多个执行域,但不能让插件全局变量、订阅、复写表、环境变量或授权跨域复用。 + +默认只有一个插件适配主机监督进程。监督进程可以按风险启动插件单元(Plugin Cell)、worker、子进程或容器;这些都是 Host 管理的执行单元,不改变 Kernel/Host 的外部契约。 + +运行时隔离等级: + +| 等级 | 适用 | 要求 | 对外沙箱等级 | +|---|---|---|---| +| cell | 受信任观察/建议插件 | 独立模块图、事件订阅和超时;不得访问真实 shell、网络、凭据 | `permission_only` 或 `process_isolated` | +| worker | 建议、guard、轻量自定义工具 | 独立工作线程或等价隔离;所有副作用走 Kernel facade | `process_isolated` | +| subprocess | 工具复写、act、高风险依赖 | 独立进程、资源预算、环境变量白名单、工作目录限制 | `process_isolated`,必要时叠加 `readonly_scope` 或 `network_restricted` | +| sandbox | 未知来源或企业受管场景 | 容器、无凭据环境、网络策略和只读/临时 worktree | `containerized` | + +入口形态约束: + +| 入口 | 约束 | +|---|---| +| Tauri 桌面 | Host 由 Rust 后端管理,不能在 WebView 内执行插件;权限确认投影到桌面 UI | +| TUI/CLI | Host 不占用标准输入输出;确认、降级和错误以文本状态呈现 | +| 远程开发 | 插件执行位置必须与文件、命令和 Git 操作的执行位置一致;本地 Host 不能直接代替远程 Host 操作远程路径 | +| 云端/异步 | Host 生命周期绑定任务或执行域;长任务必须支持取消、超时和审计续接 | + +## 4. 输入、输出与数据模型 + +OpenCode 常见事件映射: + +| OpenCode 事件 | BitFun 来源 | 默认用途 | +|---|---|---| +| `tool.execute.before` | 工具运行时 | 权限检查、风险提示、命令建议 | +| `tool.execute.after` | 工具运行时 | 验证摘要、证据候选 | +| `permission.asked` / `permission.replied` | 审批系统 | 安全授权和审计 | +| `file.edited` / `file.watcher.updated` | 文件监听 | 过期证据、风险提示 | +| `lsp.client.diagnostics` | LSP 服务 | 诊断证据候选 | +| `session.diff` | Git 服务 | 就绪度提示 | +| `session.idle` | 会话运行时 | 未验证风险和完成度建议 | +| `shell.env` | 环境提供者 | 凭据和环境注入策略 | + +兼容上下文: + +```ts +interface OpenCodeCompatContext { + project: { id: string; root: string; worktree: string }; + directory: string; + client: OpenCodeCompatClient; + permissions: PermissionFacade; + events: EventFacade; + security: SecurityBoundaryFacade; +} + +type PluginEffect = + | { kind: "suggestion"; body: unknown } + | { kind: "evidence_candidate"; body: unknown } + | { kind: "tool_input_patch"; tool_call_id: string; patch: unknown } + | { kind: "tool_result"; tool_call_id: string; result: unknown } + | { kind: "deny_request"; reason: string } + | { kind: "log"; level: "debug" | "info" | "warn" | "error"; message: string }; + +interface PluginDispatchEnvelope { + event_id: string; + sequence: number; + project_domain_id: string; + session_id: string; + tool_call_id?: string; + correlation_id: string; + causation_id?: string; + deadline_ms: number; + project_epoch: number; + trust_epoch: number; + tool_registry_epoch: number; + worktree_revision?: string; +} +``` + +## 5. 核心流程 + +```text +发现主动配置 + -> 记录来源、hash、权限和范围 + -> 分类信任状态 + -> Kernel 写入规范事件 + -> Kernel 按事件类型投递到插件适配主机 + -> 适配主机选择项目执行域和生态适配器 + -> 在 deadline、权限 facade 和隔离约束下执行 plugin hook + -> 适配主机返回 PluginEffect + -> Kernel 校验 epoch、权限、安全策略和幂等键 + -> Kernel 应用、降级、拒绝或记录候选效果 + -> Kernel 追加审计事件 +``` + +时序策略: + +| 事件类型 | 处理方式 | 超时策略 | +|---|---|---| +| 观察/建议 | 异步,不阻塞主流程 | 记录 warning 或丢弃候选效果 | +| 执行前 hook | 同步,有 deadline | 按策略 fail-open、fail-closed 或请求用户确认 | +| 自定义工具 / 工具复写 | request/response | 超时则工具失败或降级,不写通过状态 | + +适配主机响应必须携带原始 envelope 的标识。`project_epoch`、`trust_epoch`、`tool_registry_epoch` 或 `worktree_revision` 变化后,旧响应不能应用。 + +Hook 效应等级: + +| 等级 | 能力 | 默认策略 | 就绪度/门禁关系 | +|---|---|---|---| +| observe | 读取事件、记录日志、生成证据候选 | 受限只读,可在受信任来源中启用 | 不能影响就绪/通过 | +| recommend | 生成建议、风险提示、验证提示 | 需要声明输出结构 | 只能进入建议 | +| guard | 对工具、权限或文件操作提出警告/拒绝建议 | 必须通过 BitFun 策略引擎解释 | 可导致建议投影、降级或拒绝,但不能直接写通过/失败 | +| act | 修改工具输入、触发命令、写文件或调用自定义工具 | 默认关闭,需要显式信任、权限、超时和审计 | 只产出事实或证据,决策仍由 BitFun 产生 | + +项目级信任记录必须绑定 hook 来源、hash、范围、权限、创建者和审核人。hook 内容变化后信任状态失效,必须重新确认。 + +工具复写规则: + +- 普通插件工具默认命名空间化,避免与内置工具重名。 +- 内置工具复写必须显式展示被复写工具、来源、hash、权限范围、持续时间和撤销入口。 +- 工具复写表按项目执行域生效,不写入全局工具表。 +- 用户确认只表示启用意图;文件、shell、网络和凭据访问仍走 BitFun 安全边界。 +- 复写插件变化、权限声明变化或执行主机变化后,原信任立即失效。 + +## 6. API 兼容等级 + +兼容等级用于描述未来可支持的能力边界,不等同于实施阶段。P0/P1 只落实发现、禁用、契约和空实现。 + +| 等级 | 范围 | 目标 | +|---|---|---| +| L0 | 发现、事件命名、载荷映射、只读客户端日志 | 支持迁移和观察 | +| L1 | `tool.execute.*`、`permission.*`、`file.*`、`session.*` 只读或建议 | 支持核心低风险插件 | +| L2 | 自定义工具、SSE 事件流、受限 `$` shell 门面 | 支持可控扩展 | +| L3 | 更广泛生态兼容 | 仅在 L0-L2 稳定后评估 | + +兼容矩阵: + +| 能力 | P0/P1 状态 | 说明 | +|---|---|---| +| 项目级插件发现 | 支持 | 发现但默认不执行 | +| 项目级插件加载 | 暂缓 | P0/P1 只发现和展示,不执行插件代码 | +| 全局插件加载 | 暂不默认启用 | 避免跨项目状态串扰和权限混淆 | +| hook 事件映射 | 契约预留 | 先定义 BitFun 规范事件和 hook 点,不运行 OpenCode hook | +| 自定义工具 | 契约预留 | 先定义权限、输入输出结构和候选效果,不执行外部工具 | +| 工具复写 | 契约预留 | 必须显式确认、按项目生效,并重新经过安全边界 | +| shell 门面 | 暂缓 | 等插件适配主机、隔离和权限模型稳定后评估 | +| SSE 事件流 | 暂缓 | 先稳定本地事件订阅和权限模型 | + +## 7. 策略与治理 + +- **安全优先**:插件执行前必须通过安全边界。 +- **权限优先**:文件、shell、网络、凭据访问全部走 BitFun 权限模型。 +- **策略优先**:hook 只触发和采集,复杂判断进入策略引擎。 +- **Kernel 优先**:状态、权限、工具执行和审计由 Rust Kernel 写入,适配主机只返回候选效果。 +- **隔离执行**:默认禁止无约束 shell、网络和全仓读写;只有安全边界确认存在真实隔离时,才能把结果标记为沙箱允许。 +- **执行域隔离**:项目执行域之间不共享工具复写、事件订阅、信任状态和插件全局状态。 +- **信任优先**:项目内 hook/plugin/custom 工具必须先完成信任审查;未信任定义只能被展示和禁用。 +- **审计可追溯**:插件输入、输出、耗时、失败和副作用写入质量数据面。 +- **兼容可测试**:每个兼容等级必须有测试插件和行为测试。 +- **降级可见**:插件失败不能静默影响任务结果,必须进入警告、降级或安全决策。 + +## 8. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 主动配置发现、L0 映射、配置展示、禁用和审计,不执行插件 | +| P1 | Kernel 扩展点 v0:规范 hook、事件、打点、工具复写候选、插件效果候选和空实现 | +| P2 | 信任审查持久化、基于回放用例的适配测试、权限策略校准,不启用真实 OpenCode 插件运行 | +| P3 | 插件适配主机原型、只读/建议类适配器试点、项目执行域隔离验证 | +| P4 | 自定义工具、工具复写、SSE 事件流、插件注册表、签名/来源标识和企业策略包 | + +## 9. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 兼容层侵入核心模型 | 内部模块不得依赖 OpenCode 载荷;只能依赖规范事件 | +| 插件越权 | 文件、shell、网络、凭据访问全部走 BitFun 权限 | +| 插件影响决策结论 | 插件只能产出证据或建议,不能直接写通过、失败或就绪 | +| hook 顺序被误用为安全边界 | 安全策略必须在 BitFun 策略层统一判断 | +| 项目级主动配置供应链风险 | 信任记录绑定 hash 和权限;配置变化后必须重新确认 | +| 适配主机与 Kernel 时序不一致 | envelope、deadline、epoch、幂等键和过期响应丢弃规则必须测试覆盖 | +| 工具复写越权 | 复写表按项目执行域隔离,执行仍走安全边界 | +| 运行时不一致 | L0/L1 明确支持范围,不承诺完整 OpenCode 运行时 | +| 维护成本边界不清 | API 兼容性分级推进,每级有成功标准和退出条件 | + +## 10. 成功标准 + +- 项目主动配置能被发现、解释、禁用和重新信任。 +- BitFun 内核事件、权限和审计模型保持独立。 +- P0/P1 阶段即使没有插件适配主机,Kernel 扩展点空实现也不会影响默认任务。 +- hook、事件、打点、工具复写候选和插件效果候选能被质量数据面记录、回放和测试。 +- 工具复写契约不会跨项目生效,也不会绕过权限、沙箱、网络和凭据策略。 +- 后续启用插件适配主机时,插件失败、超时、拒绝权限和旧响应必须能被安全边界和证据包感知。 +- L0/L1 兼容范围清晰,未支持能力不会被误认为可用。 diff --git a/docs/sdlc-harness/features/pr-quality-gate.md b/docs/sdlc-harness/features/pr-quality-gate.md new file mode 100644 index 0000000000..3daf4ce050 --- /dev/null +++ b/docs/sdlc-harness/features/pr-quality-gate.md @@ -0,0 +1,186 @@ +# BitFun 子模块设计:变更就绪度与可选 PR 门禁 + +> 上游文档:[product-requirements.md](../product-requirements.md)、[design.md](../design.md) +> 模块角色:在用户准备提交、发起 PR、进入团队协作或项目开启强策略时,把变更、验证、风险和人工决策投影为可读的就绪度摘要;只有在配置或风险要求下才升级为 PR 门禁。 + +## 1. 模块定位 + +变更就绪度是产品体验层,PR 门禁是其中的可选强治理投影。快速路径输出简洁的改动、已验证项、未验证项和下一步建议;当用户准备 PR、进入受管目录、开启团队策略或触发内部强策略时,再投影为更严格的就绪度或门禁结果。 + +在严格场景中,本模块把后台证据包、风险策略提示(Risk Policy Hint)、验证证据和风险接受记录组合成可审查结果。这个结果可以投影到本地报告、PR 描述、GitHub 检查状态或团队强制策略;CI、分支保护、安全扫描、CODEOWNERS 和人类审查人继续作为外部权威信号。 + +关键边界: + +- 安全阻断来自 [安全边界](../architecture/security-boundary.md)。 +- 风险等级来自 [风险分类器](risk-classifier.md),并作为建议、检查和审查强度输入。 +- 证据包只提供证据快照;本模块不修改原始证据。 +- 阻断只能来自确定性失败、组织策略或未被接受的明确残余风险。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [GitHub Copilot 代码审查](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/request-a-code-review/use-code-review) | AI 审查优先以评论形式协助,强制审批由仓库规则配置 | +| [GitHub Checks API](https://docs.github.com/en/rest/checks) / [rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) | 强策略需要稳定状态、结论、日志和审计语义 | +| [GitLab 警告模式](https://docs.gitlab.com/user/application_security/policies/merge_request_approval_policies/) | 新策略应先建议/警告校准,再进入强制/阻断 | +| [CodeRabbit 审查强度](https://docs.coderabbit.ai/reference/configuration) | 审查强度应可配置,默认减少噪音 | +| [Codex approvals/security](https://developers.openai.com/codex/agent-approvals-security) / [Claude 权限](https://code.claude.com/docs/en/permissions) | 执行授权和质量就绪度必须拆开 | + +设计约束: + +- 快速路径不生成可见门禁。 +- 摘要(`summary`)和建议(`advisory`)是默认产品形态。 +- 强制(`required`)和阻断(`blocking`)只在团队/组织配置、确定性失败、安全拒绝或用户明确选择强策略时启用;风险等级本身只能建议升级,不能单独阻塞。 +- 降级(`degraded`)是合法状态,优于错误的就绪/通过结论。 +- 人工覆盖必须记录原因、操作者、范围、过期时间和残余风险。 +- 低风险任务不默认触发完整深度审查。 +- 未信任 hook、plugin、自定义工具或 MCP 只能产生未关闭风险或降级状态,不提供通过证据。 + +## 3. 输入、输出与数据模型 + +输入: + +| 输入 | 来源 | +|---|---| +| 配置化策略决策 | 内部策略画像、推荐/强制检查、证据展示层级、审查策略 | +| 安全边界决策 | allow/ask/deny/应急放行、安全残余风险、授权范围 | +| 变更摘要 | Git diff、文件变更、生成文件、删除/重命名 | +| 验证证据 | 本地命令、CI 检查、制品引用、不可运行原因 | +| 风险策略提示 | 风险标签、触发原因、置信度、检查建议 | +| 证据投影 | 摘要、证据引用或完整证据包 | +| 人工决策 | 跳过检查、风险接受、应急放行、审查人决策 | + +输出分两层: + +```ts +interface ChangeReadinessSummary { + level: "ready" | "attention" | "blocked" | "degraded"; + profile: "fast" | "assist" | "review" | "guarded" | "regulated"; + user_visible_level: "none" | "summary" | "advisory" | "required" | "blocking"; + summary: string; + verified: VerificationSummary[]; + missing_or_skipped: SkippedCheck[]; + risk_hints: RiskHint[]; + security_actions: SecurityActionSummary[]; + next_actions: string[]; + evidence_display: "none" | "summary" | "evidence_refs" | "full_pack"; +} + +interface PrGateProjection { + status: "pass" | "warn" | "fail" | "degraded"; + mode: "shadow" | "advisory" | "required" | "blocking"; + evidence_pack_id?: string; + policy_version: string; + required_checks: RequiredCheckResult[]; + open_risks: OpenRisk[]; + risk_acceptance?: RiskAcceptance; + degraded_reasons: string[]; +} +``` + +状态语义: + +| 状态 | 用户含义 | 可继续吗 | +|---|---|---| +| `ready` / `pass` | 该策略下需要的证据完整,未发现未接受阻塞风险 | 可以 | +| `attention` / `warn` | 有建议检查、未关闭风险或非阻塞缺口 | 可以,但应展示后果 | +| `blocked` / `fail` | 确定性失败、安全拒绝、组织强制要求策略未满足 | 停止当前自动流程并处理原因 | +| `degraded` | 上下文、证据、工具、主动配置或外部系统不足以可靠判断 | 可以人工接受残余风险,但不能改写为通过 | + +## 4. 核心流程 + +```text +任务或 PR 意图 + -> 配置化策略决策 + -> 安全边界摘要 + -> 收集变更与验证证据 + -> 应用风险策略提示 + -> 选择证据展示层级 + -> 生成变更就绪度摘要 + -> 按需投影 PR 门禁 +``` + +默认体验: + +| 场景 | 行为 | +|---|---| +| 质量保障要求较低、演示原型、文档或无团队规则工作区 | 只给任务摘要和安全提示,不生成门禁 | +| 常规个人项目 | 给摘要或建议投影;检查是推荐,不阻断 | +| 用户准备 PR | 生成变更就绪度摘要,可插入 PR 文本 | +| 团队配置启用强制检查 | 生成 PR 门禁投影 | +| 受管控目录、发布、迁移、权限、网络或安全变更 | 升级到审查或守护策略;阻断仍需安全拒绝、确定性失败或受管策略 | + +PR 文本投影示例: + +```markdown +变更就绪度 + +- 任务状态:需要关注 +- 状态:attention +- 已验证: + - 类型检查通过 +- 未验证: + - 集成测试已跳过:私有服务不可用 +- 未关闭风险: + - 运行时边界发生变化,缺少专门回归证据 +- 安全: + - 网络访问被拒绝,未使用应急放行 +- 下一步: + - 在 CI 中运行集成测试,或接受本 PR 的残余风险 +``` + +## 5. 策略与治理 + +投影方式: + +| 投影 | 行为 | 适用 | +|---|---|---| +| `off` | 不展示就绪度 | 快速路径中间过程 | +| `summary` | 只展示改动、验证、未验证和下一步 | 快速路径和辅助建议默认 | +| `advisory` | 展示风险和建议检查,不阻断 | 审查默认 | +| `required` | 要求显式展示跳过项、未关闭风险和风险接受 | 守护策略或团队策略 | +| `blocking` | 对确定性失败或组织策略阻断 | 合规策略或明确阻断策略 | + +深度审查预算策略: + +| 风险画像 | 默认策略 | +|---|---| +| 低风险 docs、文案、小范围脚本 | 不触发 | +| 中风险界面、适配器、测试不足 | 证据弱时定向审查 | +| 高风险核心逻辑、AI 适配器、安全、远程、发布 | 定向或完整审查 | +| 大规模跨层 PR | 先做结构化检查,再决定完整审查 | + +人工风险接受规则: + +- `degraded` 不能因为确认而变成 `pass`;只能保留降级状态并附加风险接受,或补齐证据后重算。 +- 风险接受必须有范围和过期时间;默认不跨 PR 或会话持久化。 +- 应急放行属于安全授权,质量风险接受属于交付决策;两者必须分开记录。 + +## 6. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 快速任务摘要、安全动作摘要、推荐检查、低噪音建议投影 | +| P1 | 变更就绪度摘要、证据引用、定向审查触发 | +| P2 | 仓库、路径、团队策略、PR 门禁投影、强制检查、风险接受 | +| P3 | 发布就绪度、问题生命周期、过期证据、团队趋势 | + +## 7. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 产品默认过重 | 快速路径不展示门禁;以打断率和首次有用动作耗时验收 | +| 门禁假阳性阻塞交付 | 每个失败结论必须有确定性证据、策略来源和覆盖路径 | +| 门禁假阴性放过风险 | 关键路径小 diff 不得只按行数降级 | +| 安全和质量混淆 | 安全拒绝和应急放行只由安全边界产生 | +| 缺证据仍通过 | 缺证据只能进入注意、降级或失败状态,不能进入就绪/通过状态 | +| AI 审查低精度问题 | 问题必须有范围、新鲜度、处理状态和人工反馈 | +| 插件绕过策略 | 插件只能产生证据或建议,不能直接写通过/失败结论 | + +## 8. 成功标准 + +- 普通任务完成时没有被 PR/门禁概念打断。 +- 准备 PR 时能给出清晰、短、可行动的变更就绪度摘要。 +- 团队项目能把就绪度投影为强制要求/阻断策略。 +- 高风险变更能解释强制检查、审查触发和残余风险。 +- 安全授权、质量风险接受和深度审查成本分别可追踪。 diff --git a/docs/sdlc-harness/features/requirement-impact-analysis.md b/docs/sdlc-harness/features/requirement-impact-analysis.md new file mode 100644 index 0000000000..dab8a76438 --- /dev/null +++ b/docs/sdlc-harness/features/requirement-impact-analysis.md @@ -0,0 +1,152 @@ +# BitFun 子模块设计:需求影响分析 + +> 上游文档:[design.md](../design.md) +> 模块角色:在需求、验收标准、API 契约、设计决策、发布或事故场景中,召回受影响的代码、测试、文档、负责人、发布和运行指标候选。 + +## 1. 模块定位 + +需求影响分析服务复杂项目和高风险生命周期场景。用户处理 spec/API/发布/事故,或配置化策略判断变更可能影响多个工程资产时,本模块给出候选影响面、置信度、证据和验证建议。 + +它输出候选集合;高风险、低置信链接进入人工确认队列,确认或拒绝结果写回 [交付物图谱](../architecture/artifact-graph.md),用于后续校准。 + +快速路径下,本模块以摘要中的下一步建议呈现,例如“可能需要需求/影响确认”。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [TraceLLM](https://arxiv.org/html/2602.01253v1) | LLM 可辅助建立轨迹链接,但需要人工确认和持续校准 | +| [LLM-driven requirements change impact analysis](https://arxiv.org/html/2511.00262v1) | 影响分析应衡量召回、精度和人工检查成本 | +| [Rovo acceptance criteria 检查](https://support.atlassian.com/rovo/docs/check-acceptance-criteria-in-a-code-review/) | PR 可检查代码是否满足关联工作项的验收标准 | +| [Kiro Specs](https://kiro.dev/docs/specs/) | spec 应作为 AI 原生交付物参与实现、测试和追踪 | +| MBSE traceability | 复杂系统中需求、模型、验证之间需要结构化追踪 | + +设计约束: + +- 图谱优先召回优先于纯 LLM 语义召回。 +- 项目画像必须说明项目结构、负责人、验证能力和未知区域。 +- 高风险低置信影响项必须人工确认。 +- 输出必须包含信心、证据、推荐/强制检查和残余风险。 +- 确认/拒绝结果必须反馈到交付物图谱。 +- 普通 PR 默认使用轻量变更就绪度;影响分析由 spec/API/发布/事故或高风险信号触发。 + +## 3. 范围与边界 + +范围: + +- 识别变更需求、验收标准、API 契约、设计决策、发布/事故信号。 +- 从交付物图谱、代码图谱、静态依赖、历史记录和 LLM 语义扩展召回候选。 +- 生成推荐/强制检查和人工确认清单。 +- 为发布就绪度和事故到测试回归提供候选关系。 + +边界: + +- 需求管理工具保留系统主权,BitFun 只读取、链接和投影影响候选。 +- 影响面以候选、置信度和证据表达,完整性通过人工确认和后验反馈逐步校准。 +- 本模块生成建议和候选关系,代码或测试修改由任务执行链路处理。 +- 低置信 LLM 推断进入候选状态,通过/就绪依据来自确认关系、验证证据和策略。 +- 质量保障要求较低的任务只显示轻量下一步建议。 + +## 4. 输入、输出与数据模型 + +输入: + +| 输入 | 示例 | +|---|---| +| 需求差异 | issue 描述、验收标准、spec diff | +| 项目画像 | 模块边界、负责人、验证能力、未知区域 | +| API 契约变更 | DTO、Tauri 命令、MCP 工具结构、OpenAPI | +| 交付物图谱 | requirement -> spec -> diff/test/审查链接 | +| 代码图谱 | 文件、符号、导入、测试 | +| 历史记录 | 历史事故、审查问题、不稳定测试 | +| 运行期信号 | 事故、指标、轨迹/日志链接、告警 | +| 人工提示 | 负责人、模块、风险标签 | + +输出: + +```ts +interface ImpactCandidate { + artifact_id: string; + artifact_type: string; + confidence: number; + evidence: EvidenceReference[]; + reason: string; + recommended_checks: RequiredCheck[]; + required_checks: RequiredCheck[]; + confirmation: "required" | "optional" | "not_required"; + residual_risk?: string; + user_visible_mode: "hidden_hint" | "summary" | "review_queue" | "release_gate"; +} +``` + +## 5. 核心流程 + +```text +检测明确的 spec/API/发布/事故触发 + -> 分类变更类型和风险 + -> 加载项目画像和未知区域 + -> 召回已确认图谱链接 + -> 通过静态依赖和历史记录扩展 + -> 按需进行 LLM 语义扩展 + -> 按置信度、风险和检查成本排序候选 + -> 生成检查和确认队列 + -> 更新交付物图谱 +``` + +召回策略: + +| 策略 | 作用 | +|---|---| +| 图谱优先召回 | 使用已确认需求、spec、文件、测试和审查链接 | +| 静态扩展 | 通过导入、符号引用和测试映射扩展 | +| 历史扩展 | 结合事故、审查问题、不稳定测试和热点文件 | +| LLM 语义扩展 | 在语义相似但无结构链接时生成候选 | + +## 6. 显露策略 + +| 场景 | 行为 | +|---|---| +| 快速路径普通改动 | 不展示影响分析;最多进入下一步建议 | +| PR 就绪度且有关联 issue/spec | 展示摘要级候选和推荐检查 | +| 高风险 API/schema/契约变更 | 进入审查队列,要求人工确认关键候选 | +| 发布就绪度 | 未确认高风险候选进入残余风险 | +| 事故回溯 | 输出事故到测试回归候选 | + +治理规则: + +- **置信度分层**:已确认链接 > 静态依赖 > 历史共同变更 > LLM 候选。 +- **人工确认**:高风险低置信候选进入确认队列。 +- **反向学习**:确认、拒绝、遗漏项都写回图谱和评测待办。 +- **门禁集成**:未确认的高风险候选不能直接通过;可进入降级状态或残余风险。 +- **成本控制**:优先使用图谱和静态分析,LLM 语义扩展只处理候选不足或歧义场景。 + +## 7. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 不作为默认能力;只保留数据模型和隐藏提示 | +| P1 | PR 关联 issue/spec 的摘要候选和推荐检查 | +| P2 | 图谱优先影响视图、静态依赖扩展、确认队列 | +| P3 | 发布就绪度、事故到测试回归、LLM 语义候选 | +| P4 | 精度/召回校准、长期需求追踪质量指标 | + +## 8. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 影响分析拖慢普通开发 | 快速路径不展示、不阻塞 | +| 高召回导致低价值候选过多 | 输出必须排序,并显示人工检查成本 | +| 高精度导致漏项 | 对高风险区域保留低置信候选和降级状态 | +| LLM 语义幻觉 | LLM 结果只能作为候选,需要证据和确认 | +| 影响面过期 | 图谱边需要新鲜度;diff、测试或审查变化触发更新 | +| 与就绪度脱节 | 每个高风险候选必须映射检查或残余风险 | +| 团队不确认链接 | 确认动作必须嵌入 PR 审查、发布或事故流程 | + +## 9. 成功标准 + +- 普通任务只接收轻量下一步建议。 +- 明确需求或 API 变更能生成可解释影响候选。 +- 高风险候选有人工确认路径。 +- 确认/拒绝结果写回交付物图谱。 +- 强制要求/推荐检查能覆盖主要影响类别。 +- 精度、召回、人工检查成本和误打断率可量化。 diff --git a/docs/sdlc-harness/features/risk-classifier.md b/docs/sdlc-harness/features/risk-classifier.md new file mode 100644 index 0000000000..c2148ff0aa --- /dev/null +++ b/docs/sdlc-harness/features/risk-classifier.md @@ -0,0 +1,164 @@ +# BitFun 子模块设计:风险与策略分类器 + +> 上游文档:[design.md](../design.md) +> 模块角色:根据任务意图、操作类型、项目画像、变更内容、路径、历史信号和团队策略,生成风险提示、策略建议、验证建议和审查强度。 + +## 1. 模块定位 + +风险与策略分类器是配置化策略的输入层。它回答: + +```text +这个任务或变更需要多少额外信心? +``` + +它输出可解释建议:建议哪些检查、是否建议定向审查、是否需要证据引用、是否可能进入团队治理。阻塞由确定性证据、安全边界或明确项目/组织策略触发;分类器负责给出建议、原因和置信度。 + +安全敏感信号会被识别并传递给 [安全边界](../architecture/security-boundary.md),但安全允许、拒绝或应急放行不由本模块决定。 + +## 2. 行业参照与设计约束 + +| 参照 | 启发 | +|---|---| +| [GitHub rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) | 强约束需要可解释、可配置、可审计 | +| [CodeRabbit 路径指令](https://docs.coderabbit.ai/configuration/path-instructions) | 路径级规则比项目级单一规则更适合复杂仓库 | +| [Kiro steering](https://kiro.dev/docs/steering/) | 工作区、团队、条件加载能减少无关上下文和误触发 | +| [Agentless](https://arxiv.org/abs/2407.01489) | 简单可解释流程是强基线,复杂自治能力应建立在可解释基线之上 | +| [NIST SP 800-218A](https://csrc.nist.gov/pubs/sp/800/218/a/final) | AI 相关变更需要把模型、数据、工具和供应链纳入风险识别 | + +设计约束: + +- 输出必须包含原因、证据、信心和覆盖路径。 +- 风险标签用于提示、检查和审查建议;人工责任边界由团队策略和风险接受记录确定。 +- 规则、模型提示、路径矩阵和团队配置都必须版本化。 +- 校准依赖合入后缺陷、审查阻塞项、CI 失败、覆盖和误升级。 +- 项目画像中 `unknown/conflicting/stale` 的规则进入未知或降级路径。 +- 主动配置变化,例如 hook、plugin、自定义工具、MCP server 或智能体规则,必须进入安全敏感风险路径。 +- 默认规则保持技术栈无关;BitFun 自身验证路径只作为内部样本。 + +## 3. 风险维度 + +| 维度 | 示例 | 下游用途 | +|---|---|---| +| 任务风险 | 一次性脚本、PR、发布、迁移、事故修复 | 决定默认模式 | +| 动作风险 | shell、网络、凭据、跨目录写、删除、发布凭据 | 传递给安全边界 | +| 变更风险 | 核心逻辑、API/schema、适配器、界面行为、生成 diff | 生成验证和审查建议 | +| 环境信任度 | 本地项目、远程工作区、未信任主动配置、私有 CI | 决定提示和证据需求 | +| 项目策略 | 仓库/路径/团队规则、CODEOWNERS、强制检查 | 决定强制要求或建议投影 | +| 历史风险 | 高频热点文件、不稳定测试、事故、审查阻塞项 | 校准风险等级 | + +## 4. 输入、输出与数据模型 + +输入: + +| 输入 | 示例 | +|---|---| +| 用户意图 | ask、edit、debug、PR、发布、快速试验 | +| 项目画像 | 语言、框架、模块、负责人、规则来源、验证能力、发布模型 | +| Diff 元数据 | 文件路径、hunk、重命名/删除、生成文件、行数 | +| 项目策略 | 智能体规则、贡献指南、模块文档、CODEOWNERS、验证模式 | +| 主动配置状态 | hook、plugin、自定义工具、MCP server 的 discovered/trusted/changed/disabled 状态 | +| 交付物链接 | issue、spec、验收标准、设计决策 | +| 历史信号 | 不稳定测试、历史事故、审查问题、高频热点文件 | +| 验证状态 | 已运行/缺失/失败/过期的推荐/强制检查 | + +输出: + +```ts +interface RiskPolicyHint { + level: "low" | "medium" | "high" | "unknown"; + tags: RiskTag[]; + axes: { + task: RiskAxis; + action: RiskAxis; + change: RiskAxis; + environment: RiskAxis; + project_policy: RiskAxis; + }; + reasons: string[]; + evidence: EvidenceReference[]; + confidence: number; + recommended_checks: RequiredCheck[]; + required_checks: RequiredCheck[]; + review_profile: "none" | "targeted" | "full"; + evidence_display_hint: "none" | "summary" | "evidence_refs" | "full_pack"; + policy_profile_hint: "fast" | "assist" | "review" | "guarded" | "regulated"; + override_policy: OverridePolicy; +} +``` + +风险标签示例: + +| 标签 | 触发条件 | +|---|---| +| `low_assurance_or_demo` | 质量保障要求较低的快速试验、演示、无 git 项目、用户明确快速试验 | +| `project_core` | 关键业务逻辑、核心服务、公共库或关键运行路径 | +| `integration_adapter` | 外部服务、provider、协议、schema、stream、cache | +| `security_sensitive` | auth、凭据、filesystem、shell、网络、权限 | +| `prompt_injection_sensitive` | 外部文档、issue、网页、MCP 输出可能影响智能体指令 | +| `active_config_sensitive` | hook、plugin、custom 工具、MCP server、智能体规则或自动化配置变化 | +| `deployment_sensitive` | 发布、migration、infra、remote 工作区、运行时 boundary | +| `ui_behavior` | 用户可见状态、交互流程、前端适配器、审查 surface | +| `docs_only` | 文档或注释变更,无行为影响 | +| `generated_large_diff` | 大量生成文件、snapshot、lockfile | + +## 5. 核心流程 + +```text +用户意图和项目上下文 + -> 扫描任务、动作、变更和环境风险 + -> 检查画像新鲜度与冲突 + -> 匹配路径和模块规则 + -> 补充历史风险 + -> 生成推荐/强制检查 + -> 生成审查画像和证据展示提示 + -> 发出 risk.policy_hinted 事件 + -> 收集校准反馈 +``` + +策略: + +| 风险等级 | 默认策略 | +|---|---| +| low | 保持低干预策略画像,只给推荐检查,不触发深度审查 | +| medium | 建议相关验证;证据弱时建议定向审查或证据引用 | +| high | 列出按策略强制的检查、负责人/审查人、审查强度和覆盖条件 | +| unknown | 不降级为 low;保持摘要或建议投影,必要时进入降级状态并等待确认 | + +主动配置策略: + +| 状态 | 默认策略 | +|---|---| +| discovered | 不执行;输出 `active_config_sensitive`,交给安全边界 | +| trusted | 按声明权限和影响范围参与分类 | +| changed | 至少中风险;涉及 shell、网络、凭据或文件系统时为高风险 | +| disabled | 记录未关闭风险,确认不影响验证后可降级 | + +## 6. 分阶段落地 + +| 阶段 | 目标 | +|---|---| +| P0 | 任务/动作/变更风险标签、推荐检查、`risk.policy_hinted` 事件 | +| P1 | 路径矩阵、项目规则、定向审查触发、误升级反馈 | +| P2 | 团队策略、强制检查、守护/合规配置 | +| P3 | 交付物图谱上下文、历史风险和发布/事故信号 | +| P4 | 后验校准、策略 A/B、模型辅助排序和异常检测 | + +## 7. 风险与反证 + +| 风险 | 反证或治理要求 | +|---|---| +| 风险等级被当成事实 | 界面和 PR 文本必须展示证据、信心和覆盖范围 | +| 低风险误判 | 关键路径小 diff 必须触发规则,不得只按行数判断 | +| 普通任务被误升级 | 误升级率必须进入 P0/P1 指标 | +| 主动配置被当成纯文档变更 | hook、plugin、自定义工具、MCP、智能体规则变化必须触发 active_config_sensitive | +| 强制检查过多 | 每个强制检查必须有触发原因和取消条件 | +| 深度审查成本被放大 | 只有高风险或证据薄弱的中风险才默认触发定向/完整审查 | +| 规则长期失准 | 合入后缺陷、审查阻塞项、覆盖和跳过原因必须回流校准 | + +## 8. 成功标准 + +- 配置化策略能用分类结果选择合理模式。 +- 普通低风险任务保持快速路径和轻量建议。 +- 高风险变更能暴露风险原因、必跑验证和未覆盖风险。 +- 安全敏感动作能被正确转交安全边界。 +- 误升级、漏推荐、无价值检查都可通过反馈量化和修正。 diff --git a/docs/sdlc-harness/governance/metrics-spec.md b/docs/sdlc-harness/governance/metrics-spec.md new file mode 100644 index 0000000000..3ffc6274f4 --- /dev/null +++ b/docs/sdlc-harness/governance/metrics-spec.md @@ -0,0 +1,97 @@ +# BitFun 可配置开发体验看护指标规格 + +> 上游文档:[product-requirements.md](../product-requirements.md)、[implementation-plan.md](../implementation-plan.md) +> 用途:把实施计划中的看护指标转成可采样、可解释、可用于阶段评审的指标规格,避免指标停留在口号层。 + +## 1. 指标治理原则 + +- 每个指标必须有负责人、分母、采样窗口、数据来源和适用阶段。 +- P0/P1 先收集基线,不用指标直接阻塞交付。 +- 指标用于趋势、校准和阶段退出判断;具体决策仍引用证据包、事件和评测卡。 +- 体验、安全、质量和成本联合解释策略效果。 +- 与质量或安全相关的指标必须能追溯到证据包、LifecycleEvent、策略版本或评测卡。 +- 指标口径变化必须记录版本,并保留兼容读取或重新计算策略。 +- 核心体验指标必须按用户画像、任务风险、内部策略画像、用户可见投影、入口平台和受管状态切片,避免平均值掩盖局部人群摩擦。 + +## 2. P0/P1 核心体验与安全指标 + +| 指标 | 公式 | 负责人 | 数据来源 | 窗口 | 用途 | +|---|---|---|---|---|---| +| 首次有用动作耗时 | 项目打开到首次可见有用结果的中位耗时 | 产品体验 | `task.started`、`tool.completed`、`task.completed` | 每周 | 判断快速路径是否真实低摩擦 | +| 阶段收益达成率 | 达到阶段用户收益验收的回放任务数 / 阶段验收任务总数 | 产品体验 | `stage.outcome.evaluated`、界面事件、`task.completed` | 每两周 | 防止阶段只完成后台技术而没有用户收益 | +| 质量一致性缺口率 | 同一任务中需求、开发、验证、审查或发布强度明显不匹配的抽样任务数 / 抽样任务总数 | 产品体验 + 配置化策略 | `quality.consistency.sampled`、`policy.decided`、`verification.completed`、PR 投影 | 每两周 | 发现阶段内质量要求强弱断层 | +| 阶段性能回退率 | 超过阶段性能预算的回放或真实任务数 / 阶段抽样任务总数 | 产品体验 + 运行时 | `performance.budget.evaluated`、工具耗时、用户界面事件、成本事件 | 每两周 | 防止新治理能力拖慢默认开发路径 | +| 事件载荷预算命中率 | 符合事件大小、采样、保留和脱敏预算的事件数 / 新增治理事件总数 | 质量数据面 | LifecycleEvent 注册表、事件采样、导出审计 | 每两周 | 控制 QDP、证据和评测数据膨胀 | +| 低风险任务完成率 | 完成的低风险任务数 / 低风险任务总数 | 配置化策略 | `policy.decided`、`task.completed` | 每周 | 防止低风险场景被强流程拖慢 | +| 用户打断率 | 每个任务的确认、提示、阻断次数 | 产品体验 | `security.decided`、`policy.decided`、用户界面事件 | 每周 | 降低提示噪音 | +| 弹窗触发率 | 模态确认或阻断次数 / 任务数 | 产品体验 | 用户界面事件、`security.decided` | 每周 | 防止确认打断泛化 | +| 重复提示抑制率 | 被合并或延后的同类提示数 / 同类提示候选总数 | 产品体验 | `policy.decided`、用户界面事件 | 每周 | 验证弱提示降噪是否生效 | +| 误升级率 | 人工或后验判定无必要的模式升级数 / 模式升级总数 | 配置化策略 | `policy.decided`、覆盖、反馈 | 每两周 | 校准配置化策略 | +| 安全提示接受率 | 用户接受安全提示建议的次数 / 安全提示总数 | 安全边界 | `security.decided`、用户响应 | 每周 | 判断提示是否有价值 | +| 应急放行率和范围 | 应急放行次数按范围/风险聚合 / 安全提示总数 | 安全边界 | `security.decided`、`user.override.recorded` | 每周 | 发现安全策略过紧或真实绕行需求 | +| 沙箱降级率 | 无法按目标沙箱等级组合执行的动作数 / 请求沙箱动作总数 | 安全边界 | `security.decided`、`sandbox.capability.evaluated` | 每周 | 防止把权限确认误展示成系统级隔离 | +| 主动配置未解决率 | 未确认主动配置关联任务数 / 含主动配置任务总数 | 项目画像 | 主动配置事件、安全事件 | 每周 | 判断信任审查是否阻塞体验 | +| 信心摘要覆盖率 | 生成摘要的完成任务数 / 完成任务总数 | 交付物与证据层 | `confidence.summary.generated` | 每周 | 确保任务结果可解释 | +| 远程不可支持状态恢复率 | 用户按替代路径继续完成的不可支持状态数 / 不可支持状态总数 | 远程适配 | 不可支持状态事件、任务完成事件 | 每周 | 验证远程能力缺口是否可恢复 | +| 跨入口语义一致率 | 保留任务状态、安全范围、验证摘要的跨入口任务数 / 跨入口任务总数 | 产品体验 | 投影事件、任务摘要 | 每两周 | 验证桌面、命令行、远程和 PR 入口表达一致 | +| 关键用例验收覆盖率 | 已有回放用例或截图预期的关键体验用例数 / PRD 定义关键体验用例总数 | 产品体验 | 用例验收记录、界面事件、回放用例 | 每两周 | 验证主要体验变化是否落到可检查交互和用户收益 | + +口径说明: + +- 低风险任务由风险与策略分类器给出,并需排除已触发安全高风险动作的任务。 +- 打断必须区分安全提示、质量建议、审查建议、强制策略和用户主动进入关键流程。 +- 弹窗触发率需要排除用户主动打开的详情页、设置页和 PR 就绪度面板。 +- 重复提示抑制率只统计同一任务、同一原因、同一作用范围内的提示候选。 +- 应急放行用于发现策略和真实工作之间的张力,需要结合风险等级、范围和后验结果解释。 +- 主动配置未解决率上升时,需要区分恶意/未知配置、实现缺口和用户主动禁用。 +- 关键用例验收覆盖率只统计用户可见入口、默认状态、触发条件、主要交互和用户收益均明确的用例;纯文字概念不计入。 +- 阶段收益达成率只统计用户能实际感知的收益,例如更快完成任务、更清楚理解风险、更容易准备 PR;只完成 API、迁移或空实现不计入分子。 +- 质量一致性缺口率需要按场景解释:低风险任务可以轻量,但必须说明未验证项;受管路径可以严格,但需求、开发、验证和审查强度需要相互匹配。 +- 阶段性能回退率以阶段基线为比较对象;P0 建立基线,P1 以后任一默认路径能力超过预算都要标记为回退候选。 +- 事件载荷预算命中率需要同时看字段必要性、隐私分级、保留周期、采样策略和导出范围;不服务用户收益或风险解释的字段应删除或降采样。 + +## 3. P1/P2 上下文信心与团队治理指标 + +| 指标 | 公式 | 负责人 | 数据来源 | 窗口 | 用途 | +|---|---|---|---|---|---| +| 推荐检查执行率 | 被用户执行或 CI 覆盖的推荐检查 / 推荐检查总数 | 风险分类器 | 就绪度、验证事件 | 每两周 | 判断建议是否有行动价值 | +| 强制检查精度 | 人工或后验确认有价值的强制检查 / 强制检查总数 | 风险分类器 | 门禁结果、覆盖、审查反馈 | 每两周 | 校准路径矩阵,减少低价值检查 | +| 强制检查漏报率 | 后验发现应运行但未推荐的检查 / 后验确认需要的检查总数 | 风险分类器 | CI 失败、审查阻塞项、合入后缺陷 | 每两周 | 降低错误就绪风险 | +| PR 就绪度采用率 | 使用就绪度摘要的 PR 数 / BitFun 准备的 PR 总数 | 变更就绪度 | `readiness.generated`、PR 投影 | 每周 | 衡量 PR 体验价值 | +| 门禁降级率 | `degraded` 门禁数 / 门禁投影总数 | 变更就绪度 | `gate.projected` | 每周 | 发现模式、证据、工具或信任模型缺口 | +| 深度审查价值率 | 产生有效问题或避免缺陷的深度审查数 / 深度审查总数 | 审查系统 | 审查事件、反馈 | 每两周 | 防止高成本审查泛化 | +| 风险接受审计覆盖率 | 有操作者/原因/范围/残余风险的风险接受数 / 风险接受总数 | 变更就绪度 | 风险接受事件 | 每周 | 确保人工放行可追踪 | + +## 4. P3/P4 复杂生命周期与评测指标 + +| 指标 | 公式 | 负责人 | 数据来源 | 窗口 | 用途 | +|---|---|---|---|---|---| +| 已确认链接占比 | 已确认图谱边 / 未过期图谱边 | 交付物图谱 | 图谱边状态 | 每两周 | 衡量图谱是否可信 | +| 链接过期率 | 过期图谱边 / 全部图谱边 | 交付物图谱 | 图谱边状态、文件/检查/审查变更 | 每两周 | 判断图谱刷新是否跟上变更 | +| 影响候选精度 | 被确认有效的影响候选 / 影响候选总数 | 需求影响分析 | 确认队列、审查反馈 | 每两周 | 降低低价值候选 | +| 影响召回代理指标 | 后验发现遗漏影响项 / 后验确认影响项总数 | 需求影响分析 | 审查阻塞项、事故、人工补充 | 每月 | 发现高风险漏报 | +| 评测卡覆盖率 | 有评测卡的决策任务集 / 用于决策的任务集总数 | 智能体评测 | 评测注册表 | 每月 | 防止无血缘评测进入决策 | +| 保留集污染率 | 标记污染的保留集任务 / 保留集任务总数 | 智能体评测 | 评测血缘、prompt/导出日志 | 每月 | 防止评测集失效 | +| 回放可复现率 | 可在固定环境复现的回放运行数 / 回放运行总数 | 智能体评测 | 轨迹回放结果 | 每月 | 判断评估基础设施稳定性 | +| 事故到回归延迟 | 事故确认到回归候选/测试/规则入库的中位耗时 | 生命周期上下文 | 事故、图谱、评测待办 | 每月 | 衡量右移反馈闭环 | + +## 5. 阶段退出建议 + +这些阈值作为阶段评审参考。每个目标项目可以在项目画像或团队策略中覆盖阈值。 + +| 阶段 | 建议观察条件 | +|---|---| +| P-1 -> P0 | 配置化策略、安全边界、LifecycleEvent、证据展示层级和指标规格均有负责人与版本 | +| P0 -> P1 | 首次有用动作耗时、阶段收益达成率、质量一致性缺口率、阶段性能回退率、事件载荷预算命中率、打断率、安全提示响应、沙箱降级率、信心摘要覆盖率和关键用例验收覆盖率有基线 | +| P1 -> P2 | 风险和远程状态收益可回放;误升级可归类;推荐检查执行率有样本;PR 就绪度被实际使用 | +| P2 -> P3 | PR/团队规则收益可回放;强制检查精度有人工反馈;风险接受审计接近完整;主动配置未解决不再由实现缺口主导 | +| P3 -> P4 | 影响精度/召回代理指标可采样;发布/事故证据可回写图谱;评测卡覆盖率接近完整 | + +## 6. 指标解释边界 + +- 单次基准测试分数只代表指定任务集表现,产品质量需要结合真实任务、缺陷、审查反馈和用户体验。 +- PR 周期需要和错误就绪、错误阻断、审查反馈、缺陷和用户打断一起解释。 +- token 成本需要和质量、风险等级、用户接受度一起优化。 +- 模型生成的“风险摘要数量”属于提示量指标,真实问题密度以确认问题和后验结果为准。 +- 未确认图谱边进入候选链接,不计入已确认链接占比。 +- 应急放行按风险等级、范围、后验结果和组织策略共同判断。 diff --git a/docs/sdlc-harness/governance/self-governance-notes.md b/docs/sdlc-harness/governance/self-governance-notes.md new file mode 100644 index 0000000000..8f76269bec --- /dev/null +++ b/docs/sdlc-harness/governance/self-governance-notes.md @@ -0,0 +1,49 @@ +# BitFun 自身治理说明 + +> 范围:记录 BitFun 仓库自身在验证可配置开发体验、工程治理、安全和质量能力时暴露出的文档、边界、规则和内部验证问题。 +> 边界:本文只用于内部验证和自我改进;目标项目默认流程、技术栈和质量要求以产品需求、设计文档及项目配置为准。 + +## 1. 为什么需要独立文档 + +BitFun 仓库可以作为可配置工程能力的内部验证项目。产品需求和设计文档中的“目标项目”代表任意被用户加载的软件工程;BitFun 仓库的技术栈、模块边界、AGENTS 文档、CI 规则和 fork PR 工作流仅作为高复杂度样本。 + +因此,BitFun 自身治理问题应单独记录: + +1. BitFun 仓库自身为了更好验证这些能力需要补齐的问题。 +2. 这些问题如何作为样本输入项目画像、风险分类器、变更就绪度、安全边界和评测。 +3. 哪些规则属于 BitFun 仓库特定样本,哪些能力可以抽象为产品默认。 + +## 2. 当前内部验证问题 + +| 问题 | 表现 | 影响 | 处理边界 | +|---|---|---|---| +| 产品对象与内部验证对象混用 | 文档中同时描述 BitFun 自身代码治理和 BitFun 作为产品支持外部项目 | 读者难以判断设计是在改某个 PR 功能,还是在定义产品级体验架构 | 产品需求和设计文档聚焦目标项目;本文承载自身治理问题 | +| Harness 术语过重 | 容易被误解为 BitFun 产品定位或默认用户流程 | 普通项目会被强治理叙事劝退 | Harness 只作为内部支撑能力术语 | +| 默认流程偏重 | 早期文档把 PR 门禁/证据包作为首个闭环 | 质量保障要求较低的快速试验、演示和个人项目体验过重 | P0 改为快速路径 + 安全边界 | +| 项目维度过强 | 容易默认一个项目只有一种质量等级 | 实际上路径、任务、权限、团队配置都可能改变治理强度 | 使用配置化策略画像 | +| 安全与质量混淆 | 安全风险、质量建议、PR 策略容易放进同一个门禁 | 快速任务可能绕过安全,或安全提示被质量流程噪音淹没 | 安全边界独立常开 | +| BitFun 特定验证路径容易外溢 | 示例命令、fork PR 流程、模块边界可能被当作通用默认 | 目标项目适配性降低 | 作为内部模式样本,不写入产品默认 | + +## 3. 内部验证项目应如何使用 BitFun 仓库 + +- 项目画像示例可以读取 BitFun 的 AGENTS.md、CONTRIBUTING、package/cargo scripts、CI 和模块边界。 +- 变更就绪度示例可以使用 BitFun 的 fork PR 流程,但必须标注为项目特定。 +- 风险分类器示例可以覆盖 Rust 工作区、React frontend、Tauri desktop、remote、AI 适配器、深度审查等高风险区域。 +- 安全边界示例应覆盖 shell、网络、凭据、MCP、hook、跨目录写、删除、发布 credential 等安全面。 +- 证据包示例应表达为“任务和变更证据”,不默认要求所有项目生成完整证据包。 +- 评测示例需要同时覆盖快速路径、误升级、安全提示、PR 就绪度和高风险审查。 + +## 4. BitFun 特定规则的适用边界 + +- `pnpm`、`cargo`、Tauri、Rust 工作区和 React 前端只代表 BitFun 技术栈样本。 +- fork PR 到 `GCWing/BitFun` 只代表 BitFun 当前协作模式样本。 +- BitFun 的严格审查习惯用于验证 `guarded/regulated` 场景,不作为所有项目默认质量要求。 +- BitFun 的 AGENTS.md 完整度用于验证规则读取能力,不作为目标项目最低要求。 +- BitFun 的 CI 行为、耗时和不稳定风险用于验证复杂项目指标,不作为通用阈值。 + +## 5. 成功标准 + +- BitFun 仓库能作为高复杂度、强协作、强安全样本验证设计。 +- 普通目标项目仍能保持低摩擦快速路径。 +- 文档读者能清楚区分产品默认、团队可选强治理和 BitFun 仓库自身规则。 +- 所有从 BitFun 仓库抽象出的能力,都有“哪些场景不适用”的反证说明。 diff --git a/docs/sdlc-harness/implementation-plan.md b/docs/sdlc-harness/implementation-plan.md new file mode 100644 index 0000000000..e8e412ea8c --- /dev/null +++ b/docs/sdlc-harness/implementation-plan.md @@ -0,0 +1,342 @@ +# BitFun 可配置开发体验与工程治理实施计划 + +> 范围:基于 [product-requirements.md](product-requirements.md) 和 [design.md](design.md),定义 BitFun 面向外部目标项目落地快速路径、配置化策略、安全边界、团队治理和复杂生命周期能力的阶段路线。 +> 目标:每个阶段都交付短而明确的产品收益,同时保留必要技术跑道、API 预设、迁移准备和测试用例预设。 + +## 1. 执行定位 + +本计划以用户当期可感知收益为阶段主线。每条技术能力按最小可用切片进入阶段;模块完整度作为后台边界管理,完整边界能力可以跨阶段补齐。 + +```text +技术跑道 P-1 + -> P0 可解释的轻量开发闭环 + -> P1 风险和远程状态可理解 + -> P2 PR 协作和团队规则按需显露 + -> P3 受管项目一致治理 + -> P4 生命周期追溯与策略优化 +``` + +每个阶段都必须回答四个问题: + +1. 用户今天能更快、更清楚或更安全地完成什么任务。 +2. 哪些技术前置必须先做,且不应伪装成用户价值。 +3. 哪些边界能力暂不完整,用户侧如何被解释和保护。 +4. 需求、开发、验证、审查和安全提示的质量要求是否相对一致。 + +## 2. 执行原则 + +| 原则 | 执行含义 | +|---|---| +| 收益切片优先 | 先选择能形成明确体验变化的最小闭环,不以模块完整度作为阶段目标 | +| 技术跑道显式化 | 事件、API、迁移、测试桩和空实现可以先做,但必须标注服务的产品收益和进入用户界面的时机 | +| 质量要求一致 | 同一阶段不能只对需求或 PR 设高要求,却让开发过程完全无约束;每个风险等级都要有相对一致的提示、验证和摘要 | +| 安全常驻先行 | prompt 注入、凭据、网络、shell、删除、发布、hook/MCP 和主动配置风险不等待后续治理阶段 | +| 能力声明先于执行 | tool、MCP、skills、插件、hook 和内置能力先声明能力、目标、数据类别和副作用;未知能力进入受限模式 | +| 用户语言优先 | 用户侧展示任务状态、原因、影响和下一步;内部策略画像和阶段名只在日志、设置或文档中出现 | +| 阶段边界可解释 | 未完成的沙箱、远程、证据、图谱或团队能力必须显示降级原因、替代路径或明确不可支持状态 | +| 可回放用例先行 | 每个阶段至少有低风险任务、安全确认、远程/不可支持状态和 PR/分享场景的回放用例 | +| 证据后台化 | 证据包、图谱和评测先服务摘要、就绪度、发布和复盘,不成为普通任务默认负担 | +| 指标保护体验 | 阶段评审同时看速度、打断、安全、质量、成本和后验反馈 | +| 反证驱动 | 一旦某切片增加提示噪音、误升级、误阻断或无用户收益,应降级、合并或延后 | + +## 3. 收益切片队列 + +同一能力可以跨阶段出现。早期切片只交付最小用户收益,后续再补齐团队、审计、图谱或评测能力。 + +| 能力线 | P0 切片 | P1 切片 | P2 切片 | P3/P4 切片 | +|---|---|---|---|---| +| 项目理解 | 展示语言、脚本、规则来源、执行位置 | 解释验证能力缺口和推荐检查 | 读取团队路径规则和受保护分支 | 关联需求、发布、事故和历史质量反馈 | +| 任务结果 | 短摘要:改动、已验证、未验证、下一步 | 风险原因、跳过后果和替代验证 | PR 就绪度摘要和证据引用 | 发布/事故/复盘摘要 | +| 安全与沙箱 | allow/ask/deny、执行位置、授权范围、沙箱降级原因 | 沙箱状态块、远程不可支持状态和替代路径 | 项目/团队安全策略、风险接受和审计 | 企业受管 sandbox、跨项目审计导出 | +| 风险与验证 | 未验证项进入任务摘要 | 行内弱提示、推荐检查、不可运行原因 | 配置化强制检查和审查强度 | 后验缺陷、CI、审查反馈回流 | +| 团队规则 | 规则来源只作为上下文展示 | 冲突或受管信号折叠提示 | 路径规则、CODEOWNERS、PR 策略按需生效 | 合规批准、发布就绪和组织审计 | +| 证据与图谱 | 记录最小事件和验证摘要 | 生成证据引用,不默认完整包 | PR/团队场景投影证据包 | 交付物图谱、需求影响、事故回归 | +| 扩展与插件 | 预留 hook、事件、打点和候选契约 | 空实现、过期候选和幂等校验 | 工具复写契约和主动配置信任 | 插件适配主机原型、生态评测 | +| 指标与评测 | 建立速度、打断、安全和摘要覆盖基线 | 采样误升级、推荐检查和不可支持恢复 | 采样强制检查精度和 PR 采用 | 轨迹回放、策略 A/B、保留集 | + +## 4. 阶段路线图 + +| 阶段 | 产品收益 | 纳入切片 | 必要技术前置 | 进入下一阶段条件 | +|---|---|---|---|---| +| P-1 | 不作为用户发布;明确产品边界和技术跑道 | 策略契约、安全契约、事件契约、阶段用例和指标口径 | LifecycleEvent、策略对象、配置优先级、测试桩、API 预设 | 能说明每个 P0/P1 切片服务的用户收益、降级解释和验收方法 | +| P0 | 用户能低摩擦完成低风险任务,并看懂安全范围和未验证项 | 轻量项目理解、任务短摘要、安全确认、执行位置、主动配置发现、最小 QDP | 项目扫描、基础安全决策、事件记录、摘要投影、关键回放用例 | 普通任务可完成;安全越界不静默;低风险任务不被重流程拖慢 | +| P1 | 风险、远程和不可支持状态变得可理解,用户知道该补什么验证 | 弱提示、推荐检查、远程/沙箱降级、Kernel 扩展点 v0、证据引用摘要 | 风险分类器、提示降噪、sandbox capability 事件、扩展点空实现 | 高风险解释清楚;低风险仍快速;远程和不可支持状态有替代路径 | +| P2 | 准备 PR 或进入团队规则时,用户获得可复制的协作摘要和规则说明 | PR 就绪度、团队规则折叠说明、风险接受、主动配置信任、工具复写契约 | 规则来源合成、PR 投影、风险接受记录、证据引用状态 | PR 摘要减少审查追问;团队规则来源可解释;强制项只在配置或确定性风险下出现 | +| P3 | 受管项目的需求、开发、验证、审查和发布质量要求保持一致 | 路径级强策略、完整证据包投影、最小图谱、发布/事故链接、组织审计 | 图谱边状态、证据过期、受管配置、发布和事故事件 | 复杂项目可追溯;阻断/批准来源明确;快速路径仍保留 | +| P4 | 用真实任务持续优化策略,淘汰无收益治理能力 | 轨迹回放、策略 A/B、评测卡、保留集、生态适配评估 | 回放环境、评测血缘、策略版本和成本采样 | 能用速度、质量、安全和成本联合评估治理策略 | + +### 4.1 阶段详细治理矩阵 + +| 阶段 | 详细设计影响面 | 阶段结果 | 主要副作用和返工风险 | 质量管理方法 | 性能和成本看护 | +|---|---|---|---|---|---| +| P-1 | PRD、设计、事件信封、策略对象、安全边界、测试夹具、指标口径 | 技术跑道清晰;P0/P1 的收益、降级和验收方法可解释 | 契约过度设计导致后续实现被早期字段锁死;事件和策略对象未服务真实体验;测试夹具只覆盖理想路径 | 契约评审必须逐项映射 PRD、事件、用例和指标;新增字段要声明生产者、消费者和删除条件 | 不设用户路径预算;限制设计期事件和指标数量,避免 P0 一开始背负遥测膨胀 | +| P0 | 项目打开、项目上下文条、任务短摘要、安全确认、远程执行位置、主动配置发现、最小 QDP | 低风险任务能完成;用户看到已验证/未验证、安全范围、执行位置和下一步 | 默认体验被安全/摘要/扫描拖慢;项目扫描误判后形成强策略;安全确认过多;沙箱能力被夸大;主动配置发现误阻断普通任务 | 回放低风险任务、安全高敏动作、远程容器、主动配置发现;摘要必须包含未验证项;安全与质量事件分开审计 | 首次有用动作以 P0 基线为准;项目扫描分层、可取消、可延后;安全确认和摘要渲染不阻塞工具结果展示;QDP 只采最小事件 | +| P1 | 风险提示、推荐检查、远程不可支持状态、沙箱降级说明、Kernel 扩展点、证据引用摘要 | 用户能理解风险和能力缺口,并知道补什么验证 | 风险提示泛化成噪音;推荐检查不可运行;远程缺口被本地验证掩盖;扩展点空实现被误认为可用插件能力;证据引用缺少新鲜度 | 中/高风险用例必须说明原因、证据、建议和跳过后果;推荐检查要有不可运行分支;扩展点候选不能写权威状态 | 风险分类和提示生成后台化;同类提示合并;扩展点有 deadline、epoch、幂等键;证据引用只保存摘要和引用 | +| P2 | PR 就绪度、团队规则来源、路径规则、风险接受、主动配置信任、工具复写契约 | PR 或团队场景可获得可复制摘要和规则说明;普通任务不被团队流程打断 | 团队规则冲突导致误阻断;PR 摘要生成过重;风险接受被滥用;工具复写绕过安全;未信任主动配置产生假通过 | PR/团队用例抽样验证规则来源、强制项、风险接受和撤销;风险接受必须记录范围、原因、操作者、期限;工具复写重新经过安全边界 | PR 投影按需生成;证据引用增量更新;团队规则读取缓存并可解释失效;不创建默认强制 GitHub 检查 | +| P3 | 路径级强策略、完整证据包投影、最小交付物图谱、需求/API/架构影响候选、发布/事故事件 | 受管项目的需求、实现、验证、审查、发布和风险接受强度一致 | 图谱误关联造成错误责任链;证据过期未失效;需求质量很高但开发/验证仍低保障;发布阻断来源不清;普通任务被图谱拖慢 | 受管路径抽样检查质量一致性;图谱边必须有来源、新鲜度和确认状态;发布就绪区分事实、候选和风险接受 | 图谱和证据只在 PR、发布、事故、合规场景显露;增量计算优先;过期重算后台化;普通任务保留短摘要 | +| P4 | 轨迹回放、评测卡、策略 A/B、保留集、成本采样、生态适配评估 | 策略能用真实任务持续优化,低价值治理能力可下线 | 评测集污染;只优化强管控任务;A/B 影响用户信任;成本指标压倒质量;插件生态评估反向推动不成熟运行时提前接入 | 每个评测任务有血缘、泄漏状态和判定标准;A/B 只在可回滚策略上运行;后验缺陷、审查阻塞和用户反馈共同校准 | 回放和评测离线或低优先级执行;token、墙钟耗时、工具调用和提示次数进入评测卡;生态适配默认不改变内核权威状态 | + +### 4.2 深度风险与返工预防 + +| 风险类型 | 触发方式 | 可能后果 | 预防和回退 | +|---|---|---|---| +| 默认体验变重 | P0/P1 把证据包、图谱、深度审查或团队规则提前显露 | 低风险任务完成变慢,用户认为产品只适合强流程项目 | 默认只展示短摘要和必要安全确认;复杂能力按 PR、团队、发布、事故和合规触发;打断率和首次有用动作耗时超基线时降级 | +| 质量要求断层 | 需求或 PR 阶段要求很高,但开发过程中缺少风险提示、验证建议或未验证摘要 | 用户在末端突然被阻断,返工集中在 PR 或发布前 | `quality.consistency.sampled` 抽样检查需求、开发、验证、审查、发布强度;受管路径从开发中就显示规则来源和验证缺口 | +| 风险误升级 | 风险分类把文档、小范围非核心改动或个人任务升级到团队治理 | 提示噪音、误阻断、用户绕过安全确认 | 风险等级只驱动建议和展示层级;阻断只能来自安全拒绝、组织策略、确定性失败或明确强制策略 | +| 风险漏报 | 小 diff 命中安全、权限、schema、远程、发布或主动配置 | 高风险变更通过轻量路径,后续缺陷或安全事件返工 | 路径、动作、主动配置和历史风险优先于 diff 行数;高风险低置信进入降级状态和推荐检查 | +| 沙箱误表述 | 只有用户确认或工作区快照,却展示为系统级隔离 | 用户误判安全边界,安全事故后需要重做授权模型 | `allow_in_sandbox` 只在真实隔离可用时出现;沙箱等级组合、降级原因和残余风险写入 `security.decided` | +| 远程/本地语义漂移 | 本地扫描、测试或路径判断被用于远程 SSH、容器或云端任务 | 无效验证、路径误写、权限扩大 | 所有工具和摘要显示执行位置、路径映射、端口/网络边界;本地能力不可替代远程能力,缺口必须给替代路径 | +| 事件和指标膨胀 | P0 就采集完整日志、轨迹、证据和图谱字段 | 性能下降、隐私风险、事件迁移成本变高 | P0 只采最小 QDP;新事件必须声明生产者、消费者、保留周期、隐私分级和删除条件 | +| 证据或图谱过期 | 新提交、策略变化、验证失败、审查范围变化后仍复用旧证据 | PR、发布或事故复盘引用错误事实 | 证据和图谱边带新鲜度、来源和确认状态;过期后只能作为历史引用,不能作为通过依据 | +| 团队规则冲突 | AGENTS、CODEOWNERS、CI、工具配置和组织策略给出不同要求 | 误阻断、重复检查、规则不可解释 | 配置优先级固定;冲突显示来源和作用域;不能静默选择高风险路径 | +| 插件和工具复写越权 | 外部插件、OpenCode 适配层或工具复写候选直接影响权威状态 | 执行链不可信,后续需要重构 Kernel/Host 边界 | P0/P1 只保留 Kernel 扩展点和候选契约;候选不能写通过、失败、阻断、审计或授权状态 | +| 性能优化牺牲安全 | 为了减少弹窗或延迟,把安全确认合并到质量覆盖或默认允许 | 安全边界被质量流程稀释 | 安全决策独立常驻;降噪只能通过范围限制、白名单、真实沙箱和一次性授权实现 | +| 评测过拟合 | 策略只针对固定任务集或强治理样本优化 | 普通项目体验变差,真实任务收益下降 | 评测卡记录血缘和污染状态;低风险、远程、安全、PR、受管路径都必须保留样本 | + +## 5. P-1:产品和架构跑道 + +P-1 是内部跑道,不应作为用户可见“新能力”发布。它的价值是避免后续阶段为了补契约反复返工。 + +| 交付件 | 内容 | 验收方式 | +|---|---|---| +| 阶段收益切片表 | 将每条能力线拆成 P0-P4 可交付用户收益、技术前置和延期边界 | 每个切片都能对应 PRD、设计模块、事件和测试方法 | +| 配置化策略契约 | 内部策略画像、原因、展示层级、检查、审查策略、覆盖选项 | 能解释每次提示、升级、阻断来源 | +| 体验投影契约 | 将内部策略画像映射为用户可见状态、弱提示、确认、设置和日志 | 用户无需学习内部模式也能理解当前状态 | +| 产品需求映射 | 将 PRD 中目标、画像、体验路径和功能需求映射到策略对象、界面状态、事件和验收方法 | 能说明能力如何服务不同人群和任务阶段 | +| 关键用例清单 | 低风险开发、安全确认、远程上下文、PR 就绪、受管路径、主动配置信任的回放用例 | 每个 PRD-18 用例都有入口、默认状态、体验收益和验证方法 | +| 安全边界契约 | 权限、执行位置、沙箱等级组合、网络、凭据、主动配置信任、应急放行 | 安全决策不依赖门禁或质量策略 | +| 能力/效果模型契约 | tool、MCP、skills、插件、hook 和内置能力统一声明能力、目标、数据类别、信任来源和副作用候选 | 新扩展不需要修改策略核心即可进入安全判定;未声明能力默认受限 | +| 执行与沙箱矩阵 | 本地、远程、ACP、MCP、MiniApp/前端、插件适配主机、浏览器/桌面、云端任务的执行位置、隔离等级、降级原因和用户选项 | `allow_in_sandbox` 只在真实隔离可用时出现 | +| Kernel 扩展点契约 | 生命周期 hook、事件信封、打点、工具复写候选、插件效果候选、deadline、epoch 和幂等键 | 未来适配器可接入,空实现不影响默认任务 | +| 最小事件注册表 | `project.opened`、`task.started`、`policy.decided`、`security.decided`、`verification.completed`、`task.completed`、`stage.outcome.evaluated`、`quality.consistency.sampled`、`performance.budget.evaluated` | 快速路径、安全提示、任务摘要、阶段收益、质量一致性和性能回退可追踪 | +| 产品指标规格 | 首次有用动作耗时、打断率、阶段收益达成率、质量一致性缺口率、应急放行率 | 阶段验收不只看技术完成度 | + +退出条件:可以描述 P0/P1 每个切片的用户收益、技术前置、降级解释和回放方法;每个高权限动作都有 allow/ask/deny/应急放行路径。 + +## 6. P0:可解释的轻量开发闭环 + +### 6.1 阶段目标 + +让用户打开任意外部项目后,能快速完成一次低风险开发任务,并在不理解内部治理术语的情况下获得简洁结果、安全范围和未验证项说明。 + +### 6.2 交付件 + +| 用户可见收益 | 必要实现 | 明确不做或延期 | +|---|---|---| +| 项目上下文条能说明项目、执行位置、主要入口和规则来源 | 轻量扫描语言、包管理器、常用脚本、git 状态、README/AGENTS/CONTRIBUTING/CI 入口 | 不完整建模模块边界,不用推断结果形成强制策略 | +| 任务结束有短摘要 | 记录改动、已运行命令、未验证项、下一步建议和信心摘要 | 不默认展示完整证据包、图谱或 PR 门禁 | +| 安全动作有低噪音确认 | 工作区写、shell、网络、凭据、删除、跨根目录写入基础决策,记录执行位置、沙箱等级组合和降级原因 | 不声称具备未实现的运行时沙箱 | +| 远程或云端执行位置可见 | 识别本地、SSH、容器、远程工作区、云端异步任务和不可用本地能力 | 不把本地能力投影为远程能力 | +| 主动配置默认安全 | 发现 hook、plugin、MCP、自定义工具、工具复写和智能体规则,默认未信任且不执行 | 不执行外部插件运行时 | +| 低风险任务也有一致质量说明 | 低风险任务摘要必须包含已验证、未验证、跳过原因和建议补证路径 | 不要求深度审查、强制检查或完整需求分析 | +| 阶段数据可采样 | 记录任务、工具、验证、安全决策、用户覆盖、摘要生成、阶段收益和质量一致性抽样事件 | 不用指标阻塞 P0 用户任务 | + +### 6.3 验收成果 + +- 质量保障要求较低的探索性改动可以快速获得可用结果和短摘要。 +- 常规项目可以在不配置 `.bitfun` 的情况下识别主要开发命令。 +- 用户默认不需要理解内部策略模式。 +- 安全敏感动作会提示原因、范围、执行位置和可选隔离路径。 +- 权限确认、快照/worktree 隔离和运行时沙箱在提示中分开表达;没有真实隔离时不展示为沙箱允许。 +- 远程项目能展示执行主机、工作区根、权限边界和不可用能力。 +- 用户可以一次性放行低/中风险越界,但不能静默持久化。 +- 低风险任务虽然不强制检查,但仍保留已验证/未验证项、跳过原因和下一步。 +- 低风险任务、安全确认和远程容器用例可回放,布局、文案、交互和用户收益符合 PRD-18。 + +### 6.4 过程风险 + +| 风险 | 处置 | +|---|---| +| 为了安全把 P0 做重 | 优先用范围限制、沙箱、白名单和短确认降噪 | +| 沙箱能力被夸大 | `security.decided` 必须记录实际沙箱等级组合和降级原因,文案禁止把权限确认描述成系统级隔离 | +| 项目理解不准 | 推断结论只用于提示,不用于强策略 | +| 低风险任务质量解释不足 | 任务摘要必须列出已验证、未验证和跳过原因 | +| 远程上下文不清 | 先提示执行位置和能力缺口,不自动扩大权限 | + +## 7. P1:风险和远程状态可理解 + +### 7.1 阶段目标 + +当任务出现风险、远程环境差异或验证缺口时,BitFun 解释为什么需要额外动作,并给出可执行的检查、替代验证或降级路径。 + +### 7.2 交付件 + +| 用户可见收益 | 必要实现 | 明确不做或延期 | +|---|---|---| +| 中风险动作有行内弱提示 | 路径、模块、操作、历史信号和安全敏感标签进入风险提示 v0 | 不默认弹窗,不默认深度审查 | +| 推荐检查可执行或可解释 | 推荐验证命令、替代验证、不可运行原因和跳过后果 | 不把推荐检查等同阻断 | +| 远程不可支持状态可恢复 | 私有依赖、本地二进制、端口、凭据或权限不可用时给替代路径 | 不假装本地检查覆盖远程环境 | +| 沙箱降级可见 | 本地/远程/ACP/MCP/插件/浏览器/云端执行面的状态块、降级说明和替代路径 | 不把权限确认包装成沙箱 | +| 扩展点可预留但不干扰 | 稳定事件、hook 注册点、打点、工具复写候选契约和空实现 | 不执行 OpenCode 插件,不让候选写权威状态 | +| 证据引用按需出现 | 只生成摘要和证据引用,服务任务摘要和后续 PR | 不默认生成完整证据包 | + +### 7.3 验收成果 + +- 低风险改动不触发深度审查。 +- 高风险或中风险改动能解释升级原因、建议检查和跳过后果。 +- 同类提示不会在同一任务中反复弹窗。 +- CI、私有环境或远程能力不可运行时输出替代建议、信心影响和后续补证路径。 +- 扩展点无适配器、空实现或返回过期候选时,不影响内核权威状态。 +- 风险提示、推荐检查、沙箱降级和远程不可支持用例可回放。 + +## 8. P2:PR 协作和团队规则按需显露 + +### 8.1 阶段目标 + +用户准备 PR、分享变更或命中团队规则时,BitFun 提供可复制的协作摘要、规则来源和风险接受入口,而不影响普通任务中间过程。 + +### 8.2 交付件 + +| 用户可见收益 | 必要实现 | 明确不做或延期 | +|---|---|---| +| PR 就绪度可复制 | 变更、验证、未验证项、风险、证据引用和复制到 PR 的 Markdown 块 | 不默认创建强制 GitHub 检查 | +| 团队规则来源可解释 | AGENTS、CONTRIBUTING、CODEOWNERS、CI、`.github/instructions`、`.coderabbit.yaml`、`.gitlab/duo` 读取和来源展示 | 不让冲突规则静默生效 | +| 受管路径有折叠说明 | 路径规则、审查强度、强制检查和冲突状态按需展示 | 不把所有团队配置变成默认首屏 | +| 风险接受可追踪 | 操作者、原因、范围、残余风险、过期时间和撤销入口 | 不允许绕过安全拒绝和组织拒绝策略 | +| 主动配置可先审后用 | hook、plugin、MCP、自定义工具和工具复写记录来源、hash、权限和信任状态 | 不执行未信任主动配置 | +| 工具复写有契约 | 定义内核工具表、复写候选、权限校验和撤销入口 | 外部插件运行时接入延后 | + +### 8.3 验收成果 + +- PR 摘要减少审查人追问,但不默认阻塞。 +- 团队能用仓库配置统一体验,个人一次性任务仍使用个人默认值。 +- 路径规则冲突能显示并要求确认。 +- 强制要求或阻断有明确来源,不由模型单独触发。 +- 工具复写契约不会跨项目生效,也不会绕过文件、shell、网络和凭据策略。 +- PR 就绪、受管路径、风险接受和主动配置信任用例可回放。 + +## 9. P3:受管项目一致治理 + +### 9.1 阶段目标 + +为高质量要求、受管路径、发布和事故相关任务提供一致治理。需求、开发、验证、审查、发布和风险接受不应出现明显强弱断层。 + +### 9.2 交付件 + +| 用户可见收益 | 必要实现 | 明确不做或延期 | +|---|---|---| +| 受管项目质量要求一致 | 路径级强策略、审查强度、强制检查、风险接受和发布要求共同生效 | 不把单个需求文档质量当成全部质量保证 | +| 完整证据包按场景投影 | 团队、发布、事故或合规场景生成可追踪证据包 | 普通任务仍只看摘要 | +| 最小交付物图谱可解释 | `diff -> verification -> evidence_pack -> PR`,并支持过期状态 | 不把未确认图谱边当成事实 | +| 需求/API/架构影响候选可确认 | 高风险需求、API、权限、数据和架构变更给影响候选和人工确认 | 不由模型直接写入权威影响事实 | +| 发布和事故回流 | 发布就绪度、回滚风险、事故到回归候选和规则更新 | 不要求所有项目一次性启用 | + +### 9.3 验收成果 + +- 普通任务不需要看到图谱或完整证据包。 +- 复杂 PR 可以解释证据和关系来源。 +- 发布就绪度能说明可发布、不可发布或已接受风险。 +- 事故能转成回归候选或规则更新。 +- 受管任务抽样时,需求、实现、验证、审查和发布要求强度一致。 + +## 10. P4:生命周期追溯与策略优化 + +### 10.1 阶段目标 + +用真实任务和产品指标优化智能体行为、治理策略和质量治理,避免只优化基准测试或只优化强管控。 + +### 10.2 交付件 + +| 交付件 | 内容 | +|---|---| +| 轨迹回放 | 回放任务、命令、验证、提示、覆盖和安全决策 | +| 评测卡 | 任务集、判定标准、模型/上下文/工具/策略版本、成本、安全事件 | +| 治理策略 A/B | 比较提示频率、误升级、成功率、用户放行和质量结果 | +| 保留集 | 防泄漏任务集和真实项目回归 | +| 策略校准 | 合入后缺陷、审查阻塞项、CI 失败和用户反馈回流 | +| 生态适配评估 | OpenCode 等插件系统作为适配层评估,不改变内核权威状态 | + +### 10.3 验收成果 + +- 能证明新策略没有让普通任务变慢。 +- 能证明安全提示减少盲目确认。 +- 能证明高风险场景质量更稳定。 +- 能发现哪些门禁、审查、图谱或证据能力没有产品价值,应下线或降级。 + +## 11. 阶段质量一致性检查 + +每个阶段退出前必须抽样检查同一风险等级下的体验强度是否一致。 + +| 场景 | 需求/意图 | 开发过程 | 验证 | 审查/分享 | 必须避免 | +|---|---|---|---|---|---| +| 低风险个人任务 | 简短任务目标即可 | 低干预、必要安全确认 | 可选检查,摘要记录未验证项 | 不默认 PR 门禁 | 要求完整需求说明却不说明开发未验证风险 | +| 中风险变更 | 说明风险来源和影响范围 | 行内弱提示和推荐检查 | 推荐检查或替代验证 | 分享/PR 时展示未验证项 | 风险提示很多但没有下一步 | +| 受管路径 | 读取团队规则和路径要求 | 按规则提示或阻断 | 强制检查有来源 | PR 就绪度和风险接受 | 只在 PR 阶段突然加重流程 | +| 安全高敏动作 | 展示命令、范围、执行位置和后果 | 安全边界独立确认 | 记录授权、沙箱和降级 | 审计和撤销入口 | 把质量覆盖当成安全授权 | +| 远程/云端任务 | 标注执行域和不可用能力 | 不假设本地能力可用 | 给替代验证或补证路径 | 摘要说明环境差异 | 本地验证结果冒充远程验证 | + +### 11.1 阶段发布闸口 + +| 闸口 | 检查项 | 不满足时的处理 | +|---|---|---| +| 用户收益 | 阶段至少有一个可回放用户场景达到预期体验结果,并能说明未完成边界 | 不发布为阶段完成;只能作为技术跑道或实验能力记录 | +| 质量一致性 | 同一风险等级下,需求、开发、验证、审查和发布强度没有明显断层 | 降低阶段范围,或补齐开发中提示、验证建议、风险接受记录 | +| 风险副作用 | 新增提示、确认、规则、证据或图谱没有引入明显误升级、误阻断、隐私扩大或安全误表述 | 默认关闭该切片,改为设置项、实验开关或后台记录 | +| 性能看护 | 首次有用动作、用户打断率、后台扫描耗时、事件载荷和 token/工具成本没有超过阶段预算 | 延后后台分析、降低采样、只保留摘要,或回滚高成本切片 | +| 降级和回退 | 每个新切片都有不可用状态、降级文案、替代路径和关闭方式 | 不允许进入默认路径 | +| 数据和隐私 | 新事件、证据、日志、评测轨迹都声明隐私分级、保留周期和导出策略 | 不采集原始数据,只保留脱敏摘要或引用 | + +### 11.2 性能预算口径 + +| 预算项 | 口径 | 回退策略 | +|---|---|---| +| 首次有用动作 | P0 建立基线;P1 以后默认路径不得无解释高于上一阶段基线 | 延后项目深扫、证据生成、图谱更新和评测采样 | +| 项目扫描 | 轻量扫描先返回入口、脚本、规则来源和执行位置;完整建模后台化 | 取消或降级为“未知区域”,不阻塞任务开始 | +| 提示与确认 | 同一任务、同一原因、同一范围只出现一次强提示;低价值提示合并到摘要 | 合并、延后、折叠或进入任务结束摘要 | +| 证据和图谱 | 只在 PR、团队、发布、事故或合规场景计算完整投影 | 默认只保留摘要和引用,过期重算后台执行 | +| QDP 事件 | 新事件字段必须服务用户收益、风险解释、质量一致性或性能看护 | 删除字段、降采样、缩短保留周期或只保存脱敏引用 | +| 模型和工具成本 | 深度审查、影响分析、评测回放、插件适配评估默认不进入低风险路径 | 改为用户触发、团队配置触发或离线任务 | + +## 12. 看护指标 + +详细口径见 [metrics-spec.md](governance/metrics-spec.md)。P0/P1 必须优先看这些指标: + +- 首次有用动作耗时。 +- 阶段收益达成率。 +- 质量一致性缺口率。 +- 阶段性能回退率。 +- 事件载荷预算命中率。 +- 用户打断率。 +- 弹窗触发率。 +- 重复提示抑制率。 +- 安全提示接受/忽略率。 +- 应急放行率和范围。 +- 沙箱降级率和降级原因分布。 +- 误升级率。 +- 低风险任务完成率。 +- 推荐检查执行率。 +- PR 就绪度采用率。 +- 远程不可支持状态恢复率。 +- 跨入口语义一致率。 +- 强制检查精度。 +- 合入后缺陷 / 审查阻塞项反馈。 + +## 13. 发布和 PR 策略 + +- 每次阶段发布必须说明用户可见收益、必要技术前置、延期边界和降级解释。 +- P0/P1 默认不创建强制 GitHub 检查;优先本地摘要和 PR 文本块。 +- 团队治理开启后,才将就绪度或门禁投影到 PR 状态/评论。 +- 阻断只允许来自安全边界、组织策略、确定性失败或明确强制要求策略。 +- 每次发布 PR 文档必须说明默认体验是否变重,以及如何避免普通项目被重流程影响。 + +## 14. 关键里程碑 + +| 里程碑 | 判定标准 | +|---|---| +| M0:收益切片清晰 | 每条能力线都有用户收益、技术前置、延期边界和验收方法 | +| M1:轻量闭环可用 | 普通项目无需配置即可完成有用开发任务,并看懂已验证/未验证项 | +| M2:风险状态可解释 | 风险、远程、沙箱和不可支持状态有原因、替代路径和低噪音提示 | +| M3:协作摘要可复用 | PR/团队场景可复用就绪度、规则来源、风险接受和证据引用 | +| M4:受管质量一致 | 需求、开发、验证、审查、发布和风险接受强度一致 | +| M5:复杂项目可追溯 | 证据包/图谱支撑 PR/发布/事故,快速路径保留轻量摘要 | +| M6:策略可评估 | 治理策略能用效率、质量、安全和成本联合衡量 | + +## 15. 完成标准 + +这组能力完成时,BitFun 应表现为: + +- 普通开发像轻快助手,且一直说明已验证和未验证内容。 +- 高风险动作像可靠安全执行环境,且不把授权误称为沙箱。 +- 团队项目像可配置协作系统,且规则来源和强制项可解释。 +- 复杂项目像可追溯工程平台,且图谱和证据按场景显露。 +- 所有复杂技术都服务于明确体验:更快完成任务、更准识别风险、更清楚解释信心或更可靠复盘。 diff --git a/docs/sdlc-harness/product-requirements.md b/docs/sdlc-harness/product-requirements.md new file mode 100644 index 0000000000..290ae76c70 --- /dev/null +++ b/docs/sdlc-harness/product-requirements.md @@ -0,0 +1,207 @@ +# BitFun 可配置开发体验产品需求 + +> 范围:定义 BitFun 加载任意外部目标项目后的产品定位、目标用户、核心体验、产品规格、关键边界、平台入口差异、功能需求和成功指标。 +> 关联文档:[external-research.md](research/external-research.md) 提供外部证据,[design.md](design.md) 承接架构设计,[implementation-plan.md](implementation-plan.md) 承接阶段落地,[traceability-matrix.md](traceability-matrix.md) 承接覆盖关系检查。 + +## 1. 背景 + +AI 编码工具正在从代码补全、单轮问答和单点审查,演进为覆盖理解、修改、验证、审查、发布和复盘的智能体开发体验。BitFun 需要同时服务两类场景: + +- 质量保障要求较低的任务、探索性改动、演示原型和文档改动:默认简洁快速。 +- 团队协作、核心路径、权限、网络、发布、迁移、合规和受管项目:按配置、风险和阶段显露证据、审查、门禁、风险接受和审计。 + +## 2. 产品目标 + +| 目标 | 产品要求 | +|---|---| +| 默认快速 | 无显式规则时,用户能直接开始任务并获得简洁结果摘要 | +| 风险可解释 | 风险、检查、审查和阻断都说明来源、后果和下一步 | +| 治理可配置 | 高强度审查、强制检查、门禁和审计由项目、路径、团队、组织或用户选择启用 | +| 安全常驻 | prompt 注入、hook、MCP、网络、凭据、删除、跨目录写和发布凭据始终走安全边界 | +| 沙箱边界可见 | 用户能分清本机、远程、容器、ACP、MCP、插件和浏览器/桌面动作的执行位置、隔离等级和授权范围 | +| 阶段收益明确 | 每个落地阶段都先交付短而明确的用户收益,技术前置和延期边界必须可解释 | +| 复杂能力后台化 | 证据包、交付物图谱、质量数据面和评测只在 PR、团队、发布、事故或合规场景显性化 | +| 生态扩展预留 | 先稳定 Kernel 扩展点;OpenCode 等插件生态后续接入时必须在 BitFun 边界内生效 | +| 体验可度量 | 速度、打断、误升级、安全确认、用户覆盖和质量结果按用户画像切片 | + +## 3. 用户画像 + +| 用户画像 | 默认体验 | 典型升级触发 | 关键保护 | +|---|---|---|---| +| 快速试验者 | 无配置启动,直接完成低风险任务,结束时展示简洁摘要 | 高权限 shell、网络、凭据、删除、跨目录写或准备分享 | 安全边界常驻,质量建议可跳过 | +| 独立开发者 | 自动复用 README、脚本和仓库规则,给弱提示和可选检查 | 准备 PR、跨模块改动、验证缺口 | 未验证项和信心摘要清晰可见 | +| 团队开发者 / 审查人 | 准备 PR 或进入团队规则时展示就绪度和规则来源 | 受保护分支、CODEOWNERS、路径规则、团队配置 | 审查强度可配置,阻断来源可追踪 | +| 技术负责人 / 架构师 | 关键变更展示影响候选、验证缺口和风险接受入口 | API、数据、权限或架构边界变化 | 影响候选、证据引用和风险接受分离 | +| 安全 / 合规负责人 | 受管场景展示批准、审计、发布和回滚证据 | 凭据、网络、MCP、hook、生产发布、受管项目 | 组织策略高于本地覆盖 | +| 平台 / 插件维护者 | 主动配置以待确认能力展示,确认后再参与执行 | hook、plugin、MCP、自定义工具、工具复写或远程执行面变化 | 主动配置先作为未授权候选,隔离后授权 | + +用户画像用于选择默认入口、解释语言和提示密度。内部策略画像不是用户侧主概念。体验呈现应聚焦任务状态、弱提示、确认、受限原因、设置项和下一步。 + +## 4. 核心体验路径 + +| 路径 | 触发 | 体验呈现 | 系统动作 | +|---|---|---|---| +| 快速开发 | 质量保障要求较低、低风险任务、个人项目、探索性改动 | 任务结果、已运行检查、未验证项、下一步建议 | 轻量项目理解,保持低干预策略 | +| 上下文保障 | 核心路径、权限、网络、迁移、发布、验证缺口 | 风险原因、推荐检查、跳过后果、信心摘要 | 使用弱提示、可折叠摘要或定向确认 | +| 团队治理 | 团队配置、受保护分支、路径规则、PR 协作 | PR 就绪度、审查建议、风险接受入口 | 按团队配置投影证据引用和检查要求 | +| 合规 / 发布 | 合规、生产发布、受管项目、事故复盘 | 批准、审计、发布/回滚证据、完整证据包 | 按合规策略保留完整追溯 | +| 执行安全 | 高权限 shell、网络、凭据、MCP、hook、删除、跨目录写 | 安全确认、范围、后果、可选隔离路径 | 安全边界独立判定 allow/ask/deny/应急放行 | + +## 5. 产品规格 + +| 规格 | 用户可见结果 | 产品约束 | +|---|---|---| +| 项目打开 | 项目类型、主要入口、规则来源、可运行命令和风险提示 | 无配置时只做轻量推断;推断结果默认用于提示,不直接形成强制策略 | +| 任务执行 | 当前任务状态、关键动作、必要确认和可选检查 | 低风险任务不要求用户先理解治理概念 | +| 任务完成 | 改动摘要、已验证项、未验证项、残余风险和下一步 | 默认摘要短;证据引用只在 PR、团队、发布、事故或合规场景显露 | +| 内部策略画像 | 策略原因、作用范围、展示层级和可覆盖选项 | 内部画像按任务和动作计算,不作为用户侧标签,不把整个项目永久贴上单一模式 | +| 提示与确认 | 静默执行、行内弱提示、可折叠摘要、特定场景确认、阻断原因 | 默认不用弹窗;同类低价值提示应合并、延后或进入任务结束摘要 | +| 检查建议 | 推荐检查、不可运行原因、替代验证和跳过后果 | 推荐检查不等同阻断;强制检查必须有配置、确定性失败或用户显式升级来源 | +| 安全确认 | 命令、文件、目录、域名、凭据、权限范围和后果 | 安全确认独立于质量治理,且优先用沙箱、白名单和范围限制降噪 | +| 沙箱与执行位置 | 本机/远程/容器/ACP/MCP/插件/浏览器动作的位置、隔离等级组合、降级原因和可选替代路径 | 只有真实隔离存在时才展示“沙箱允许”;权限确认不能包装成系统级沙箱 | +| 阶段性收益 | 当前阶段用户能得到的体验变化、必要技术前置、延期边界和质量一致性说明 | 不用完整模块边界代替阶段收益;后台技术能力必须说明服务哪个用户场景 | +| 用户覆盖 | 单次、会话、任务或工作树(worktree)范围,带期限和撤销入口 | 覆盖只能降低建议噪音或接受质量风险,不能绕过安全拒绝和组织拒绝策略 | +| 团队配置 | 配置来源、优先级、冲突状态和生效范围 | 团队配置需要可解释;冲突规则先提示或降级,不静默选择高风险路径 | +| PR 就绪度 | 可复制到 PR 的变更、验证、风险和证据引用摘要 | 只有准备 PR 或团队策略启用时显露,不干扰普通任务中间过程 | +| 复杂能力入口 | 证据包、交付物图谱、发布就绪度、事故回溯和评测入口 | 复杂能力从用户任务触发,不作为默认首屏或默认流程 | +| Kernel 扩展点 | 生命周期 hook、事件、打点、工具复写候选和插件效果候选 | 先提供空实现和稳定契约;外部插件运行时不作为默认能力 | +| 工具复写契约 | 被复写工具、复写来源、权限范围、持续时间和撤销入口 | 用户确认只表达意图;实际文件、shell、网络和凭据访问仍由安全边界执行 | + +### 5.1 实际产品变更清单 + +| 产品入口与组件 | 变更 | 默认状态 | 触发条件 | +|---|---|---|---| +| 项目上下文条 | 展示项目名、执行位置、主要语言/包管理器、规则来源和远程状态 | 常驻但紧凑 | 打开项目、切换 worktree、进入远程工作区 | +| 任务结果摘要 | 在任务结束处展示改动、已运行检查、未验证项、残余风险和下一步 | 默认展示短摘要 | 每次任务完成或用户准备复制结果 | +| 行内风险提示 | 在相关消息或工具调用旁展示风险原因、推荐检查和跳过后果 | 弱提示,不弹窗 | 中风险路径、验证缺口、跨模块改动 | +| 安全确认面板 | 展示命令/文件/域名/凭据、授权范围、执行位置、沙箱等级、后果、允许一次/会话/拒绝/隔离选项 | 仅安全动作触发 | shell、网络、凭据、删除、跨目录写、发布 | +| 沙箱状态块 | 展示本次动作是否为权限确认、快照/worktree 隔离、只读范围、网络限制、进程隔离或容器隔离 | 默认并入安全确认或任务头 | 远程、ACP、MCP、插件、未知网络、批量写入 | +| PR 就绪度面板 | 展示变更摘要、验证状态、未验证项、风险接受、证据引用和复制到 PR 按钮 | 默认隐藏 | 用户准备 PR、检测到受保护分支或团队策略 | +| 团队规则说明 | 展示规则来源、优先级、冲突和生效路径 | 折叠展示 | 规则冲突、受管路径、强制检查 | +| 主动配置信任详情 | 展示 hook/plugin/MCP/custom 工具来源、hash、权限、触发条件和信任状态 | 发现后提示,默认不执行 | 新增或变更主动配置 | +| 远程上下文提示 | 展示执行主机、工作区根、端口/网络范围、路径映射和不可用能力 | 远程项目常驻紧凑提示 | SSH、容器、Dev Container、远程工作区 | +| 设置页扩展 | 提供提示密度、默认授权范围、团队规则来源、应急放行记录和插件契约开关 | 不打断主流程 | 用户主动进入设置或审计场景 | + +### 5.2 页面与面板布局 + +| 入口 | 布局要求 | +|---|---| +| 桌面工作台 | 顶部项目上下文条;中间保留聊天/任务主流;右侧使用可折叠上下文与就绪度面板;底部继续承载终端/工具输出。普通任务不进入独立治理仪表盘 | +| 命令行 / 终端 | 使用短状态块:项目、执行位置、安全范围、已验证/未验证、下一步。确认问题用单行选项和编号,避免长表格阻塞 | +| PR / 代码托管平台 | 生成可复制 Markdown 块:变更、验证、未验证、风险接受、证据引用。默认不创建强制检查 | +| 远程 / 云端 | 在任务头和安全确认中固定显示执行位置、路径映射和网络边界。无法执行的本地能力显示替代路径 | +| 设置 / 审计 | 把团队规则、用户覆盖、应急放行、主动配置信任、扩展点事件作为可查看记录,不进入默认首屏 | + +### 5.3 关键用例与体验增益 + +这里的质量要求指当前任务需要的验证、审查和证据保障强度,不表示允许低质量交付。 + +| 用例 | 质量要求 | 触发条件 | 体验变化 | 用户收益 | 验收点 | +|---|---|---|---|---|---| +| 质量保障要求较低的探索性改动 | 低 | 用户明确追求快速试验,且未触发安全高风险或团队强策略 | 保留紧凑项目上下文条和任务结束短摘要;不展示 PR 就绪度、完整证据包或审查建议 | 更快获得首个可用结果,减少与任务目标无关的治理负担 | 首次有用动作耗时可采样;无多余弹窗;摘要包含已验证/未验证项 | +| 文档或小范围非关键改动 | 低到中 | 变更集中在文档、文案或非核心路径,缺少验证但风险有限 | 行内显示已改文件、未运行检查和可选验证;推荐检查不默认阻断 | 用户能保持写作或小改动流畅,同时知道未验证风险 | 提示可忽略、可展开、可复制;跳过检查会进入任务摘要 | +| 未知网络命令或外部访问 | 安全高敏 | shell、网络、凭据、删除、跨目录写或发布动作触发安全边界 | 弹出安全确认面板,展示命令、域名、目的、授权范围、后果和隔离选项 | 用户能快速判断是否允许执行,并控制授权范围 | 用户确认后只在声明范围内生效;拒绝或隔离路径可继续任务 | +| 准备 PR 或分享变更 | 中到高 | 用户准备提交 PR、复制结果,或检测到受保护分支/团队策略 | 按需显示 PR 就绪度面板,列出变更、验证、未验证、风险和证据引用 | 减少审查人追问,用户不用手动整理验证和残余风险 | 就绪度不在普通任务中间过程显露;PR 摘要可复制 | +| 受管路径或团队规则命中 | 团队定义 | 变更命中 CODEOWNERS、CI、路径规则、受保护分支或组织策略 | 折叠展示团队规则来源、优先级、强制项和冲突处理 | 用户知道为什么需要额外检查,并能定位负责人或规则来源 | 阻断来源可追踪;风险接受和强制检查有记录 | +| 远程或云端执行 | 按任务风险 | SSH、容器、Dev Container、远程工作区或云端异步任务 | 在任务头和安全确认中固定显示执行主机、路径映射、端口/网络边界和不可用能力 | 用户能分清本地与远程能力差异,减少误操作和无效验证 | 不可支持能力有替代路径;网络和端口范围可确认 | +| 新增主动配置 | 安全高敏 | hook、plugin、MCP、自定义工具或工具复写被发现或 hash 变化 | 显示主动配置信任详情,提供禁用、仅记录、信任当前 hash、查看差异 | 用户能先理解来源和权限,再决定是否让配置生效 | 默认不执行;hash 变化后重新确认;信任状态可撤销 | + +## 6. 关键边界 + +| 边界 | 产品判定 | +|---|---| +| 内部策略与用户语言 | 内部策略画像可以存在;用户侧不直接学习内部模式名,只看到自然语言状态、原因和操作 | +| 质量治理与执行安全 | 质量治理回答“是否足够可靠”,执行安全回答“是否允许执行”。两者分开展示、分开授权、分开记录 | +| 建议与阻断 | 默认提供建议、摘要和跳过后果;阻断只来自安全拒绝、组织策略、确定性失败或明确强制策略 | +| 弱提示与弹窗 | 默认使用行内提示、可折叠摘要和任务结束汇总;弹窗仅用于安全确认、不可逆操作、组织策略或用户明确进入关键流程 | +| 用户覆盖与组织策略 | 用户可以临时选择更快或更严;受管组织策略、受保护分支和安全拒绝保持更高优先级 | +| 项目规则与主动配置 | AGENTS、CONTRIBUTING、CODEOWNERS、CI 等规则可作为项目知识;hook、plugin、MCP 和自定义工具先进入信任审查 | +| 插件兼容与核心模型 | OpenCode 等生态后续通过适配器接入;BitFun 内部只依赖规范事件、权限和插件效果候选契约 | +| 工具复写与用户确认 | 用户可启用工具复写,但复写必须绑定项目、来源、hash、权限范围和期限,不能静默改变全局工具语义 | +| 模型判断与系统状态 | 模型可生成候选风险、摘要和建议;策略状态、验证结果、授权状态必须来自确定性事件或用户决策 | +| 本地能力与远程能力 | 本地可直接感知文件、命令和环境;远程/云端必须显式标注工作区、权限、环境缺口和不可支持动作 | +| 质量保障要求较低的任务与受管任务 | 质量保障要求较低的任务默认低摩擦;受管任务按配置显露审查、审计、批准和门禁 | + +## 7. 平台与入口差异 + +| 入口 | 默认体验 | 差异要求 | +|---|---|---| +| 桌面本地 | 文件、终端、Git 和项目规则整合在同一工作台 | 高权限命令、跨目录写、网络和凭据访问需要清晰确认;本地不可运行检查要给替代验证 | +| 命令行 / 终端 | 以文本摘要、确认提示和命令结果为主 | 输出必须短、可复制、可继续;没有富界面时仍要保留任务状态、未验证项和安全范围 | +| 远程开发 | 通过 SSH、容器、Dev Container 或远程工作区执行文件、命令和 Git 操作 | 必须标注执行主机、工作区根、权限范围、网络边界、端口暴露和本地/远程路径差异 | +| Web / 远程控制台 | 通过远程适配器管理任务、diff、验证和审批 | 不假设本地路径或本机依赖;不支持的本地能力必须显示原因和可替代路径 | +| 云端异步任务 | 以任务计划、diff、验证结果和批准为中心 | 长任务减少中途打断;需要在执行前或关键阶段收集授权,完成后给可审查摘要 | +| PR / 代码托管平台 | 以就绪度摘要、检查状态、评论和风险接受呈现 | 默认不创建强制检查;只有团队或组织策略启用时才投影为门禁 | +| 受管企业环境 | 以组织策略、审计、批准和合规证据为中心 | 组织拒绝策略不可被本地覆盖;所有放行都需要范围、操作者、原因和期限 | +| 插件 / MCP / hook 入口 | 作为能力扩展和主动执行面 | 默认最小权限;先展示来源、hash、权限、触发条件、工具复写和信任状态,再允许在对应执行域内生效 | + +## 8. 功能需求 + +| ID | 需求 | 验收标准 | +|---|---|---| +| PRD-01 | 首次打开项目时生成轻量项目理解 | 无 `.bitfun` 配置也能识别主要语言、脚本、规则入口和 git 状态 | +| PRD-02 | 普通任务默认输出简洁闭环 | 任务结束展示改动、验证、未验证项和下一步建议 | +| PRD-03 | 配置化策略决定内部策略画像 | 内部画像带原因、来源、展示层级和覆盖选项,并投影为用户可理解的状态、提示或确认 | +| PRD-04 | 执行安全独立常驻 | 安全动作不依赖质量模式,且每次确认都有范围、期限和残余风险 | +| PRD-05 | 用户覆盖有边界 | 单次、会话、任务或工作树(worktree)覆盖可记录、可撤销,并受组织策略约束 | +| PRD-06 | 团队规则按作用域生效 | 仓库、路径、团队和组织规则有优先级、来源和冲突处理 | +| PRD-07 | PR 就绪度按需显露 | 准备 PR 或团队策略启用时,生成变更、验证、风险和证据引用摘要 | +| PRD-08 | 复杂项目能力后台支撑 | 证据包、图谱、影响分析和发布就绪度按 PR、发布、事故或合规场景显露 | +| PRD-09 | 主动配置先审后用 | hook、plugin、MCP、自定义工具和智能体规则记录来源、hash、权限和信任状态 | +| PRD-10 | 体验与质量共同度量 | 指标覆盖速度、打断、误升级、安全提示、风险接受、PR 采用和后验质量 | +| PRD-11 | 平台入口保持一致语义 | 桌面、命令行、远程、云端和 PR 入口都能展示任务状态、验证状态、安全范围和下一步 | +| PRD-12 | 不可支持状态明确 | 远程、本地依赖、私有服务或权限不足导致能力不可用时,展示原因、影响和替代路径 | +| PRD-13 | 内部策略不直接外露 | 用户默认看不到内部策略枚举;设置和日志可以展示策略来源、原因和调试信息 | +| PRD-14 | 提示降噪 | 同类提示合并或延后,弹窗仅用于安全、不可逆、组织强制或用户进入关键流程 | +| PRD-15 | 远程开发适配 | 远程执行必须展示执行位置、权限边界、端口/网络范围、路径映射和不可用能力 | +| PRD-16 | Kernel 扩展点预留 | 生命周期 hook、事件、打点、工具复写候选和插件效果候选有稳定契约,且空实现不影响默认任务 | +| PRD-17 | 工具复写受限启用 | 内置工具复写必须显示复写来源、能力范围和撤销入口,执行仍经过安全边界 | +| PRD-18 | 关键用例体验可验收 | 质量保障要求较低的任务、安全确认、PR 就绪、受管路径、远程上下文和主动配置信任都有预期体验变化、用户收益、布局、交互和验收点 | +| PRD-19 | 沙箱边界可见 | 每次高风险动作都能展示执行位置、沙箱等级组合、授权范围、降级原因和替代路径;没有真实沙箱时不得展示为沙箱允许 | +| PRD-20 | 阶段收益可验收 | 每个实施阶段都列出用户可见收益、必要技术前置、明确延期边界、风险/副作用分析、降级解释、质量一致性检查和性能看护方法 | + +## 9. 配置优先级 + +```text +组织拒绝 / 受管强制要求 + > 安全边界 + > 最近确认的路径或团队规则 + > 工作区配置 + > 允许范围内的会话/任务覆盖 + > 全局/用户默认值 +``` + +用户覆盖可以降低建议噪音、跳过推荐检查或请求风险接受。安全边界、组织拒绝策略、受管路径强制策略和受保护分支规则保持更高优先级。 + +## 10. 成功指标 + +| 指标 | 目的 | +|---|---| +| 首次有用动作耗时 | 验证默认路径足够快 | +| 低风险任务完成率 | 验证普通任务没有被重流程拖慢 | +| 用户打断率 | 控制提示、确认和阻断噪音 | +| 误升级率 | 校准配置化策略 | +| 安全提示接受率 | 验证安全提示是否有行动价值 | +| 弹窗触发率 | 控制模态确认对开发流的打断 | +| 重复提示抑制率 | 验证同类弱提示是否被合并或延后 | +| 应急放行率和范围 | 发现策略过紧或真实绕行需求 | +| 沙箱降级率 | 验证用户看到的隔离等级是否真实可用 | +| 阶段收益达成率 | 验证阶段交付是否产生真实用户侧收益 | +| 质量一致性缺口率 | 发现需求、开发、验证、审查或发布要求在同一阶段内强弱不一致的问题 | +| 阶段性能回退率 | 验证新阶段能力是否拖慢默认开发路径 | +| 事件载荷预算命中率 | 控制质量数据面、证据和评测事件增长 | +| PR 就绪度采用率 | 验证团队协作价值 | +| 强制检查精度 | 控制低价值门禁 | +| 不可支持状态恢复率 | 验证替代路径是否能帮助用户继续推进 | +| 跨入口语义一致率 | 验证不同平台是否保留相同任务状态、安全范围和验证摘要 | +| 关键用例验收覆盖率 | 验证主要体验用例是否有可回放用例、预期交互和用户收益说明 | + +## 11. 版本边界 + +| 阶段 | 用户可见收益 | 后台前置和延期边界 | +|---|---|---| +| P0 | 低风险任务能快速完成,并看懂项目入口、安全范围、已验证/未验证项和下一步 | 只做轻量项目理解、基础安全决策和最小事件;完整证据包、图谱和团队门禁延期 | +| P1 | 风险、远程能力缺口、沙箱降级和推荐检查变得可理解 | 需要风险分类、提示降噪、sandbox capability 事件和扩展点空实现;不执行外部插件运行时 | +| P2 | 准备 PR 或命中团队规则时获得可复制摘要、规则来源和风险接受入口 | 需要规则来源合成、PR 投影和证据引用;强制检查只在配置或确定性风险下出现 | +| P3 | 受管项目的需求、开发、验证、审查、发布和风险接受强度保持一致 | 需要路径级强策略、证据包投影、最小图谱和发布/事故事件;普通任务仍保留轻量路径 | +| P4 | 用真实任务回放和指标持续优化治理策略,并淘汰无收益能力 | 需要评测血缘、策略版本、成本采样和保留集;生态适配以不改变内核权威状态为前提 | diff --git a/docs/sdlc-harness/research/external-research.md b/docs/sdlc-harness/research/external-research.md new file mode 100644 index 0000000000..30e3276251 --- /dev/null +++ b/docs/sdlc-harness/research/external-research.md @@ -0,0 +1,75 @@ +# BitFun 可配置开发体验与工程治理外部调研 + +> 范围:围绕 AI 编码智能体、仓库指令、权限与沙箱、可选代码审查、hook/plugin、交付物图谱、质量治理和评测体系整理外部产品、论文、标准与趋势信号。 +> 用途:作为产品需求和架构设计的外部证据池。产品需求文档只提炼必要产品判断,本文保留较完整参考资料。 + +## 1. 产品趋势 + +| 产品/方向 | 核心能力 | 对 BitFun 的启发 | +|---|---|---| +| [OpenAI Codex](https://openai.com/index/introducing-codex/) / [Codex Cloud](https://developers.openai.com/codex/cloud) / [Codex CLI](https://developers.openai.com/codex/cli) | 云端任务、CLI、本地/云端执行、AGENTS.md、沙箱、审批、日志/测试证据 | 用户体验应先围绕任务、计划、diff 和批准展开;执行安全与质量治理需要拆开 | +| [Codex approvals/security](https://developers.openai.com/codex/agent-approvals-security) / [Codex hooks](https://developers.openai.com/codex/hooks) | 审批模式、沙箱、可信命令、hook 生命周期、信任审查 | 安全边界独立常驻;hook 按主动执行面管理信任状态 | +| [GitHub Copilot 编码智能体](https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent) | issue 到 PR、Actions 后台执行、PR 审查、智能体会话 | 异步智能体的核心体验围绕任务、计划、变更和审查组织 | +| [GitHub Copilot 仓库指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions) | 支持仓库指令、路径指令、AGENTS.md | 项目规则应优先读取现有资产,并按路径和上下文渐进加载 | +| [GitHub Copilot 代码审查](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/request-a-code-review/use-code-review) | AI 审查提供评论和建议 | AI 审查默认应是低摩擦建议态,不天然等同阻断审批 | +| [Claude Code](https://github.com/anthropics/claude-code) / [权限](https://code.claude.com/docs/en/permissions) / [sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) | 终端 Agent、权限配置、沙箱、allow/deny 规则 | 产品需要用技术隔离减少弹窗,同时保留用户可理解的放行路径 | +| [CodeRabbit configuration](https://docs.coderabbit.ai/reference/configuration) / [路径指令](https://docs.coderabbit.ai/configuration/path-instructions) | 审查强度、路径级指令、规则 | 审查强度和路径规则可配置,默认模式降低噪音 | +| [GitLab Duo 自定义指令](https://docs.gitlab.com/user/gitlab_duo/customize_duo/review_instructions/) / [警告模式](https://docs.gitlab.com/user/application_security/policies/merge_request_approval_policies/) | 审查指令、审批策略、警告模式 | 强策略应先建议态/警告校准,再进入强制要求/阻断 | +| [Kiro Specs](https://kiro.dev/docs/specs/) / [Steering](https://kiro.dev/docs/steering/) / [Hooks](https://kiro.dev/docs/hooks/) | spec 驱动开发、工作区/全局/团队 steering、加载范围模式、智能体 hooks | 项目知识需要作用域、优先级和加载时机;复杂上下文按条件加载 | +| [Jules](https://jules.google/) | 选择仓库/分支、云端计划、diff、用户批准 | 异步编码智能体的高体验入口是计划和 diff 审批 | +| [Atlassian Software Collection](https://www.atlassian.com/collections/software) / [Rovo Dev](https://www.atlassian.com/software/rovo-dev) | Jira、Confluence、Bitbucket、Pipelines、PR 审查、acceptance criteria 检查 | 复杂项目需要连接任务、文档、代码、CI 和团队上下文,但应按需显露 | +| [Harness](https://www.harness.io/) / [Harness AI](https://developer.harness.io/docs/platform/harness-ai/overview) / [Software Delivery Knowledge Graph](https://www.harness.io/blog/knowledge-graphs-for-ai-software-delivery) | CI/CD、测试、AppSec、SRE、成本优化、软件交付知识图谱 | 知识图谱应从最小高价值场景开始,保持新鲜度和可验证价值 | +| [OpenCode Plugins](https://opencode.ai/docs/plugins/) / [SDK](https://opencode.ai/docs/sdk/) / [Server API](https://opencode.ai/docs/server/) | JS/TS plugin、hook、自定义工具、SSE 事件流 | 可提供兼容层,但底层必须由 BitFun 自己的权限、策略和事件模型约束 | +| [Cursor Bugbot](https://cursor.com/blog/building-bugbot) / [Qodo Code Review](https://docs.qodo.ai/code-review) | PR 级逻辑缺陷、安全、合规审查 | PR 审查是团队和高风险场景的重要扩展 | +| [LangChain Harness Engineering](https://www.langchain.com/blog/improving-deep-agents-with-harness-engineering) | 固定模型下优化智能体外部工程层显著提升基准测试表现 | prompt、上下文、工具、策略和工作流是能力杠杆,需要评测和 A/B | + +## 2. 研究和基准趋势 + +| 研究/基准 | 信号 | 设计启发 | +|---|---|---| +| [SWE-bench](https://github.com/swe-bench/SWE-bench) | 真实 GitHub issue 正成为代码 Agent 评测基础 | BitFun 需要真实 issue 黄金集和长期回归集 | +| [SWE-Bench Pro](https://labs.scale.com/leaderboard/swe_bench_pro_public) | 更长程、更真实、更复杂代码库暴露评测集泄漏、任务多样性和测试可靠性问题 | 公开榜单、内部保留集、复杂项目和环境可复现性需要组合评估 | +| [SWE-agent](https://arxiv.org/abs/2405.15793) | 智能体-计算机接口影响修复能力 | 工具结构、终端反馈、错误呈现和文件浏览本身是能力杠杆 | +| [Agentless](https://arxiv.org/abs/2407.01489) | 简单、可解释的定位/修复/验证流程可达到强基线 | 不宜默认采用全自治或强流程;结构化流程应作为基线 | +| [Agentic AI in the SDLC](https://arxiv.org/abs/2604.26275) | Agentic SDLC 需要从架构、证据、生产力和治理同时评估 | BitFun 可扩展到 SDLC,但必须以产品体验和可验证价值逐步推进 | +| [Terminal-Bench](https://arxiv.org/abs/2601.11868) / [Terminal-Bench 3.0](https://www.tbench.ai/) | 真实终端任务覆盖软件工程、ML、安全、数据科学等场景 | 需要终端任务回放和工具轨迹评测,防止任务泄漏和基准测试过拟合 | +| [RovoDev Code Reviewer](https://arxiv.org/html/2601.01129v1) | 在线评估显示 AI 审查可缩短 PR 周期,但缺少上下文会产生错误反馈 | 深度审查必须有上下文完整性、问题生命周期、反证和预算控制 | +| [TraceLLM](https://arxiv.org/html/2602.01253v1) / [LLM-driven requirements change impact analysis](https://arxiv.org/html/2511.00262v1) | LLM 可辅助需求追踪和变更影响分析,但输出仍需成本、召回、精度和人工确认约束 | 需求变更影响面分析应输出候选集合、置信度和人工检查成本 | +| [Testing with AI Agents](https://arxiv.org/abs/2603.13724) | AI 已大量参与测试生成,但测试质量需要结构化衡量 | 测试质量保护要关注质量、稳定性和变异杀伤,而非仅增加测试数量 | +| [NIST SP 800-218A](https://csrc.nist.gov/pubs/sp/800/218/a/final) | 将生成式 AI 和基础模型纳入 SSDF 生命周期实践 | AI 参与开发后,安全开发框架需要覆盖模型、工具、数据、权限和供应链风险 | + +## 3. 标准与治理趋势 + +| 标准/方向 | 信号 | 设计启发 | +|---|---|---| +| [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/concepts/semantic-conventions/) | 轨迹、指标、日志、画像和资源需要统一语义命名 | 质量数据面应定义稳定语义属性,避免每个模块自造事件字段 | +| [CDEvents](https://cdevents.dev/docs/primer/) | CI/CD 事件强调声明式、松耦合、跨工具互操作 | BitFun 生命周期事件应保持规范事实和松耦合互操作 | +| [SLSA Provenance](https://slsa.dev/spec/v0.1/provenance) / [in-toto](https://slsa.dev/blog/2023/05/in-toto-and-slsa) | 构建和供应链证据需要说明来源、时间和生成方式 | 证据包应支持来源/证明引用,为发布就绪度和审计预留接口 | +| [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/) | prompt 注入、敏感信息、供应链、过度代理和模型拒绝服务都是 LLM 应用风险 | Hook/Event、plugin、工具、memory、外部适配器必须默认最小权限、脱敏、超时和预算 | +| DORA / SPACE / DevEx | 速度、稳定性、协作和开发者体验需要联合衡量 | 指标体系同时覆盖速度、打断、信心、安全、质量和成本 | +| AI 编码成本治理 | 高级模型、AI 审查、CI/Actions 资源和长上下文都会形成显性成本 | 深度审查、评测、Hook 和智能体运行必须将 token、耗时、缓存命中和降级原因作为核心指标 | + +## 4. 对抗性审查后的趋势判断 + +外部趋势共同指向六点: + +1. 默认体验正在走向快速执行、计划、diff、批准和轻量审查。 +2. 项目知识正在产品化为仓库/路径/团队指令、steering、AGENTS.md、hook 和 plugin,但这些主动配置必须经过信任和权限边界。 +3. AI 审查和门禁有价值,但先进产品普遍提供审查强度、评论/建议态、警告模式或强制要求/阻断分级。 +4. 安全与质量必须分层:prompt 注入、网络、凭据、MCP、hook、shell、跨目录写和删除风险在快速路径中也需要明确授权和可审计记录。 +5. 复杂项目能力仍然重要,但图谱、证据包、需求影响和发布就绪度应作为按需显露的后台能力。 +6. 基准测试分数无法直接证明产品质量;真实项目的保留集、轨迹回放、判定标准、成本、安全事件和用户打断指标才是可演进能力的核心评估资产。 + +## 5. 参考资料 + +- OpenAI: [Codex](https://openai.com/index/introducing-codex/), [Codex 智能体 loop](https://openai.com/index/unrolling-the-codex-agent-loop/), [Codex approvals/security](https://developers.openai.com/codex/agent-approvals-security), [Codex sandboxing](https://developers.openai.com/codex/concepts/sandboxing), [Codex hooks](https://developers.openai.com/codex/hooks), [Agent improvement loop](https://developers.openai.com/cookbook/examples/agents_sdk/agent_improvement_loop) +- GitHub: [Copilot 编码智能体](https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent), [Copilot 仓库指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions), [Copilot 代码审查](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/request-a-code-review/use-code-review) +- Anthropic: [Claude Code](https://github.com/anthropics/claude-code), [Claude 权限](https://code.claude.com/docs/en/permissions), [Claude Code Review](https://code.claude.com/docs/en/code-review), [Claude Code hooks](https://code.claude.com/docs/en/hooks), [Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) +- CodeRabbit 与 GitLab: [CodeRabbit configuration](https://docs.coderabbit.ai/reference/configuration), [CodeRabbit 路径指令](https://docs.coderabbit.ai/configuration/path-instructions), [GitLab Duo 自定义指令](https://docs.gitlab.com/user/gitlab_duo/customize_duo/review_instructions/), [GitLab 审批策略](https://docs.gitlab.com/user/application_security/policies/merge_request_approval_policies/) +- Atlassian: [Software Collection](https://www.atlassian.com/collections/software), [Rovo Dev](https://www.atlassian.com/software/rovo-dev), [Acceptance criteria 检查](https://support.atlassian.com/rovo/docs/check-acceptance-criteria-in-a-code-review/), [RovoDev Code Reviewer paper](https://arxiv.org/html/2601.01129v1) +- Linear 与 Jules: [Linear](https://linear.app/), [Jules](https://jules.google/) +- Harness: [AI software delivery platform](https://www.harness.io/), [Harness AI overview](https://developer.harness.io/docs/platform/harness-ai/overview), [Software Delivery Knowledge Graph](https://www.harness.io/blog/knowledge-graphs-for-ai-software-delivery) +- OpenCode 与 Kiro: [OpenCode Plugins](https://opencode.ai/docs/plugins/), [OpenCode SDK](https://opencode.ai/docs/sdk/), [OpenCode Server API](https://opencode.ai/docs/server/), [Kiro Specs](https://kiro.dev/docs/specs/), [Kiro Hooks](https://kiro.dev/docs/hooks/), [Kiro Steering](https://kiro.dev/docs/steering/) +- PR 审查系统: [Cursor Bugbot](https://cursor.com/blog/building-bugbot), [Qodo Code Review](https://docs.qodo.ai/code-review) +- 标准与指标: [DORA](https://dora.dev/), [SPACE](https://queue.acm.org/detail.cfm?id=3454124), [DevEx](https://queue.acm.org/detail.cfm?id=3595878), [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/concepts/semantic-conventions/), [CDEvents](https://cdevents.dev/docs/primer/), [SLSA provenance](https://slsa.dev/spec/v0.1/provenance), [in-toto and SLSA](https://slsa.dev/blog/2023/05/in-toto-and-slsa), [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/), [NIST SP 800-218A](https://csrc.nist.gov/pubs/sp/800/218/a/final) +- 研究: [SWE-bench](https://github.com/swe-bench/SWE-bench), [SWE-Bench Pro](https://labs.scale.com/leaderboard/swe_bench_pro_public), [SWE-agent](https://arxiv.org/abs/2405.15793), [Agentless](https://arxiv.org/abs/2407.01489), [Agentic AI in the SDLC](https://arxiv.org/abs/2604.26275), [Terminal-Bench](https://arxiv.org/abs/2601.11868), [Testing with AI Agents](https://arxiv.org/abs/2603.13724), [TraceLLM](https://arxiv.org/html/2602.01253v1), [LLM-driven requirements change impact analysis](https://arxiv.org/html/2511.00262v1) diff --git a/docs/sdlc-harness/traceability-matrix.md b/docs/sdlc-harness/traceability-matrix.md new file mode 100644 index 0000000000..20606f4984 --- /dev/null +++ b/docs/sdlc-harness/traceability-matrix.md @@ -0,0 +1,33 @@ +# BitFun 可配置开发体验追踪矩阵 + +> 范围:把产品需求、架构设计、功能规格、执行阶段和测试方法连起来,避免关键体验只停留在单份文档中。 +> 上游文档:[product-requirements.md](product-requirements.md)、[design.md](design.md)、[implementation-plan.md](implementation-plan.md) + +| 关键点 | 产品需求 | 设计承接 | 功能规格 | 执行阶段 | 测试方法 | +|---|---|---|---|---|---| +| 默认低摩擦 | PRD-01、PRD-02 | 快速路径轻量、体验投影层 | 配置化策略画像、项目画像 | P0 | 质量保障要求较低的探索性改动、无配置项目、文档改动任务回放;统计首次有用动作耗时和低风险任务完成率 | +| 内部策略不外露 | PRD-03、PRD-13 | 体验投影层、用户语言稳定 | 配置化策略画像 | P-1、P0 | 检查桌面、命令行、远程、PR 摘要是否只展示任务状态、原因和下一步,不展示内部枚举 | +| 弱提示和弹窗降噪 | PRD-14 | 提示先弱后强、弹窗触发约束 | 配置化策略画像、质量数据面 | P0、P1 | 同类提示合并/延后用例;统计用户打断率、弹窗触发率、重复提示抑制率 | +| 执行安全常驻 | PRD-04 | 安全边界独立 | 安全边界 | P0 | shell、网络、凭据、删除、跨目录写、hook/MCP 用例;验证 allow/ask/deny/应急放行记录 | +| 沙箱边界可见 | PRD-19 | 执行位置和沙箱等级分层 | 安全边界、质量数据面、证据包 | P0、P1、P3 | 本地 shell、远程 SSH、ACP、MCP、MiniApp、插件适配主机、浏览器/桌面和云端任务用例;验证执行位置、沙箱等级组合、降级原因、授权范围和替代路径 | +| 阶段性用户收益 | PRD-20 | 阶段收益编排、质量一致性检查、阶段风险副作用分析 | 实施计划、指标规格、质量数据面 | P-1、P0-P4 | 每阶段回放低风险任务、安全确认、远程/不可支持状态、PR/团队场景;验证用户收益、技术前置、延期边界、风险副作用、质量一致性和性能预算 | +| 远程开发适配 | PRD-11、PRD-12、PRD-15 | 本地/远程能力边界、项目集成面 | 项目画像、风险分类器、安全边界 | P0、P1 | SSH、容器、远程工作区、云端任务用例;验证执行位置、路径映射、端口/网络、不可支持状态和替代路径 | +| 团队配置治理 | PRD-05、PRD-06 | 配置层级、组织策略优先级 | 配置化策略画像、PR 门禁 | P2 | `.bitfun`、AGENTS、CODEOWNERS、CI、路径规则冲突用例;验证来源、优先级和覆盖限制 | +| PR 就绪度 | PRD-07 | 证据按需投影、PR 场景显露 | PR 门禁、证据包 | P1、P2 | 准备 PR、受保护分支、不可运行 CI 用例;验证就绪度摘要、未验证项、证据引用和非默认阻断 | +| 复杂项目追溯 | PRD-08 | 交付物图谱、证据可追溯 | 证据包、交付物图谱、需求影响分析 | P3 | 需求变更、发布、事故复盘用例;验证链接来源、新鲜度、人工确认和过期处理 | +| 主动配置信任 | PRD-09 | 项目规则与主动配置分层 | OpenCode 兼容层、安全边界 | P0、P2 | hook、plugin、MCP、自定义工具回放用例;验证来源、hash、权限、触发条件和信任状态 | +| Kernel 扩展点 | PRD-16 | hook、事件、打点、插件效果候选、内核权威状态 | OpenCode 兼容层、质量数据面 | P-1、P1 | 空实现、过期 epoch、重复 event、候选效果丢弃、打点采样回放用例;验证不影响默认任务 | +| 能力/效果模型 | PRD-09、PRD-16、PRD-17 | 能力/效果模型统一、未声明能力受限、策略不写死工具名 | 安全边界、OpenCode 兼容层、质量数据面 | P-1、P0、P1 | tool、MCP、skills、插件、hook、内置工具回放用例;验证能力声明、目标对象、数据类别、信任来源、副作用候选和未知能力受限 | +| 工具复写安全 | PRD-17 | 工具复写显式授权、复写表按项目执行域生效 | OpenCode 兼容层、安全边界 | P1、P2 | 内置工具复写契约、同名工具候选、权限撤销、hash 变化、远程执行主机变化用例;验证复写不跨项目且仍经安全边界 | +| 关键用例体验 | PRD-18 | 体验投影层、页面/面板布局、跨入口语义一致 | 产品需求、配置化策略画像、质量数据面 | P0、P1 | 质量保障要求较低的探索性改动、文档改动、安全确认、PR 就绪、受管路径、远程容器、主动配置回放用例;验证体验变化、用户收益、布局、文案、交互和降噪 | +| 产品指标闭环 | PRD-10 | 质量数据面、评测与学习 | 指标规格、智能体评测 | P0-P4 | 指标采样回放、策略 A/B、保留集;验证速度、打断、安全、质量和成本联合解释 | + +## 文档边界 + +| 文档 | 回答的问题 | 不承担的问题 | +|---|---|---| +| [product-requirements.md](product-requirements.md) | 产品目标、用户画像、平台入口差异、关键边界、功能需求、成功指标 | 不定义模块内部数据结构和实现顺序 | +| [design.md](design.md) | 架构边界、领域模型、配置层级、模块职责和硬约束 | 不重复用户画像、产品规格和阶段任务 | +| [implementation-plan.md](implementation-plan.md) | 阶段用户收益、必要技术前置、延期边界、验收条件和过程风险 | 不替代产品需求或模块契约 | +| 子模块文档 | 单模块输入、输出、状态、边界场景和成功标准 | 不重新定义产品定位 | +| [governance/metrics-spec.md](governance/metrics-spec.md) | 指标公式、分母、窗口、负责人和解释边界 | 不直接作为阻断策略 | diff --git a/package-lock.json b/package-lock.json index 3fa58a6572..d3fa5ef4cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.7", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.7", + "version": "0.2.10", "hasInstallScript": true, "dependencies": { "pnpm": "^10.32.1", diff --git a/package.json b/package.json index b3393b4d41..9245051f6f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "BitFun", "private": true, - "version": "0.2.8", + "version": "0.2.10", "type": "module", "engines": { "node": ">=18.0.0" @@ -15,11 +15,17 @@ "postinstall": "pnpm run copy-assets", "dev": "node scripts/dev.cjs web", "dev:web": "pnpm --dir src/web-ui dev", + "dev:mobile-web": "pnpm --dir src/mobile-web dev", + "dev:mobile-web:host": "pnpm --dir src/mobile-web dev --host", + "preview:mobile-web": "pnpm --dir src/mobile-web preview", "lint:web": "pnpm --dir src/web-ui run lint", "lint:web:fix": "pnpm --dir src/web-ui run lint:fix", + "type-check:mobile-web": "pnpm --dir src/mobile-web run type-check", "i18n:generate": "node scripts/generate-i18n-contract.mjs", "i18n:contract:test": "node --test scripts/i18n-contract.test.mjs", + "i18n:contract:test:ci": "cross-env BITFUN_I18N_CONTRACT_TEST_PROFILE=ci node --test scripts/i18n-contract.test.mjs", "i18n:audit": "node scripts/i18n-audit.mjs", + "theme:color-audit": "node scripts/audit-theme-colors.mjs", "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", @@ -71,8 +77,8 @@ "e2e:test:l1": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:l1", "e2e:test:smoke": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:smoke", "e2e:test:chat": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:chat", - "e2e:test:perf:debug": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:perf", - "e2e:test:perf:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast pnpm --dir tests/e2e run test:perf" + "e2e:test:perf:debug": "cross-env BITFUN_E2E_APP_MODE=debug E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf", + "e2e:test:perf:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf" }, "devDependencies": { "@tauri-apps/cli": "^2.10.0", diff --git a/scripts/audit-theme-colors.mjs b/scripts/audit-theme-colors.mjs new file mode 100644 index 0000000000..ad3365d3ab --- /dev/null +++ b/scripts/audit-theme-colors.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const DEFAULT_ROOT = 'src/web-ui/src'; +const COLOR_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.ts', '.tsx', '.js', '.jsx']); +const TOKEN_PATH_PARTS = [ + 'component-library/styles', + 'infrastructure/theme', + 'theme/presets', +]; +const EXCEPTION_PATH_PARTS = [ + 'monaco', + 'terminal', + 'mermaid', + 'syntax', + 'CodeEditor', +]; + +const COLOR_PATTERN = + /#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[-+]?\d*\.?\d+\s*,\s*[-+]?\d*\.?\d+\s*,\s*[-+]?\d*\.?\d+(?:\s*,\s*(?:[-+]?\d*\.?\d+|var\([^)]+\)))?\s*\)|hsla?\(\s*[-+]?\d*\.?\d+(?:deg|rad|turn)?\s*,\s*[-+]?\d*\.?\d+%\s*,\s*[-+]?\d*\.?\d+%(?:\s*,\s*(?:[-+]?\d*\.?\d+|var\([^)]+\)))?\s*\)/g; +const CSS_VAR_USAGE_PATTERN = /var\(\s*(--[a-zA-Z0-9_-]+)/g; +const CSS_VAR_DEFINITION_PATTERN = /(^|[;{\s])(--[a-zA-Z0-9_-]+)\s*:/g; +const VAR_FALLBACK_PATTERN = /var\(\s*(--[a-zA-Z0-9_-]+)\s*,/g; + +function parseArgs(argv) { + const options = { + root: DEFAULT_ROOT, + json: false, + top: 15, + budget: 120, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') { + options.json = true; + } else if (arg === '--root') { + options.root = argv[++index] ?? DEFAULT_ROOT; + } else if (arg === '--top') { + options.top = Number(argv[++index] ?? options.top); + } else if (arg === '--budget') { + options.budget = Number(argv[++index] ?? options.budget); + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/audit-theme-colors.mjs [options] + +Options: + --root Directory to scan. Default: ${DEFAULT_ROOT} + --top Number of top rows to print. Default: 15 + --budget Unique app color budget for the summary. Default: 120 + --json Print machine-readable JSON instead of text. +`); +} + +function walkFiles(root) { + const result = []; + const stack = [root]; + + while (stack.length > 0) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && COLOR_EXTENSIONS.has(path.extname(entry.name))) { + result.push(fullPath); + } + } + } + + return result.sort(); +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function isTokenFile(relativePath) { + return TOKEN_PATH_PARTS.some(part => relativePath.includes(part)); +} + +function isExceptionFile(relativePath) { + return EXCEPTION_PATH_PARTS.some(part => relativePath.toLowerCase().includes(part.toLowerCase())); +} + +function incrementMap(map, key, amount = 1) { + map.set(key, (map.get(key) ?? 0) + amount); +} + +function collectMatches(content, pattern) { + pattern.lastIndex = 0; + return Array.from(content.matchAll(pattern)); +} + +function parseColor(color) { + const trimmed = color.trim().toLowerCase(); + const hex = /^#([0-9a-f]{3,8})$/.exec(trimmed); + if (hex) { + const raw = hex[1]; + const expanded = raw.length === 3 || raw.length === 4 + ? raw.split('').map(char => char + char).join('') + : raw; + const rgbHex = expanded.slice(0, 6); + const alphaHex = expanded.length === 8 ? expanded.slice(6, 8) : null; + return { + r: parseInt(rgbHex.slice(0, 2), 16), + g: parseInt(rgbHex.slice(2, 4), 16), + b: parseInt(rgbHex.slice(4, 6), 16), + a: alphaHex ? Math.round((parseInt(alphaHex, 16) / 255) * 1000) / 1000 : 1, + }; + } + + const rgb = /^rgba?\(\s*([-+]?\d*\.?\d+)\s*,\s*([-+]?\d*\.?\d+)\s*,\s*([-+]?\d*\.?\d+)(?:\s*,\s*([-+]?\d*\.?\d+))?\s*\)$/.exec(trimmed); + if (rgb) { + return { + r: Number(rgb[1]), + g: Number(rgb[2]), + b: Number(rgb[3]), + a: rgb[4] === undefined ? 1 : Number(rgb[4]), + }; + } + + return null; +} + +function colorDistance(a, b) { + return Math.sqrt( + (a.r - b.r) ** 2 + + (a.g - b.g) ** 2 + + (a.b - b.b) ** 2 + ); +} + +function buildNearColorPairs(colorCounts) { + const parsed = Array.from(colorCounts.entries()) + .map(([color, count]) => ({ color, count, parsed: parseColor(color) })) + .filter(entry => entry.parsed); + + const indistinguishable = []; + const near = []; + + for (let left = 0; left < parsed.length; left += 1) { + for (let right = left + 1; right < parsed.length; right += 1) { + const a = parsed[left]; + const b = parsed[right]; + const alphaDiff = Math.abs(a.parsed.a - b.parsed.a); + const distance = colorDistance(a.parsed, b.parsed); + if (distance <= 2 && alphaDiff <= 0.003) { + indistinguishable.push({ a: a.color, b: b.color, distance, alphaDiff, count: a.count + b.count }); + } else if (distance <= 10 && alphaDiff <= 0.03) { + near.push({ a: a.color, b: b.color, distance, alphaDiff, count: a.count + b.count }); + } + } + } + + const byImpact = (a, b) => b.count - a.count || a.distance - b.distance; + return { + indistinguishable: indistinguishable.sort(byImpact).slice(0, 50), + near: near.sort(byImpact).slice(0, 50), + }; +} + +function topEntries(map, limit) { + return Array.from(map.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([key, count]) => ({ key, count })); +} + +function audit(options) { + const root = path.resolve(options.root); + const files = walkFiles(root); + const cwd = process.cwd(); + + const colorCounts = new Map(); + const componentColorCounts = new Map(); + const fallbackTokenCounts = new Map(); + const varUsageCounts = new Map(); + const varDefinitionCounts = new Map(); + const fileColorCounts = new Map(); + const componentFileColorCounts = new Map(); + const exceptionColorCounts = new Map(); + const tokenColorCounts = new Map(); + + let colorOccurrences = 0; + let componentColorOccurrences = 0; + let fallbackOccurrences = 0; + + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const relativePath = normalizePath(path.relative(cwd, file)); + const tokenFile = isTokenFile(relativePath); + const exceptionFile = isExceptionFile(relativePath); + const colors = collectMatches(content, COLOR_PATTERN).map(match => match[0]); + + if (colors.length > 0) { + fileColorCounts.set(relativePath, colors.length); + } + + for (const color of colors) { + colorOccurrences += 1; + incrementMap(colorCounts, color); + if (tokenFile) { + incrementMap(tokenColorCounts, color); + } else if (exceptionFile) { + incrementMap(exceptionColorCounts, color); + } else { + componentColorOccurrences += 1; + incrementMap(componentColorCounts, color); + incrementMap(componentFileColorCounts, relativePath); + } + } + + for (const match of collectMatches(content, CSS_VAR_USAGE_PATTERN)) { + incrementMap(varUsageCounts, match[1]); + } + + for (const match of collectMatches(content, CSS_VAR_DEFINITION_PATTERN)) { + incrementMap(varDefinitionCounts, match[2]); + } + + for (const match of collectMatches(content, VAR_FALLBACK_PATTERN)) { + fallbackOccurrences += 1; + incrementMap(fallbackTokenCounts, match[1]); + } + } + + const definedVars = new Set(varDefinitionCounts.keys()); + const undefinedVars = Array.from(varUsageCounts.entries()) + .filter(([name]) => !definedVars.has(name)) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 100) + .map(([key, count]) => ({ key, count })); + + const nearPairs = buildNearColorPairs(componentColorCounts); + const uniqueComponentColors = componentColorCounts.size; + + return { + root: normalizePath(path.relative(cwd, root)) || '.', + filesScanned: files.length, + filesWithColors: fileColorCounts.size, + colorOccurrences, + uniqueColors: colorCounts.size, + componentColorOccurrences, + componentFilesWithColors: componentFileColorCounts.size, + uniqueComponentColors, + tokenUniqueColors: tokenColorCounts.size, + exceptionUniqueColors: exceptionColorCounts.size, + fallbackOccurrences, + budget: { + uniqueAppColorBudget: options.budget, + uniqueComponentColors, + overBudgetBy: Math.max(0, uniqueComponentColors - options.budget), + }, + topColors: topEntries(colorCounts, options.top), + topComponentColors: topEntries(componentColorCounts, options.top), + topFiles: topEntries(fileColorCounts, options.top), + topFallbackTokens: topEntries(fallbackTokenCounts, options.top), + undefinedVars, + nearPairs, + }; +} + +function printText(report) { + const printRows = rows => rows.map(row => ` ${row.count.toString().padStart(5)} ${row.key}`).join('\n') || ' none'; + + console.log(`Theme color audit: ${report.root}`); + console.log(`Files scanned: ${report.filesScanned}`); + console.log(`Files with colors: ${report.filesWithColors}`); + console.log(`Color occurrences: ${report.colorOccurrences}`); + console.log(`Unique colors: ${report.uniqueColors}`); + console.log(`Component/non-token color occurrences: ${report.componentColorOccurrences}`); + console.log(`Files with component/non-token colors: ${report.componentFilesWithColors}`); + console.log(`Unique component/non-token colors: ${report.uniqueComponentColors}`); + console.log(`Unique component color budget: ${report.budget.uniqueAppColorBudget}`); + console.log(`Over budget by: ${report.budget.overBudgetBy}`); + console.log(`Fallback var occurrences: ${report.fallbackOccurrences}`); + + console.log('\nTop colors:'); + console.log(printRows(report.topColors)); + + console.log('\nTop component/non-token colors:'); + console.log(printRows(report.topComponentColors)); + + console.log('\nTop files:'); + console.log(printRows(report.topFiles)); + + console.log('\nTop fallback tokens:'); + console.log(printRows(report.topFallbackTokens)); + + console.log('\nUndefined or dynamically-defined CSS vars (top):'); + console.log(printRows(report.undefinedVars)); + + console.log('\nIndistinguishable component color pairs (sample):'); + if (report.nearPairs.indistinguishable.length === 0) { + console.log(' none'); + } else { + for (const pair of report.nearPairs.indistinguishable.slice(0, 10)) { + console.log(` ${pair.a} <-> ${pair.b} distance=${pair.distance.toFixed(2)} alphaDiff=${pair.alphaDiff.toFixed(3)} combined=${pair.count}`); + } + } + + console.log('\nNear component color pairs needing evidence (sample):'); + if (report.nearPairs.near.length === 0) { + console.log(' none'); + } else { + for (const pair of report.nearPairs.near.slice(0, 10)) { + console.log(` ${pair.a} <-> ${pair.b} distance=${pair.distance.toFixed(2)} alphaDiff=${pair.alphaDiff.toFixed(3)} combined=${pair.count}`); + } + } +} + +try { + const options = parseArgs(process.argv.slice(2)); + const report = audit(options); + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printText(report); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 648a8409d4..1781d62db9 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1,9030 +1,5 @@ #!/usr/bin/env node -import { readdirSync, readFileSync, statSync } from 'fs'; -import { join, relative } from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { runCoreBoundaryCheck } from './core-boundaries/checker.mjs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); - -const noCoreDependencyCrates = [ - 'core-types', - 'events', - 'ai-adapters', - 'agent-stream', - 'agent-runtime', - 'runtime-ports', - 'runtime-services', - 'services-core', - 'services-integrations', - 'agent-tools', - 'tool-packs', - 'product-domains', - 'terminal', - 'tool-runtime', - 'transport', - 'api-layer', - 'webdriver', -]; - -const lightweightBoundaryRules = [ - { - crateName: 'core-types', - reason: 'core-types must stay low-level DTO-only', - forbiddenDeps: [ - 'bitfun-core', - 'bitfun-events', - 'bitfun-ai-adapters', - 'bitfun-agent-stream', - 'bitfun-runtime-ports', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-agent-tools', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'runtime-ports', - reason: 'runtime-ports must stay DTO/trait-only', - forbiddenDeps: [ - 'bitfun-core', - 'bitfun-agent-stream', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-agent-tools', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'runtime-services', - reason: 'runtime-services must stay a typed service assembly contract without concrete runtime implementations', - forbiddenDeps: [ - 'bitfun-core', - 'bitfun-ai-adapters', - 'bitfun-agent-stream', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-agent-tools', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'agent-runtime', - reason: 'agent-runtime must own portable runtime decisions without concrete service or product implementations', - forbiddenDeps: [ - 'bitfun-core', - 'bitfun-ai-adapters', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'agent-tools', - reason: 'agent-tools must not depend on concrete service or product runtime implementations', - forbiddenDeps: [ - 'bitfun-core', - 'bitfun-ai-adapters', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, -]; - -const dependencyProfileRules = [ - { - crateName: 'core', - profileName: 'no-default runtime-surface-light profile', - reason: - 'bitfun-core no-default profile must not force product/runtime integration dependencies', - forbiddenNonOptionalDeps: [ - 'aes', - 'aes-gcm', - 'bitfun-product-domains', - 'bitfun-relay-server', - 'bitfun-tool-packs', - 'chrono-tz', - 'cron', - 'dashmap', - 'eventsource-stream', - 'filetime', - 'flate2', - 'fs2', - 'git2', - 'glob', - 'globset', - 'hostname', - 'image', - 'include_dir', - 'indexmap', - 'local-ip-address', - 'mac_address', - 'md5', - 'qrcode', - 'rand', - 'rmcp', - 'russh', - 'russh-keys', - 'russh-sftp', - 'shellexpand', - 'sse-stream', - 'ssh_config', - 'similar', - 'tool-runtime', - 'tokio-tungstenite', - 'x25519-dalek', - ], - }, - { - crateName: 'core-types', - profileName: 'default DTO profile', - reason: 'core-types default profile must stay DTO-only', - forbiddenNonOptionalDeps: [ - 'bitfun-core', - 'bitfun-events', - 'bitfun-ai-adapters', - 'bitfun-agent-stream', - 'bitfun-runtime-ports', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-agent-tools', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'runtime-ports', - profileName: 'default ports profile', - reason: 'runtime-ports default profile must stay trait/DTO-only', - forbiddenNonOptionalDeps: [ - 'bitfun-core', - 'bitfun-ai-adapters', - 'bitfun-agent-stream', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-agent-tools', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'runtime-services', - profileName: 'default runtime service assembly profile', - reason: 'runtime-services default profile must not compile concrete service or product runtime implementations', - forbiddenNonOptionalDeps: [ - 'bitfun-core', - 'bitfun-ai-adapters', - 'bitfun-agent-stream', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-agent-tools', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tool-runtime', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'agent-runtime', - profileName: 'default agent runtime decision profile', - reason: 'agent-runtime default profile must not compile concrete services or product surfaces', - forbiddenNonOptionalDeps: [ - 'bitfun-core', - 'bitfun-ai-adapters', - 'bitfun-services-core', - 'bitfun-services-integrations', - 'bitfun-tool-packs', - 'bitfun-product-domains', - 'bitfun-transport', - 'terminal-core', - 'tauri', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'agent-tools', - profileName: 'tool contract-only profile', - reason: 'agent-tools must stay a lightweight tool contract crate', - forbiddenNonOptionalDeps: [ - 'bitfun-ai-adapters', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'tauri', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'product-domains', - profileName: 'default product domain profile', - reason: 'product-domains default profile must not compile runtime/platform helpers', - forbiddenNonOptionalDeps: [ - 'dirs', - 'log', - 'sha2', - 'which', - 'reqwest', - 'git2', - 'rmcp', - 'image', - 'tokio-tungstenite', - 'tauri', - 'bitfun-cli', - 'ratatui', - 'crossterm', - 'arboard', - 'syntect-tui', - ], - }, - { - crateName: 'services-integrations', - profileName: 'default integrations profile', - reason: 'services-integrations default profile must not compile feature-gated integrations', - forbiddenNonOptionalDeps: [ - 'aes-gcm', - 'anyhow', - 'async-trait', - 'base64', - 'bitfun-runtime-ports', - 'bitfun-services-core', - 'chrono', - 'dunce', - 'futures', - 'git2', - 'notify', - 'rand', - 'reqwest', - 'rmcp', - 'sha2', - 'sse-stream', - 'thiserror', - 'tokio-util', - 'tokio-tungstenite', - 'uuid', - 'bitfun-relay-server', - ], - }, -]; - -const optionalDependencyFeatureOwnerRules = [ - { - crateName: 'core', - reason: - 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', - dependencies: [ - { depName: 'aes', ownerFeatures: ['service-integrations'] }, - { depName: 'aes-gcm', ownerFeatures: ['service-integrations', 'ssh-remote'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, - { depName: 'bitfun-relay-server', ownerFeatures: ['service-integrations'] }, - { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, - { depName: 'chrono-tz', ownerFeatures: ['product-full'] }, - { depName: 'cron', ownerFeatures: ['product-full'] }, - { depName: 'dashmap', ownerFeatures: ['product-full'] }, - { depName: 'eventsource-stream', ownerFeatures: ['product-full'] }, - { depName: 'filetime', ownerFeatures: ['product-full'] }, - { depName: 'flate2', ownerFeatures: ['product-full'] }, - { depName: 'fs2', ownerFeatures: ['product-full'] }, - { depName: 'git2', ownerFeatures: ['service-integrations'] }, - { depName: 'glob', ownerFeatures: ['product-full'] }, - { depName: 'globset', ownerFeatures: ['product-full'] }, - { depName: 'hostname', ownerFeatures: ['service-integrations'] }, - { depName: 'image', ownerFeatures: ['service-integrations'] }, - { depName: 'include_dir', ownerFeatures: ['product-full'] }, - { depName: 'indexmap', ownerFeatures: ['product-full'] }, - { depName: 'local-ip-address', ownerFeatures: ['service-integrations'] }, - { depName: 'mac_address', ownerFeatures: ['service-integrations'] }, - { depName: 'md5', ownerFeatures: ['product-full', 'service-integrations'] }, - { depName: 'qrcode', ownerFeatures: ['service-integrations'] }, - { depName: 'rand', ownerFeatures: ['service-integrations', 'ssh-remote'] }, - { depName: 'rmcp', ownerFeatures: ['service-integrations'] }, - { depName: 'russh', ownerFeatures: ['ssh-remote'] }, - { depName: 'russh-keys', ownerFeatures: ['ssh-remote'] }, - { depName: 'russh-sftp', ownerFeatures: ['ssh-remote'] }, - { depName: 'shellexpand', ownerFeatures: ['ssh-remote'] }, - { depName: 'similar', ownerFeatures: ['product-full'] }, - { depName: 'sse-stream', ownerFeatures: ['service-integrations'] }, - { depName: 'ssh_config', ownerFeatures: ['ssh-remote'] }, - { depName: 'tokio-tungstenite', ownerFeatures: ['service-integrations'] }, - { depName: 'tool-runtime', ownerFeatures: ['product-full'] }, - { depName: 'x25519-dalek', ownerFeatures: ['service-integrations'] }, - ], - }, - { - crateName: 'services-integrations', - reason: - 'services-integrations optional runtime dependencies must stay owned by explicit integration features', - dependencies: [ - { depName: 'aes-gcm', ownerFeatures: ['mcp'] }, - { depName: 'anyhow', ownerFeatures: ['mcp'] }, - { depName: 'async-trait', ownerFeatures: ['mcp', 'remote-connect'] }, - { depName: 'base64', ownerFeatures: ['mcp', 'remote-connect'] }, - { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect'] }, - { depName: 'bitfun-services-core', ownerFeatures: ['git', 'mcp'] }, - { depName: 'chrono', ownerFeatures: ['git'] }, - { depName: 'dunce', ownerFeatures: ['remote-ssh'] }, - { depName: 'futures', ownerFeatures: ['mcp'] }, - { depName: 'git2', ownerFeatures: ['git'] }, - { depName: 'notify', ownerFeatures: ['file-watch'] }, - { depName: 'rand', ownerFeatures: ['mcp'] }, - { depName: 'reqwest', ownerFeatures: ['mcp'] }, - { depName: 'rmcp', ownerFeatures: ['mcp'] }, - { depName: 'sha2', ownerFeatures: ['remote-ssh'] }, - { depName: 'sse-stream', ownerFeatures: ['mcp'] }, - { depName: 'thiserror', ownerFeatures: ['git'] }, - { depName: 'tokio-util', ownerFeatures: ['remote-ssh'] }, - { depName: 'uuid', ownerFeatures: ['remote-connect'] }, - ], - }, - { - crateName: 'product-domains', - reason: - 'product-domains optional runtime dependencies must stay owned by explicit product-domain features', - dependencies: [ - { depName: 'dirs', ownerFeatures: ['miniapp'] }, - { depName: 'log', ownerFeatures: ['function-agents'] }, - { depName: 'sha2', ownerFeatures: ['miniapp'] }, - { depName: 'which', ownerFeatures: ['miniapp'] }, - ], - }, -]; - -const productCoreFeatureAssemblyRules = [ - { - manifestPath: 'src/apps/desktop/Cargo.toml', - dependencyName: 'bitfun-core', - requiredFeatures: ['product-full'], - reason: 'desktop must explicitly assemble the full bitfun-core product runtime', - }, - { - manifestPath: 'src/apps/cli/Cargo.toml', - dependencyName: 'bitfun-core', - requiredFeatures: ['product-full'], - reason: 'CLI must explicitly assemble the full bitfun-core product runtime', - }, - { - manifestPath: 'src/crates/acp/Cargo.toml', - dependencyName: 'bitfun-core', - requiredFeatures: ['product-full'], - reason: 'ACP must explicitly assemble the full bitfun-core product runtime', - }, -]; - -const productCoreFeatureAssemblyScanRoots = ['src/apps', 'src/crates/acp']; - -const coreProductFullFeatureAssemblyRule = { - manifestPath: 'src/crates/core/Cargo.toml', - featureName: 'product-full', - requiredFeatureRefs: ['ssh-remote', 'product-domains', 'service-integrations', 'tool-packs'], - reason: 'bitfun-core product-full must explicitly assemble current owner feature groups', -}; - -const ownerCrateFeatureAssemblyRules = [ - { - manifestPath: 'src/crates/tool-packs/Cargo.toml', - reason: 'tool-packs must keep product feature groups explicit and default-light', - requiredProductFullFeatures: [ - 'basic', - 'git', - 'mcp', - 'browser-web', - 'computer-use', - 'image-analysis', - 'miniapp', - 'agent-control', - ], - }, - { - manifestPath: 'src/crates/services-integrations/Cargo.toml', - reason: 'services-integrations must keep integration feature groups explicit and default-light', - requiredProductFullFeatures: [ - 'announcement', - 'file-watch', - 'git', - 'mcp', - 'remote-connect', - 'remote-ssh', - ], - }, - { - manifestPath: 'src/crates/product-domains/Cargo.toml', - reason: 'product-domains must keep product domain feature groups explicit and default-light', - requiredProductFullFeatures: ['miniapp', 'function-agents'], - }, -]; - -const facadeOnlyFiles = [ - { - path: 'src/crates/core/src/infrastructure/filesystem/mod.rs', - importPrefix: 'bitfun_services_core::filesystem', - reason: 'core filesystem infrastructure facade must only re-export the services-core owner crate', - }, - { - path: 'src/crates/core/src/service/filesystem/listing.rs', - importPrefix: 'bitfun_services_core::filesystem', - reason: 'core filesystem listing facade must only re-export the services-core owner crate', - }, - { - path: 'src/crates/core/src/service/filesystem/types.rs', - importPrefix: 'bitfun_services_core::filesystem', - reason: 'core filesystem DTO facade must only re-export the services-core owner crate', - }, - { - path: 'src/crates/core/src/service/git/git_service.rs', - importPrefix: 'bitfun_services_integrations::git', - reason: 'core git service facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/git/git_types.rs', - importPrefix: 'bitfun_services_integrations::git', - reason: 'core git types facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/git/git_utils.rs', - importPrefix: 'bitfun_services_integrations::git', - reason: 'core git utils facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/git/graph.rs', - importPrefix: 'bitfun_services_integrations::git', - reason: 'core git graph facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/remote_ssh/types.rs', - importPrefix: 'bitfun_services_integrations::remote_ssh', - reason: 'core remote SSH types facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/tool_info.rs', - importPrefix: 'bitfun_services_integrations::mcp', - reason: 'core MCP tool info facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/tool_name.rs', - importPrefix: 'bitfun_services_integrations::mcp', - reason: 'core MCP tool name facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/protocol/types.rs', - importPrefix: 'bitfun_services_integrations::mcp', - reason: 'core MCP protocol types facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/protocol/transport.rs', - importPrefix: 'bitfun_services_integrations::mcp::protocol', - reason: 'core MCP stdio transport facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', - importPrefix: 'bitfun_services_integrations::mcp::protocol', - reason: 'core MCP remote transport facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/server/connection.rs', - importPrefix: 'bitfun_services_integrations::mcp::server', - reason: 'core MCP connection facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/config/location.rs', - importPrefix: 'bitfun_services_integrations::mcp', - reason: 'core MCP config location facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/adapter/resource.rs', - importPrefix: 'bitfun_services_integrations::mcp', - reason: 'core MCP resource adapter facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/mcp/adapter/prompt.rs', - importPrefix: 'bitfun_services_integrations::mcp', - reason: 'core MCP prompt adapter facade must only re-export the integrations owner crate', - }, - { - path: 'src/crates/core/src/service/announcement/types.rs', - importPrefix: 'bitfun_services_integrations::announcement', - reason: 'core announcement types facade must only re-export the integrations owner crate', - }, -]; - -const forbiddenContentRules = [ - { - path: 'src/crates/core/src/service/filesystem/service.rs', - patterns: [ - { - regex: /\btokio::fs::/, - message: - 'core filesystem service must not own async local filesystem IO; use bitfun-services-core filesystem primitives', - }, - { - regex: /\bstd::fs::/, - message: - 'core filesystem service must not own sync local filesystem IO; use bitfun-services-core filesystem primitives', - }, - { - regex: /\bignore::WalkBuilder\b/, - message: - 'core filesystem service must not own local file walking/search implementation; use bitfun-services-core filesystem primitives', - }, - { - regex: /\bsha2::/, - message: - 'core filesystem service must not own editor-sync hashing implementation; use bitfun-services-core filesystem primitives', - }, - { - regex: /\bbase64::/, - message: - 'core filesystem service must not own binary file encoding implementation; use bitfun-services-core filesystem primitives', - }, - ], - }, - { - path: 'src/crates/core/src/miniapp/runtime_detect.rs', - patterns: [ - { - regex: /\bCoreMiniAppRuntimeProbe\b/, - message: - 'core MiniApp runtime_detect must remain a compatibility facade; concrete probe owner is product-domains', - }, - { - regex: /\bwhich::which\b/, - message: - 'core MiniApp runtime_detect must not own PATH lookup; use product-domain runtime detection', - }, - { - regex: /\bcreate_command\b/, - message: - 'core MiniApp runtime_detect must not own version process execution; use product-domain runtime detection', - }, - { - regex: /\bstd::fs::read_dir\b/, - message: - 'core MiniApp runtime_detect must not own version-manager directory scanning; use product-domain runtime detection', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/framework.rs', - patterns: [ - { - regex: /\bpub struct DynamicMcpToolInfo\b/, - message: 'core tool framework must not redefine DynamicMcpToolInfo; use bitfun-agent-tools', - }, - { - regex: /\bpub struct DynamicToolInfo\b/, - message: 'core tool framework must not redefine DynamicToolInfo; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ToolRenderOptions\b/, - message: 'core tool framework must not redefine ToolRenderOptions; use bitfun-agent-tools', - }, - { - regex: /\bpub enum ToolPathBackend\b/, - message: 'core tool framework must not redefine ToolPathBackend; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ToolPathResolution\b/, - message: 'core tool framework must not redefine ToolPathResolution; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ToolContextFacts\b/, - message: 'core tool framework must not redefine ToolContextFacts; use bitfun-agent-tools', - }, - { - regex: /\bpub enum ToolWorkspaceKind\b/, - message: 'core tool framework must not redefine ToolWorkspaceKind; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ToolUseContext\b/, - message: - 'core tool framework must not own ToolUseContext; re-export it from tool_context_runtime', - }, - { - regex: /\bcall_with_tool_runtime_hooks\b/, - message: - 'core tool framework must not wire runtime hooks directly; delegate through tool_context_runtime', - }, - { - regex: /\bdeep_review_shared_context_measurement_snapshot\b/, - message: - 'core tool framework must not own runtime hook regressions; keep them in tool_context_runtime', - }, - { - regex: /\bget_global_coordinator\b/, - message: - 'core tool framework must not own runtime checkpoint coordination; keep it in tool_context_runtime', - }, - { - regex: /\bGitService\b/, - message: - 'core tool framework must not own git-backed checkpoint runtime; keep it in tool_context_runtime', - }, - { - regex: /\bget_workspace_runtime_service_arc\b/, - message: - 'core tool framework must not own workspace runtime lookup; keep it in tool_context_runtime', - }, - { - regex: /\bremote_workspace_runtime_root\b/, - message: - 'core tool framework must not own remote runtime-root lookup; keep it in tool_context_runtime', - }, - { - regex: /\bget_path_manager_arc\b/, - message: - 'core tool framework must not own host runtime-root lookup; keep it in tool_context_runtime', - }, - { - regex: /\bpost_call_hooks::record_successful_tool_call\b/, - message: - 'core tool framework must not own post-call runtime hooks; keep them in tool_context_runtime', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'tool pipeline must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - { - regex: /\bfn serialize_result_for_assistant\b/, - message: - 'core tool pipeline must not own provider-neutral assistant result rendering; use bitfun-agent-tools', - }, - { - regex: /\bconst TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES\b/, - message: - 'core tool pipeline must not own tool error argument preview limits; use bitfun-agent-tools', - }, - { - regex: /\bfn truncate_arguments_preview\b/, - message: - 'core tool pipeline must not own tool error argument preview rendering; use bitfun-agent-tools', - }, - { - regex: /\bfn truncate_raw_arguments_preview\b/, - message: - 'core tool pipeline must not own raw tool argument preview rendering; use bitfun-agent-tools', - }, - { - regex: /\bconst USER_STEERING_INTERRUPTED_MESSAGE\b/, - message: - 'core tool pipeline must not own steering-interrupted result presentation; use bitfun-agent-tools', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', - patterns: [ - { - regex: /\bstruct\s+DelegationPolicy\b/, - message: - 'core subagent runtime must not redefine DelegationPolicy; use bitfun-runtime-ports', - }, - { - regex: /\benum\s+SubagentContextMode\b/, - message: - 'core subagent runtime must not redefine SubagentContextMode; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/coordinator.rs', - patterns: [ - { - regex: /\benum\s+DialogTriggerSource\b/, - message: - 'core coordinator must not redefine DialogTriggerSource; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/scheduler.rs', - patterns: [ - { - regex: /\benum\s+DialogQueuePriority\b/, - message: - 'core scheduler must not redefine DialogQueuePriority; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+DialogSubmissionPolicy\b/, - message: - 'core scheduler must not redefine DialogSubmissionPolicy; use bitfun-runtime-ports', - }, - { - regex: /\benum\s+DialogSubmitOutcome\b/, - message: - 'core scheduler must not redefine DialogSubmitOutcome; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+AgentSessionReplyRoute\b/, - message: - 'core scheduler must not redefine AgentSessionReplyRoute; use bitfun-runtime-ports', - }, - { - regex: /\benum\s+DialogSteerOutcome\b/, - message: - 'core scheduler must not redefine DialogSteerOutcome; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/round_preempt.rs', - patterns: [ - { - regex: /\btrait\s+DialogRoundPreemptSource\b/, - message: - 'core round preempt runtime must not redefine DialogRoundPreemptSource; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+RoundInjection\b/, - message: - 'core round preempt runtime must not redefine RoundInjection; use bitfun-runtime-ports', - }, - { - regex: /\btrait\s+DialogRoundInjectionSource\b/, - message: - 'core round preempt runtime must not redefine DialogRoundInjectionSource; use bitfun-runtime-ports', - }, - { - regex: /\benum\s+RoundInjectionKind\b/, - message: - 'core round preempt runtime must not redefine RoundInjectionKind; use bitfun-runtime-ports', - }, - { - regex: /\benum\s+RoundInjectionTarget\b/, - message: - 'core round preempt runtime must not redefine RoundInjectionTarget; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/goal_mode/types.rs', - patterns: [ - { - regex: /\bconst\s+GOAL_MODE_METADATA_KEY\b/, - message: 'core goal mode types must not redefine GOAL_MODE_METADATA_KEY; use bitfun-runtime-ports', - }, - { - regex: /\bconst\s+MAX_GOAL_CONTINUATIONS\b/, - message: 'core goal mode types must not redefine MAX_GOAL_CONTINUATIONS; use bitfun-runtime-ports', - }, - { - regex: /\bconst\s+MAX_CONTEXT_SUMMARY_CHARS\b/, - message: 'core goal mode types must not redefine MAX_CONTEXT_SUMMARY_CHARS; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+GoalModeInitialGoal\b/, - message: 'core goal mode types must not redefine GoalModeInitialGoal; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+GoalModeState\b/, - message: 'core goal mode types must not redefine GoalModeState; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+GoalGenerationResult\b/, - message: 'core goal mode types must not redefine GoalGenerationResult; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+GoalVerificationResult\b/, - message: 'core goal mode types must not redefine GoalVerificationResult; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+GoalActivationResult\b/, - message: 'core goal mode types must not redefine GoalActivationResult; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+GoalContinuationPlan\b/, - message: 'core goal mode types must not redefine GoalContinuationPlan; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/core/message.rs', - patterns: [ - { - regex: /\bstruct\s+CompressionContract\b/, - message: 'core message model must not redefine CompressionContract; use bitfun-runtime-ports', - }, - { - regex: /\bstruct\s+CompressionContractItem\b/, - message: 'core message model must not redefine CompressionContractItem; use bitfun-runtime-ports', - }, - { - regex: /\bfn\s+render_contract_items\b/, - message: 'core message model must not own compression contract rendering; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/service/workspace/manager.rs', - patterns: [ - { - regex: /\bstruct\s+RelatedPath\b/, - message: 'core workspace manager must not redefine RelatedPath; use bitfun-runtime-ports', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/file_read_state_runtime.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'file read-state runtime must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_result_storage.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'tool-result storage runtime must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/post_call_hooks.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'post-call hooks must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'tool adapter must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/product_runtime.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'product tool runtime must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - patterns: [ - { - regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, - message: - 'manifest resolver must import ToolUseContext from tool_context_runtime, not the framework re-export', - }, - ], - }, - { - path: 'src/crates/core/src/miniapp/manager.rs', - patterns: [ - { - regex: /\bbuild_runtime_state\b/, - message: - 'core MiniApp manager must not build runtime state directly; use product-domain lifecycle helpers', - }, - { - regex: /\bbuild_source_revision\b/, - message: - 'core MiniApp manager must not build source revisions directly; use product-domain lifecycle helpers', - }, - { - regex: /\bbuild_deps_revision\b/, - message: - 'core MiniApp manager must not build dependency revisions directly; use product-domain lifecycle helpers', - }, - { - regex: /\bapp\.version\s*\+=\s*1\b/, - message: - 'core MiniApp manager must not own version increments for lifecycle transitions; use product-domain lifecycle helpers', - }, - { - regex: /\bapp\.runtime\s*=/, - message: - 'core MiniApp manager must not own runtime-state replacement for lifecycle transitions; use product-domain lifecycle helpers', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/restrictions.rs', - patterns: [ - { - regex: /\bpub enum ToolPathOperation\b/, - message: 'core tool restrictions must not redefine ToolPathOperation; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ToolPathPolicy\b/, - message: 'core tool restrictions must not redefine ToolPathPolicy; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ToolRuntimeRestrictions\b/, - message: - 'core tool restrictions must not redefine ToolRuntimeRestrictions; use bitfun-agent-tools', - }, - { - regex: /\bfn\s+normalize_absolute_posix_path\b/, - message: - 'core tool restrictions must not redefine remote POSIX path normalization; use bitfun-agent-tools', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/workspace_paths.rs', - patterns: [ - { - regex: /\bpub const BITFUN_RUNTIME_URI_PREFIX\b/, - message: - 'core workspace path facade must not redefine the runtime URI prefix; use bitfun-agent-tools', - }, - { - regex: /\bpub struct ParsedBitFunRuntimeUri\b/, - message: - 'core workspace path facade must not redefine ParsedBitFunRuntimeUri; use bitfun-agent-tools', - }, - { - regex: /\bfn\s+posix_normalize_components\b/, - message: - 'core workspace path facade must not redefine remote POSIX path normalization; use bitfun-agent-tools', - }, - { - regex: /Component::ParentDir/, - message: - 'core workspace path facade must not redefine host path normalization; use bitfun-agent-tools', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/registry.rs', - patterns: [ - { - regex: /\bstruct DynamicToolMetadata\b/, - message: - 'core tool registry must not own dynamic tool metadata storage; use bitfun-agent-tools ToolRegistry', - }, - { - regex: /\btools\s*:\s*IndexMap\b/, - message: - 'core tool registry must not own the generic tool map; use bitfun-agent-tools ToolRegistry', - }, - { - regex: /\bdynamic_tools\s*:\s*IndexMap\b/, - message: - 'core tool registry must not own the dynamic tool map; use bitfun-agent-tools ToolRegistry', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/file_read_state_runtime.rs', - patterns: [ - { - regex: /\bnormalize_string\b/, - message: - 'core file read-state runtime must delegate pure freshness normalization to bitfun-agent-tools', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_result_storage.rs', - patterns: [ - { - regex: /\bfn\s+generate_preview\b/, - message: - 'core tool result storage must delegate pure preview generation to bitfun-agent-tools', - }, - { - regex: /\bfn\s+build_persisted_output_message\b/, - message: - 'core tool result storage must delegate persisted-output rendering to bitfun-agent-tools', - }, - { - regex: /\bfn\s+select_candidates_to_persist\b/, - message: - 'core tool result storage must delegate round-budget selection to bitfun-agent-tools', - }, - { - regex: /\bstruct\s+ToolResultStoragePolicy\b/, - message: - 'core tool result storage must use the provider-neutral storage policy from bitfun-agent-tools', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/server/process.rs', - patterns: [ - { - regex: /\bpub enum MCPServerType\b/, - message: 'core MCP server process runtime must not redefine MCPServerType; use the integrations contract', - }, - { - regex: /\bpub enum MCPServerStatus\b/, - message: 'core MCP server process runtime must not redefine MCPServerStatus; use the integrations contract', - }, - { - regex: /\bfn is_auth_error\b/, - message: 'core MCP server process runtime must not own auth error classification; use the integrations helper', - }, - { - regex: /\bconst AUTHORIZATION_KEYS\b/, - message: 'core MCP server process runtime must not own remote authorization key constants; use the integrations helper', - }, - { - regex: /\bcontains_key\("Authorization"\)/, - message: 'core MCP server process runtime must not inline legacy authorization header fallback; use the integrations helper', - }, - { - regex: /\bprocess_manager::create_tokio_command\b/, - message: 'core MCP server process facade must not spawn MCP child processes; use the integrations owner crate', - }, - { - regex: /\bMCPTransport::start_receive_loop\b/, - message: 'core MCP server process facade must not own stdio receive lifecycle; use the integrations owner crate', - }, - { - regex: /\bMCPConnection::new_remote\b/, - message: 'core MCP server process facade must not own remote transport lifecycle; use the integrations owner crate', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/server/manager/mod.rs', - patterns: [ - { - regex: /\benum ListChangedKind\b/, - message: 'core MCP server manager must not own list-changed classification; use the integrations helper', - }, - { - regex: /\bresource_catalog_cache\b/, - message: 'core MCP server manager must not own resource catalog cache state; use the integrations owner crate', - }, - { - regex: /\bprompt_catalog_cache\b/, - message: 'core MCP server manager must not own prompt catalog cache state; use the integrations owner crate', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/server/manager/reconnect.rs', - patterns: [ - { - regex: /\bfn compute_backoff_delay\b/, - message: 'core MCP reconnect runtime must not own backoff policy math; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/server/manager/interaction.rs', - patterns: [ - { - regex: /\bfn detect_list_changed_kind\b/, - message: 'core MCP interaction runtime must not own list-changed classification; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/adapter/tool.rs', - patterns: [ - { - regex: /\bfn behavior_hints\b/, - message: 'core MCP tool adapter must not own dynamic tool behavior hint rendering; use the integrations helper', - }, - { - regex: /\bfn truncate_for_assistant\b/, - message: 'core MCP tool adapter must not own result truncation rendering; use the integrations helper', - }, - { - regex: /\bMCPToolResultContent\b/, - message: 'core MCP tool adapter must not own MCP result content rendering; use the integrations helper', - }, - { - regex: /Tool '\{\}' from MCP server/, - message: 'core MCP tool adapter must not own dynamic descriptor text; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/adapter/context.rs', - patterns: [ - { - regex: /\bpub struct ContextEnhancerConfig\b/, - message: 'core MCP context provider must not own enhancer config; use the integrations helper', - }, - { - regex: /\bpub struct ContextEnhancer\b/, - message: 'core MCP context provider must not own resource selection logic; use the integrations helper', - }, - { - regex: /\bpartial_cmp\b/, - message: 'core MCP context provider must not own resource ranking logic; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', - patterns: [ - { - regex: /\bGitService::get_status\b/, - message: - 'Git function-agent commit generator must use CoreProductDomainRuntime for Git adapter wiring', - }, - { - regex: /\bAIAnalysisService::new_with_agent_config\b/, - message: - 'Git function-agent commit generator must use CoreProductDomainRuntime for AI adapter wiring', - }, - { - regex: /\bto_string_lossy\b/, - message: - 'Git function-agent commit generator must preserve PathBuf paths when routing through the facade', - }, - ], - }, - { - path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', - patterns: [ - { - regex: /\bAIWorkStateService::new_with_agent_config\b/, - message: - 'Startchat work-state analyzer must use CoreProductDomainRuntime for AI adapter wiring', - }, - { - regex: /\bcreate_command\("git"\)/, - message: - 'Startchat work-state analyzer must use CoreProductDomainRuntime for Git adapter wiring', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/server/config.rs', - patterns: [ - { - regex: /\bpub enum MCPServerTransport\b/, - message: 'core MCP server config facade must not redefine MCPServerTransport; use the integrations contract', - }, - { - regex: /\bpub struct MCPServerOAuthConfig\b/, - message: 'core MCP server config facade must not redefine OAuth config; use the integrations contract', - }, - { - regex: /\bpub struct MCPServerXaaConfig\b/, - message: 'core MCP server config facade must not redefine XAA config; use the integrations contract', - }, - { - regex: /\bpub struct MCPServerConfig\b/, - message: 'core MCP server config facade must not redefine server config; use the integrations contract', - }, - { - regex: /\bfn default_true\b/, - message: 'core MCP server config facade must not redefine config serde defaults; use the integrations contract', - }, - { - regex: /\bpub fn resolved_transport\b/, - message: 'core MCP server config facade must not redefine transport defaults; use the integrations contract', - }, - { - regex: /\bpub fn validate\b/, - message: 'core MCP server config facade must not redefine config validation; use the integrations contract', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/config/cursor_format.rs', - patterns: [ - { - regex: /\bfn parse_source\b/, - message: 'core MCP cursor facade must not redefine source parsing; use the integrations contract', - }, - { - regex: /\bfn parse_transport\b/, - message: 'core MCP cursor facade must not redefine transport parsing; use the integrations contract', - }, - { - regex: /\bfn parse_legacy_type\b/, - message: 'core MCP cursor facade must not redefine legacy type parsing; use the integrations contract', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/config/json_config.rs', - patterns: [ - { - regex: /\bfn normalize_source\b/, - message: 'core MCP JSON config facade must not redefine source normalization; use the integrations helper', - }, - { - regex: /\bfn normalize_transport\b/, - message: 'core MCP JSON config facade must not redefine transport normalization; use the integrations helper', - }, - { - regex: /\bfn normalize_legacy_type\b/, - message: 'core MCP JSON config facade must not redefine legacy type normalization; use the integrations helper', - }, - { - regex: /\bconfig_value\.get\("mcpServers"\)\.is_none\(\)/, - message: 'core MCP JSON config facade must not inline save validation; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/config/service.rs', - patterns: [ - { - regex: /\bconst AUTHORIZATION_KEYS\b/, - message: 'core MCP config service facade must not own authorization key constants; use the integrations helper', - }, - { - regex: /\bfn config_signature\b/, - message: 'core MCP config service facade must not own merge signatures; use the integrations helper', - }, - { - regex: /\bfn precedence\b/, - message: 'core MCP config service facade must not own merge precedence; use the integrations helper', - }, - { - regex: /\bfn config_authorization_from_map\b/, - message: 'core MCP config service facade must not own authorization extraction; use the integrations helper', - }, - { - regex: /\bBTreeMap\b/, - message: 'core MCP config service facade must not rebuild stable merge signatures; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/auth.rs', - patterns: [ - { - regex: /\bstruct VaultFile\b/, - message: 'core MCP auth facade must not own OAuth vault storage; use the integrations owner crate', - }, - { - regex: /\bconst NONCE_LEN\b/, - message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', - }, - { - regex: /\bfn encrypt_value\b/, - message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', - }, - { - regex: /\bfn decrypt_value\b/, - message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', - }, - { - regex: /\bAuthorizationManager::new\b/, - message: 'core MCP auth facade must not assemble OAuth authorization manager internals; use the integrations owner crate', - }, - { - regex: /\bOAuthState::new\b/, - message: 'core MCP auth facade must not assemble OAuth authorization state internals; use the integrations owner crate', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', - patterns: [ - { - regex: /\bfn normalize_authorization_value\b/, - message: 'core MCP remote transport must not redefine authorization normalization; use the integrations helper', - }, - { - regex: /starts_with\("bearer "\)/, - message: 'core MCP remote transport must not inline bearer normalization; use the integrations helper', - }, - { - regex: /\bfn build_client_info\b/, - message: 'core MCP remote transport must not own client capability construction; use the integrations helper', - }, - { - regex: /\bClientCapabilities::builder\b/, - message: 'core MCP remote transport must not inline client capability construction; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?initialize_result\b/, - message: 'core MCP remote transport must not own rmcp initialize mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?tool\b/, - message: 'core MCP remote transport must not own rmcp tool mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?resource\b/, - message: 'core MCP remote transport must not own rmcp resource mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?resource_content\b/, - message: 'core MCP remote transport must not own rmcp resource content mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?prompt\b/, - message: 'core MCP remote transport must not own rmcp prompt mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?prompt_message\b/, - message: 'core MCP remote transport must not own rmcp prompt message mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?tool_result\b/, - message: 'core MCP remote transport must not own rmcp tool result mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?content_block\b/, - message: 'core MCP remote transport must not own rmcp content block mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?icons\b/, - message: 'core MCP remote transport must not own rmcp icon mapping; use the integrations helper', - }, - { - regex: /\bfn map_(?:rmcp_)?annotations\b/, - message: 'core MCP remote transport must not own rmcp annotation mapping; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/mcp/protocol/jsonrpc.rs', - patterns: [ - { - regex: /\bfn serialize_params\b/, - message: 'core MCP jsonrpc facade must not redefine request parameter serialization; use the integrations contract', - }, - { - regex: /\bpub fn create_initialize_request\b/, - message: 'core MCP jsonrpc facade must not redefine initialize request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_resources_list_request\b/, - message: 'core MCP jsonrpc facade must not redefine resources/list request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_resources_read_request\b/, - message: 'core MCP jsonrpc facade must not redefine resources/read request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_prompts_list_request\b/, - message: 'core MCP jsonrpc facade must not redefine prompts/list request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_prompts_get_request\b/, - message: 'core MCP jsonrpc facade must not redefine prompts/get request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_tools_list_request\b/, - message: 'core MCP jsonrpc facade must not redefine tools/list request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_tools_call_request\b/, - message: 'core MCP jsonrpc facade must not redefine tools/call request builders; use the integrations contract', - }, - { - regex: /\bpub fn create_ping_request\b/, - message: 'core MCP jsonrpc facade must not redefine ping request builders; use the integrations contract', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_ssh/workspace_state.rs', - patterns: [ - { - regex: /\bpub const LOCAL_WORKSPACE_SSH_HOST\b/, - message: 'core remote SSH workspace runtime must not redefine LOCAL_WORKSPACE_SSH_HOST; use the integrations contract', - }, - { - regex: /\bpub fn normalize_remote_workspace_path\b/, - message: 'core remote SSH workspace runtime must not redefine remote path normalization; use the integrations contract', - }, - { - regex: /\bpub fn sanitize_ssh_connection_id_for_local_dir\b/, - message: 'core remote SSH workspace runtime must not redefine SSH connection id sanitization; use the integrations contract', - }, - { - regex: /\bpub fn sanitize_remote_mirror_path_component\b/, - message: 'core remote SSH workspace runtime must not redefine remote mirror path sanitization; use the integrations contract', - }, - { - regex: /\bpub fn sanitize_ssh_hostname_for_mirror\b/, - message: 'core remote SSH workspace runtime must not redefine SSH hostname mirror sanitization; use the integrations contract', - }, - { - regex: /\bpub fn remote_root_to_mirror_subpath\b/, - message: 'core remote SSH workspace runtime must not redefine remote mirror subpath mapping; use the integrations contract', - }, - { - regex: /\bpub fn workspace_logical_key\b/, - message: 'core remote SSH workspace runtime must not redefine workspace logical keys; use the integrations contract', - }, - { - regex: /\bpub fn local_workspace_stable_storage_id\b/, - message: 'core remote SSH workspace runtime must not redefine local workspace stable ids; use the integrations contract', - }, - { - regex: /\bpub fn remote_workspace_stable_id\b/, - message: 'core remote SSH workspace runtime must not redefine remote workspace stable ids; use the integrations contract', - }, - { - regex: /\bpub fn unresolved_remote_session_storage_key\b/, - message: 'core remote SSH workspace runtime must not redefine unresolved session keys; use the integrations contract', - }, - { - regex: /\bstruct RegisteredRemoteWorkspace\b/, - message: 'core remote SSH workspace runtime must not own workspace registrations; use the integrations registry', - }, - { - regex: /\bpub struct RemoteWorkspaceEntry\b/, - message: 'core remote SSH workspace runtime must not redefine workspace entries; use the integrations registry', - }, - { - regex: /\bpub struct RemoteWorkspaceState\b/, - message: 'core remote SSH workspace runtime must not redefine legacy workspace state; use the integrations registry', - }, - { - regex: /\bregistration_matches_path\b/, - message: 'core remote SSH workspace runtime must not own path-to-registration matching; use the integrations registry', - }, - { - regex: /\bdunce::canonicalize\b/, - message: 'core remote SSH workspace runtime must not own local root canonicalization; use the integrations path helper', - }, - { - regex: /\bfn path_buf_to_stable_local_root_string\b/, - message: 'core remote SSH workspace runtime must not own local root string normalization; use the integrations path helper', - }, - { - regex: /join\("_unresolved"\)/, - message: 'core remote SSH workspace runtime must not own unresolved session path layout; use the integrations path helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_connect/remote_server.rs', - patterns: [ - { - regex: /\bpub\(crate\) struct CoreRemoteDialogRuntimeHost\b/, - message: - 'remote_server must not own concrete remote dialog runtime host; keep it in service_agent_runtime', - }, - { - regex: /\bpub\(crate\) struct CoreRemoteCancelRuntimeHost\b/, - message: - 'remote_server must not own concrete remote cancel runtime host; keep it in service_agent_runtime', - }, - { - regex: /\bpub\(crate\) struct CoreRemoteWorkspaceFileRuntimeHost\b/, - message: - 'remote_server must not own concrete remote workspace file runtime host; keep it in service_agent_runtime', - }, - { - regex: /\bstruct CoreRemoteSessionTrackerHost\b/, - message: - 'remote_server must not own concrete remote tracker host; keep it in service_agent_runtime', - }, - { - regex: /\basync fn resolve_session_model_id\b/, - message: - 'remote_server must not own remote session model resolution; keep it in service_agent_runtime', - }, - { - regex: /\basync fn load_remote_model_catalog\b/, - message: - 'remote_server must not own remote model catalog loading; keep it in service_agent_runtime', - }, - { - regex: /\bget_global_config_service\b/, - message: - 'remote_server must not own remote model config access; route it through service_agent_runtime', - }, - { - regex: /\bfn compress_data_url_for_mobile\b/, - message: - 'remote_server must not own remote chat thumbnail compression; keep it in service_agent_runtime', - }, - { - regex: /\bfn turns_to_chat_messages\b/, - message: - 'remote_server must not own persisted turn to remote chat conversion; keep it in service_agent_runtime', - }, - { - regex: /\basync fn load_chat_messages_from_conversation_persistence\b/, - message: - 'remote_server must not own remote chat history persistence loading; keep it in service_agent_runtime', - }, - { - regex: /\bfn strip_user_input_tags\b/, - message: - 'remote_server must not own remote user input display cleanup; keep it in service_agent_runtime', - }, - { - regex: /\bpub struct ImageAttachment\b/, - message: 'core remote-connect server must not redefine image attachment wire DTOs; use the integrations contract', - }, - { - regex: /\bpub struct ChatImageAttachment\b/, - message: 'core remote-connect server must not redefine chat image wire DTOs; use the integrations contract', - }, - { - regex: /\bpub struct ChatMessage\b/, - message: 'core remote-connect server must not redefine chat message wire DTOs; use the integrations contract', - }, - { - regex: /\bpub struct ChatMessageItem\b/, - message: 'core remote-connect server must not redefine chat message item DTOs; use the integrations contract', - }, - { - regex: /\bpub struct RemoteToolStatus\b/, - message: 'core remote-connect server must not redefine remote tool status DTOs; use the integrations contract', - }, - { - regex: /\bpub struct ActiveTurnSnapshot\b/, - message: 'core remote-connect server must not redefine active turn snapshot DTOs; use the integrations contract', - }, - { - regex: /\bpub struct SessionInfo\b/, - message: 'core remote-connect server must not redefine session info DTOs; use the integrations contract', - }, - { - regex: /\bpub struct RemoteDefaultModelsConfig\b/, - message: 'core remote-connect server must not redefine remote model default DTOs; use the integrations contract', - }, - { - regex: /\bpub struct RemoteModelConfig\b/, - message: 'core remote-connect server must not redefine remote model DTOs; use the integrations contract', - }, - { - regex: /\bpub struct RemoteModelCatalog\b/, - message: 'core remote-connect server must not redefine remote model catalog DTOs; use the integrations contract', - }, - { - regex: /\bpub struct RemoteModelCatalogPollDelta\b/, - message: 'core remote-connect server must not redefine remote model catalog poll delta; use the integrations contract', - }, - { - regex: /\bpub enum RemoteCommand\b/, - message: 'core remote-connect server must not redefine remote command wire DTOs; use the integrations contract', - }, - { - regex: /\bpub enum RemoteResponse\b/, - message: 'core remote-connect server must not redefine remote response wire DTOs; use the integrations contract', - }, - { - regex: /\bstruct TrackerState\b/, - message: 'core remote-connect server must not own tracker state; use the integrations tracker', - }, - { - regex: /\bpub enum TrackerEvent\b/, - message: 'core remote-connect server must not redefine tracker events; use the integrations tracker', - }, - { - regex: /\bpub struct RemoteSessionStateTracker\b/, - message: 'core remote-connect server must not own tracker state; use the integrations tracker', - }, - { - regex: /\bDashMap\b/, - message: 'core remote-connect server must not own tracker storage; use the integrations registry', - }, - { - regex: /\bfn make_slim_params\b/, - message: 'core remote-connect server must not own remote tool preview slimming; use the integrations helper', - }, - { - regex: /\bmatch mobile_type\b/, - message: 'core remote-connect server must not own remote agent type alias mapping; use the integrations helper', - }, - { - regex: /\bfn resolve_remote_cancel_decision\b/, - message: 'core remote-connect server must not own cancel decision policy; use the integrations helper', - }, - { - regex: /\benum RemoteCancelDecision\b/, - message: 'core remote-connect server must not own cancel decision types; use the integrations contract', - }, - { - regex: /\bstruct RemoteCancelTaskRequest\b/, - message: 'core remote-connect server must not own cancel task request contracts; use the integrations contract', - }, - { - regex: /\btrait RemoteCancelRuntimeHost\b/, - message: 'core remote-connect server must not own cancel runtime host contracts; use the integrations contract', - }, - { - regex: /\bfn cancel_remote_task\b/, - message: 'core remote-connect server must not own cancel orchestration; use the integrations helper', - }, - { - regex: /\bfn remote_session_restore_target\b/, - message: 'core remote-connect server must not own restore-target policy; use the integrations helper', - }, - { - regex: /\bfn resolve_remote_execution_image_contexts\b/, - message: 'core remote-connect server must not own image-context preference policy; use the integrations helper', - }, - { - regex: /\btrait RemoteImageContextAdapter\b/, - message: 'core remote-connect server must not own image-context adapter contracts; use the integrations contract', - }, - { - regex: /\bconst MAX_SIZE\b/, - message: 'core remote-connect server must not own remote file max-read policy; use the integrations helper', - }, - { - regex: /\bconst MAX_CHUNK\b/, - message: 'core remote-connect server must not own remote file chunk policy; use the integrations helper', - }, - { - regex: /unwrap_or\("file"\)/, - message: 'core remote-connect server must not own remote file display-name fallback; use the integrations helper', - }, - { - regex: /\bresolve_workspace_path\b/, - message: 'core remote-connect server must not own workspace file path resolution; use the integrations helper', - }, - { - regex: /\bdetect_mime_type\b/, - message: 'core remote-connect server must not own workspace file MIME detection; use the integrations helper', - }, - { - regex: /\bread_workspace_file\b/, - message: 'core remote-connect server must not own workspace file read helpers; use the integrations helper', - }, - { - regex: /\bfn read_remote_workspace_file\b/, - message: 'core remote-connect server must not redefine remote workspace full-file readers; use the integrations helper', - }, - { - regex: /\bfn read_remote_workspace_file_chunk\b/, - message: 'core remote-connect server must not redefine remote workspace chunk readers; use the integrations helper', - }, - { - regex: /\bfn read_remote_workspace_file_info\b/, - message: 'core remote-connect server must not redefine remote workspace file-info readers; use the integrations helper', - }, - { - regex: /\bfn remote_file_content_response\b/, - message: 'core remote-connect server must not own remote file content response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_file_chunk_response\b/, - message: 'core remote-connect server must not own remote file chunk response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_file_info_response\b/, - message: 'core remote-connect server must not own remote file-info response assembly; use the integrations helper', - }, - { - regex: /\bfn handle_remote_workspace_file_command\b/, - message: 'core remote-connect server must not own remote file command orchestration; use the integrations helper', - }, - { - regex: /general_purpose::STANDARD\.encode/, - message: 'core remote-connect server must not own remote response base64 wrapping; use the integrations helper', - }, - { - regex: /\bfn remote_dialog_submit_response\b/, - message: 'core remote-connect server must not own remote dialog response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_task_cancel_response\b/, - message: 'core remote-connect server must not own remote cancel response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_interaction_accepted_response\b/, - message: 'core remote-connect server must not own remote interaction response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_answer_question_response\b/, - message: 'core remote-connect server must not own remote answer response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_workspace_info_response\b/, - message: 'core remote-connect server must not own workspace-info response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_recent_workspaces_response\b/, - message: 'core remote-connect server must not own recent-workspaces response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_assistant_list_response\b/, - message: 'core remote-connect server must not own assistant-list response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_workspace_updated_response\b/, - message: 'core remote-connect server must not own workspace-updated response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_assistant_updated_response\b/, - message: 'core remote-connect server must not own assistant-updated response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_session_info\b/, - message: 'core remote-connect server must not own session response facts assembly; use the integrations helper', - }, - { - regex: /\bfn remote_session_list_response\b/, - message: 'core remote-connect server must not own session-list response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_initial_sync_response\b/, - message: 'core remote-connect server must not own initial-sync response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_session_created_response\b/, - message: 'core remote-connect server must not own session-created response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_session_model_updated_response\b/, - message: 'core remote-connect server must not own session-model response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_messages_response\b/, - message: 'core remote-connect server must not own remote messages response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_session_deleted_response\b/, - message: 'core remote-connect server must not own session-deleted response assembly; use the integrations helper', - }, - { - regex: /\bfn should_send_remote_model_catalog\b/, - message: 'core remote-connect server must not own poll model-catalog policy; use the integrations helper', - }, - { - regex: /\bfn remote_model_catalog_poll_delta\b/, - message: 'core remote-connect server must not own poll model-catalog delta policy; use the integrations helper', - }, - { - regex: /\bfn remote_no_change_poll_response\b/, - message: 'core remote-connect server must not own no-change poll response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_snapshot_poll_response\b/, - message: 'core remote-connect server must not own streaming poll response assembly; use the integrations helper', - }, - { - regex: /\bfn remote_persisted_poll_response\b/, - message: 'core remote-connect server must not own persisted poll response assembly; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_connect/bot/mod.rs', - patterns: [ - { - regex: /\bfn strip_workspace_path_prefix\b/, - message: 'core remote-connect bot facade must not own workspace path prefix stripping; use the integrations helper', - }, - { - regex: /\bfn is_absolute_workspace_path\b/, - message: 'core remote-connect bot facade must not own workspace path absolute detection; use the integrations helper', - }, - { - regex: /\bmatch ext\.as_str\(\)/, - message: 'core remote-connect bot facade must not own workspace file MIME mapping; use the integrations helper', - }, - { - regex: /\btokio::fs::read\(&abs_path\)/, - message: 'core remote-connect bot facade must not own workspace file reads; use the integrations helper', - }, - ], - }, - { - path: 'src/crates/core/src/service/announcement/state_store.rs', - patterns: [ - { - regex: /\btokio::fs\b/, - message: 'core announcement state store facade must not own filesystem persistence; use the integrations state store', - }, - { - regex: /\bserde_json::to_string_pretty\b/, - message: 'core announcement state store facade must not own state serialization; use the integrations state store', - }, - { - regex: /\bserde_json::from_str\b/, - message: 'core announcement state store facade must not own state deserialization; use the integrations state store', - }, - ], - }, -]; - -const forbiddenContentUnderRules = [ - { - path: 'src/crates/core/src', - reason: - 'core must use runtime-ports as the owner path for portable subagent contracts', - patterns: [ - { - regex: - /crate::agentic::subagent_runtime(?:::|\s*::|::\{)(?:[^;\n]*\b(?:DelegationPolicy|SubagentContextMode)\b)/, - message: - 'DelegationPolicy and SubagentContextMode must be imported from bitfun-runtime-ports, not the core compatibility re-export', - }, - ], - }, - { - path: 'src/crates/product-domains/src', - reason: - 'product-domains must not own IO/process/Git/AI/platform runtime behavior without an approved port/provider migration', - patterns: [ - { - regex: /\bCommand::new\(/, - allowPaths: ['src/crates/product-domains/src/miniapp/runtime.rs'], - message: - 'product-domains must not spawn processes outside the reviewed MiniApp runtime detector owner', - }, - { - regex: /\bprocess_manager::/, - message: - 'product-domains must not use the core process manager; keep process execution in core/adapters', - }, - { - regex: /\btokio::process::/, - message: 'product-domains must not own async process execution', - }, - { - regex: /\btokio::fs::/, - message: - 'product-domains must not own async storage IO; storage runtime remains in core/adapters', - }, - { - regex: /\bGitService::/, - message: 'product-domains must not call concrete Git services; use reviewed ports/adapters', - }, - { - regex: /\b(?:AIService|AiService)::/, - message: 'product-domains must not call concrete AI services; use reviewed ports/adapters', - }, - { - regex: /\breqwest::/, - message: 'product-domains must not own network clients', - }, - { - regex: /\bgit2::/, - message: 'product-domains must not own libgit2 runtime integration', - }, - { - regex: /\brmcp::/, - message: 'product-domains must not own MCP runtime integration', - }, - { - regex: /\btauri::/, - message: 'product-domains must not depend on Tauri platform APIs', - }, - { - regex: /\bAppHandle\b/, - message: 'product-domains must not own desktop platform handles', - }, - { - regex: /\bstd::net::/, - message: 'product-domains must not own network sockets', - }, - { - regex: /\b(?:TcpStream|UdpSocket)\b/, - message: 'product-domains must not own network sockets', - }, - ], - }, - { - path: 'src/crates/agent-tools/src', - reason: - 'agent-tools may own pure tool manifest contracts, but not product manifest runtime or GetToolSpec execution without an approved provider migration', - patterns: [ - { - regex: /\bGetToolSpecTool\b/, - message: 'GetToolSpec implementation stays in core product tool runtime', - }, - { - regex: /\bmanifest_resolver\b/, - message: 'tool manifest resolution stays in core product tool runtime', - }, - { - regex: /\bunlocked_collapsed_tools\b/, - message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', - }, - { - regex: /\bToolUseContext\b/, - message: 'ToolUseContext stays in core until a portable context port is reviewed', - }, - ], - }, - { - path: 'src/crates/tool-packs/src', - reason: - 'tool-packs may own provider group plans, but not product tool manifest/exposure or GetToolSpec runtime', - patterns: [ - { - regex: /\bGetToolSpecTool\b/, - message: 'GetToolSpec implementation stays in core product tool runtime', - }, - { - regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, - message: 'GetToolSpec manifest insertion stays in core product tool runtime', - }, - { - regex: /\bmanifest_resolver\b/, - message: 'tool manifest resolution stays in core product tool runtime', - }, - { - regex: /\bunlocked_collapsed_tools\b/, - message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', - }, - { - regex: /\bToolExposure\b/, - message: 'expanded/collapsed exposure policy stays in core until provider migration', - }, - ], - }, -]; - -const requiredContentRules = [ - { - path: 'src/crates/services-core/src/filesystem/mod.rs', - reason: - 'services-core filesystem owner must expose local filesystem primitives behind a single module boundary', - patterns: [ - { - regex: /mod error;/, - message: 'filesystem owner must expose its error boundary', - }, - { - regex: /mod operations;/, - message: 'filesystem owner must expose local file operation primitives', - }, - { - regex: /mod tree;/, - message: 'filesystem owner must expose local file tree/search primitives', - }, - { - regex: /pub use error::\{FileSystemError, FileSystemResult\};/, - message: 'filesystem owner must re-export the unified filesystem error type', - }, - { - regex: /pub use service::FileSystemService;/, - message: 'filesystem owner must keep the consolidated service facade', - }, - ], - }, - { - path: 'src/crates/core/src/service/filesystem/service.rs', - reason: - 'core filesystem service may keep remote-workspace overlay and BitFunError compatibility, but local filesystem owner must remain services-core', - patterns: [ - { - regex: /lookup_remote_connection_with_hint/, - message: 'core filesystem wrapper must preserve remote workspace connection disambiguation', - }, - { - regex: /get_remote_workspace_manager/, - message: 'core filesystem wrapper must preserve existing remote file service lookup', - }, - { - regex: /map_filesystem_error/, - message: 'core filesystem wrapper must map services-core errors at the compatibility boundary', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/session/session_manager.rs', - reason: - 'core session manager must keep forked Task prompt-cache and existing-context turn baselines until session branch ownership migrates', - patterns: [ - { - regex: /\bpub async fn clone_prompt_cache\b/, - message: 'missing prompt cache clone runtime entry point', - }, - { - regex: /\bpub async fn start_dialog_turn_with_existing_context\b/, - message: 'missing existing-context dialog turn entry point', - }, - { - regex: /\bstart_dialog_turn_with_existing_context_persists_turn_and_snapshot\b/, - message: 'missing existing-context dialog turn persistence regression', - }, - { - regex: /\bclone_prompt_cache_copies_runtime_and_persisted_entries\b/, - message: 'missing prompt cache clone runtime/disk regression', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', - reason: - 'core tool pipeline must keep latest-main truncation and per-tool denial behavior until tool runtime ownership migrates', - patterns: [ - { - regex: /\bfn build_truncation_recovery_notice\b/, - message: 'missing tool-call truncation recovery notice helper', - }, - { - regex: /\btruncation_notice_for_interactive_tools_does_not_claim_file_write\b/, - message: 'missing interactive-tool truncation recovery regression', - }, - { - regex: /\btruncation_notice_for_write_tools_keeps_write_continuation_guidance\b/, - message: 'missing write-tool truncation recovery regression', - }, - { - regex: /\bdenied_tool_messages\b/, - message: 'missing per-tool denial message propagation', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/restrictions.rs', - reason: - 'core tool restrictions facade must preserve per-tool denial messages while runtime restrictions live in agent-tools', - patterns: [ - { - regex: /\bdenied_tool_messages\b/, - message: 'missing per-tool denial message field propagation', - }, - { - regex: /\bcustom_deny_message_overrides_generic_runtime_error\b/, - message: 'missing custom deny message regression', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_result_storage.rs', - reason: - 'core tool-result storage must keep explicit file flush until runtime artifact ownership migrates', - patterns: [ - { - regex: /\basync fn write_once\b/, - message: 'missing single-write persistence helper', - }, - { - regex: /file\.flush\(\)\.await/, - message: 'missing explicit persisted tool-result flush', - }, - ], - }, - { - path: 'src/crates/services-integrations/src/mcp/server/connection.rs', - reason: - 'services-integrations MCP connection must keep initialize-scoped timeout and channel-close cleanup until MCP owner migration is reviewed', - patterns: [ - { - regex: /\bsend_request_with_id\b/, - message: 'missing stable local JSON-RPC request id path', - }, - { - regex: /\binitialize_timeout\b/, - message: 'missing initialize-scoped timeout', - }, - { - regex: /notifications\/initialized/, - message: 'missing MCP initialized notification', - }, - { - regex: /\bpending\.clear\(\)/, - message: 'missing pending request waiter drain on channel close', - }, - { - regex: /\blocal_tool_calls_do_not_inherit_initialize_timeout\b/, - message: 'missing local tool request timeout-scope regression', - }, - { - regex: /\blocal_initialize_uses_initialize_timeout\b/, - message: 'missing local initialize timeout regression', - }, - ], - }, - { - path: 'src/crates/services-integrations/src/mcp/protocol/transport.rs', - reason: - 'services-integrations MCP local transport must keep explicit request ids and stdin flush semantics', - patterns: [ - { - regex: /\bpub async fn send_request_with_id\b/, - message: 'missing explicit JSON-RPC request id send path', - }, - { - regex: /\.flush\(\)\s*\.await/, - message: 'missing local MCP stdin flush', - }, - ], - }, - { - path: 'src/crates/core/Cargo.toml', - reason: - 'bitfun-core product-full must explicitly aggregate owner crate feature groups instead of forcing them through dependency declarations', - patterns: [ - { - regex: - /bitfun-tool-packs = \{ path = "\.\.\/tool-packs", default-features = false, optional = true \}/, - message: 'bitfun-tool-packs dependency must stay optional and not force product-full outside the core feature graph', - }, - { - regex: - /bitfun-services-integrations = \{ path = "\.\.\/services-integrations", default-features = false, features = \["remote-ssh"\] \}/, - message: - 'bitfun-services-integrations dependency may keep remote workspace identity helpers but must not force product-full outside the core feature graph', - }, - { - regex: - /bitfun-product-domains = \{ path = "\.\.\/product-domains", default-features = false, optional = true \}/, - message: - 'bitfun-product-domains dependency must stay optional and not force product-full outside the core feature graph', - }, - { - regex: /"dep:bitfun-tool-packs"/, - message: 'core tool-packs feature must explicitly enable the optional dependency', - }, - { - regex: /"bitfun-tool-packs\/product-full"/, - message: 'core product-full must explicitly enable tool pack product features', - }, - { - regex: /"bitfun-services-integrations\/product-full"/, - message: 'core product-full must explicitly enable integration product features', - }, - { - regex: /"dep:bitfun-product-domains"/, - message: 'core product-domains feature must explicitly enable the optional dependency', - }, - { - regex: /"bitfun-product-domains\/product-full"/, - message: 'core product-full must explicitly enable product-domain features', - }, - ], - }, - { - path: 'src/crates/core/src/lib.rs', - reason: - 'no-default bitfun-core must keep product runtime surfaces behind explicit features', - patterns: [ - { - regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod agentic\b/s, - message: 'agentic runtime must stay behind product-full for no-default builds', - }, - { - regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod function_agents\b/s, - message: 'function-agent product domain facade must stay behind product-domains', - }, - { - regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod miniapp\b/s, - message: 'MiniApp product domain facade must stay behind product-domains', - }, - { - regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub\(crate\) mod service_agent_runtime\b/s, - message: 'service agent runtime owner assembly must stay behind service-integrations', - }, - ], - }, - { - path: 'src/crates/core/src/service/mod.rs', - reason: - 'service integration and agent-runtime surfaces must not compile in no-default core builds', - patterns: [ - { - regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod git\b/s, - message: 'git service facade must stay behind service-integrations', - }, - { - regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod mcp\b/s, - message: 'MCP service facade must stay behind service-integrations', - }, - { - regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod remote_connect\b/s, - message: 'remote-connect service facade must stay behind service-integrations', - }, - { - regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod review_platform\b/s, - message: 'review platform facade must stay behind service-integrations', - }, - { - regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod snapshot\b/s, - message: 'snapshot service must stay behind product-full until tool-runtime ownership is split', - }, - ], - }, - { - path: 'src/crates/core/src/service/config/mod.rs', - reason: - 'mode config canonicalization depends on product agent/tool registries and must stay out of no-default builds', - patterns: [ - { - regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod mode_config_canonicalizer\b/s, - message: 'mode config canonicalizer must stay behind product-full', - }, - ], - }, - { - path: 'src/crates/core/src/service/workspace/manager.rs', - reason: - 'workspace metadata may omit git worktree enrichment when service integrations are disabled', - patterns: [ - { - regex: /#\[cfg\(feature = "service-integrations"\)\]\s*use crate::service::git::GitService\b/s, - message: 'GitService import must stay gated for no-default builds', - }, - { - regex: /#\[cfg\(not\(feature = "service-integrations"\)\)\]\s*\{\s*let _ = workspace_root;\s*return None;\s*\}/s, - message: 'no-default worktree enrichment fallback must remain explicit', - }, - ], - }, - { - path: 'src/crates/core/src/service/workspace_runtime/service.rs', - reason: - 'workspace runtime binding helpers may depend on agentic runtime only in full product builds', - patterns: [ - { - regex: /#\[cfg\(feature = "product-full"\)\]\s*use crate::agentic::WorkspaceBinding\b/s, - message: 'WorkspaceBinding import must stay gated for no-default builds', - }, - { - regex: /#\[cfg\(feature = "product-full"\)\]\s*pub async fn ensure_runtime_for_workspace_binding\b/s, - message: 'WorkspaceBinding runtime helper must stay behind product-full', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_ssh/mod.rs', - reason: - 'core remote SSH runtime must keep concrete SSH dependencies behind the ssh-remote feature while preserving lightweight workspace identity helpers', - patterns: [ - { - regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod disabled\b/s, - message: 'missing disabled remote SSH runtime surface for no-default builds', - }, - { - regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod manager\b/s, - message: 'remote SSH manager must stay gated behind ssh-remote', - }, - { - regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_fs\b/s, - message: 'remote SSH filesystem runtime must stay gated behind ssh-remote', - }, - { - regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_terminal\b/s, - message: 'remote SSH terminal runtime must stay gated behind ssh-remote', - }, - { - regex: /\bpub mod workspace_state\b/, - message: 'remote workspace identity helpers must remain available without ssh-remote', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_ssh/disabled.rs', - reason: - 'no-default core builds must expose explicit unsupported remote SSH stubs instead of compiling russh-backed runtime code', - patterns: [ - { - regex: /Remote SSH support is disabled; enable the `ssh-remote` feature/, - message: 'missing explicit disabled remote SSH diagnostic', - }, - { - regex: /\bpub struct SSHConnectionManager\b/, - message: 'missing disabled SSH manager compatibility surface', - }, - { - regex: /\bpub struct RemoteFileService\b/, - message: 'missing disabled remote file compatibility surface', - }, - { - regex: /\bpub struct RemoteTerminalManager\b/, - message: 'missing disabled remote terminal compatibility surface', - }, - ], - }, - { - path: 'src/crates/runtime-ports/src/lib.rs', - reason: - 'runtime-ports must keep remote and subagent runtime boundary contracts DTO/trait-only', - patterns: [ - { - regex: /\bpub trait AgentTurnCancellationPort\b/, - message: 'missing turn cancellation port contract', - }, - { - regex: /\bpub trait RemoteControlStatePort\b/, - message: 'missing remote control state port contract', - }, - { - regex: /\bpub trait RuntimeEventSink\b/, - message: 'missing runtime event sink contract', - }, - { - regex: /\bpub fn remote_image\b/, - message: 'missing remote image attachment helper contract', - }, - { - regex: /\bpub type DialogTriggerSource = AgentSubmissionSource\b/, - message: 'missing dialog trigger source compatibility contract', - }, - { - regex: /\bdialog_trigger_source_reuses_agent_submission_source_contract\b/, - message: 'missing dialog trigger source alias regression', - }, - { - regex: /\bpub enum DialogQueuePriority\b/, - message: 'missing dialog queue priority contract', - }, - { - regex: /\bpub struct DialogSubmissionPolicy\b/, - message: 'missing dialog submission policy contract', - }, - { - regex: /\bdialog_submission_policy_preserves_current_surface_queue_defaults\b/, - message: 'missing dialog submission policy regression', - }, - { - regex: /\bpub enum DialogSubmitOutcome\b/, - message: 'missing dialog submit outcome contract', - }, - { - regex: /\bdialog_submit_outcome_preserves_started_and_queued_fields\b/, - message: 'missing dialog submit outcome regression', - }, - { - regex: /\bpub enum DialogSessionStateFact\b/, - message: 'missing dialog session state fact contract', - }, - { - regex: /\bpub struct DialogSubmitQueueFacts\b/, - message: 'missing dialog submit queue facts contract', - }, - { - regex: /\bpub enum DialogSubmitQueueAction\b/, - message: 'missing dialog submit queue action contract', - }, - { - regex: /\bpub const fn dialog_policy_may_preempt\b/, - message: 'missing dialog preempt policy contract', - }, - { - regex: /\bpub const fn resolve_dialog_submit_queue_action\b/, - message: 'missing dialog submit queue action resolver', - }, - { - regex: /\bdialog_submit_queue_action_preserves_current_scheduler_routing_policy\b/, - message: 'missing dialog submit queue action regression', - }, - { - regex: /\bpub fn should_suppress_agent_session_cancelled_reply\b/, - message: 'missing agent-session cancel suppression contract', - }, - { - regex: /\bpub enum DialogTurnOutcomeKind\b/, - message: 'missing dialog turn outcome kind contract', - }, - { - regex: /\bpub const fn should_skip_agent_session_reply\b/, - message: 'missing agent-session reply skip contract', - }, - { - regex: /\bagent_session_reply_decisions_preserve_cancel_suppression_boundary\b/, - message: 'missing agent-session reply decision regression', - }, - { - regex: /\bpub struct AgentSessionReplyRoute\b/, - message: 'missing agent session reply route contract', - }, - { - regex: /\bagent_session_reply_route_keeps_requester_fields\b/, - message: 'missing agent session reply route regression', - }, - { - regex: /\bpub enum DialogSteerOutcome\b/, - message: 'missing dialog steer outcome contract', - }, - { - regex: /\bdialog_steer_outcome_preserves_buffered_fields\b/, - message: 'missing dialog steer outcome regression', - }, - { - regex: /\bpub enum RoundInjectionKind\b/, - message: 'missing round injection kind contract', - }, - { - regex: /\bpub enum RoundInjectionTarget\b/, - message: 'missing round injection target contract', - }, - { - regex: /\bpub struct RoundInjection\b/, - message: 'missing round injection message contract', - }, - { - regex: /\bpub trait DialogRoundPreemptSource\b/, - message: 'missing dialog round preempt source contract', - }, - { - regex: /\bpub trait DialogRoundInjectionSource\b/, - message: 'missing dialog round injection source contract', - }, - { - regex: /\bround_injection_contract_keeps_kind_and_target_identity\b/, - message: 'missing round injection contract regression', - }, - { - regex: /\bround_injection_source_contract_drains_portable_injections\b/, - message: 'missing round injection source contract regression', - }, - { - regex: /\bpub struct GoalModeInitialGoal\b/, - message: 'missing goal mode initial goal contract', - }, - { - regex: /\bpub struct GoalModeState\b/, - message: 'missing goal mode state contract', - }, - { - regex: /\bpub struct GoalGenerationResult\b/, - message: 'missing goal generation result contract', - }, - { - regex: /\bpub struct GoalVerificationResult\b/, - message: 'missing goal verification result contract', - }, - { - regex: /\bpub struct GoalActivationResult\b/, - message: 'missing goal activation result contract', - }, - { - regex: /\bpub struct GoalContinuationPlan\b/, - message: 'missing goal continuation plan contract', - }, - { - regex: /\bgoal_mode_state_requires_active_non_empty_goal\b/, - message: 'missing goal mode state contract regression', - }, - { - regex: /\bgoal_verification_result_serializes_current_wire_shape\b/, - message: 'missing goal verification wire-shape regression', - }, - { - regex: /\bpub struct CompressionContract\b/, - message: 'missing compression contract', - }, - { - regex: /\bpub struct CompressionContractItem\b/, - message: 'missing compression contract item', - }, - { - regex: /\bcompression_contract_renders_model_visible_fields\b/, - message: 'missing compression contract rendering regression', - }, - { - regex: /\bpub struct RelatedPath\b/, - message: 'missing related path request-context contract', - }, - { - regex: /\brelated_path_serializes_as_request_context_fact\b/, - message: 'missing related path serialization regression', - }, - { - regex: /\bpub struct DelegationPolicy\b/, - message: 'missing delegation policy contract', - }, - { - regex: /\bpub enum SubagentContextMode\b/, - message: 'missing subagent context mode contract', - }, - { - regex: /\bdelegation_policy_child_blocks_recursive_spawn_without_losing_depth\b/, - message: 'missing delegation policy contract regression', - }, - { - regex: /\bsubagent_context_mode_preserves_fork_wire_value\b/, - message: 'missing subagent context mode contract regression', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', - reason: - 'core subagent runtime must preserve legacy import path while runtime-ports owns portable subagent contracts', - patterns: [ - { - regex: /pub\(crate\) use bitfun_runtime_ports::\{DelegationPolicy, SubagentContextMode\};/, - message: 'missing core compatibility re-export for subagent runtime contracts', - }, - { - regex: /pub\(crate\) mod queue_timing;/, - message: 'queue timing must remain core-owned until it has a reviewed non-DTO owner', - }, - ], - }, - { - path: 'src/crates/agent-tools/src/framework.rs', - reason: - 'agent-tools may own pure and generic prompt-visible tool contracts and provider-neutral execution gate policy without owning product registry or concrete execution', - patterns: [ - { - regex: /\bpub const GET_TOOL_SPEC_TOOL_NAME\b/, - message: 'missing shared GetToolSpec manifest name contract', - }, - { - regex: /\bpub enum ToolExposure\b/, - message: 'missing lightweight tool exposure contract', - }, - { - regex: /\bpub struct ToolManifestPolicyTool\b/, - message: 'missing pure tool manifest policy input contract', - }, - { - regex: /\bpub fn resolve_tool_manifest_policy\b/, - message: 'missing pure tool manifest policy resolver', - }, - { - regex: /\bfn default_exposure\b/, - message: 'missing generic tool exposure contract', - }, - { - regex: /\bpub fn build_tool_manifest_policy_tools\b/, - message: 'missing registry snapshot to manifest policy input helper', - }, - { - regex: /\bpub fn build_collapsed_tool_stub_definition\b/, - message: 'missing collapsed-tool prompt stub contract', - }, - { - regex: /\bpub enum PromptVisibleToolManifestItem\b/, - message: 'missing prompt-visible manifest item contract', - }, - { - regex: /\bpub fn build_prompt_visible_tool_manifest_definitions\b/, - message: 'missing prompt-visible manifest definition builder', - }, - { - regex: /\bpub trait ContextualToolManifestItem\b/, - message: 'missing generic contextual manifest item adapter contract', - }, - { - regex: /\bpub trait ToolCatalogSnapshotProvider\b/, - message: 'missing generic tool catalog snapshot provider contract', - }, - { - regex: /\bpub trait GetToolSpecCatalogProvider\b/, - message: 'missing generic GetToolSpec catalog provider contract', - }, - { - regex: /\bpub struct ContextualVisibleTools\b/, - message: 'missing generic contextual visible-tools result contract', - }, - { - regex: /\bpub struct ContextualToolManifest\b/, - message: 'missing generic contextual tool manifest result contract', - }, - { - regex: /\bpub async fn resolve_contextual_visible_tools\b/, - message: 'missing generic contextual visible-tools resolver', - }, - { - regex: /\bpub async fn resolve_contextual_tool_manifest\b/, - message: 'missing generic contextual tool manifest resolver', - }, - { - regex: /\bpub async fn resolve_contextual_visible_tools_from_provider\b/, - message: 'missing provider-backed contextual visible-tools resolver', - }, - { - regex: /\bpub async fn resolve_contextual_tool_manifest_from_provider\b/, - message: 'missing provider-backed contextual manifest resolver', - }, - { - regex: /\bpub async fn build_get_tool_spec_catalog_description_from_provider\b/, - message: 'missing provider-backed GetToolSpec catalog description builder', - }, - { - regex: /\bpub async fn resolve_get_tool_spec_detail_from_provider\b/, - message: 'missing provider-backed GetToolSpec detail resolver', - }, - { - regex: /\bpub fn build_get_tool_spec_description\b/, - message: 'missing pure GetToolSpec prompt description contract', - }, - { - regex: /\bpub struct GetToolSpecCollapsedToolSummary\b/, - message: 'missing pure GetToolSpec collapsed catalog summary', - }, - { - regex: /\bpub struct GetToolSpecDetail\b/, - message: 'missing pure GetToolSpec detail contract', - }, - { - regex: /\bpub fn summarize_get_tool_spec_collapsed_tools\b/, - message: 'missing pure GetToolSpec collapsed summary helper', - }, - { - regex: /\bpub async fn resolve_get_tool_spec_detail\b/, - message: 'missing generic GetToolSpec detail resolver', - }, - { - regex: /\bpub fn build_get_tool_spec_catalog_description\b/, - message: 'missing pure GetToolSpec catalog description builder', - }, - { - regex: /\bpub fn get_tool_spec_input_schema\b/, - message: 'missing pure GetToolSpec input schema contract', - }, - { - regex: /\bpub fn get_tool_spec_short_description\b/, - message: 'missing pure GetToolSpec short description contract', - }, - { - regex: /\bpub fn render_get_tool_spec_tool_use_message\b/, - message: 'missing pure GetToolSpec tool-use message renderer', - }, - { - regex: /\bpub fn get_tool_spec_is_readonly\b/, - message: 'missing pure GetToolSpec readonly metadata contract', - }, - { - regex: /\bpub fn get_tool_spec_is_concurrency_safe\b/, - message: 'missing pure GetToolSpec concurrency metadata contract', - }, - { - regex: /\bpub fn get_tool_spec_needs_permissions\b/, - message: 'missing pure GetToolSpec permission metadata contract', - }, - { - regex: /\bpub fn validate_get_tool_spec_input\b/, - message: 'missing pure GetToolSpec input validation contract', - }, - { - regex: /\bpub fn build_get_tool_spec_assistant_detail\b/, - message: 'missing pure GetToolSpec assistant detail rendering contract', - }, - { - regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, - message: 'missing pure GetToolSpec duplicate-load result assembly contract', - }, - { - regex: /\bpub fn build_get_tool_spec_detail_result\b/, - message: 'missing pure GetToolSpec detail result assembly contract', - }, - { - regex: /\bpub enum GetToolSpecExecutionPlan\b/, - message: 'missing pure GetToolSpec execution plan contract', - }, - { - regex: /\bpub enum GetToolSpecExecutionError\b/, - message: 'missing pure GetToolSpec execution error contract', - }, - { - regex: /\bpub fn resolve_get_tool_spec_execution_plan\b/, - message: 'missing pure GetToolSpec execution plan resolver', - }, - { - regex: /\bpub async fn resolve_get_tool_spec_execution_result_from_provider\b/, - message: 'missing provider-backed GetToolSpec execution result resolver', - }, - { - regex: /\bpub struct GetToolSpecRuntime\b/, - message: 'missing provider-backed GetToolSpec runtime facade', - }, - { - regex: /\bpub async fn call_results\b/, - message: 'missing provider-backed GetToolSpec Tool-result vector adapter facade', - }, - { - regex: /\bpub struct GetToolSpecLoadObservation\b/, - message: 'missing pure GetToolSpec load observation contract', - }, - { - regex: /\bpub fn collect_loaded_collapsed_tool_names\b/, - message: 'missing pure collapsed-tool load collection contract', - }, - { - regex: /\bpub enum CollapsedToolUsageError\b/, - message: 'missing collapsed-tool execution gate error contract', - }, - { - regex: /\bpub enum ToolExecutionAccessError\b/, - message: 'missing tool execution allowed-list gate error contract', - }, - { - regex: /\bpub fn validate_tool_allowed_by_list\b/, - message: 'missing tool execution allowed-list gate policy', - }, - { - regex: /\bpub fn validate_collapsed_tool_usage\b/, - message: 'missing collapsed-tool execution gate policy', - }, - { - regex: /\bpub fn is_tool_path_allowed_by_resolved_roots\b/, - message: 'missing provider-neutral path policy root matcher', - }, - { - regex: /\bpub fn build_tool_path_policy_denial_message\b/, - message: 'missing provider-neutral path policy denial message', - }, - { - regex: /\bpub fn resolve_tool_path_with_context\b/, - message: 'missing provider-neutral tool path resolution owner', - }, - { - regex: /\bpub fn tool_path_is_effectively_absolute\b/, - message: 'missing provider-neutral tool path absolute check', - }, - { - regex: /\bpub fn build_tool_runtime_artifact_reference\b/, - message: 'missing provider-neutral runtime artifact reference builder', - }, - { - regex: /\bpub fn build_tool_session_runtime_artifact_reference\b/, - message: 'missing provider-neutral session runtime artifact reference builder', - }, - { - regex: /\bpub fn sort_tool_manifest_definitions\b/, - message: 'missing prompt-visible manifest ordering helper', - }, - { - regex: /\bpub struct StaticToolProviderGroup\b/, - message: 'missing generic static provider group container', - }, - { - regex: /\bpub struct ToolRuntimeAssembly\b/, - message: 'missing generic tool runtime assembly owner', - }, - { - regex: /\bpub type ToolDecoratorRef\b/, - message: 'missing generic tool decorator reference contract', - }, - { - regex: /\bpub trait SnapshotToolWrapper\b/, - message: 'missing generic snapshot wrapper port', - }, - { - regex: /\bpub struct SnapshotToolDecorator\b/, - message: 'missing generic snapshot decorator adapter', - }, - { - regex: /\bcreate_registry_from_static_providers\b/, - message: 'missing generic static-provider runtime assembly helper', - }, - { - regex: /\bpub fn is_tool_collapsed\b/, - message: 'missing generic collapsed-tool registry query', - }, - { - regex: /\bpub fn get_collapsed_tool_names\b/, - message: 'missing generic collapsed-tool registry catalog query', - }, - { - regex: /\bpub async fn resolve_readonly_enabled_tools\b/, - message: 'missing generic readonly enabled tool filter', - }, - { - regex: /\bpub struct ToolCatalogRuntime\b/, - message: 'missing provider-backed tool catalog runtime facade', - }, - ], - }, - { - path: 'src/crates/agent-tools/src/file_guidance.rs', - reason: 'agent-tools owns provider-neutral file tool guidance marker contracts', - patterns: [ - { - regex: /\bpub const FILE_TOOL_GUIDANCE_PREFIX\b/, - message: 'missing file tool guidance marker prefix', - }, - { - regex: /\bpub fn file_tool_guidance_message\b/, - message: 'missing file tool guidance message helper', - }, - { - regex: /\bpub fn is_file_tool_guidance_message\b/, - message: 'missing file tool guidance classifier', - }, - ], - }, - { - path: 'src/crates/agent-tools/src/file_read_freshness.rs', - reason: 'agent-tools owns pure file-read freshness policy for Read/Edit/Write guardrails', - patterns: [ - { - regex: /\bpub struct FileReadFreshnessFacts\b/, - message: 'missing file-read freshness facts contract', - }, - { - regex: /\bpub fn normalize_tool_file_content\b/, - message: 'missing provider-neutral file content normalization helper', - }, - { - regex: /\bpub fn file_read_facts_content_matches\b/, - message: 'missing file-read content equivalence helper', - }, - { - regex: /\bpub fn file_read_facts_are_fresh\b/, - message: 'missing file-read freshness policy helper', - }, - ], - }, - { - path: 'src/crates/agent-tools/src/tool_result_storage.rs', - reason: - 'agent-tools owns pure oversized tool-result storage policy and rendering without session IO', - patterns: [ - { - regex: /\bpub struct ToolResultStoragePolicy\b/, - message: 'missing provider-neutral tool result storage policy', - }, - { - regex: /\bpub struct PersistedToolOutput\b/, - message: 'missing persisted tool output render contract', - }, - { - regex: /\bpub struct ToolResultPersistenceCandidate\b/, - message: 'missing provider-neutral persistence candidate contract', - }, - { - regex: /\bpub fn select_tool_result_indices_for_persistence\b/, - message: 'missing round-budget persistence candidate selector', - }, - { - regex: /\bpub fn sanitize_tool_result_file_component\b/, - message: 'missing tool-result file component sanitizer', - }, - { - regex: /\bpub fn generate_tool_result_preview\b/, - message: 'missing tool-result preview generator', - }, - { - regex: /\bpub fn count_tool_result_lines\b/, - message: 'missing tool-result line counter', - }, - { - regex: /\bpub fn build_persisted_tool_output_message\b/, - message: 'missing persisted-output message renderer', - }, - { - regex: /\bpub fn tool_result_is_persisted_output\b/, - message: 'missing persisted-output classifier', - }, - ], - }, - { - path: 'src/crates/agent-tools/src/tool_execution_presentation.rs', - reason: - 'agent-tools owns provider-neutral tool execution result and error presentation helpers', - patterns: [ - { - regex: /\bpub const TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES\b/, - message: 'missing tool error argument preview limit contract', - }, - { - regex: /\bpub const USER_STEERING_INTERRUPTED_MESSAGE\b/, - message: 'missing steering-interrupted assistant message contract', - }, - { - regex: /\bpub struct ToolExecutionErrorPresentation\b/, - message: 'missing tool execution error presentation DTO', - }, - { - regex: /\bpub fn render_tool_result_for_assistant\b/, - message: 'missing tool result assistant rendering helper', - }, - { - regex: /\bpub fn truncate_tool_arguments_preview\b/, - message: 'missing structured tool argument preview helper', - }, - { - regex: /\bpub fn truncate_raw_tool_arguments_preview\b/, - message: 'missing raw tool argument preview helper', - }, - { - regex: /\bpub fn build_tool_execution_error_presentation\b/, - message: 'missing tool execution error presentation helper', - }, - { - regex: /\bpub fn build_user_steering_interrupted_presentation\b/, - message: 'missing steering-interrupted presentation helper', - }, - { - regex: /\bpub fn build_invalid_tool_call_error_message\b/, - message: 'missing invalid tool call error message helper', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/coordinator.rs', - reason: - 'core must keep current coordinator port adapters and attachment guard until remote runtime migration is reviewed', - patterns: [ - { - regex: /impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator/, - message: 'missing agent submission port adapter', - }, - { - regex: /impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator/, - message: 'missing session transcript reader adapter', - }, - { - regex: /impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator/, - message: 'missing turn cancellation port adapter', - }, - { - regex: /impl bitfun_runtime_ports::RemoteControlStatePort for ConversationCoordinator/, - message: 'missing remote control state port adapter', - }, - { - regex: /agent submission port does not yet accept generic attachments/, - message: 'missing generic attachment guard on agent submission port', - }, - { - regex: /pub use bitfun_runtime_ports::DialogTriggerSource;/, - message: 'missing dialog trigger source compatibility re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/scheduler.rs', - reason: - 'core scheduler must preserve legacy submission policy import path while runtime-ports owns portable dialog policy contracts', - patterns: [ - { - regex: - /pub use bitfun_runtime_ports::\{[\s\S]*AgentSessionReplyRoute[\s\S]*DialogQueuePriority[\s\S]*DialogSteerOutcome[\s\S]*DialogSubmissionPolicy[\s\S]*DialogSubmitOutcome[\s\S]*\};/, - message: 'missing dialog submission policy compatibility re-export', - }, - { - regex: - /use bitfun_runtime_ports::\{(?=[\s\S]*DialogSessionStateFact)(?=[\s\S]*DialogSubmitQueueAction)(?=[\s\S]*DialogSubmitQueueFacts)(?=[\s\S]*DialogTurnOutcomeKind)(?=[\s\S]*resolve_dialog_submit_queue_action)(?=[\s\S]*should_skip_agent_session_reply_contract)(?=[\s\S]*should_suppress_agent_session_cancelled_reply_contract)[\s\S]*\};/, - message: 'missing dialog scheduler decision contract import', - }, - { - regex: - /use bitfun_agent_runtime::scheduler::\{(?=[\s\S]*BackgroundDeliveryAction)(?=[\s\S]*BackgroundDeliveryFacts)(?=[\s\S]*resolve_background_delivery_action)[\s\S]*\};/, - message: 'missing agent-runtime background delivery decision import', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/round_preempt.rs', - reason: - 'core round preempt runtime must preserve legacy injection import path while runtime-ports owns portable injection contracts', - patterns: [ - { - regex: - /pub use bitfun_runtime_ports::\{[\s\S]*DialogRoundInjectionSource[\s\S]*DialogRoundPreemptSource[\s\S]*RoundInjection[\s\S]*RoundInjectionKind[\s\S]*RoundInjectionTarget[\s\S]*\};/, - message: 'missing round injection compatibility re-export', - }, - { - regex: /\bpub struct SessionRoundInjectionBuffer\b/, - message: 'round injection buffer must remain core-owned until concrete runtime migration', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/goal_mode/types.rs', - reason: - 'core goal mode types must preserve legacy import path while runtime-ports owns portable goal contracts', - patterns: [ - { - regex: - /pub use bitfun_runtime_ports::\{[\s\S]*GoalActivationResult[\s\S]*GoalContinuationPlan[\s\S]*GoalGenerationResult[\s\S]*GoalModeInitialGoal[\s\S]*GoalModeState[\s\S]*GoalVerificationResult[\s\S]*GOAL_MODE_METADATA_KEY[\s\S]*MAX_CONTEXT_SUMMARY_CHARS[\s\S]*MAX_GOAL_CONTINUATIONS[\s\S]*\};/, - message: 'missing goal mode compatibility re-export', - }, - { - regex: /\bpub const GOAL_MODE_FUNC_AGENT\b/, - message: 'goal mode function-agent marker must remain core-owned', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/core/message.rs', - reason: - 'core message model must preserve legacy compression contract import path while runtime-ports owns portable compaction facts', - patterns: [ - { - regex: /pub use bitfun_runtime_ports::\{CompressionContract, CompressionContractItem\};/, - message: 'missing compression contract compatibility re-export', - }, - ], - }, - { - path: 'src/crates/core/src/service/workspace/manager.rs', - reason: - 'core workspace manager must preserve legacy related-path import path while runtime-ports owns portable request-context facts', - patterns: [ - { - regex: /pub use bitfun_runtime_ports::RelatedPath;/, - message: 'missing related path compatibility re-export', - }, - ], - }, - { - path: 'src/crates/core/src/service_agent_runtime.rs', - reason: - 'core service/agent runtime owner must centralize concrete remote-connect and agent runtime port bindings without moving runtime behavior', - patterns: [ - { - regex: /\bpub\(crate\) struct CoreServiceAgentRuntime\b/, - message: 'missing core service/agent runtime owner type', - }, - { - regex: /\bfn remote_dialog_host\b/, - message: 'missing remote dialog host owner factory', - }, - { - regex: /\bfn remote_cancel_host\b/, - message: 'missing remote cancel host owner factory', - }, - { - regex: /\bfn remote_image_context\b/, - message: 'missing remote image context owner adapter', - }, - { - regex: /\bfn load_remote_model_catalog\b/, - message: 'missing remote model catalog owner adapter', - }, - { - regex: /\bRemoteModelCatalogFacts\b/, - message: 'missing remote model catalog fact projection', - }, - { - regex: /\bRemoteModelCapabilityFact\b/, - message: 'missing remote model capability fact projection', - }, - { - regex: /\bRemoteReasoningModeFact\b/, - message: 'missing remote reasoning mode fact projection', - }, - { - regex: /\bbuild_remote_model_catalog\b/, - message: 'missing remote model catalog assembly delegation', - }, - { - regex: /\bfn update_remote_session_model\b/, - message: 'missing remote session model update owner adapter', - }, - { - regex: /\bfn normalize_remote_session_model_id\b/, - message: 'missing remote session model id normalization regression hook', - }, - { - regex: /\bnormalize_remote_session_model_id_contract\b/, - message: 'missing remote session model id owner delegation', - }, - { - regex: /\bfn normalize_remote_model_selection\b/, - message: 'missing remote model selection normalization regression hook', - }, - { - regex: /\bnormalize_remote_model_selection_contract\b/, - message: 'missing remote model selection owner delegation', - }, - { - regex: /\bfn remote_chat_messages_from_turns\b/, - message: 'missing remote chat history conversion owner adapter', - }, - { - regex: /\bRemoteDialogSchedulerOutcomeFact\b/, - message: 'missing remote dialog scheduler outcome fact projection', - }, - { - regex: /\bremote_dialog_submit_outcome_from_scheduler\b/, - message: 'missing remote dialog submit outcome assembly delegation', - }, - { - regex: /\bRemoteChatHistoryTurn\b/, - message: 'missing remote chat history owner DTO projection', - }, - { - regex: /\bbuild_remote_chat_messages\b/, - message: 'missing remote chat history assembly delegation', - }, - { - regex: /\bfn strip_remote_user_input_tags\b/, - message: 'missing remote user input display cleanup owner adapter', - }, - { - regex: /\bfn compress_remote_chat_data_url_for_mobile\b/, - message: 'missing remote chat thumbnail compression owner adapter', - }, - { - regex: /\bfn load_remote_chat_messages\b/, - message: 'missing remote chat history persistence owner adapter', - }, - { - regex: /\bfn agent_submission_port\b/, - message: 'missing agent submission port owner binding', - }, - { - regex: /\bfn agent_turn_cancellation_port\b/, - message: 'missing agent turn cancellation port owner binding', - }, - { - regex: /\bfn remote_control_state_port\b/, - message: 'missing remote control state port owner binding', - }, - { - regex: /\bCoreRemoteDialogRuntimeHost\b/, - message: 'missing core remote dialog host binding', - }, - { - regex: /\bCoreRemoteCancelRuntimeHost\b/, - message: 'missing core remote cancel host binding', - }, - { - regex: /\bCoreRemoteWorkspaceFileRuntimeHost\b/, - message: 'missing core remote workspace file host binding', - }, - { - regex: /\bCoreRemoteSessionTrackerHost\b/, - message: 'missing core remote session tracker host binding', - }, - { - regex: /\bRemoteExecutionDispatcher\b/, - message: 'missing remote execution dispatcher binding', - }, - { - regex: /\bimpl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost\b/, - message: 'missing remote dialog host adapter implementation in runtime owner', - }, - { - regex: /\bimpl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost\b/, - message: 'missing remote cancel host adapter implementation in runtime owner', - }, - { - regex: /\bimpl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost\b/, - message: 'missing remote workspace file host adapter implementation in runtime owner', - }, - { - regex: /\bimpl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost\b/, - message: 'missing remote tracker host adapter implementation in runtime owner', - }, - { - regex: /\bImageContextData\b/, - message: 'missing core image context binding', - }, - { - regex: /\bRemoteImageContextAdapter\b/, - message: 'missing remote image context adapter implementation', - }, - { - regex: /\bAgentSubmissionPort\b/, - message: 'missing agent submission port binding', - }, - { - regex: /\bAgentTurnCancellationPort\b/, - message: 'missing agent turn cancellation port contract guard', - }, - { - regex: /\bRemoteControlStatePort\b/, - message: 'missing remote control state port contract guard', - }, - { - regex: /\bSessionTranscriptReader\b/, - message: 'missing session transcript reader contract guard', - }, - { - regex: /\bcore_service_agent_runtime_owner_keeps_coordinator_port_contracts\b/, - message: 'missing coordinator runtime port contract regression', - }, - { - regex: /\bcore_service_agent_runtime_owner_normalizes_remote_session_model_ids\b/, - message: 'missing remote session model id normalization regression', - }, - { - regex: /\bcore_service_agent_runtime_owner_normalizes_remote_model_selection_aliases\b/, - message: 'missing remote model selection alias regression', - }, - { - regex: /\bcore_service_agent_runtime_owner_preserves_remote_chat_history_shape\b/, - message: 'missing remote chat history conversion regression', - }, - { - regex: /\bcore_service_agent_runtime_owner_skips_in_progress_remote_assistant_history\b/, - message: 'missing in-progress remote assistant history regression', - }, - ], - }, - { - path: 'src/crates/services-integrations/src/remote_connect.rs', - reason: - 'services-integrations must own remote-connect wire/response assembly and preserve remote owner compatibility re-exports', - patterns: [ - { - regex: /\bpub struct RemoteSessionStateTracker\b/, - message: 'missing remote session state tracker owner', - }, - { - regex: /\bpub enum TrackerEvent\b/, - message: 'missing remote tracker event owner', - }, - { - regex: /\bpub trait RemoteSessionTrackerHost\b/, - message: 'missing remote tracker host port', - }, - { - regex: /\bpub struct RemoteSessionTrackerRegistry\b/, - message: 'missing remote tracker registry owner', - }, - { - regex: /\bpub fn make_slim_tool_params\b/, - message: 'missing remote tool preview slimming helper', - }, - { - regex: /\bfn handle_agentic_event\b/, - message: 'missing tracker event reducer', - }, - { - regex: /\bpub fn resolve_remote_agent_type\b/, - message: 'missing remote agent type helper', - }, - { - regex: /\bpub struct RemoteImageContext\b/, - message: 'missing portable remote image context contract', - }, - { - regex: /\bpub trait RemoteImageContextAdapter\b/, - message: 'missing remote image context adapter contract', - }, - { - regex: /\bpub fn build_remote_image_contexts\b/, - message: 'missing legacy remote image context builder', - }, - { - regex: /\bpub fn resolve_remote_execution_image_contexts\b/, - message: 'missing remote image context preference helper', - }, - { - regex: /\bpub fn remote_session_restore_target\b/, - message: 'missing remote restore-target helper', - }, - { - regex: /\bpub enum RemoteCancelDecision\b/, - message: 'missing remote cancel decision contract', - }, - { - regex: /\bpub fn resolve_remote_cancel_decision\b/, - message: 'missing remote cancel decision resolver', - }, - { - regex: /\bpub struct RemoteCancelTaskRequest\b/, - message: 'missing remote cancel task request contract', - }, - { - regex: /\bpub trait RemoteCancelRuntimeHost\b/, - message: 'missing remote cancel runtime host port', - }, - { - regex: /\bpub async fn cancel_remote_task\b/, - message: 'missing remote cancel orchestration owner', - }, - { - regex: /\bpub trait RemoteDialogRuntimeHost\b/, - message: 'missing remote dialog runtime host port', - }, - { - regex: /\bpub async fn submit_remote_dialog\b/, - message: 'missing remote dialog orchestration owner', - }, - { - regex: /\bpub struct RemoteChatHistoryTurn\b/, - message: 'missing remote chat history turn DTO', - }, - { - regex: /\bpub struct RemoteChatHistoryRound\b/, - message: 'missing remote chat history round DTO', - }, - { - regex: /\bpub struct RemoteChatHistoryToolItem\b/, - message: 'missing remote chat history tool item DTO', - }, - { - regex: /\bpub fn build_remote_chat_messages\b/, - message: 'missing remote chat history assembly owner', - }, - { - regex: /\bpub const REMOTE_FILE_MAX_READ_BYTES\b/, - message: 'missing remote file max-read policy', - }, - { - regex: /\bpub const REMOTE_FILE_MAX_CHUNK_BYTES\b/, - message: 'missing remote file chunk policy', - }, - { - regex: /\bpub fn resolve_remote_file_chunk_range\b/, - message: 'missing remote file chunk range helper', - }, - { - regex: /\bpub fn remote_file_display_name\b/, - message: 'missing remote file display-name fallback', - }, - { - regex: /\bpub fn resolve_remote_workspace_path\b/, - message: 'missing remote workspace path resolver', - }, - { - regex: /\bpub fn detect_remote_mime_type\b/, - message: 'missing remote MIME detector', - }, - { - regex: /\bpub async fn read_remote_workspace_file\b/, - message: 'missing remote workspace full-file reader', - }, - { - regex: /\bpub async fn read_remote_workspace_file_chunk\b/, - message: 'missing remote workspace chunk reader', - }, - { - regex: /\bpub async fn read_remote_workspace_file_info\b/, - message: 'missing remote workspace file-info reader', - }, - { - regex: /\bRemoteWorkspaceFileRuntimeHost\b/, - message: 'missing remote workspace file runtime host contract', - }, - { - regex: /\bpub async fn handle_remote_workspace_file_command\b/, - message: 'missing remote workspace file command owner handler', - }, - { - regex: /\bpub fn remote_file_content_response\b/, - message: 'missing remote file content response assembly helper', - }, - { - regex: /\bpub fn remote_file_chunk_response\b/, - message: 'missing remote file chunk response assembly helper', - }, - { - regex: /\bpub fn remote_file_info_response\b/, - message: 'missing remote file-info response assembly helper', - }, - { - regex: /\bpub fn remote_dialog_submit_response\b/, - message: 'missing remote dialog response assembly helper', - }, - { - regex: /\bpub fn remote_task_cancel_response\b/, - message: 'missing remote task cancel response assembly helper', - }, - { - regex: /\bpub fn remote_interaction_accepted_response\b/, - message: 'missing remote interaction response assembly helper', - }, - { - regex: /\bpub fn remote_answer_question_response\b/, - message: 'missing remote answer response assembly helper', - }, - { - regex: /\bRemoteWorkspaceFacts\b/, - message: 'missing remote workspace response facts DTO', - }, - { - regex: /\bRemoteSessionMetadata\b/, - message: 'missing remote session response metadata DTO', - }, - { - regex: /\bpub fn remote_workspace_info_response\b/, - message: 'missing remote workspace-info response assembly helper', - }, - { - regex: /\bpub fn remote_recent_workspaces_response\b/, - message: 'missing remote recent-workspaces response assembly helper', - }, - { - regex: /\bpub fn remote_assistant_list_response\b/, - message: 'missing remote assistant-list response assembly helper', - }, - { - regex: /\bpub fn remote_session_info\b/, - message: 'missing remote session response facts helper', - }, - { - regex: /\bpub fn remote_session_list_response\b/, - message: 'missing remote session-list response assembly helper', - }, - { - regex: /\bpub fn remote_initial_sync_response\b/, - message: 'missing remote initial-sync response assembly helper', - }, - { - regex: /\bpub fn remote_messages_response\b/, - message: 'missing remote messages response assembly helper', - }, - { - regex: /\bpub struct RemoteDefaultModelsConfig\b/, - message: 'missing remote model default DTO', - }, - { - regex: /\bpub struct RemoteModelConfig\b/, - message: 'missing remote model DTO', - }, - { - regex: /\bpub struct RemoteModelCatalog\b/, - message: 'missing remote model catalog DTO', - }, - { - regex: /\bpub enum RemoteModelCapabilityFact\b/, - message: 'missing remote model capability owner fact', - }, - { - regex: /\bpub enum RemoteReasoningModeFact\b/, - message: 'missing remote reasoning mode owner fact', - }, - { - regex: /\bpub struct RemoteModelFacts\b/, - message: 'missing remote model owner facts', - }, - { - regex: /\bpub struct RemoteModelCatalogFacts\b/, - message: 'missing remote model catalog owner facts', - }, - { - regex: /\bpub fn build_remote_model_catalog\b/, - message: 'missing remote model catalog assembly owner', - }, - { - regex: /\bpub struct RemoteModelCatalogPollDelta\b/, - message: 'missing remote model catalog poll delta', - }, - { - regex: /\bpub fn normalize_remote_session_model_id\b/, - message: 'missing remote session model normalization policy', - }, - { - regex: /\bpub fn normalize_remote_model_selection\b/, - message: 'missing remote model selection policy', - }, - { - regex: /\bpub fn remote_model_selection_needs_config\b/, - message: 'missing remote model selection config-gate policy', - }, - { - regex: /\bpub enum RemoteCommand\b/, - message: 'missing remote command wire contract', - }, - { - regex: /\bpub enum RemoteDialogSchedulerOutcomeFact\b/, - message: 'missing remote dialog scheduler outcome fact', - }, - { - regex: /\bpub fn remote_dialog_submit_outcome_from_scheduler\b/, - message: 'missing remote dialog submit outcome assembly owner', - }, - { - regex: /\bpub enum RemoteResponse\b/, - message: 'missing remote response wire contract', - }, - { - regex: /\bpub fn should_send_remote_model_catalog\b/, - message: 'missing remote model catalog poll policy', - }, - { - regex: /\bpub fn remote_model_catalog_poll_delta\b/, - message: 'missing remote model catalog poll delta helper', - }, - { - regex: /\bpub fn remote_no_change_poll_response\b/, - message: 'missing remote no-change poll response helper', - }, - { - regex: /\bpub fn remote_snapshot_poll_response\b/, - message: 'missing remote snapshot poll response helper', - }, - { - regex: /\bpub fn remote_persisted_poll_response\b/, - message: 'missing remote persisted poll response helper', - }, - ], - }, - { - path: 'src/crates/services-integrations/tests/remote_connect_contracts.rs', - reason: 'remote-connect owner crate must keep focused behavior contracts', - patterns: [ - { - regex: /\bremote_connect_command_wire_shape_lives_in_owner_contract\b/, - message: 'missing remote command wire contract test', - }, - { - regex: /\bremote_connect_response_wire_shape_lives_in_owner_contract\b/, - message: 'missing remote response wire contract test', - }, - { - regex: /\bremote_connect_model_catalog_delta_preserves_poll_invalidation_policy\b/, - message: 'missing remote model catalog delta contract test', - }, - { - regex: /\bremote_connect_model_catalog_builder_preserves_config_shape\b/, - message: 'missing remote model catalog builder contract test', - }, - { - regex: /\bremote_connect_model_selection_policy_owns_alias_and_config_reference_rules\b/, - message: 'missing remote model selection policy contract test', - }, - { - regex: /\bremote_connect_poll_helpers_preserve_delta_and_completion_policy\b/, - message: 'missing remote poll helper contract test', - }, - { - regex: /\bremote_connect_image_context_policy_preserves_legacy_fallback_shape\b/, - message: 'missing legacy image context fallback test', - }, - { - regex: /\bremote_connect_image_context_policy_prefers_explicit_contexts\b/, - message: 'missing explicit image context preference test', - }, - { - regex: /\bremote_connect_image_context_adapter_owns_portable_conversion_shape\b/, - message: 'missing image context adapter contract test', - }, - { - regex: /\bremote_connect_cancel_and_restore_policy_preserve_runtime_decisions\b/, - message: 'missing cancel/restore policy test', - }, - { - regex: /\bremote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order\b/, - message: 'missing dialog runtime order test', - }, - { - regex: /\bremote_connect_dialog_runtime_preserves_explicit_turn_without_restore\b/, - message: 'missing dialog explicit-turn test', - }, - { - regex: /\bremote_connect_dialog_submit_outcome_builder_preserves_scheduler_shape\b/, - message: 'missing remote dialog outcome builder contract test', - }, - { - regex: /\bremote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance\b/, - message: 'missing restore failure tolerance test', - }, - { - regex: /\bremote_chat_history_assembly_preserves_message_shape_and_item_order\b/, - message: 'missing remote chat history assembly shape/order test', - }, - { - regex: /\bremote_chat_history_assembly_skips_in_progress_assistant_history\b/, - message: 'missing remote chat history in-progress guard test', - }, - { - regex: /\bremote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges\b/, - message: 'missing remote file transfer policy test', - }, - { - regex: /\bremote_connect_file_transfer_policy_preserves_name_fallback\b/, - message: 'missing remote file display-name test', - }, - { - regex: /\bremote_connect_file_path_resolution_stays_within_workspace_root\b/, - message: 'missing remote file path resolution test', - }, - { - regex: /\bremote_connect_file_read_helpers_preserve_current_wire_inputs\b/, - message: 'missing remote full-read helper test', - }, - { - regex: /\bremote_connect_file_chunk_and_info_helpers_preserve_response_facts\b/, - message: 'missing remote chunk/info helper test', - }, - { - regex: /\bremote_connect_file_response_assembly_owns_base64_wire_shape\b/, - message: 'missing remote file response assembly contract test', - }, - { - regex: /\bremote_connect_file_command_handler_owns_owner_flow_and_uses_host_root\b/, - message: 'missing remote file command handler owner-flow test', - }, - { - regex: /\bremote_connect_execution_response_helpers_preserve_wire_shape\b/, - message: 'missing remote execution response helper contract test', - }, - { - regex: /\bremote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes\b/, - message: 'missing tracker completion contract test', - }, - { - regex: /\bremote_connect_tracker_registry_owns_lifecycle_without_core_state\b/, - message: 'missing tracker registry owner test', - }, - { - regex: /\bremote_connect_tracker_ignores_unrelated_direct_session_events\b/, - message: 'missing tracker unrelated-event guard test', - }, - { - regex: /\bremote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings\b/, - message: 'missing remote tool preview slimming test', - }, - { - regex: /\bremote_connect_cancel_runtime_restores_missing_session_before_cancel\b/, - message: 'missing remote cancel restore/order regression', - }, - { - regex: /\bremote_connect_cancel_runtime_preserves_stale_and_idle_errors_without_restore\b/, - message: 'missing remote cancel stale/idle regression', - }, - { - regex: /\bremote_connect_cancel_runtime_preserves_restore_failure_error\b/, - message: 'missing remote cancel restore failure regression', - }, - { - regex: /\bremote_connect_workspace_response_helpers_own_wire_shape\b/, - message: 'missing remote workspace response assembly regression', - }, - { - regex: /\bremote_connect_session_response_helpers_own_pagination_and_timestamps\b/, - message: 'missing remote session response assembly regression', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_connect/remote_server.rs', - reason: - 'core remote-connect server must remain a product runtime adapter around integrations-owned contracts', - patterns: [ - { - regex: /\bCoreServiceAgentRuntime\b/, - message: 'missing core service/agent runtime owner routing', - }, - { - regex: /\bsubmit_remote_dialog\b/, - message: 'missing remote dialog owner orchestration delegation', - }, - { - regex: /\bcancel_remote_task\b/, - message: 'missing remote cancel owner orchestration delegation', - }, - { - regex: /\bhandle_remote_workspace_file_command\b/, - message: 'missing remote file command owner delegation', - }, - { - regex: /\bremote_dialog_submit_response\b/, - message: 'missing remote dialog response assembly delegation', - }, - { - regex: /\bremote_task_cancel_response\b/, - message: 'missing remote cancel response assembly delegation', - }, - { - regex: /\bhandle_remote_interaction_command\b/, - message: 'missing remote interaction command owner orchestration delegation', - }, - { - regex: /\bgenerate_remote_initial_sync\b/, - message: 'missing remote initial-sync owner orchestration delegation', - }, - { - regex: /\bhandle_remote_workspace_command\b/, - message: 'missing remote workspace command owner orchestration delegation', - }, - { - regex: /\bhandle_remote_session_command\b/, - message: 'missing remote session command owner orchestration delegation', - }, - { - regex: /\bhandle_remote_poll_command\b/, - message: 'missing remote poll command owner orchestration delegation', - }, - { - regex: /\bhandle_remote_interaction_command\b/, - message: 'missing remote interaction command owner orchestration delegation', - }, - { - regex: /\bremote_image_context\b/, - message: 'missing image context adapter contract delegation', - }, - { - regex: /\bcore_service_agent_runtime_owner_maps_remote_image_context\b/, - message: 'missing core service/agent image-context owner regression', - }, - { - regex: /\bremote_execution_prefers_unified_image_contexts_over_legacy_images\b/, - message: 'missing unified image context preference regression', - }, - { - regex: /\bremote_execution_falls_back_to_legacy_images_as_image_contexts\b/, - message: 'missing legacy image context fallback regression', - }, - { - regex: /\bremote_cancel_decision_preserves_current_turn_boundaries\b/, - message: 'missing remote cancel boundary regression', - }, - { - regex: /\bremote_restore_target_only_restores_cold_sessions_with_workspace_binding\b/, - message: 'missing remote restore target regression', - }, - { - regex: /\bremote_command_snapshot_covers_execution_poll_and_cancel_surfaces\b/, - message: 'missing remote command snapshot regression', - }, - { - regex: /\bremote_response_snapshot_preserves_active_turn_and_result_shapes\b/, - message: 'missing remote response snapshot regression', - }, - ], - }, - { - path: 'src/crates/core/src/service/remote_connect/bot/command_router.rs', - reason: - 'remote-connect bot must route concrete agent runtime port bindings through the core service/agent runtime owner', - patterns: [ - { - regex: /\bCoreServiceAgentRuntime\b/, - message: 'missing core service/agent runtime owner routing', - }, - { - regex: /\bagent_submission_port\b/, - message: 'missing agent submission port owner binding', - }, - { - regex: /\bbuild_remote_session_create_request\b/, - message: 'missing integrations-owned remote session create request builder', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/scheduler.rs', - reason: - 'core scheduler keeps remote queue policy semantics until agent-runtime migration is reviewed', - patterns: [ - { - regex: /\bremote_queue_policy_preserves_interactive_preempt_and_confirmation_boundary\b/, - message: 'missing remote queue policy regression', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/registry.rs', - reason: - 'core registry must stay a compatibility container that delegates product tool runtime assembly through the core owner module', - patterns: [ - { - regex: /\bfrom_inner\b/, - message: 'missing generic agent-tools registry adapter hook', - }, - { - regex: /\bProductToolDecoratorRef\b/, - message: 'missing product decorator ref alias using agent-tools contract', - }, - { - regex: /\bProductToolRuntime\b/, - message: 'missing product tool runtime owner delegation', - }, - { - regex: /\bget_collapsed_tool_names\b/, - message: 'missing collapsed-tool catalog owner', - }, - { - regex: /\bresolve_product_readonly_enabled_tools\b/, - message: 'missing product tool catalog readonly facade delegation', - }, - { - regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, - message: 'missing collapsed-tool manifest migration baseline', - }, - { - regex: /\binner\.is_tool_collapsed\b/, - message: 'missing collapsed exposure lookup delegation', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/product_runtime.rs', - reason: - 'core product tool runtime owner keeps registry assembly, static tool materialization, catalog manifests, and GetToolSpec facades explicit until concrete tools migrate', - patterns: [ - { - regex: /\bProductToolRuntime\b/, - message: 'missing core product tool runtime owner', - }, - { - regex: /\bSnapshotToolDecorator\b/, - message: 'missing generic snapshot decorator injection', - }, - { - regex: /\bProductSnapshotToolWrapper\b/, - message: 'missing core product snapshot wrapper adapter', - }, - { - regex: /\bbuiltin_static_tool_providers\b/, - message: 'missing builtin provider assembly input', - }, - { - regex: /\bStaticToolProviderGroup\b/, - message: 'missing generic static provider group contract use', - }, - { - regex: /\bproduct_tool_provider_group_plan\b/, - message: 'missing tool-pack provider group plan delegation', - }, - { - regex: /\bmaterialize_tool\b/, - message: 'missing core concrete tool materialization boundary', - }, - { - regex: /\bGetToolSpecTool::new\(\)/, - message: 'missing GetToolSpec registration anchor', - }, - { - regex: /\bToolRuntimeAssembly\b/, - message: 'missing generic agent-tools runtime assembly delegation', - }, - { - regex: /\bcreate_registry_from_static_providers\b/, - message: 'missing generic static provider assembly delegation', - }, - { - regex: /\bwrap_tool_for_snapshot_tracking\b/, - message: 'missing snapshot wrapper boundary', - }, - { - regex: /\bProductToolCatalogProvider\b/, - message: 'missing core product tool catalog provider owner', - }, - { - regex: /\bimpl ToolCatalogSnapshotProvider for ProductToolCatalogProvider\b/, - message: 'missing core tool catalog snapshot provider implementation', - }, - { - regex: /\bimpl GetToolSpecCatalogProvider for ProductToolCatalogProvider\b/, - message: 'missing core product GetToolSpec catalog provider implementation', - }, - { - regex: /\bget_global_tool_registry\b/, - message: 'missing core product registry snapshot access', - }, - { - regex: /\bget_agent_registry\b/, - message: 'missing core agent policy source for contextual catalog', - }, - { - regex: /\bToolCatalogRuntime\b/, - message: 'missing agent-tools product catalog runtime facade delegation', - }, - { - regex: /\bproduct_tool_catalog_runtime\b/, - message: 'missing product catalog runtime factory', - }, - { - regex: /\bGetToolSpecRuntime\b/, - message: 'missing agent-tools GetToolSpec runtime facade delegation', - }, - { - regex: /\bproduct_get_tool_spec_runtime\b/, - message: 'missing product GetToolSpec runtime factory', - }, - { - regex: /\bresolve_product_tool_manifest\b/, - message: 'missing product manifest facade', - }, - { - regex: /\bresolve_product_readonly_enabled_tools\b/, - message: 'missing product readonly enabled tools facade', - }, - { - regex: /\bresolve_product_get_tool_spec_results\b/, - message: 'missing product GetToolSpec Tool-result vector facade', - }, - { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing core-owned collapsed-tool unlock state source', - }, - { - regex: /\bproduct_catalog_provider_default_get_tool_spec_catalog_matches_registry\b/, - message: 'missing product catalog provider collapsed catalog regression', - }, - { - regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, - message: 'missing product runtime owner registry equivalence regression', - }, - { - regex: /\bGetToolSpec requires agent type context\b/, - message: 'missing contextual GetToolSpec catalog validation boundary', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', - reason: - 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', - patterns: [ - { - regex: /\bimpl ToolRegistryItem for dyn Tool\b/, - message: 'missing core Tool registry adapter', - }, - { - regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, - message: 'missing core ToolUseContext contextual manifest adapter', - }, - { - regex: /\bTool::dynamic_tool_info\b/, - message: 'missing dynamic tool metadata adapter', - }, - { - regex: /\bTool::is_readonly\b/, - message: 'missing readonly metadata adapter', - }, - { - regex: /\bTool::is_enabled\b/, - message: 'missing enabled-state metadata adapter', - }, - { - regex: /\bTool::description_with_context\b/, - message: 'missing context-aware tool description adapter', - }, - { - regex: /\bTool::input_schema_for_model_with_context\b/, - message: 'missing context-aware tool schema adapter', - }, - ], - }, - { - path: 'src/crates/agent-tools/src/framework.rs', - reason: 'agent-tools owns portable tool facts plus generic registry and provider contracts', - patterns: [ - { - regex: /\bpub struct ToolContextFacts\b/, - message: 'missing portable tool context facts contract', - }, - { - regex: /\bpub trait PortableToolContextProvider\b/, - message: 'missing portable tool context provider contract', - }, - { - regex: /\bpub enum ToolWorkspaceKind\b/, - message: 'missing portable workspace kind contract', - }, - { - regex: /\bpub trait StaticToolProvider\b/, - message: 'missing static tool provider contract', - }, - { - regex: /\bpub struct ToolRuntimeAssembly\b/, - message: 'missing generic runtime assembly contract', - }, - { - regex: /\bpub type ToolDecoratorRef\b/, - message: 'missing generic decorator ref contract', - }, - { - regex: /\bpub trait SnapshotToolWrapper\b/, - message: 'missing generic snapshot wrapper contract', - }, - { - regex: /\bpub struct SnapshotToolDecorator\b/, - message: 'missing generic snapshot decorator contract', - }, - { - regex: /\bcreate_registry_from_static_providers\b/, - message: 'missing generic static-provider assembly helper', - }, - { - regex: /\bpub fn install_static_provider\b/, - message: 'missing static provider registry installer', - }, - { - regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, - message: 'missing provider-neutral GetToolSpec duplicate-load result helper', - }, - { - regex: /\bpub fn build_get_tool_spec_detail_result\b/, - message: 'missing provider-neutral GetToolSpec detail result helper', - }, - { - regex: /\bpub fn resolve_get_tool_spec_execution_plan\b/, - message: 'missing provider-neutral GetToolSpec execution plan helper', - }, - { - regex: /\bpub async fn resolve_get_tool_spec_execution_result_from_provider\b/, - message: 'missing provider-backed GetToolSpec execution result helper', - }, - { - regex: /\bpub struct GetToolSpecRuntime\b/, - message: 'missing provider-backed GetToolSpec runtime facade', - }, - ], - }, - { - path: 'src/crates/tool-packs/src/lib.rs', - reason: - 'tool-packs must keep its feature-group scaffold explicit without owning concrete tools yet', - patterns: [ - { - regex: /\bpub enum ToolPackFeatureGroup\b/, - message: 'missing tool-pack feature group scaffold', - }, - { - regex: /\bpub fn all_feature_groups\b/, - message: 'missing tool-pack full feature group metadata helper', - }, - { - regex: /\bpub fn enabled_feature_groups\b/, - message: 'missing tool-pack compile-time feature metadata helper', - }, - { - regex: /\bpub struct ToolProviderGroupPlan\b/, - message: 'missing tool-pack provider group plan contract', - }, - { - regex: /\bpub fn product_tool_provider_group_plan\b/, - message: 'missing product tool provider group plan', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - reason: - 'core must continue owning manifest resolver wrappers while delegating product catalog access and generic manifest assembly', - patterns: [ - { - regex: /\bpub async fn resolve_tool_manifest\b/, - message: 'missing tool manifest resolver owner', - }, - { - regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, - message: 'missing GetToolSpec manifest insertion anchor', - }, - { - regex: /\bresolve_product_visible_tools\b/, - message: 'missing core product visible-tools facade delegation', - }, - { - regex: /\bresolve_product_tool_manifest\b/, - message: 'missing core product manifest facade delegation', - }, - { - regex: /\bcollapsed_tool_names\b/, - message: 'missing collapsed-tool name tracking', - }, - { - regex: /\bmanifest_preserves_explicit_get_tool_spec_runtime_contract\b/, - message: 'missing core GetToolSpec manifest insertion regression', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', - reason: - 'core must continue owning the GetToolSpec Tool adapter and product boundary while delegating generic runtime surface to agent-tools', - patterns: [ - { - regex: /\bpub struct GetToolSpecTool\b/, - message: 'missing GetToolSpec owner type', - }, - { - regex: /\bresolve_product_get_tool_spec_results\b/, - message: 'missing product GetToolSpec Tool-result vector facade delegation', - }, - { - regex: /\bmap_get_tool_spec_execution_error\b/, - message: 'missing core GetToolSpec execution error mapping boundary', - }, - { - regex: /\bbuild_collapsed_tools_context_section\b/, - message: 'missing core collapsed-tool request-context section renderer', - }, - { - regex: /\bproduct_get_tool_spec_runtime\b/, - message: 'missing product GetToolSpec runtime facade delegation', - }, - { - regex: /\bwith_runtime\b/, - message: 'missing core GetToolSpec static surface facade boundary', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/framework.rs', - reason: - 'core tool framework must keep compatibility re-exports while ToolUseContext is owned by tool_context_runtime', - patterns: [ - { - regex: /\bToolExposure\b/, - message: 'missing ToolExposure compatibility re-export', - }, - { - regex: /\bpub use crate::agentic::tools::tool_context_runtime::ToolUseContext\b/, - message: 'missing ToolUseContext compatibility re-export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_context_runtime.rs', - reason: - 'core must keep ToolUseContext runtime/service bindings centralized while ToolUseContext and concrete tools remain core-owned', - patterns: [ - { - regex: /\bpub struct ToolUseContext\b/, - message: 'missing ToolUseContext owner type', - }, - { - regex: /\bto_tool_context_facts\b/, - message: 'missing portable ToolUseContext facts projection', - }, - { - regex: /\bimpl PortableToolContextProvider for ToolUseContext\b/, - message: 'missing portable ToolUseContext facts provider impl', - }, - { - regex: /\btool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated\b/, - message: 'missing portable facts runtime-owner leak guard', - }, - { - regex: /customData/, - message: 'missing custom data runtime-only facts guard', - }, - { - regex: /cancellationToken/, - message: 'missing cancellation token runtime-only facts guard', - }, - { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock state', - }, - { - regex: /\bimpl ToolUseContext\b/, - message: 'missing ToolUseContext runtime binding owner impl', - }, - { - regex: /\brecord_light_checkpoint\b/, - message: 'missing Deep Review checkpoint binding', - }, - { - regex: /\bcall_with_tool_runtime_hooks\b/, - message: 'missing tool-call cancellation/post-call hook binding', - }, - { - regex: /\bcall_tool_with_runtime_hooks\b/, - message: 'missing unified Tool::call runtime hook facade', - }, - { - regex: /\bcall_records_deep_review_read_file_measurement_without_touching_result\b/, - message: 'missing Deep Review post-call hook regression in runtime owner', - }, - { - regex: /\bbuild_tool_use_context_for_task\b/, - message: 'missing tool pipeline context materialization binding', - }, - { - regex: /\bbuild_tool_description_context\b/, - message: 'missing tool manifest description context materialization binding', - }, - { - regex: /\bbuild_write_preflight_context\b/, - message: 'missing write preflight context materialization binding', - }, - { - regex: /\bensure_current_workspace_runtime\b/, - message: 'missing workspace runtime ensure binding', - }, - { - regex: /\bresolve_tool_path\b/, - message: 'missing tool path resolution binding', - }, - { - regex: /\benforce_path_operation\b/, - message: 'missing runtime path policy binding', - }, - { - regex: /\bis_tool_path_allowed_by_resolved_roots\b/, - message: 'missing path policy owner delegation to agent-tools', - }, - { - regex: /\bbuild_tool_path_policy_denial_message\b/, - message: 'missing shared path policy denial contract', - }, - { - regex: /\bresolve_tool_path_with_context\b/, - message: 'missing shared tool path resolution owner delegation', - }, - { - regex: /\btool_path_is_effectively_absolute\b/, - message: 'missing shared tool path absolute owner delegation', - }, - { - regex: /\bbuild_tool_runtime_artifact_reference\b/, - message: 'missing runtime artifact reference owner delegation', - }, - { - regex: /\bbuild_tool_session_runtime_artifact_reference\b/, - message: 'missing session runtime artifact reference owner delegation', - }, - { - regex: /\bworkspace_path_resolution_rejects_absolute_paths_outside_remote_workspace\b/, - message: 'missing remote workspace containment regression', - }, - { - regex: /\bruntime_uri_resolution_rejects_different_workspace_scope\b/, - message: 'missing runtime artifact scope regression', - }, - { - regex: /\bpath_policy_allows_only_configured_local_roots\b/, - message: 'missing path policy enforcement regression', - }, - { - regex: /\btool_call_runtime_hook_returns_cancelled_before_impl_completes\b/, - message: 'missing tool-call cancellation regression', - }, - { - regex: /\btool_task_context_materialization_preserves_runtime_fields\b/, - message: 'missing tool task context materialization regression', - }, - { - regex: /\btool_description_context_preserves_manifest_custom_data_shape\b/, - message: 'missing tool description context regression', - }, - { - regex: /\bwrite_preflight_context_preserves_minimal_runtime_fields\b/, - message: 'missing write preflight context regression', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', - reason: - 'core must continue carrying collapsed-tool unlock state while delegating provider-neutral execution gate policy to agent-tools', - patterns: [ - { - regex: /\bfn validate_collapsed_tool_usage\b/, - message: 'missing collapsed-tool execution gate compatibility wrapper', - }, - { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock state propagation', - }, - { - regex: /\bpipeline_preserves_core_owned_tool_context_without_portable_runtime_leak\b/, - message: 'missing ToolUseContext runtime boundary regression', - }, - { - regex: /\bGetToolSpec\b/, - message: 'missing GetToolSpec gating contract', - }, - { - regex: /\brender_tool_result_for_assistant\b/, - message: 'missing tool result presentation owner delegation', - }, - { - regex: /\bbuild_tool_execution_error_presentation\b/, - message: 'missing tool execution error presentation owner delegation', - }, - { - regex: /\bbuild_user_steering_interrupted_presentation\b/, - message: 'missing steering-interrupted presentation owner delegation', - }, - { - regex: /\bbuild_invalid_tool_call_error_message\b/, - message: 'missing invalid tool call presentation owner delegation', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/execution/execution_engine.rs', - reason: - 'core execution must continue carrying collapsed-tool unlock state and DeepResearch post-turn hooks until approved runtime migrations exist', - patterns: [ - { - regex: /\bfn collect_unlocked_collapsed_tools\b/, - message: 'missing GetToolSpec result unlock collector', - }, - { - regex: /\bcollect_unlocked_collapsed_tools_dedupes_and_filters_runtime_unlocks\b/, - message: 'missing GetToolSpec unlock filtering regression', - }, - { - regex: /\bcollapsed_tool_names\b/, - message: 'missing manifest collapsed-tool handoff', - }, - { - regex: /\bGetToolSpec\b/, - message: 'missing GetToolSpec execution contract', - }, - { - regex: /\bcitation_renumber\b/, - message: 'missing DeepResearch citation renumber hook', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/availability.rs', - reason: - 'core agent registry must continue owning mode-scoped subagent availability until an approved agent-runtime migration exists', - patterns: [ - { - regex: /\bpub fn resolve_availability\b/, - message: 'missing mode-scoped subagent availability resolver', - }, - { - regex: /\bpub fn resolve_override_layers\b/, - message: 'missing project/user override layering contract', - }, - { - regex: /\bAgentSubagentOverrideState\b/, - message: 'missing subagent override state contract', - }, - { - regex: /\bSubagentStateReason\b/, - message: 'missing frontend-visible availability reason contract', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/types.rs', - reason: - 'core agent registry must continue exposing subagent query and availability DTOs until registry ownership migrates with API equivalence tests', - patterns: [ - { - regex: /\bpub struct SubagentQueryContext\b/, - message: 'missing subagent query context', - }, - { - regex: /\bpub enum SubagentListScope\b/, - message: 'missing subagent list scope contract', - }, - { - regex: /\bdefault_enabled\b/, - message: 'missing default availability field', - }, - { - regex: /\beffective_enabled\b/, - message: 'missing effective availability field', - }, - { - regex: /\bpub enum SubagentStateReason\b/, - message: 'missing availability reason wire contract', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/definitions/modes/mod.rs', - reason: - 'core agent mode definitions must continue exposing Multitask mode until an approved agent-runtime migration preserves mode registration semantics', - patterns: [ - { - regex: /\bmod multitask\b/, - message: 'missing Multitask mode module', - }, - { - regex: /\bpub use multitask::MultitaskMode\b/, - message: 'missing Multitask mode export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/definitions/subagents/mod.rs', - reason: - 'core subagent definitions must continue exposing the built-in GeneralPurpose subagent until registry ownership migration has equivalence coverage', - patterns: [ - { - regex: /\bmod general_purpose\b/, - message: 'missing GeneralPurpose subagent module', - }, - { - regex: /\bpub use general_purpose::GeneralPurposeAgent\b/, - message: 'missing GeneralPurpose subagent export', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/builtin.rs', - reason: - 'core builtin registry must continue registering latest-main mode and subagent defaults until agent registry ownership migrates with API equivalence tests', - patterns: [ - { - regex: /\bbuiltin_agent_specs\(\)/, - message: 'missing builtin agent spec registration source', - }, - { - regex: /"Multitask"\s*=>\s*"auto"/, - message: 'missing Multitask default model mapping', - }, - { - regex: /"GeneralPurpose"\s*=>\s*"fast"/, - message: 'missing GeneralPurpose default model mapping', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', - reason: - 'core Task tool must continue owning fork-aware background subagent launch semantics until a reviewed agent-runtime port preserves delivery behavior', - patterns: [ - { - regex: /\bfork_context\b/, - message: 'missing Task fork_context schema and validation surface', - }, - { - regex: /\bSubagentContextMode::Fork\b/, - message: 'missing forked subagent context mode path', - }, - { - regex: /delegation_policy\(\)\.spawn_child\(\)/, - message: 'missing child delegation policy propagation', - }, - { - regex: /"run_in_background"/, - message: 'missing Task run_in_background schema flag', - }, - { - regex: /\bstart_background_subagent\b/, - message: 'missing background subagent launch path', - }, - { - regex: /\bbackground_task_id\b/, - message: 'missing background task id result contract', - }, - { - regex: /Background \{\} started successfully/, - message: 'missing assistant-visible background start acknowledgement', - }, - { - regex: / { - const trimmed = line.trim(); - if (trimmed.startsWith('#') || trimmed === '') { - return; - } - - if (currentInline) { - currentInline.text.push(trimmed); - if (/\boptional\s*=\s*true\b/.test(trimmed)) { - currentInline.optional = true; - } - if (trimmed.includes('}')) { - currentInline = null; - } - return; - } - - const headerMatch = trimmed.match(/^\[(.+)]$/); - if (headerMatch) { - inDependencyList = isDependencyListHeader(trimmed); - currentTable = null; - for (const depName of collectKnownDependencyNames()) { - if (manifestDependencyHeaderPattern(depName).test(trimmed)) { - currentTable = { - name: depName, - line: index + 1, - optional: false, - text: [trimmed], - }; - deps.push(currentTable); - break; - } - } - return; - } - - if (currentTable) { - currentTable.text.push(trimmed); - if (/\boptional\s*=\s*true\b/.test(trimmed)) { - currentTable.optional = true; - } - return; - } - - if (!inDependencyList) { - return; - } - - const inlineMatch = trimmed.match(/^([A-Za-z0-9_-]+|"[A-Za-z0-9_-]+")\s*=/); - if (inlineMatch) { - const name = inlineMatch[1].replace(/^"|"$/g, ''); - deps.push({ - name, - line: index + 1, - optional: /\boptional\s*=\s*true\b/.test(trimmed), - text: [trimmed], - }); - if (trimmed.includes('{') && !trimmed.includes('}')) { - currentInline = deps[deps.length - 1]; - } - return; - } - - }); - - return deps; -} - -function manifestDependencyText(dep) { - return dep?.text?.join('\n') ?? ''; -} - -function manifestDependencyDisablesDefaultFeatures(dep) { - return /\bdefault-features\s*=\s*false\b/.test(manifestDependencyText(dep)); -} - -function parseManifestDependencyFeatureNames(dep) { - const features = new Set(); - const text = manifestDependencyText(dep); - for (const match of text.matchAll(/\bfeatures\s*=\s*\[([\s\S]*?)\]/g)) { - for (const featureMatch of match[1].matchAll(/"([^"]+)"/g)) { - features.add(featureMatch[1]); - } - } - return features; -} - -function collectProductCoreDependencyManifestPaths(manifestEntries) { - return manifestEntries - .filter((entry) => { - const deps = parseManifestDependencies(entry.text.split(/\r?\n/)); - return deps.some((dep) => dep.name === 'bitfun-core'); - }) - .map((entry) => entry.manifestPath) - .sort(); -} - -function collectProductCoreDependencyManifests(scanRoots = productCoreFeatureAssemblyScanRoots) { - const manifestEntries = []; - for (const repoDir of scanRoots) { - const dir = join(ROOT, ...repoDir.split('/')); - walkFiles(dir, (path) => { - if (!path.endsWith('Cargo.toml')) { - return; - } - manifestEntries.push({ - manifestPath: toRepoPath(path), - text: readText(path), - }); - }); - } - return collectProductCoreDependencyManifestPaths(manifestEntries); -} - -function parseManifestFeatures(lines) { - const features = new Map(); - let inFeatures = false; - let currentFeature = null; - - const appendRefs = (feature, text) => { - const refs = [...text.matchAll(/"([^"]+)"/g)].map((match) => match[1]); - feature.refs.push(...refs); - }; - - lines.forEach((line, index) => { - const trimmed = line.trim(); - if (trimmed.startsWith('#') || trimmed === '') { - return; - } - - const headerMatch = trimmed.match(/^\[(.+)]$/); - if (headerMatch) { - inFeatures = trimmed === '[features]'; - currentFeature = null; - return; - } - - if (!inFeatures) { - return; - } - - if (currentFeature) { - appendRefs(currentFeature, trimmed); - if (trimmed.includes(']')) { - currentFeature = null; - } - return; - } - - const featureMatch = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/); - if (!featureMatch) { - return; - } - - const feature = { - name: featureMatch[1], - line: index + 1, - refs: [], - }; - appendRefs(feature, featureMatch[2]); - features.set(feature.name, feature); - if (featureMatch[2].includes('[') && !featureMatch[2].includes(']')) { - currentFeature = feature; - } - }); - - return features; -} - -function collectKnownDependencyNames() { - return Array.from( - new Set([ - 'bitfun-core', - ...lightweightBoundaryRules.flatMap((rule) => rule.forbiddenDeps), - ...dependencyProfileRules.flatMap((rule) => rule.forbiddenNonOptionalDeps), - ...optionalDependencyFeatureOwnerRules.flatMap((rule) => - rule.dependencies.map((dependency) => dependency.depName), - ), - ...productCoreFeatureAssemblyRules.map((rule) => rule.dependencyName), - ]), - ); -} - -function runManifestParserSelfTest() { - const positiveCases = [ - 'bitfun-core = { path = "../core" }', - '[dependencies.bitfun-core]', - '[dev-dependencies."bitfun-core"]', - "[target.'cfg(windows)'.dependencies.bitfun-core]", - "[target.'cfg(unix)'.build-dependencies.\"bitfun-core\"]", - ]; - const negativeCases = [ - '# bitfun-core = { path = "../core" }', - '[dependencies]', - '[workspace.dependencies.bitfun-core]', - '[dependencies.bitfun-core-extra]', - ]; - - for (const line of positiveCases) { - if (!isManifestDependencyDeclaration(line, 'bitfun-core')) { - throw new Error(`manifest parser missed dependency declaration: ${line}`); - } - } - for (const line of negativeCases) { - if (isManifestDependencyDeclaration(line, 'bitfun-core')) { - throw new Error(`manifest parser matched non-dependency declaration: ${line}`); - } - } - - const parsedDeps = parseManifestDependencies([ - '[dependencies]', - 'reqwest = { workspace = true, optional = true }', - 'dirs = { workspace = true }', - 'rmcp = { version = "0.12.0", default-features = false, features = [', - ' "auth",', - '], optional = true }', - 'bitfun-core = { path = "../core", default-features = false, features = ["product-full"] }', - '[dependencies.git2]', - 'workspace = true', - 'optional = true', - '[target.\'cfg(windows)\'.dependencies."bitfun-cli"]', - 'path = "../../apps/cli"', - '[features]', - 'image = []', - ]); - const parsedByName = new Map(parsedDeps.map((dep) => [dep.name, dep])); - if (parsedByName.get('reqwest')?.optional !== true) { - throw new Error('dependency profile parser must detect inline optional dependencies'); - } - if (parsedByName.get('dirs')?.optional !== false) { - throw new Error('dependency profile parser must detect non-optional inline dependencies'); - } - if (parsedByName.get('rmcp')?.optional !== true) { - throw new Error('dependency profile parser must detect multiline optional inline dependencies'); - } - if (parsedByName.get('git2')?.optional !== true) { - throw new Error('dependency profile parser must detect optional dependency tables'); - } - if (parsedByName.get('bitfun-cli')?.optional !== false) { - throw new Error('dependency profile parser must detect non-optional target dependency tables'); - } - const parsedCoreDep = parsedByName.get('bitfun-core'); - if (!manifestDependencyDisablesDefaultFeatures(parsedCoreDep)) { - throw new Error('dependency profile parser must detect default-features = false'); - } - if (!parseManifestDependencyFeatureNames(parsedCoreDep).has('product-full')) { - throw new Error('dependency profile parser must detect inline dependency features'); - } - const parsedCoreTableDeps = parseManifestDependencies([ - '[dependencies."bitfun-core"]', - 'path = "../core"', - 'default-features = false', - 'features = [', - ' "product-full",', - ' "ssh-remote",', - ']', - ]); - const parsedCoreTableDep = parsedCoreTableDeps.find((dep) => dep.name === 'bitfun-core'); - if (!manifestDependencyDisablesDefaultFeatures(parsedCoreTableDep)) { - throw new Error('dependency profile parser must detect table default-features = false'); - } - if (!parseManifestDependencyFeatureNames(parsedCoreTableDep).has('ssh-remote')) { - throw new Error('dependency profile parser must detect table dependency features'); - } - if (parsedByName.has('image')) { - throw new Error('dependency profile parser must ignore feature entries named like dependencies'); - } - - const productCoreRulePaths = new Set( - productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath), - ); - for (const manifestPath of [ - 'src/apps/desktop/Cargo.toml', - 'src/apps/cli/Cargo.toml', - 'src/crates/acp/Cargo.toml', - ]) { - if (!productCoreRulePaths.has(manifestPath)) { - throw new Error(`product core feature assembly rule must cover ${manifestPath}`); - } - } - for (const rule of productCoreFeatureAssemblyRules) { - if (!rule.requiredFeatures.includes('product-full')) { - throw new Error(`${rule.manifestPath} must require bitfun-core product-full`); - } - } - for (const featureName of ['ssh-remote', 'product-domains', 'service-integrations', 'tool-packs']) { - if (!coreProductFullFeatureAssemblyRule.requiredFeatureRefs.includes(featureName)) { - throw new Error(`core product-full assembly rule must require ${featureName}`); - } - } - const discoveredProductCoreManifests = collectProductCoreDependencyManifestPaths([ - { - manifestPath: 'src/apps/desktop/Cargo.toml', - text: - '[dependencies]\nbitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] }', - }, - { - manifestPath: 'src/apps/server/Cargo.toml', - text: '[dependencies]\naxum = { workspace = true }', - }, - { - manifestPath: 'src/crates/acp/Cargo.toml', - text: '[dependencies."bitfun-core"]\npath = "../core"\ndefault-features = false\nfeatures = ["product-full"]', - }, - ]); - if (discoveredProductCoreManifests.join(',') !== 'src/apps/desktop/Cargo.toml,src/crates/acp/Cargo.toml') { - throw new Error('product core dependency scanner must discover only manifests that depend on bitfun-core'); - } - const ownerFeatureRulePaths = new Set( - ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), - ); - for (const manifestPath of [ - 'src/crates/tool-packs/Cargo.toml', - 'src/crates/services-integrations/Cargo.toml', - 'src/crates/product-domains/Cargo.toml', - ]) { - if (!ownerFeatureRulePaths.has(manifestPath)) { - throw new Error(`owner crate feature assembly rule must cover ${manifestPath}`); - } - } - for (const rule of ownerCrateFeatureAssemblyRules) { - const declaredFeatures = new Set(rule.requiredProductFullFeatures); - if (declaredFeatures.size !== rule.requiredProductFullFeatures.length) { - throw new Error(`${rule.manifestPath} product-full guard must not duplicate feature groups`); - } - if (rule.requiredProductFullFeatures.some((featureName) => featureName.startsWith('dep:'))) { - throw new Error(`${rule.manifestPath} product-full guard must track owner feature groups only`); - } - } - - const parsedFeatures = parseManifestFeatures([ - '[package]', - 'name = "example"', - '[features]', - 'default = ["product-full"]', - 'product-full = [', - ' "dep:tool-runtime",', - ' "service-integrations",', - ']', - 'service-integrations = ["dep:git2", "dep:rmcp"]', - 'ssh-remote = [', - ' "russh",', - ' "russh-sftp",', - ']', - '[dependencies]', - 'git2 = { workspace = true, optional = true }', - ]); - if (!parsedFeatures.get('default')?.refs.includes('product-full')) { - throw new Error('feature parser must detect inline feature references'); - } - if (!parsedFeatures.get('product-full')?.refs.includes('dep:tool-runtime')) { - throw new Error('feature parser must detect multiline dependency feature references'); - } - if (!parsedFeatures.get('service-integrations')?.refs.includes('dep:rmcp')) { - throw new Error('feature parser must detect inline dependency feature references'); - } - if (!parsedFeatures.get('ssh-remote')?.refs.includes('russh-sftp')) { - throw new Error('feature parser must detect implicit optional dependency feature references'); - } - - const acceptsGitFacadeLine = createFacadeLineChecker('bitfun_services_integrations::git'); - const facadePositiveCases = [ - '', - '//! Compatibility facade.', - 'pub use bitfun_services_integrations::git::GitService;', - 'pub use bitfun_services_integrations::git::types::*;', - 'pub use bitfun_services_integrations::git::{', - ' build_git_graph, build_git_graph_for_branch,', - '};', - 'pub use bitfun_services_integrations::git::{build_git_graph, build_git_graph_for_branch};', - ]; - for (const line of facadePositiveCases) { - if (!acceptsGitFacadeLine(line)) { - throw new Error(`facade parser rejected allowed line: ${line}`); - } - } - - const rejectsGitImplementationLine = createFacadeLineChecker('bitfun_services_integrations::git'); - const facadeNegativeCases = [ - 'pub mod service;', - 'use bitfun_services_integrations::git::GitService;', - 'fn parse_git_status() {}', - ]; - for (const line of facadeNegativeCases) { - if (rejectsGitImplementationLine(line)) { - throw new Error(`facade parser accepted implementation line: ${line}`); - } - } - - const cliBoundaryDeps = ['bitfun-cli', 'ratatui', 'crossterm', 'arboard', 'syntect-tui']; - for (const rule of lightweightBoundaryRules) { - for (const dep of cliBoundaryDeps) { - if (!rule.forbiddenDeps.includes(dep)) { - throw new Error( - `lightweight boundary rule for ${rule.crateName} must forbid CLI-only dependency: ${dep}`, - ); - } - } - } - - const agentToolsRule = lightweightBoundaryRules.find((rule) => rule.crateName === 'agent-tools'); - if (!agentToolsRule?.forbiddenDeps.includes('bitfun-ai-adapters')) { - throw new Error('agent-tools lightweight boundary must forbid bitfun-ai-adapters'); - } - const coreToolFrameworkRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/tools/framework.rs', - ); - if (!coreToolFrameworkRule) { - throw new Error('missing core tool framework boundary rule'); - } - const coreToolFrameworkContracts = [ - 'DynamicMcpToolInfo', - 'DynamicToolInfo', - 'ToolRenderOptions', - 'ToolPathBackend', - 'ToolPathResolution', - 'get_global_coordinator', - 'GitService', - 'get_workspace_runtime_service_arc', - 'remote_workspace_runtime_root', - 'get_path_manager_arc', - 'post_call_hooks::record_successful_tool_call', - ]; - const coreToolFrameworkRuleText = coreToolFrameworkRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of coreToolFrameworkContracts) { - if (!coreToolFrameworkRuleText.includes(contract)) { - throw new Error(`core tool framework boundary rule must forbid contract: ${contract}`); - } - } - const coreToolRestrictionRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/tools/restrictions.rs', - ); - if (!coreToolRestrictionRule) { - throw new Error('missing core tool restrictions boundary rule'); - } - const coreToolRestrictionContracts = [ - 'ToolPathOperation', - 'ToolPathPolicy', - 'ToolRuntimeRestrictions', - 'normalize_absolute_posix_path', - ]; - const coreToolRestrictionRuleText = coreToolRestrictionRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of coreToolRestrictionContracts) { - if (!coreToolRestrictionRuleText.includes(contract)) { - throw new Error(`core tool restrictions boundary rule must forbid contract: ${contract}`); - } - } - const agentToolsFrameworkRule = requiredContentRules.find( - (rule) => rule.path === 'src/crates/agent-tools/src/framework.rs', - ); - if (!agentToolsFrameworkRule) { - throw new Error('missing agent-tools framework boundary rule'); - } - const agentToolsFrameworkContracts = [ - 'is_tool_path_allowed_by_resolved_roots', - 'build_tool_path_policy_denial_message', - 'resolve_tool_path_with_context', - 'tool_path_is_effectively_absolute', - 'build_tool_runtime_artifact_reference', - 'build_tool_session_runtime_artifact_reference', - ]; - const agentToolsFrameworkRuleText = agentToolsFrameworkRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of agentToolsFrameworkContracts) { - if (!agentToolsFrameworkRuleText.includes(contract)) { - throw new Error(`agent-tools framework boundary rule must require contract: ${contract}`); - } - } - const coreWorkspacePathRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/tools/workspace_paths.rs', - ); - if (!coreWorkspacePathRule) { - throw new Error('missing core workspace path boundary rule'); - } - const coreWorkspacePathContracts = [ - 'BITFUN_RUNTIME_URI_PREFIX', - 'ParsedBitFunRuntimeUri', - 'posix_normalize_components', - 'Component::ParentDir', - ]; - const coreWorkspacePathRuleText = coreWorkspacePathRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of coreWorkspacePathContracts) { - if (!coreWorkspacePathRuleText.includes(contract)) { - throw new Error(`core workspace path boundary rule must forbid contract: ${contract}`); - } - } - const coreToolRegistryRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/tools/registry.rs', - ); - if (!coreToolRegistryRule) { - throw new Error('missing core tool registry boundary rule'); - } - const coreToolRegistryRuleText = coreToolRegistryRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - const coreToolRegistryContracts = [ - 'DynamicToolMetadata', - 'tools\\s*:\\s*IndexMap', - 'dynamic_tools\\s*:\\s*IndexMap', - ]; - for (const contract of coreToolRegistryContracts) { - if (!coreToolRegistryRuleText.includes(contract)) { - throw new Error(`core tool registry boundary rule must forbid contract: ${contract}`); - } - } - const coreSubagentRuntimeRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/subagent_runtime/mod.rs', - ); - if (!coreSubagentRuntimeRule) { - throw new Error('missing core subagent runtime boundary rule'); - } - const coreSubagentRuntimeRuleText = coreSubagentRuntimeRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of ['DelegationPolicy', 'SubagentContextMode']) { - if (!coreSubagentRuntimeRuleText.includes(contract)) { - throw new Error(`core subagent runtime boundary rule must forbid contract: ${contract}`); - } - } - const coreCoordinatorRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/coordination/coordinator.rs', - ); - if (!coreCoordinatorRule) { - throw new Error('missing core coordinator boundary rule'); - } - const coreCoordinatorRuleText = coreCoordinatorRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - if (!coreCoordinatorRuleText.includes('DialogTriggerSource')) { - throw new Error('core coordinator boundary rule must forbid DialogTriggerSource redefinition'); - } - const coreSchedulerRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/coordination/scheduler.rs', - ); - if (!coreSchedulerRule) { - throw new Error('missing core scheduler boundary rule'); - } - const coreSchedulerRuleText = coreSchedulerRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of [ - 'DialogQueuePriority', - 'DialogSubmissionPolicy', - 'DialogSubmitOutcome', - 'AgentSessionReplyRoute', - 'DialogSteerOutcome', - ]) { - if (!coreSchedulerRuleText.includes(contract)) { - throw new Error(`core scheduler boundary rule must forbid contract: ${contract}`); - } - } - const coreRoundPreemptRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/round_preempt.rs', - ); - if (!coreRoundPreemptRule) { - throw new Error('missing core round preempt boundary rule'); - } - const coreRoundPreemptRuleText = coreRoundPreemptRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of [ - 'DialogRoundPreemptSource', - 'RoundInjection', - 'DialogRoundInjectionSource', - 'RoundInjectionKind', - 'RoundInjectionTarget', - ]) { - if (!coreRoundPreemptRuleText.includes(contract)) { - throw new Error(`core round preempt boundary rule must forbid contract: ${contract}`); - } - } - const coreGoalModeTypesRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/goal_mode/types.rs', - ); - if (!coreGoalModeTypesRule) { - throw new Error('missing core goal mode types boundary rule'); - } - const coreGoalModeTypesRuleText = coreGoalModeTypesRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of [ - 'GoalModeState', - 'GoalModeInitialGoal', - 'GoalGenerationResult', - 'GoalVerificationResult', - 'GoalActivationResult', - 'GoalContinuationPlan', - ]) { - if (!coreGoalModeTypesRuleText.includes(contract)) { - throw new Error(`core goal mode types boundary rule must forbid contract: ${contract}`); - } - } - const coreMessageRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/agentic/core/message.rs', - ); - if (!coreMessageRule) { - throw new Error('missing core message boundary rule'); - } - const coreMessageRuleText = coreMessageRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of ['CompressionContract', 'CompressionContractItem']) { - if (!coreMessageRuleText.includes(contract)) { - throw new Error(`core message boundary rule must forbid contract: ${contract}`); - } - } - const coreWorkspaceRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/workspace/manager.rs', - ); - if (!coreWorkspaceRule) { - throw new Error('missing core workspace manager boundary rule'); - } - if ( - !coreWorkspaceRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n') - .includes('RelatedPath') - ) { - throw new Error('core workspace manager boundary rule must forbid contract: RelatedPath'); - } - const coreSubagentRuntimeOwnerPathRule = forbiddenContentUnderRules.find( - (rule) => rule.path === 'src/crates/core/src', - ); - if (!coreSubagentRuntimeOwnerPathRule) { - throw new Error('missing core subagent runtime owner-path boundary rule'); - } - const coreSubagentRuntimeOwnerPathRuleText = coreSubagentRuntimeOwnerPathRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of ['DelegationPolicy', 'SubagentContextMode']) { - if (!coreSubagentRuntimeOwnerPathRuleText.includes(contract)) { - throw new Error( - `core subagent runtime owner-path rule must forbid compatibility import: ${contract}`, - ); - } - } - - const productDomainProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'product-domains', - ); - for (const dep of ['dirs', 'log', 'sha2', 'which']) { - if (!productDomainProfile?.forbiddenNonOptionalDeps.includes(dep)) { - throw new Error(`product-domains default profile must forbid non-optional ${dep}`); - } - } - const servicesIntegrationsDefaultProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'services-integrations', - ); - if (!servicesIntegrationsDefaultProfile?.forbiddenNonOptionalDeps.includes('uuid')) { - throw new Error('services-integrations default profile must forbid non-optional uuid'); - } - const coreProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core'); - for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { - if (!coreProfile?.forbiddenNonOptionalDeps.includes(dep)) { - throw new Error(`core no-default profile must forbid non-optional ${dep}`); - } - } - const coreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( - (rule) => rule.crateName === 'core', - ); - const coreOptionalOwnerDeps = new Set( - coreOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [], - ); - for (const dep of coreProfile?.forbiddenNonOptionalDeps ?? []) { - if (!coreOptionalOwnerDeps.has(dep)) { - throw new Error(`core optional dependency owner rule must cover forbidden dependency ${dep}`); - } - } - for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { - if (!coreOptionalOwnerDeps.has(dep)) { - throw new Error(`core optional dependency owner rule must cover ${dep}`); - } - } - const coreGit2Owner = coreOptionalOwnerRule?.dependencies.find( - (dependency) => dependency.depName === 'git2', - ); - if (!coreGit2Owner?.ownerFeatures.includes('service-integrations')) { - throw new Error('core optional dependency owner rule must keep git2 under service-integrations'); - } - const servicesOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( - (rule) => rule.crateName === 'services-integrations', - ); - for (const dep of ['bitfun-runtime-ports', 'git2', 'notify', 'rmcp']) { - if (!servicesOptionalOwnerRule?.dependencies.some((dependency) => dependency.depName === dep)) { - throw new Error(`services-integrations optional dependency owner rule must cover ${dep}`); - } - } - const productDomainsOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( - (rule) => rule.crateName === 'product-domains', - ); - for (const dep of ['dirs', 'log', 'sha2']) { - if (!productDomainsOptionalOwnerRule?.dependencies.some((dependency) => dependency.depName === dep)) { - throw new Error(`product-domains optional dependency owner rule must cover ${dep}`); - } - } - const productDomainRuntimeRule = forbiddenContentUnderRules.find( - (rule) => rule.path === 'src/crates/product-domains/src', - ); - if (!productDomainRuntimeRule) { - throw new Error('missing product-domains runtime-owner boundary rule'); - } - const productDomainRuntimeContracts = [ - 'Command::new\\(', - 'process_manager::', - 'GitService::', - 'reqwest::', - 'tauri::', - ]; - const productDomainRuntimeRuleText = productDomainRuntimeRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of productDomainRuntimeContracts) { - if (!productDomainRuntimeRuleText.includes(contract)) { - throw new Error(`product-domains runtime boundary rule must forbid: ${contract}`); - } - } - const productDomainCommandRule = productDomainRuntimeRule.patterns.find((pattern) => - pattern.regex.source.includes('Command::new'), - ); - if ( - !productDomainCommandRule?.allowPaths?.includes( - 'src/crates/product-domains/src/miniapp/runtime.rs', - ) - ) { - throw new Error('product-domains Command::new exception must stay scoped to MiniApp runtime detection'); - } - const coreTypesProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core-types'); - if (!coreTypesProfile?.forbiddenNonOptionalDeps.includes('bitfun-ai-adapters')) { - throw new Error('core-types dependency profile must forbid ai-adapter dependencies'); - } - const runtimePortsProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'runtime-ports', - ); - if (!runtimePortsProfile?.forbiddenNonOptionalDeps.includes('bitfun-services-core')) { - throw new Error('runtime-ports dependency profile must forbid service implementations'); - } - const runtimeServicesRule = lightweightBoundaryRules.find( - (rule) => rule.crateName === 'runtime-services', - ); - if (!runtimeServicesRule?.forbiddenDeps.includes('bitfun-core')) { - throw new Error('runtime-services lightweight boundary must forbid bitfun-core'); - } - if (!runtimeServicesRule?.forbiddenDeps.includes('bitfun-services-integrations')) { - throw new Error('runtime-services lightweight boundary must forbid concrete service integrations'); - } - const runtimeServicesProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'runtime-services', - ); - if (!runtimeServicesProfile?.forbiddenNonOptionalDeps.includes('tool-runtime')) { - throw new Error('runtime-services dependency profile must forbid tool runtime implementations'); - } - const agentRuntimeRule = lightweightBoundaryRules.find( - (rule) => rule.crateName === 'agent-runtime', - ); - if (!agentRuntimeRule?.forbiddenDeps.includes('bitfun-core')) { - throw new Error('agent-runtime lightweight boundary must forbid bitfun-core'); - } - if (!agentRuntimeRule?.forbiddenDeps.includes('bitfun-services-integrations')) { - throw new Error('agent-runtime lightweight boundary must forbid concrete service integrations'); - } - const agentRuntimeProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'agent-runtime', - ); - if (!agentRuntimeProfile?.forbiddenNonOptionalDeps.includes('tauri')) { - throw new Error('agent-runtime dependency profile must forbid product surface dependencies'); - } - const agentToolsManifestRule = forbiddenContentUnderRules.find( - (rule) => rule.path === 'src/crates/agent-tools/src', - ); - if (!agentToolsManifestRule) { - throw new Error('missing agent-tools manifest-owner boundary rule'); - } - const agentToolsRuntimeForbiddenContracts = [ - 'GetToolSpecTool', - 'manifest_resolver', - 'unlocked_collapsed_tools', - 'ToolUseContext', - ]; - const agentToolsManifestRuleText = agentToolsManifestRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of agentToolsRuntimeForbiddenContracts) { - if (!agentToolsManifestRuleText.includes(contract)) { - throw new Error(`agent-tools manifest boundary rule must forbid: ${contract}`); - } - } - const toolPacksManifestRule = forbiddenContentUnderRules.find( - (rule) => rule.path === 'src/crates/tool-packs/src', - ); - if (!toolPacksManifestRule) { - throw new Error('missing tool-packs manifest-owner boundary rule'); - } - const toolPacksManifestRuleText = toolPacksManifestRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - const toolPacksManifestContracts = [ - 'GetToolSpecTool', - 'GET_TOOL_SPEC_TOOL_NAME', - 'manifest_resolver', - 'unlocked_collapsed_tools', - 'ToolExposure', - ]; - for (const contract of toolPacksManifestContracts) { - if (!toolPacksManifestRuleText.includes(contract)) { - throw new Error(`tool-packs manifest boundary rule must forbid: ${contract}`); - } - } - - const requiredContentContracts = [ - { - path: 'src/crates/runtime-ports/src/lib.rs', - contracts: [ - 'AgentTurnCancellationPort', - 'RemoteControlStatePort', - 'RuntimeEventSink', - 'RemoteWorkspaceFacts', - 'RemoteWorkspaceRuntimeHost', - 'RemoteWorkspacePort', - 'RemoteWorkspaceFileRuntimeHost', - 'RemoteProjectionPort', - 'RemoteInitialSyncRuntimeHost', - 'remote_workspace_contracts_preserve_workspace_and_session_facts', - 'remote_projection_contract_preserves_file_chunk_identity', - 'remote_image', - 'DialogTriggerSource', - 'dialog_trigger_source_reuses_agent_submission_source_contract', - 'DialogQueuePriority', - 'DialogSubmissionPolicy', - 'dialog_submission_policy_preserves_current_surface_queue_defaults', - 'DialogSubmitOutcome', - 'dialog_submit_outcome_preserves_started_and_queued_fields', - 'DialogSessionStateFact', - 'DialogSubmitQueueFacts', - 'DialogSubmitQueueAction', - 'dialog_policy_may_preempt', - 'resolve_dialog_submit_queue_action', - 'dialog_submit_queue_action_preserves_current_scheduler_routing_policy', - 'should_suppress_agent_session_cancelled_reply', - 'DialogTurnOutcomeKind', - 'should_skip_agent_session_reply', - 'agent_session_reply_decisions_preserve_cancel_suppression_boundary', - 'AgentSessionReplyRoute', - 'agent_session_reply_route_keeps_requester_fields', - 'DialogSteerOutcome', - 'dialog_steer_outcome_preserves_buffered_fields', - 'RoundInjectionKind', - 'RoundInjectionTarget', - 'RoundInjection', - 'DialogRoundPreemptSource', - 'DialogRoundInjectionSource', - 'round_injection_contract_keeps_kind_and_target_identity', - 'round_injection_source_contract_drains_portable_injections', - 'GoalModeState', - 'GoalModeInitialGoal', - 'GoalGenerationResult', - 'GoalVerificationResult', - 'GoalActivationResult', - 'GoalContinuationPlan', - 'goal_mode_state_requires_active_non_empty_goal', - 'goal_verification_result_serializes_current_wire_shape', - 'CompressionContract', - 'CompressionContractItem', - 'compression_contract_renders_model_visible_fields', - 'RelatedPath', - 'related_path_serializes_as_request_context_fact', - 'DelegationPolicy', - 'SubagentContextMode', - 'delegation_policy_child_blocks_recursive_spawn_without_losing_depth', - 'subagent_context_mode_preserves_fork_wire_value', - ], - }, - { - path: 'src/crates/runtime-services/src/lib.rs', - contracts: [ - 'RuntimeServices', - 'RuntimeServicesBuilder', - 'CapabilityAvailability', - 'RuntimeServicesProvider', - 'RuntimeServicesRegistry', - 'CapabilityMismatch', - 'require_capability', - ], - }, - { - path: 'src/crates/runtime-services/tests/runtime_services_contracts.rs', - contracts: [ - 'builder_requires_mandatory_runtime_services', - 'fake_provider_registers_required_and_remote_services_through_registry', - 'missing_optional_capability_returns_typed_unsupported_error', - 'capability_availability_reports_optional_service_status_without_side_effects', - 'builder_rejects_port_registered_under_the_wrong_capability', - 'registered_remote_ports_expose_owner_contract_methods', - ], - }, - { - path: 'src/crates/agent-runtime/src/scheduler.rs', - contracts: [ - 'BackgroundDeliveryFacts', - 'BackgroundDeliveryAction', - 'resolve_background_delivery_action', - 'follow_up_submission_policy', - 'SubmitAgentSessionFollowUp', - 'InjectIntoRunningTurn', - ], - }, - { - path: 'src/crates/agent-runtime/tests/scheduler_contracts.rs', - contracts: [ - 'background_delivery_injects_when_session_is_processing', - 'background_delivery_starts_agent_session_follow_up_when_session_is_not_processing', - 'background_delivery_follow_up_uses_agent_session_source_semantics', - 'background_delivery_injection_does_not_expose_follow_up_policy', - ], - }, - { - path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', - contracts: [ - 'bitfun_runtime_ports', - 'DelegationPolicy', - 'SubagentContextMode', - 'queue_timing', - ], - }, - { - path: 'src/crates/core/src/agentic/session/session_manager.rs', - contracts: [ - 'clone_prompt_cache', - 'start_dialog_turn_with_existing_context', - 'start_dialog_turn_with_existing_context_persists_turn_and_snapshot', - 'clone_prompt_cache_copies_runtime_and_persisted_entries', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', - contracts: [ - 'build_truncation_recovery_notice', - 'truncation_notice_for_interactive_tools_does_not_claim_file_write', - 'truncation_notice_for_write_tools_keeps_write_continuation_guidance', - 'denied_tool_messages', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/restrictions.rs', - contracts: ['denied_tool_messages', 'custom_deny_message_overrides_generic_runtime_error'], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_result_storage.rs', - contracts: ['write_once', 'file\\.flush\\(\\)\\.await'], - }, - { - path: 'src/crates/services-integrations/src/mcp/server/connection.rs', - contracts: [ - 'send_request_with_id', - 'initialize_timeout', - 'notifications/initialized', - 'pending\\.clear\\(\\)', - 'local_tool_calls_do_not_inherit_initialize_timeout', - 'local_initialize_uses_initialize_timeout', - ], - }, - { - path: 'src/crates/services-integrations/src/mcp/protocol/transport.rs', - contracts: ['send_request_with_id', '\\.flush\\(\\)\\s*\\.await'], - }, - { - path: 'src/crates/agent-tools/src/framework.rs', - contracts: [ - 'GET_TOOL_SPEC_TOOL_NAME', - 'ToolExposure', - 'ToolManifestPolicyTool', - 'resolve_tool_manifest_policy', - 'default_exposure', - 'build_tool_manifest_policy_tools', - 'build_collapsed_tool_stub_definition', - 'PromptVisibleToolManifestItem', - 'build_prompt_visible_tool_manifest_definitions', - 'ContextualToolManifestItem', - 'ToolCatalogSnapshotProvider', - 'GetToolSpecCatalogProvider', - 'ContextualVisibleTools', - 'ContextualToolManifest', - 'resolve_contextual_visible_tools', - 'resolve_contextual_tool_manifest', - 'resolve_contextual_visible_tools_from_provider', - 'resolve_contextual_tool_manifest_from_provider', - 'build_get_tool_spec_catalog_description_from_provider', - 'resolve_get_tool_spec_detail_from_provider', - 'build_get_tool_spec_description', - 'GetToolSpecCollapsedToolSummary', - 'GetToolSpecDetail', - 'summarize_get_tool_spec_collapsed_tools', - 'resolve_get_tool_spec_detail', - 'build_get_tool_spec_catalog_description', - 'get_tool_spec_input_schema', - 'get_tool_spec_short_description', - 'render_get_tool_spec_tool_use_message', - 'get_tool_spec_is_readonly', - 'get_tool_spec_is_concurrency_safe', - 'get_tool_spec_needs_permissions', - 'validate_get_tool_spec_input', - 'build_get_tool_spec_assistant_detail', - 'build_get_tool_spec_duplicate_load_result', - 'build_get_tool_spec_detail_result', - 'GetToolSpecExecutionPlan', - 'GetToolSpecExecutionError', - 'resolve_get_tool_spec_execution_plan', - 'resolve_get_tool_spec_execution_result_from_provider', - 'GetToolSpecRuntime', - 'call_results', - 'GetToolSpecLoadObservation', - 'collect_loaded_collapsed_tool_names', - 'CollapsedToolUsageError', - 'ToolExecutionAccessError', - 'validate_tool_allowed_by_list', - 'validate_collapsed_tool_usage', - 'sort_tool_manifest_definitions', - 'is_tool_collapsed', - 'get_collapsed_tool_names', - ], - }, - { - path: 'src/crates/agent-tools/src/file_guidance.rs', - contracts: [ - 'FILE_TOOL_GUIDANCE_PREFIX', - 'file_tool_guidance_message', - 'is_file_tool_guidance_message', - ], - }, - { - path: 'src/crates/agent-tools/src/file_read_freshness.rs', - contracts: [ - 'FileReadFreshnessFacts', - 'normalize_tool_file_content', - 'file_read_facts_content_matches', - 'file_read_facts_are_fresh', - ], - }, - { - path: 'src/crates/agent-tools/src/tool_result_storage.rs', - contracts: [ - 'ToolResultStoragePolicy', - 'PersistedToolOutput', - 'ToolResultPersistenceCandidate', - 'select_tool_result_indices_for_persistence', - 'sanitize_tool_result_file_component', - 'generate_tool_result_preview', - 'count_tool_result_lines', - 'tool_result_is_persisted_output', - 'build_persisted_tool_output_message', - ], - }, - { - path: 'src/crates/agent-tools/src/tool_execution_presentation.rs', - contracts: [ - 'TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES', - 'USER_STEERING_INTERRUPTED_MESSAGE', - 'ToolExecutionErrorPresentation', - 'render_tool_result_for_assistant', - 'truncate_tool_arguments_preview', - 'truncate_raw_tool_arguments_preview', - 'build_tool_execution_error_presentation', - 'build_user_steering_interrupted_presentation', - 'build_invalid_tool_call_error_message', - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/coordinator.rs', - contracts: [ - 'AgentSubmissionPort', - 'SessionTranscriptReader', - 'AgentTurnCancellationPort', - 'RemoteControlStatePort', - 'generic attachments', - 'DialogTriggerSource', - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/scheduler.rs', - contracts: [ - 'AgentSessionReplyRoute', - 'DialogQueuePriority', - 'DialogSessionStateFact', - 'DialogSteerOutcome', - 'DialogSubmissionPolicy', - 'DialogSubmitOutcome', - 'DialogSubmitQueueAction', - 'DialogSubmitQueueFacts', - 'DialogTurnOutcomeKind', - 'dialog_policy_may_preempt', - 'resolve_dialog_submit_queue_action', - 'should_skip_agent_session_reply_contract', - 'should_suppress_agent_session_cancelled_reply_contract', - ], - }, - { - path: 'src/crates/core/src/agentic/round_preempt.rs', - contracts: [ - 'bitfun_runtime_ports', - 'DialogRoundInjectionSource', - 'DialogRoundPreemptSource', - 'RoundInjection', - 'RoundInjectionKind', - 'RoundInjectionTarget', - 'SessionRoundInjectionBuffer', - ], - }, - { - path: 'src/crates/core/src/agentic/goal_mode/types.rs', - contracts: [ - 'bitfun_runtime_ports', - 'GoalActivationResult', - 'GoalContinuationPlan', - 'GoalGenerationResult', - 'GoalModeInitialGoal', - 'GoalModeState', - 'GoalVerificationResult', - 'GOAL_MODE_FUNC_AGENT', - ], - }, - { - path: 'src/crates/core/src/agentic/core/message.rs', - contracts: ['bitfun_runtime_ports', 'CompressionContract', 'CompressionContractItem'], - }, - { - path: 'src/crates/core/src/service/workspace/manager.rs', - contracts: ['bitfun_runtime_ports', 'RelatedPath'], - }, - { - path: 'src/crates/core/src/service_agent_runtime.rs', - contracts: [ - 'CoreServiceAgentRuntime', - 'remote_dialog_host', - 'remote_cancel_host', - 'remote_image_context', - 'load_remote_model_catalog', - 'RemoteModelCatalogFacts', - 'RemoteModelCapabilityFact', - 'RemoteReasoningModeFact', - 'build_remote_model_catalog', - 'update_remote_session_model', - 'normalize_remote_session_model_id', - 'normalize_remote_session_model_id_contract', - 'normalize_remote_model_selection', - 'normalize_remote_model_selection_contract', - 'remote_chat_messages_from_turns', - 'RemoteDialogSchedulerOutcomeFact', - 'remote_dialog_submit_outcome_from_scheduler', - 'RemoteChatHistoryTurn', - 'build_remote_chat_messages', - 'strip_remote_user_input_tags', - 'compress_remote_chat_data_url_for_mobile', - 'load_remote_chat_messages', - 'agent_submission_port', - 'agent_turn_cancellation_port', - 'remote_control_state_port', - 'CoreRemoteDialogRuntimeHost', - 'CoreRemoteCancelRuntimeHost', - 'CoreRemoteWorkspaceFileRuntimeHost', - 'CoreRemoteWorkspaceRuntimeHost', - 'CoreRemoteSessionRuntimeHost', - 'CoreRemotePollRuntimeHost', - 'CoreRemoteInteractionRuntimeHost', - 'CoreRemoteSessionTrackerHost', - 'RemoteExecutionDispatcher', - 'ImageContextData', - 'RemoteImageContextAdapter', - 'AgentSubmissionPort', - 'AgentTurnCancellationPort', - 'RemoteControlStatePort', - 'SessionTranscriptReader', - 'core_service_agent_runtime_owner_keeps_coordinator_port_contracts', - 'core_service_agent_runtime_owner_normalizes_remote_session_model_ids', - 'core_service_agent_runtime_owner_normalizes_remote_model_selection_aliases', - 'core_service_agent_runtime_owner_preserves_remote_chat_history_shape', - 'core_service_agent_runtime_owner_skips_in_progress_remote_assistant_history', - ], - }, - { - path: 'src/crates/services-integrations/src/remote_connect.rs', - contracts: [ - 'RemoteSessionStateTracker', - 'TrackerEvent', - 'RemoteSessionTrackerHost', - 'RemoteSessionTrackerRegistry', - 'make_slim_tool_params', - 'handle_agentic_event', - 'resolve_remote_agent_type', - 'RemoteImageContext', - 'build_remote_image_contexts', - 'resolve_remote_execution_image_contexts', - 'remote_session_restore_target', - 'RemoteCancelDecision', - 'resolve_remote_cancel_decision', - 'RemoteCancelTaskRequest', - 'RemoteCancelRuntimeHost', - 'cancel_remote_task', - 'RemoteChatHistoryTurn', - 'RemoteChatHistoryRound', - 'RemoteChatHistoryToolItem', - 'build_remote_chat_messages', - 'REMOTE_FILE_MAX_READ_BYTES', - 'REMOTE_FILE_MAX_CHUNK_BYTES', - 'resolve_remote_file_chunk_range', - 'remote_file_display_name', - 'RemoteWorkspaceFacts', - 'RemoteSessionMetadata', - 'remote_workspace_info_response', - 'remote_recent_workspaces_response', - 'remote_assistant_list_response', - 'RemoteWorkspaceRuntimeHost', - 'handle_remote_workspace_command', - 'remote_workspace_handler_preserves_response_shapes', - 'RemoteInitialSyncRuntimeHost', - 'generate_remote_initial_sync', - 'remote_session_info', - 'remote_session_list_response', - 'remote_initial_sync_response', - 'remote_messages_response', - 'RemoteSessionRuntimeHost', - 'handle_remote_session_command', - 'remote_session_handler_preserves_list_and_create_policy', - 'remote_session_handler_removes_tracker_after_delete_success', - 'RemotePollRuntimeHost', - 'handle_remote_poll_command', - 'remote_poll_handler_preserves_missing_workspace_error', - 'RemoteInteractionRuntimeHost', - 'handle_remote_interaction_command', - 'remote_interaction_handler_preserves_default_reject_reason', - 'RemoteDefaultModelsConfig', - 'RemoteModelConfig', - 'RemoteModelCatalog', - 'RemoteModelCapabilityFact', - 'RemoteReasoningModeFact', - 'RemoteModelFacts', - 'RemoteModelCatalogFacts', - 'build_remote_model_catalog', - 'RemoteModelCatalogPollDelta', - 'normalize_remote_session_model_id', - 'normalize_remote_model_selection', - 'remote_model_selection_needs_config', - 'RemoteDialogSchedulerOutcomeFact', - 'remote_dialog_submit_outcome_from_scheduler', - 'RemoteCommand', - 'RemoteResponse', - 'should_send_remote_model_catalog', - 'remote_model_catalog_poll_delta', - 'remote_no_change_poll_response', - 'remote_snapshot_poll_response', - 'remote_persisted_poll_response', - ], - }, - { - path: 'src/crates/services-integrations/tests/remote_connect_contracts.rs', - contracts: [ - 'remote_connect_command_wire_shape_lives_in_owner_contract', - 'remote_connect_response_wire_shape_lives_in_owner_contract', - 'remote_connect_model_catalog_delta_preserves_poll_invalidation_policy', - 'remote_connect_model_catalog_builder_preserves_config_shape', - 'remote_connect_model_selection_policy_owns_alias_and_config_reference_rules', - 'remote_connect_poll_helpers_preserve_delta_and_completion_policy', - 'remote_connect_image_context_policy_preserves_legacy_fallback_shape', - 'remote_connect_image_context_policy_prefers_explicit_contexts', - 'remote_connect_cancel_and_restore_policy_preserve_runtime_decisions', - 'remote_connect_dialog_submit_outcome_builder_preserves_scheduler_shape', - 'remote_chat_history_assembly_preserves_message_shape_and_item_order', - 'remote_chat_history_assembly_skips_in_progress_assistant_history', - 'remote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges', - 'remote_connect_file_transfer_policy_preserves_name_fallback', - 'remote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes', - 'remote_connect_tracker_registry_owns_lifecycle_without_core_state', - 'remote_connect_tracker_ignores_unrelated_direct_session_events', - 'remote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings', - 'remote_connect_workspace_response_helpers_own_wire_shape', - 'remote_connect_session_response_helpers_own_pagination_and_timestamps', - ], - }, - { - path: 'src/crates/core/src/service/remote_connect/remote_server.rs', - contracts: [ - 'CoreServiceAgentRuntime', - 'remote_image_context', - 'handle_remote_workspace_command', - 'handle_remote_session_command', - 'generate_remote_initial_sync', - 'handle_remote_poll_command', - 'handle_remote_interaction_command', - 'core_service_agent_runtime_owner_maps_remote_image_context', - 'remote_execution_prefers_unified_image_contexts_over_legacy_images', - 'remote_cancel_decision_preserves_current_turn_boundaries', - 'remote_restore_target_only_restores_cold_sessions_with_workspace_binding', - 'remote_command_snapshot_covers_execution_poll_and_cancel_surfaces', - 'remote_response_snapshot_preserves_active_turn_and_result_shapes', - ], - }, - { - path: 'src/crates/core/src/agentic/coordination/scheduler.rs', - contracts: ['remote_queue_policy_preserves_interactive_preempt_and_confirmation_boundary'], - }, - { - path: 'src/crates/core/src/agentic/tools/registry.rs', - contracts: [ - 'from_inner', - 'ProductToolDecoratorRef', - 'ProductToolRuntime', - 'get_collapsed_tool_names', - 'resolve_product_readonly_enabled_tools', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/product_runtime.rs', - contracts: [ - 'ProductToolRuntime', - 'SnapshotToolDecorator', - 'ProductSnapshotToolWrapper', - 'builtin_static_tool_providers', - 'StaticToolProviderGroup', - 'product_tool_provider_group_plan', - 'materialize_tool', - 'GetToolSpecTool', - 'ToolRuntimeAssembly', - 'create_registry_from_static_providers', - 'wrap_tool_for_snapshot_tracking', - 'ProductToolCatalogProvider', - 'ToolCatalogSnapshotProvider', - 'GetToolSpecCatalogProvider', - 'get_global_tool_registry', - 'get_agent_registry', - 'ToolCatalogRuntime', - 'product_tool_catalog_runtime', - 'GetToolSpecRuntime', - 'product_get_tool_spec_runtime', - 'resolve_product_tool_manifest', - 'resolve_product_readonly_enabled_tools', - 'resolve_product_get_tool_spec_results', - 'unlocked_collapsed_tools', - 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', - 'product_tool_runtime_owner_preserves_registry_contract', - 'GetToolSpec requires agent type context', - ], - }, - { - path: 'src/crates/agent-tools/src/framework.rs', - contracts: [ - 'ToolContextFacts', - 'PortableToolContextProvider', - 'ToolWorkspaceKind', - 'StaticToolProvider', - 'StaticToolProviderGroup', - 'ToolRuntimeAssembly', - 'ToolCatalogRuntime', - 'ToolDecoratorRef', - 'SnapshotToolWrapper', - 'SnapshotToolDecorator', - 'create_registry_from_static_providers', - 'install_static_provider', - 'resolve_readonly_enabled_tools', - 'build_get_tool_spec_duplicate_load_result', - 'build_get_tool_spec_detail_result', - 'resolve_get_tool_spec_execution_plan', - 'resolve_get_tool_spec_execution_result_from_provider', - 'GetToolSpecRuntime', - 'call_results', - ], - }, - { - path: 'src/crates/tool-packs/src/lib.rs', - contracts: [ - 'ToolPackFeatureGroup', - 'ToolProviderGroupPlan', - 'all_feature_groups', - 'enabled_feature_groups', - 'product_tool_provider_group_plan', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', - contracts: [ - 'ToolRegistryItem', - 'ContextualToolManifestItem', - 'Tool::dynamic_tool_info', - 'Tool::is_readonly', - 'Tool::is_enabled', - 'Tool::description_with_context', - 'Tool::input_schema_for_model_with_context', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - contracts: [ - 'resolve_tool_manifest', - 'GET_TOOL_SPEC_TOOL_NAME', - 'resolve_product_visible_tools', - 'resolve_product_tool_manifest', - 'collapsed_tool_names', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', - contracts: [ - 'GetToolSpecTool', - 'build_collapsed_tools_context_section', - 'product_get_tool_spec_runtime', - 'with_runtime', - 'resolve_product_get_tool_spec_results', - 'map_get_tool_spec_execution_error', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/framework.rs', - contracts: [ - 'ToolExposure', - 'ToolUseContext', - 'pub use crate::agentic::tools::tool_context_runtime::ToolUseContext', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_context_runtime.rs', - contracts: [ - 'pub struct ToolUseContext', - 'to_tool_context_facts', - 'impl PortableToolContextProvider for ToolUseContext', - 'tool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated', - 'customData', - 'cancellationToken', - 'unlocked_collapsed_tools', - 'impl ToolUseContext', - 'record_light_checkpoint', - 'call_with_tool_runtime_hooks', - 'call_tool_with_runtime_hooks', - 'call_records_deep_review_read_file_measurement_without_touching_result', - 'build_tool_use_context_for_task', - 'build_tool_description_context', - 'build_write_preflight_context', - 'ensure_current_workspace_runtime', - 'resolve_tool_path', - 'enforce_path_operation', - 'workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace', - 'runtime_uri_resolution_rejects_different_workspace_scope', - 'path_policy_allows_only_configured_local_roots', - 'tool_call_runtime_hook_returns_cancelled_before_impl_completes', - 'tool_task_context_materialization_preserves_runtime_fields', - 'tool_description_context_preserves_manifest_custom_data_shape', - 'write_preflight_context_preserves_minimal_runtime_fields', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', - contracts: [ - 'validate_collapsed_tool_usage', - 'unlocked_collapsed_tools', - 'GetToolSpec', - 'render_tool_result_for_assistant', - 'build_tool_execution_error_presentation', - 'build_user_steering_interrupted_presentation', - 'build_invalid_tool_call_error_message', - ], - }, - { - path: 'src/crates/core/src/agentic/execution/execution_engine.rs', - contracts: [ - 'collect_unlocked_collapsed_tools', - 'collect_unlocked_collapsed_tools_dedupes_and_filters_runtime_unlocks', - 'collapsed_tool_names', - 'GetToolSpec', - 'citation_renumber', - ], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/availability.rs', - contracts: ['resolve_availability', 'resolve_override_layers', 'AgentSubagentOverrideState', 'SubagentStateReason'], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/types.rs', - contracts: ['SubagentQueryContext', 'SubagentListScope', 'default_enabled', 'effective_enabled', 'SubagentStateReason'], - }, - { - path: 'src/crates/core/src/agentic/agents/definitions/modes/mod.rs', - contracts: ['mod multitask', 'MultitaskMode'], - }, - { - path: 'src/crates/core/src/agentic/agents/definitions/subagents/mod.rs', - contracts: ['mod general_purpose', 'GeneralPurposeAgent'], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/builtin.rs', - contracts: ['builtin_agent_specs', 'Multitask', 'GeneralPurpose'], - }, - { - path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', - contracts: [ - 'fork_context', - 'SubagentContextMode::Fork', - 'delegation_policy\\(\\)\\.spawn_child\\(\\)', - 'run_in_background', - 'start_background_subagent', - 'background_task_id', - 'Background \\{\\} started successfully', - ' rule.path === path); - if (matchingRules.length === 0) { - throw new Error(`missing owner content anchor rule for ${path}`); - } - const ruleText = matchingRules - .flatMap((rule) => rule.patterns) - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of contracts) { - if (!ruleText.includes(contract) && !ruleText.includes(escapeRegex(contract))) { - throw new Error(`owner content anchor rule for ${path} must require: ${contract}`); - } - } - } - - const remoteWorkspaceRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/remote_ssh/workspace_state.rs', - ); - if (!remoteWorkspaceRule) { - throw new Error('missing remote SSH workspace_state boundary rule'); - } - const remoteWorkspaceHelpers = [ - 'LOCAL_WORKSPACE_SSH_HOST', - 'normalize_remote_workspace_path', - 'sanitize_ssh_connection_id_for_local_dir', - 'sanitize_remote_mirror_path_component', - 'sanitize_ssh_hostname_for_mirror', - 'remote_root_to_mirror_subpath', - 'workspace_logical_key', - 'local_workspace_stable_storage_id', - 'remote_workspace_stable_id', - 'unresolved_remote_session_storage_key', - 'RegisteredRemoteWorkspace', - 'RemoteWorkspaceEntry', - 'RemoteWorkspaceState', - 'registration_matches_path', - 'dunce::canonicalize', - 'path_buf_to_stable_local_root_string', - 'join\\("_unresolved"\\)', - ]; - const ruleText = remoteWorkspaceRule.patterns.map((pattern) => pattern.regex.source).join('\n'); - for (const helper of remoteWorkspaceHelpers) { - if (!ruleText.includes(helper)) { - throw new Error(`remote SSH workspace boundary rule must forbid helper: ${helper}`); - } - } - - const announcementStateStoreRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/announcement/state_store.rs', - ); - if (!announcementStateStoreRule) { - throw new Error('missing announcement state store boundary rule'); - } - - const mcpProcessRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/server/process.rs', - ); - if (!mcpProcessRule) { - throw new Error('missing MCP server process boundary rule'); - } - const mcpProcessHelpers = [ - 'MCPServerType', - 'MCPServerStatus', - 'is_auth_error', - 'AUTHORIZATION_KEYS', - 'contains_key\\("Authorization"\\)', - 'process_manager::create_tokio_command', - 'MCPTransport::start_receive_loop', - 'MCPConnection::new_remote', - ]; - const mcpProcessRuleText = mcpProcessRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpProcessHelpers) { - if (!mcpProcessRuleText.includes(helper)) { - throw new Error(`MCP server process boundary rule must forbid helper: ${helper}`); - } - } - - const mcpManagerRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/server/manager/mod.rs', - ); - if (!mcpManagerRule) { - throw new Error('missing MCP server manager boundary rule'); - } - const mcpManagerHelpers = [ - 'ListChangedKind', - 'resource_catalog_cache', - 'prompt_catalog_cache', - ]; - const mcpManagerRuleText = mcpManagerRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpManagerHelpers) { - if (!mcpManagerRuleText.includes(helper)) { - throw new Error(`MCP server manager boundary rule must forbid helper: ${helper}`); - } - } - - const mcpReconnectRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/server/manager/reconnect.rs', - ); - if (!mcpReconnectRule) { - throw new Error('missing MCP reconnect boundary rule'); - } - if ( - !mcpReconnectRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n') - .includes('compute_backoff_delay') - ) { - throw new Error('MCP reconnect boundary rule must forbid compute_backoff_delay'); - } - - const mcpInteractionRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/server/manager/interaction.rs', - ); - if (!mcpInteractionRule) { - throw new Error('missing MCP interaction boundary rule'); - } - if ( - !mcpInteractionRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n') - .includes('detect_list_changed_kind') - ) { - throw new Error('MCP interaction boundary rule must forbid detect_list_changed_kind'); - } - - const mcpToolAdapterRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/adapter/tool.rs', - ); - if (!mcpToolAdapterRule) { - throw new Error('missing MCP tool adapter boundary rule'); - } - const mcpToolAdapterHelpers = [ - 'behavior_hints', - 'truncate_for_assistant', - 'MCPToolResultContent', - 'Tool', - ]; - const mcpToolAdapterRuleText = mcpToolAdapterRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpToolAdapterHelpers) { - if (!mcpToolAdapterRuleText.includes(helper)) { - throw new Error(`MCP tool adapter boundary rule must forbid helper: ${helper}`); - } - } - - const mcpContextAdapterRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/adapter/context.rs', - ); - if (!mcpContextAdapterRule) { - throw new Error('missing MCP context adapter boundary rule'); - } - const mcpContextAdapterHelpers = [ - 'ContextEnhancerConfig', - 'ContextEnhancer', - 'partial_cmp', - ]; - const mcpContextAdapterRuleText = mcpContextAdapterRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpContextAdapterHelpers) { - if (!mcpContextAdapterRuleText.includes(helper)) { - throw new Error(`MCP context adapter boundary rule must forbid helper: ${helper}`); - } - } - - const mcpJsonConfigRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/config/json_config.rs', - ); - if (!mcpJsonConfigRule) { - throw new Error('missing MCP JSON config boundary rule'); - } - const mcpJsonConfigHelpers = [ - 'normalize_source', - 'normalize_transport', - 'normalize_legacy_type', - 'mcpServers', - ]; - const mcpJsonConfigRuleText = mcpJsonConfigRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpJsonConfigHelpers) { - if (!mcpJsonConfigRuleText.includes(helper)) { - throw new Error(`MCP JSON config boundary rule must forbid helper: ${helper}`); - } - } - - const mcpConfigServiceRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/config/service.rs', - ); - if (!mcpConfigServiceRule) { - throw new Error('missing MCP config service boundary rule'); - } - const mcpConfigServiceHelpers = [ - 'AUTHORIZATION_KEYS', - 'config_signature', - 'precedence', - 'config_authorization_from_map', - 'BTreeMap', - ]; - const mcpConfigServiceRuleText = mcpConfigServiceRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpConfigServiceHelpers) { - if (!mcpConfigServiceRuleText.includes(helper)) { - throw new Error(`MCP config service boundary rule must forbid helper: ${helper}`); - } - } - - const mcpAuthRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/auth.rs', - ); - if (!mcpAuthRule) { - throw new Error('missing MCP auth boundary rule'); - } - const mcpAuthHelpers = [ - 'VaultFile', - 'NONCE_LEN', - 'encrypt_value', - 'decrypt_value', - 'AuthorizationManager::new', - 'OAuthState::new', - ]; - const mcpAuthRuleText = mcpAuthRule.patterns.map((pattern) => pattern.regex.source).join('\n'); - for (const helper of mcpAuthHelpers) { - if (!mcpAuthRuleText.includes(escapeRegex(helper))) { - throw new Error(`MCP auth boundary rule must forbid helper: ${helper}`); - } - } - - const mcpRemoteTransportRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', - ); - if (!mcpRemoteTransportRule) { - throw new Error('missing MCP remote transport boundary rule'); - } - const mcpRemoteTransportHelpers = [ - 'normalize_authorization_value', - 'starts_with\\("bearer "\\)', - 'build_client_info', - 'ClientCapabilities::builder', - 'map_(?:rmcp_)?initialize_result', - 'map_(?:rmcp_)?tool', - 'map_(?:rmcp_)?resource', - 'map_(?:rmcp_)?resource_content', - 'map_(?:rmcp_)?prompt', - 'map_(?:rmcp_)?prompt_message', - 'map_(?:rmcp_)?tool_result', - 'map_(?:rmcp_)?content_block', - 'map_(?:rmcp_)?icons', - 'map_(?:rmcp_)?annotations', - ]; - const mcpRemoteTransportRuleText = mcpRemoteTransportRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpRemoteTransportHelpers) { - if (!mcpRemoteTransportRuleText.includes(helper)) { - throw new Error(`MCP remote transport boundary rule must forbid helper: ${helper}`); - } - } - - const mcpJsonrpcRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/protocol/jsonrpc.rs', - ); - if (!mcpJsonrpcRule) { - throw new Error('missing MCP JSON-RPC boundary rule'); - } - const mcpJsonrpcHelpers = [ - 'serialize_params', - 'create_initialize_request', - 'create_resources_list_request', - 'create_resources_read_request', - 'create_prompts_list_request', - 'create_prompts_get_request', - 'create_tools_list_request', - 'create_tools_call_request', - 'create_ping_request', - ]; - const mcpJsonrpcRuleText = mcpJsonrpcRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const helper of mcpJsonrpcHelpers) { - if (!mcpJsonrpcRuleText.includes(helper)) { - throw new Error(`MCP JSON-RPC boundary rule must forbid helper: ${helper}`); - } - } - - const mcpServerConfigRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/mcp/server/config.rs', - ); - if (!mcpServerConfigRule) { - throw new Error('missing MCP server config boundary rule'); - } - const mcpServerConfigContracts = [ - 'MCPServerTransport', - 'MCPServerOAuthConfig', - 'MCPServerXaaConfig', - 'MCPServerConfig', - 'default_true', - 'resolved_transport', - 'validate', - ]; - const mcpServerConfigRuleText = mcpServerConfigRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of mcpServerConfigContracts) { - if (!mcpServerConfigRuleText.includes(contract)) { - throw new Error(`MCP server config boundary rule must forbid contract: ${contract}`); - } - } - - const servicesIntegrationsProfile = dependencyProfileRules.find( - (rule) => rule.crateName === 'services-integrations', - ); - for (const dep of ['dunce', 'futures', 'reqwest', 'sse-stream']) { - if (!servicesIntegrationsProfile?.forbiddenNonOptionalDeps.includes(dep)) { - throw new Error(`services-integrations default profile must forbid non-optional ${dep}`); - } - } - - const remoteConnectRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/core/src/service/remote_connect/remote_server.rs', - ); - if (!remoteConnectRule) { - throw new Error('missing remote-connect remote_server boundary rule'); - } - const remoteConnectContracts = [ - 'ImageAttachment', - 'ChatImageAttachment', - 'ChatMessage', - 'ChatMessageItem', - 'RemoteToolStatus', - 'ActiveTurnSnapshot', - 'SessionInfo', - 'RemoteDefaultModelsConfig', - 'RemoteModelConfig', - 'RemoteModelCatalog', - 'RemoteModelCatalogPollDelta', - 'RemoteCommand', - 'RemoteResponse', - 'TrackerState', - 'TrackerEvent', - 'RemoteSessionStateTracker', - 'DashMap', - 'make_slim_params', - 'match mobile_type', - 'RemoteCancelDecision', - 'resolve_remote_cancel_decision', - 'RemoteCancelTaskRequest', - 'RemoteCancelRuntimeHost', - 'cancel_remote_task', - 'remote_session_restore_target', - 'resolve_remote_execution_image_contexts', - 'RemoteImageContextAdapter', - 'MAX_SIZE', - 'MAX_CHUNK', - 'unwrap_or\\("file"\\)', - 'resolve_workspace_path', - 'detect_mime_type', - 'read_workspace_file', - 'read_remote_workspace_file', - 'read_remote_workspace_file_chunk', - 'read_remote_workspace_file_info', - 'remote_file_content_response', - 'remote_file_chunk_response', - 'remote_file_info_response', - 'handle_remote_workspace_file_command', - 'general_purpose::STANDARD\\.encode', - 'remote_dialog_submit_response', - 'remote_task_cancel_response', - 'remote_interaction_accepted_response', - 'remote_answer_question_response', - 'remote_workspace_info_response', - 'remote_recent_workspaces_response', - 'remote_assistant_list_response', - 'remote_workspace_updated_response', - 'remote_assistant_updated_response', - 'remote_session_info', - 'remote_session_list_response', - 'remote_initial_sync_response', - 'remote_session_created_response', - 'remote_session_model_updated_response', - 'remote_messages_response', - 'remote_session_deleted_response', - 'should_send_remote_model_catalog', - 'remote_model_catalog_poll_delta', - 'remote_no_change_poll_response', - 'remote_snapshot_poll_response', - 'remote_persisted_poll_response', - ]; - const remoteConnectRuleText = remoteConnectRule.patterns - .map((pattern) => pattern.regex.source) - .join('\n'); - for (const contract of remoteConnectContracts) { - if (!remoteConnectRuleText.includes(contract)) { - throw new Error(`remote-connect boundary rule must forbid contract: ${contract}`); - } - } - - const facadePaths = new Set(facadeOnlyFiles.map((facade) => facade.path)); - for (const path of [ - 'src/crates/core/src/service/mcp/protocol/transport.rs', - 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', - 'src/crates/core/src/service/mcp/server/connection.rs', - ]) { - if (!facadePaths.has(path)) { - throw new Error(`missing MCP runtime facade-only rule for ${path}`); - } - } -} - -function checkCargoManifest(crateDir) { - checkForbiddenManifestDeps(crateDir, ['bitfun-core'], () => { - return 'extracted crate must not depend on bitfun-core'; - }); -} - -function checkForbiddenManifestDeps(crateDir, forbiddenDeps, messageForDep) { - const manifestPath = join(crateDir, 'Cargo.toml'); - const lines = readText(manifestPath).split(/\r?\n/); - lines.forEach((line, index) => { - const trimmed = line.trim(); - if (trimmed.startsWith('#')) { - return; - } - for (const dep of forbiddenDeps) { - if (isManifestDependencyDeclaration(trimmed, dep)) { - failures.push({ - path: manifestPath, - line: index + 1, - message: messageForDep(dep), - }); - } - } - }); -} - -function checkForbiddenNonOptionalManifestDeps(crateDir, forbiddenDeps, messageForDep) { - const manifestPath = join(crateDir, 'Cargo.toml'); - const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); - for (const dep of deps) { - if (!dep.optional && forbiddenDeps.includes(dep.name)) { - failures.push({ - path: manifestPath, - line: dep.line, - message: messageForDep(dep.name), - }); - } - } -} - -function featureReferencesDependency(feature, depName) { - if (!feature) { - return false; - } - return feature.refs.includes(`dep:${depName}`) || feature.refs.includes(depName); -} - -function featureReferencesFeature(feature, featureName) { - if (!feature) { - return false; - } - return feature.refs.includes(featureName); -} - -function checkOptionalDependencyFeatureOwners(crateDir, rule) { - const manifestPath = join(crateDir, 'Cargo.toml'); - const lines = readText(manifestPath).split(/\r?\n/); - const deps = parseManifestDependencies(lines); - const depsByName = new Map(deps.map((dep) => [dep.name, dep])); - const features = parseManifestFeatures(lines); - const declaredOwnerDeps = new Set(rule.dependencies.map((dependency) => dependency.depName)); - - for (const dependency of rule.dependencies) { - const dep = depsByName.get(dependency.depName); - if (!dep) { - failures.push({ - path: manifestPath, - line: 1, - message: `${rule.reason}; missing optional dependency: ${dependency.depName}`, - }); - continue; - } - if (!dep.optional) { - failures.push({ - path: manifestPath, - line: dep.line, - message: `${rule.reason}; dependency must be optional: ${dependency.depName}`, - }); - } - for (const featureName of dependency.ownerFeatures) { - const feature = features.get(featureName); - if (!feature) { - failures.push({ - path: manifestPath, - line: dep?.line ?? 1, - message: `${rule.reason}; missing owner feature ${featureName} for ${dependency.depName}`, - }); - continue; - } - if (!featureReferencesDependency(feature, dependency.depName)) { - failures.push({ - path: manifestPath, - line: feature.line, - message: `${rule.reason}; ${featureName} must explicitly enable ${dependency.depName}`, - }); - } - } - } - - const profileRule = dependencyProfileRules.find((profile) => profile.crateName === rule.crateName); - const depsRequiringOwner = new Set(profileRule?.forbiddenNonOptionalDeps ?? []); - const uncoveredDeps = new Map(); - for (const dep of deps) { - if (!dep.optional || !depsRequiringOwner.has(dep.name) || declaredOwnerDeps.has(dep.name)) { - continue; - } - if (!uncoveredDeps.has(dep.name)) { - uncoveredDeps.set(dep.name, dep); - } - } - for (const [depName, dep] of uncoveredDeps.entries()) { - failures.push({ - path: manifestPath, - line: dep.line, - message: `${rule.reason}; optional runtime dependency must declare owner feature coverage: ${depName}`, - }); - } -} - -function checkProductCoreFeatureAssembly(rule) { - const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); - const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); - const dep = deps.find((candidate) => candidate.name === rule.dependencyName); - if (!dep) { - failures.push({ - path: manifestPath, - line: 1, - message: `${rule.reason}; missing dependency: ${rule.dependencyName}`, - }); - return; - } - - if (!manifestDependencyDisablesDefaultFeatures(dep)) { - failures.push({ - path: manifestPath, - line: dep.line, - message: `${rule.reason}; ${rule.dependencyName} must set default-features = false`, - }); - } - - const enabledFeatures = parseManifestDependencyFeatureNames(dep); - for (const featureName of rule.requiredFeatures) { - if (!enabledFeatures.has(featureName)) { - failures.push({ - path: manifestPath, - line: dep.line, - message: `${rule.reason}; ${rule.dependencyName} must enable feature ${featureName}`, - }); - } - } -} - -function checkProductCoreFeatureAssemblyCoverage() { - const rulePaths = new Set(productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath)); - for (const manifestPath of collectProductCoreDependencyManifests()) { - if (!rulePaths.has(manifestPath)) { - failures.push({ - path: join(ROOT, ...manifestPath.split('/')), - line: 1, - message: - 'product entry crate depends on bitfun-core but is not covered by product-full assembly rules', - }); - } - } -} - -function checkCoreDefaultProductFullFeature() { - const manifestPath = join(ROOT, 'src', 'crates', 'core', 'Cargo.toml'); - const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); - if (!featureReferencesFeature(features.get('default'), 'product-full')) { - failures.push({ - path: manifestPath, - line: features.get('default')?.line ?? 1, - message: - 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', - }); - } -} - -function checkCoreProductFullFeatureAssembly(rule) { - const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); - const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); - const productFull = features.get(rule.featureName); - if (!productFull) { - failures.push({ - path: manifestPath, - line: 1, - message: `${rule.reason}; missing ${rule.featureName} feature declaration`, - }); - return; - } - for (const featureName of rule.requiredFeatureRefs) { - if (!featureReferencesFeature(productFull, featureName)) { - failures.push({ - path: manifestPath, - line: productFull.line, - message: `${rule.reason}; ${rule.featureName} must explicitly enable ${featureName}`, - }); - } - } -} - -function checkOwnerCrateFeatureAssembly(rule) { - const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); - const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); - const allowedProductFullFeatures = new Set(rule.requiredProductFullFeatures); - const defaultFeature = features.get('default'); - if (!defaultFeature) { - failures.push({ - path: manifestPath, - line: 1, - message: `${rule.reason}; missing default feature declaration`, - }); - } else if (defaultFeature.refs.length > 0) { - failures.push({ - path: manifestPath, - line: defaultFeature.line, - message: `${rule.reason}; default feature must remain empty`, - }); - } - - const productFull = features.get('product-full'); - if (!productFull) { - failures.push({ - path: manifestPath, - line: 1, - message: `${rule.reason}; missing product-full feature declaration`, - }); - return; - } - - for (const featureName of rule.requiredProductFullFeatures) { - if (!featureReferencesFeature(productFull, featureName)) { - failures.push({ - path: manifestPath, - line: productFull.line, - message: `${rule.reason}; product-full must explicitly enable ${featureName}`, - }); - } - } - for (const featureName of productFull.refs) { - if (!allowedProductFullFeatures.has(featureName)) { - failures.push({ - path: manifestPath, - line: productFull.line, - message: `${rule.reason}; product-full must not include undeclared feature group ${featureName}`, - }); - } - } -} - -function checkRustImports(crateDir) { - const srcDir = join(crateDir, 'src'); - try { - if (!statSync(srcDir).isDirectory()) { - return; - } - } catch { - return; - } - - walkFiles(srcDir, (path) => { - if (!path.endsWith('.rs')) { - return; - } - const lines = readText(path).split(/\r?\n/); - lines.forEach((line, index) => { - if (/\bbitfun_core::/.test(line)) { - failures.push({ - path, - line: index + 1, - message: 'extracted crate must not import bitfun_core', - }); - } - }); - }); -} - -function checkForbiddenRustImports(crateDir, forbiddenDeps, messageForDep) { - const srcDir = join(crateDir, 'src'); - try { - if (!statSync(srcDir).isDirectory()) { - return; - } - } catch { - return; - } - - const forbiddenImports = forbiddenDeps.map((dep) => ({ - dep, - pattern: new RegExp(`\\b${escapeRegex(rustImportName(dep))}::`), - })); - - walkFiles(srcDir, (path) => { - if (!path.endsWith('.rs')) { - return; - } - const lines = readText(path).split(/\r?\n/); - lines.forEach((line, index) => { - for (const forbidden of forbiddenImports) { - if (forbidden.pattern.test(line)) { - failures.push({ - path, - line: index + 1, - message: messageForDep(forbidden.dep), - }); - } - } - }); - }); -} - -function createFacadeLineChecker(importPrefix) { - let inPubUseBlock = false; - const escapedPrefix = escapeRegex(importPrefix); - const singleReexportPattern = new RegExp( - `^pub use ${escapedPrefix}(?:::[A-Za-z_][A-Za-z0-9_]*)*(?:::\\*)?;$`, - ); - const blockItemPattern = /^[A-Za-z_][A-Za-z0-9_]*(?:,\s*[A-Za-z_][A-Za-z0-9_]*)*,?$/; - const blockStart = `pub use ${importPrefix}::{`; - - const checker = (line) => { - const trimmed = line.trim(); - if ( - trimmed === '' || - trimmed.startsWith('//') || - trimmed.startsWith('/*') || - trimmed.startsWith('*') || - trimmed.startsWith('*/') - ) { - return true; - } - - if (inPubUseBlock) { - if (trimmed === '};') { - inPubUseBlock = false; - return true; - } - return blockItemPattern.test(trimmed); - } - - if (singleReexportPattern.test(trimmed)) { - return true; - } - - if (trimmed.startsWith(blockStart)) { - if (trimmed.endsWith('};')) { - return true; - } - if (trimmed.endsWith('{')) { - inPubUseBlock = true; - return true; - } - } - - return false; - }; - - checker.isComplete = () => !inPubUseBlock; - return checker; -} - -function checkFacadeOnlyFile(repoPath, importPrefix, reason) { - const path = join(ROOT, ...repoPath.split('/')); - const acceptsLine = createFacadeLineChecker(importPrefix); - const lines = readText(path).split(/\r?\n/); - lines.forEach((line, index) => { - if (!acceptsLine(line)) { - failures.push({ - path, - line: index + 1, - message: reason, - }); - } - }); - - if (!acceptsLine.isComplete()) { - failures.push({ - path, - line: lines.length, - message: `${reason}; unterminated pub use block`, - }); - } -} - -function checkForbiddenContent(repoPath, patterns) { - const path = join(ROOT, ...repoPath.split('/')); - const lines = readText(path).split(/\r?\n/); - lines.forEach((line, index) => { - for (const pattern of patterns) { - if (pattern.regex.test(line)) { - failures.push({ - path, - line: index + 1, - message: pattern.message, - }); - } - } - }); -} - -function checkRequiredContent(repoPath, patterns, reason) { - const path = join(ROOT, ...repoPath.split('/')); - const text = readText(path); - for (const pattern of patterns) { - if (!pattern.regex.test(text)) { - failures.push({ - path, - line: 1, - message: `${reason}; ${pattern.message}`, - }); - } - } -} - -function checkForbiddenContentUnder(repoDir, patterns, reason) { - const dir = join(ROOT, ...repoDir.split('/')); - walkFiles(dir, (path) => { - if (!path.endsWith('.rs')) { - return; - } - const repoPath = toRepoPath(path); - const lines = readText(path).split(/\r?\n/); - lines.forEach((line, index) => { - for (const pattern of patterns) { - if (pattern.allowPaths?.includes(repoPath)) { - continue; - } - if (pattern.regex.test(line)) { - failures.push({ - path, - line: index + 1, - message: `${reason}; ${pattern.message}`, - }); - } - } - }); - }); -} - -if (process.env.BITFUN_BOUNDARY_CHECK_SELF_TEST === '1') { - runManifestParserSelfTest(); - console.log('Core boundary check self-test passed.'); - process.exit(0); -} - -for (const crateName of noCoreDependencyCrates) { - const crateDir = join(ROOT, 'src', 'crates', crateName); - checkCargoManifest(crateDir); - checkRustImports(crateDir); -} - -for (const rule of lightweightBoundaryRules) { - const crateDir = join(ROOT, 'src', 'crates', rule.crateName); - const messageForDep = (dep) => `${rule.reason}; forbidden dependency: ${dep}`; - checkForbiddenManifestDeps(crateDir, rule.forbiddenDeps, messageForDep); - checkForbiddenRustImports(crateDir, rule.forbiddenDeps, messageForDep); -} - -for (const rule of dependencyProfileRules) { - const crateDir = join(ROOT, 'src', 'crates', rule.crateName); - const messageForDep = (dep) => - `${rule.reason}; ${rule.profileName} forbids non-optional dependency: ${dep}`; - checkForbiddenNonOptionalManifestDeps(crateDir, rule.forbiddenNonOptionalDeps, messageForDep); -} - -for (const rule of optionalDependencyFeatureOwnerRules) { - const crateDir = join(ROOT, 'src', 'crates', rule.crateName); - checkOptionalDependencyFeatureOwners(crateDir, rule); -} - -for (const rule of productCoreFeatureAssemblyRules) { - checkProductCoreFeatureAssembly(rule); -} -checkProductCoreFeatureAssemblyCoverage(); -checkCoreDefaultProductFullFeature(); -checkCoreProductFullFeatureAssembly(coreProductFullFeatureAssemblyRule); -for (const rule of ownerCrateFeatureAssemblyRules) { - checkOwnerCrateFeatureAssembly(rule); -} - -for (const facade of facadeOnlyFiles) { - checkFacadeOnlyFile(facade.path, facade.importPrefix, facade.reason); -} - -for (const rule of forbiddenContentRules) { - checkForbiddenContent(rule.path, rule.patterns); -} - -for (const rule of forbiddenContentUnderRules) { - checkForbiddenContentUnder(rule.path, rule.patterns, rule.reason); -} - -for (const rule of requiredContentRules) { - checkRequiredContent(rule.path, rule.patterns, rule.reason); -} - -if (failures.length > 0) { - console.error('Core boundary check failed.'); - for (const failure of failures) { - console.error(`${toRepoPath(failure.path)}:${failure.line}: ${failure.message}`); - } - process.exit(1); -} - -console.log('Core boundary check passed.'); +runCoreBoundaryCheck(); diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs new file mode 100644 index 0000000000..eb432a4e8b --- /dev/null +++ b/scripts/check-core-boundaries.test.mjs @@ -0,0 +1,65 @@ +import { access, readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url); +const MODULES = [ + './core-boundaries/checker.mjs', + './core-boundaries/self-test.mjs', + './core-boundaries/rules/crate-rules.mjs', + './core-boundaries/rules/feature-rules.mjs', + './core-boundaries/rules/source-rules.mjs', + './core-boundaries/rules/source/facade-rules.mjs', + './core-boundaries/rules/source/forbidden-rules.mjs', + './core-boundaries/rules/source/required-rules.mjs', +]; + +test('core boundary check is split into focused modules', async () => { + const entrypoint = await readFile(ENTRYPOINT, 'utf8'); + assert.ok( + entrypoint.split(/\r?\n/).length <= 20, + 'entrypoint should stay a thin wrapper around core-boundaries modules', + ); + assert.match(entrypoint, /core-boundaries\/checker\.mjs/); + + for (const modulePath of MODULES) { + await access(new URL(modulePath, import.meta.url)); + } + + const checker = await readFile(new URL('./core-boundaries/checker.mjs', import.meta.url), 'utf8'); + assert.ok( + checker.split(/\r?\n/).length <= 1200, + 'checker should stay focused on orchestration and shared check helpers', + ); + + const sourceRuleEntry = await readFile( + new URL('./core-boundaries/rules/source-rules.mjs', import.meta.url), + 'utf8', + ); + assert.ok( + sourceRuleEntry.split(/\r?\n/).length <= 40, + 'source rule entrypoint should delegate to focused source-rule modules', + ); +}); + +test('split core boundary check keeps self-test and default execution behavior', () => { + const selfTest = spawnSync( + process.execPath, + ['scripts/check-core-boundaries.mjs'], + { + cwd: new URL('..', import.meta.url), + env: { ...process.env, BITFUN_BOUNDARY_CHECK_SELF_TEST: '1' }, + encoding: 'utf8', + }, + ); + assert.equal(selfTest.status, 0, selfTest.stderr || selfTest.stdout); + assert.match(selfTest.stdout, /Core boundary check self-test passed\./); + + const defaultRun = spawnSync(process.execPath, ['scripts/check-core-boundaries.mjs'], { + cwd: new URL('..', import.meta.url), + encoding: 'utf8', + }); + assert.equal(defaultRun.status, 0, defaultRun.stderr || defaultRun.stdout); + assert.match(defaultRun.stdout, /Core boundary check passed\./); +}); diff --git a/scripts/check-repo-hygiene.mjs b/scripts/check-repo-hygiene.mjs index a69eddf6bf..725b3b962d 100644 --- a/scripts/check-repo-hygiene.mjs +++ b/scripts/check-repo-hygiene.mjs @@ -1,5 +1,19 @@ #!/usr/bin/env node +/** + * Repository hygiene guardrails for tracked and untracked workspace files. + * + * Current scanning rules: + * - Always check filenames for transient review prompts and sensitive key/cert names. + * - Scan changed text files for private key markers and token-like secrets. + * - Scan changed text files for local absolute paths that look workspace- or user-specific: + * Windows drive paths under folders such as Users, workspace, Projects, code, dev, tmp; + * file:// Windows paths under those folders; and Unix paths under /Users or /home. + * - Skip generated and dependency outputs such as node_modules, dist, target, Monaco assets, + * mobile-web dist, relay static assets, and lockfiles. + * - Skip local-path and token checks in recognized test files; also skip local-path checks for + * comment-only lines and Rust inline test blocks inside non-test source files. + */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import path from 'node:path'; @@ -67,6 +81,7 @@ const ignoredContentPaths = [ /(^|\/)node_modules\//, /(^|\/)dist\//, /(^|\/)target\//, + /(^|\/)src\/apps\/relay-server\/static\/assets\//, /(^|\/)src\/web-ui\/public\/monaco-editor\//, /(^|\/)src\/mobile-web\/dist\//, /(^|\/).*package-lock\.json$/, @@ -82,11 +97,27 @@ const temporaryPromptNames = new Set([ ]); const sensitiveFilenamePattern = /(^|[._-])(id_rsa|id_dsa|id_ecdsa|id_ed25519)([._-]|$)|\.(pem|p12|pfx|mobileprovision)$/i; +const windowsLocalPathSegment = String.raw`[A-Za-z]:[\\/](?:Users|Documents and Settings|workspace|workspaces|work|Projects|code|dev|repos?|src|tmp|temp)(?:[\\/][^\s'"\`)<\]]+)*`; const localAbsolutePathPattern = - /(^|[^A-Za-z])((?:[A-Za-z]:[\\/][^\s'"`)<\]]+)|(?:file:\/\/\/[A-Za-z]:\/[^\s'"`)<\]]+))/g; + new RegExp( + String.raw`(^|[^A-Za-z])((?:${windowsLocalPathSegment})|(?:file:\/\/\/(?:Users|Documents and Settings|workspace|workspaces|work|Projects|code|dev|repos?|src|tmp|temp)(?:\/[^\s'"\`)<\]]+)*)|(?:file:\/\/\/[A-Za-z]:\/(?:Users|Documents and Settings|workspace|workspaces|work|Projects|code|dev|repos?|src|tmp|temp)(?:\/[^\s'"\`)<\]]+)*)|(?:\/(?:Users|home)\/[^\s'"\`)<\]]+))`, + 'g', + ); const tokenPattern = /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g; const privateKeyPattern = /-----BEGIN (?:RSA |DSA |EC |OPENSSH |)?PRIVATE KEY-----/; +const slashCommentExtensions = new Set([ + '.cjs', + '.css', + '.js', + '.jsx', + '.mjs', + '.rs', + '.scss', + '.ts', + '.tsx', +]); +const hashCommentExtensions = new Set(['.toml', '.yaml', '.yml']); const violations = []; @@ -104,6 +135,79 @@ function addViolation(file, line, message) { violations.push(line ? `${file}:${line} ${message}` : `${file} ${message}`); } +function countMatches(line, pattern) { + return (line.match(pattern) || []).length; +} + +function isCommentOnlyLine(line, ext) { + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return false; + } + + if (slashCommentExtensions.has(ext)) { + return ( + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.startsWith('*/') + ); + } + + if (hashCommentExtensions.has(ext)) { + return trimmed.startsWith('#'); + } + + return false; +} + +function getRustInlineTestSkipLines(lines) { + const skipLines = new Array(lines.length).fill(false); + let braceDepth = 0; + let pendingCfgTestModule = false; + let pendingTestFunction = false; + const activeBlocks = []; + + for (const [index, line] of lines.entries()) { + const trimmed = line.trim(); + + if (activeBlocks.length > 0 || pendingCfgTestModule || pendingTestFunction) { + skipLines[index] = true; + } + + if (/^#\[\s*cfg\s*\(\s*test\s*\)\s*\]$/.test(trimmed)) { + pendingCfgTestModule = true; + skipLines[index] = true; + } + + if (/^#\[\s*(?:[A-Za-z0-9_]+::)*test(?:\s*\(|\s*\])/.test(trimmed)) { + pendingTestFunction = true; + skipLines[index] = true; + } + + if (pendingCfgTestModule && /\bmod\b/.test(trimmed) && line.includes('{')) { + activeBlocks.push({ startDepth: braceDepth + 1 }); + pendingCfgTestModule = false; + skipLines[index] = true; + } + + if (pendingTestFunction && /\bfn\b/.test(trimmed) && line.includes('{')) { + activeBlocks.push({ startDepth: braceDepth + 1 }); + pendingTestFunction = false; + skipLines[index] = true; + } + + braceDepth += countMatches(line, /\{/g) - countMatches(line, /\}/g); + + while (activeBlocks.length > 0 && braceDepth < activeBlocks[activeBlocks.length - 1].startDepth) { + activeBlocks.pop(); + } + } + + return skipLines; +} + for (const file of repositoryFiles) { const normalized = normalizePath(file); const basename = path.posix.basename(normalized).toLowerCase(); @@ -131,22 +235,28 @@ for (const file of repositoryFiles) { } const isTestFile = testFilePattern.test(normalized); + const ext = path.extname(normalized).toLowerCase(); const scanLocalPaths = !isTestFile; const scanTokenLikeSecrets = !isTestFile; const lines = content.split(/\r?\n/); + const rustInlineTestSkipLines = ext === '.rs' ? getRustInlineTestSkipLines(lines) : null; for (const [index, line] of lines.entries()) { const lineNumber = index + 1; + const isInlineRustTestLine = rustInlineTestSkipLines?.[index] === true; + const skipLocalPathScan = + isInlineRustTestLine || isCommentOnlyLine(line, ext); + const skipTokenScan = isInlineRustTestLine; if (privateKeyPattern.test(line)) { addViolation(file, lineNumber, 'contains a private key marker.'); } - if (scanTokenLikeSecrets && tokenPattern.test(line)) { + if (scanTokenLikeSecrets && !skipTokenScan && tokenPattern.test(line)) { addViolation(file, lineNumber, 'contains a token-like secret.'); } - if (scanLocalPaths && localAbsolutePathPattern.test(line)) { + if (scanLocalPaths && !skipLocalPathScan && localAbsolutePathPattern.test(line)) { addViolation(file, lineNumber, 'contains a local absolute path.'); } diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs new file mode 100644 index 0000000000..560f77d9bb --- /dev/null +++ b/scripts/core-boundaries/checker.mjs @@ -0,0 +1,956 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +import { + dependencyProfileRules, + lightweightBoundaryRules, + noCoreDependencyCrates, +} from './rules/crate-rules.mjs'; +import { + crateLayoutLayerNames, + crateLayoutRules, + cratePathForName, +} from './rules/crate-layout.mjs'; +import { + coreProductFullFeatureAssemblyRule, + optionalDependencyFeatureOwnerRules, + ownerCrateFeatureAssemblyRules, + productCoreFeatureAssemblyRules, + productCoreFeatureAssemblyScanRoots, +} from './rules/feature-rules.mjs'; +import { + facadeOnlyFiles, + forbiddenContentRules, + forbiddenContentUnderRules, + requiredContentRules, +} from './rules/source-rules.mjs'; +import { runManifestParserSelfTest } from './self-test.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..', '..'); +const failures = []; + +function toRepoPath(path) { + return relative(ROOT, path).replace(/\\/g, '/'); +} + +function readText(path) { + return readFileSync(path, 'utf8'); +} + +function repoPathToFsPath(repoPath) { + return join(ROOT, ...repoPath.split('/')); +} + +function crateDirForName(crateName) { + const repoPath = cratePathForName(crateName); + if (!repoPath) { + failures.push({ + path: ROOT, + line: 1, + message: `missing crate layout rule for ${crateName}`, + }); + return join(ROOT, 'src', 'crates', crateName); + } + return repoPathToFsPath(repoPath); +} + +function walkFiles(dir, visit) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + walkFiles(path, visit); + continue; + } + visit(path); + } +} + +function rustImportName(depName) { + return depName.replace(/-/g, '_'); +} + +function escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function regexSourceContainsContract(ruleText, contract) { + const slashEscapedContract = contract.replaceAll('/', '\\/'); + const escapedContract = escapeRegex(contract); + const slashEscapedRegexContract = escapedContract.replaceAll('/', '\\/'); + return ( + ruleText.includes(contract) || + ruleText.includes(slashEscapedContract) || + ruleText.includes(escapedContract) || + ruleText.includes(slashEscapedRegexContract) + ); +} + +function manifestDependencyHeaderPattern(depName) { + const depPattern = `(?:${escapeRegex(depName)}|"${escapeRegex(depName)}")`; + return new RegExp( + `^\\[(?:target\\.[^\\]]+\\.)?(?:dependencies|dev-dependencies|build-dependencies)\\.${depPattern}\\]$`, + ); +} + +function isManifestDependencyDeclaration(trimmedLine, depName) { + const isInlineDependency = new RegExp(`^${escapeRegex(depName)}\\s*=`).test(trimmedLine); + const isDependencyTable = manifestDependencyHeaderPattern(depName).test(trimmedLine); + return isInlineDependency || isDependencyTable; +} + +function isDependencyListHeader(trimmedLine) { + return /^\[(?:target\.[^\]]+\.)?(?:dependencies|dev-dependencies|build-dependencies)\]$/.test( + trimmedLine, + ); +} + +function parseManifestDependencies(lines) { + const deps = []; + let inDependencyList = false; + let currentTable = null; + let currentInline = null; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed === '') { + return; + } + + if (currentInline) { + currentInline.text.push(trimmed); + if (/\boptional\s*=\s*true\b/.test(trimmed)) { + currentInline.optional = true; + } + if (trimmed.includes('}')) { + currentInline = null; + } + return; + } + + const headerMatch = trimmed.match(/^\[(.+)]$/); + if (headerMatch) { + inDependencyList = isDependencyListHeader(trimmed); + currentTable = null; + for (const depName of collectKnownDependencyNames()) { + if (manifestDependencyHeaderPattern(depName).test(trimmed)) { + currentTable = { + name: depName, + line: index + 1, + optional: false, + text: [trimmed], + }; + deps.push(currentTable); + break; + } + } + return; + } + + if (currentTable) { + currentTable.text.push(trimmed); + if (/\boptional\s*=\s*true\b/.test(trimmed)) { + currentTable.optional = true; + } + return; + } + + if (!inDependencyList) { + return; + } + + const inlineMatch = trimmed.match(/^([A-Za-z0-9_-]+|"[A-Za-z0-9_-]+")\s*=/); + if (inlineMatch) { + const name = inlineMatch[1].replace(/^"|"$/g, ''); + deps.push({ + name, + line: index + 1, + optional: /\boptional\s*=\s*true\b/.test(trimmed), + text: [trimmed], + }); + if (trimmed.includes('{') && !trimmed.includes('}')) { + currentInline = deps[deps.length - 1]; + } + return; + } + + }); + + return deps; +} + +function manifestDependencyText(dep) { + return dep?.text?.join('\n') ?? ''; +} + +function manifestDependencyDisablesDefaultFeatures(dep) { + return /\bdefault-features\s*=\s*false\b/.test(manifestDependencyText(dep)); +} + +function parseManifestDependencyFeatureNames(dep) { + const features = new Set(); + const text = manifestDependencyText(dep); + for (const match of text.matchAll(/\bfeatures\s*=\s*\[([\s\S]*?)\]/g)) { + for (const featureMatch of match[1].matchAll(/"([^"]+)"/g)) { + features.add(featureMatch[1]); + } + } + return features; +} + +function collectProductCoreDependencyManifestPaths(manifestEntries) { + return manifestEntries + .filter((entry) => { + const deps = parseManifestDependencies(entry.text.split(/\r?\n/)); + return deps.some((dep) => dep.name === 'bitfun-core'); + }) + .map((entry) => entry.manifestPath) + .sort(); +} + +function collectProductCoreDependencyManifests(scanRoots = productCoreFeatureAssemblyScanRoots) { + const manifestEntries = []; + for (const repoDir of scanRoots) { + const dir = join(ROOT, ...repoDir.split('/')); + walkFiles(dir, (path) => { + if (!path.endsWith('Cargo.toml')) { + return; + } + manifestEntries.push({ + manifestPath: toRepoPath(path), + text: readText(path), + }); + }); + } + return collectProductCoreDependencyManifestPaths(manifestEntries); +} + +function parseManifestFeatures(lines) { + const features = new Map(); + let inFeatures = false; + let currentFeature = null; + + const appendRefs = (feature, text) => { + const refs = [...text.matchAll(/"([^"]+)"/g)].map((match) => match[1]); + feature.refs.push(...refs); + }; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed === '') { + return; + } + + const headerMatch = trimmed.match(/^\[(.+)]$/); + if (headerMatch) { + inFeatures = trimmed === '[features]'; + currentFeature = null; + return; + } + + if (!inFeatures) { + return; + } + + if (currentFeature) { + appendRefs(currentFeature, trimmed); + if (trimmed.includes(']')) { + currentFeature = null; + } + return; + } + + const featureMatch = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/); + if (!featureMatch) { + return; + } + + const feature = { + name: featureMatch[1], + line: index + 1, + refs: [], + }; + appendRefs(feature, featureMatch[2]); + features.set(feature.name, feature); + if (featureMatch[2].includes('[') && !featureMatch[2].includes(']')) { + currentFeature = feature; + } + }); + + return features; +} + +function collectKnownDependencyNames() { + return Array.from( + new Set([ + 'bitfun-core', + ...lightweightBoundaryRules.flatMap((rule) => rule.forbiddenDeps), + ...dependencyProfileRules.flatMap((rule) => rule.forbiddenNonOptionalDeps), + ...optionalDependencyFeatureOwnerRules.flatMap((rule) => + rule.dependencies.map((dependency) => dependency.depName), + ), + ...productCoreFeatureAssemblyRules.map((rule) => rule.dependencyName), + ]), + ); +} + +function parseWorkspaceMembers() { + const manifestPath = join(ROOT, 'Cargo.toml'); + const lines = readText(manifestPath).split(/\r?\n/); + const members = []; + let inMembers = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!inMembers) { + if (trimmed === 'members = [' || trimmed.startsWith('members = [')) { + inMembers = true; + } + } + if (!inMembers) { + continue; + } + for (const match of trimmed.matchAll(/"([^"]+)"/g)) { + members.push(match[1]); + } + if (trimmed.includes(']')) { + break; + } + } + return members; +} + +function checkCrateLayoutRules() { + const manifestPath = join(ROOT, 'Cargo.toml'); + const requiredCrateNames = new Set(['core', ...noCoreDependencyCrates]); + const layoutCrateNames = new Set(); + const layoutPaths = new Set(); + const workspaceMembers = new Set(parseWorkspaceMembers()); + const expectedWorkspaceCratePaths = new Set(crateLayoutRules.map((rule) => rule.path)); + + for (const rule of crateLayoutRules) { + if (!crateLayoutLayerNames.includes(rule.layer)) { + failures.push({ + path: manifestPath, + line: 1, + message: `crate layout rule for ${rule.crateName} uses unknown layer ${rule.layer}`, + }); + } + if (layoutCrateNames.has(rule.crateName)) { + failures.push({ + path: manifestPath, + line: 1, + message: `duplicate crate layout rule for ${rule.crateName}`, + }); + } + if (layoutPaths.has(rule.path)) { + failures.push({ + path: manifestPath, + line: 1, + message: `duplicate crate layout path ${rule.path}`, + }); + } + layoutCrateNames.add(rule.crateName); + layoutPaths.add(rule.path); + + const crateManifestPath = repoPathToFsPath(`${rule.path}/Cargo.toml`); + if (!existsSync(crateManifestPath)) { + failures.push({ + path: manifestPath, + line: 1, + message: `crate layout path for ${rule.crateName} is missing Cargo.toml: ${rule.path}`, + }); + } + if (!workspaceMembers.has(rule.path)) { + failures.push({ + path: manifestPath, + line: 1, + message: `workspace members must include crate layout path: ${rule.path}`, + }); + } + } + + for (const crateName of requiredCrateNames) { + if (!layoutCrateNames.has(crateName)) { + failures.push({ + path: manifestPath, + line: 1, + message: `crate layout rules must cover workspace owner crate: ${crateName}`, + }); + } + } + + for (const member of workspaceMembers) { + if (!member.startsWith('src/crates/')) { + continue; + } + if (!expectedWorkspaceCratePaths.has(member)) { + failures.push({ + path: manifestPath, + line: 1, + message: `workspace crate member must use an approved layered path: ${member}`, + }); + } + } + + const cratesRoot = join(ROOT, 'src', 'crates'); + const allowedRootEntries = new Set([...crateLayoutLayerNames, 'LOGGING.md']); + for (const entry of readdirSync(cratesRoot)) { + if (allowedRootEntries.has(entry)) { + continue; + } + const entryPath = join(cratesRoot, entry); + if (statSync(entryPath).isDirectory() && existsSync(join(entryPath, 'Cargo.toml'))) { + failures.push({ + path: entryPath, + line: 1, + message: `workspace crate must live under a layer directory, not directly under src/crates: ${entry}`, + }); + } + } +} + +function forbiddenRuleTextForPath(path) { + return forbiddenContentRules + .filter((rule) => rule.path === path) + .flatMap((rule) => rule.patterns) + .map((pattern) => pattern.regex.source) + .join('\n'); +} + +function checkCargoManifest(crateDir) { + checkForbiddenManifestDeps(crateDir, ['bitfun-core'], () => { + return 'extracted crate must not depend on bitfun-core'; + }); +} + +function checkForbiddenManifestDeps(crateDir, forbiddenDeps, messageForDep) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const lines = readText(manifestPath).split(/\r?\n/); + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) { + return; + } + for (const dep of forbiddenDeps) { + if (isManifestDependencyDeclaration(trimmed, dep)) { + failures.push({ + path: manifestPath, + line: index + 1, + message: messageForDep(dep), + }); + } + } + }); +} + +function checkForbiddenNonOptionalManifestDeps(crateDir, forbiddenDeps, messageForDep) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); + for (const dep of deps) { + if (!dep.optional && forbiddenDeps.includes(dep.name)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: messageForDep(dep.name), + }); + } + } +} + +function featureReferencesDependency(feature, depName) { + if (!feature) { + return false; + } + return feature.refs.includes(`dep:${depName}`) || feature.refs.includes(depName); +} + +function featureReferencesFeature(feature, featureName) { + if (!feature) { + return false; + } + return feature.refs.includes(featureName); +} + +function checkOptionalDependencyFeatureOwners(crateDir, rule) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const lines = readText(manifestPath).split(/\r?\n/); + const deps = parseManifestDependencies(lines); + const depsByName = new Map(deps.map((dep) => [dep.name, dep])); + const features = parseManifestFeatures(lines); + const declaredOwnerDeps = new Set(rule.dependencies.map((dependency) => dependency.depName)); + + for (const dependency of rule.dependencies) { + const dep = depsByName.get(dependency.depName); + if (!dep) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing optional dependency: ${dependency.depName}`, + }); + continue; + } + if (!dep.optional) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; dependency must be optional: ${dependency.depName}`, + }); + } + for (const featureName of dependency.ownerFeatures) { + const feature = features.get(featureName); + if (!feature) { + failures.push({ + path: manifestPath, + line: dep?.line ?? 1, + message: `${rule.reason}; missing owner feature ${featureName} for ${dependency.depName}`, + }); + continue; + } + if (!featureReferencesDependency(feature, dependency.depName)) { + failures.push({ + path: manifestPath, + line: feature.line, + message: `${rule.reason}; ${featureName} must explicitly enable ${dependency.depName}`, + }); + } + } + } + + const profileRule = dependencyProfileRules.find((profile) => profile.crateName === rule.crateName); + const depsRequiringOwner = new Set(profileRule?.forbiddenNonOptionalDeps ?? []); + const uncoveredDeps = new Map(); + for (const dep of deps) { + if (!dep.optional || !depsRequiringOwner.has(dep.name) || declaredOwnerDeps.has(dep.name)) { + continue; + } + if (!uncoveredDeps.has(dep.name)) { + uncoveredDeps.set(dep.name, dep); + } + } + for (const [depName, dep] of uncoveredDeps.entries()) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; optional runtime dependency must declare owner feature coverage: ${depName}`, + }); + } +} + +function checkProductCoreFeatureAssembly(rule) { + const manifestPath = repoPathToFsPath(rule.manifestPath); + const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); + const dep = deps.find((candidate) => candidate.name === rule.dependencyName); + if (!dep) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing dependency: ${rule.dependencyName}`, + }); + return; + } + + if (!manifestDependencyDisablesDefaultFeatures(dep)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; ${rule.dependencyName} must set default-features = false`, + }); + } + + const enabledFeatures = parseManifestDependencyFeatureNames(dep); + for (const featureName of rule.requiredFeatures) { + if (!enabledFeatures.has(featureName)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; ${rule.dependencyName} must enable feature ${featureName}`, + }); + } + } +} + +function checkProductCoreFeatureAssemblyCoverage() { + const rulePaths = new Set(productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath)); + for (const manifestPath of collectProductCoreDependencyManifests()) { + if (!rulePaths.has(manifestPath)) { + failures.push({ + path: join(ROOT, ...manifestPath.split('/')), + line: 1, + message: + 'product entry crate depends on bitfun-core but is not covered by product-full assembly rules', + }); + } + } +} + +function checkCoreDefaultProductFullFeature() { + const manifestPath = join(crateDirForName('core'), 'Cargo.toml'); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + if (!featureReferencesFeature(features.get('default'), 'product-full')) { + failures.push({ + path: manifestPath, + line: features.get('default')?.line ?? 1, + message: + 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', + }); + } +} + +function checkCoreProductFullFeatureAssembly(rule) { + const manifestPath = repoPathToFsPath(rule.manifestPath); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + const productFull = features.get(rule.featureName); + if (!productFull) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing ${rule.featureName} feature declaration`, + }); + return; + } + for (const featureName of rule.requiredFeatureRefs) { + if (!featureReferencesFeature(productFull, featureName)) { + failures.push({ + path: manifestPath, + line: productFull.line, + message: `${rule.reason}; ${rule.featureName} must explicitly enable ${featureName}`, + }); + } + } +} + +function checkOwnerCrateFeatureAssembly(rule) { + const manifestPath = repoPathToFsPath(rule.manifestPath); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + const allowedProductFullFeatures = new Set(rule.requiredProductFullFeatures); + const defaultFeature = features.get('default'); + if (!defaultFeature) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing default feature declaration`, + }); + } else if (defaultFeature.refs.length > 0) { + failures.push({ + path: manifestPath, + line: defaultFeature.line, + message: `${rule.reason}; default feature must remain empty`, + }); + } + + const productFull = features.get('product-full'); + if (!productFull) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing product-full feature declaration`, + }); + return; + } + + for (const featureName of rule.requiredProductFullFeatures) { + if (!featureReferencesFeature(productFull, featureName)) { + failures.push({ + path: manifestPath, + line: productFull.line, + message: `${rule.reason}; product-full must explicitly enable ${featureName}`, + }); + } + } + for (const featureName of productFull.refs) { + if (!allowedProductFullFeatures.has(featureName)) { + failures.push({ + path: manifestPath, + line: productFull.line, + message: `${rule.reason}; product-full must not include undeclared feature group ${featureName}`, + }); + } + } +} + +function checkRustImports(crateDir) { + const srcDir = join(crateDir, 'src'); + try { + if (!statSync(srcDir).isDirectory()) { + return; + } + } catch { + return; + } + + walkFiles(srcDir, (path) => { + if (!path.endsWith('.rs')) { + return; + } + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + if (/\bbitfun_core::/.test(line)) { + failures.push({ + path, + line: index + 1, + message: 'extracted crate must not import bitfun_core', + }); + } + }); + }); +} + +function checkForbiddenRustImports(crateDir, forbiddenDeps, messageForDep) { + const srcDir = join(crateDir, 'src'); + try { + if (!statSync(srcDir).isDirectory()) { + return; + } + } catch { + return; + } + + const forbiddenImports = forbiddenDeps.map((dep) => ({ + dep, + pattern: new RegExp(`\\b${escapeRegex(rustImportName(dep))}::`), + })); + + walkFiles(srcDir, (path) => { + if (!path.endsWith('.rs')) { + return; + } + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + for (const forbidden of forbiddenImports) { + if (forbidden.pattern.test(line)) { + failures.push({ + path, + line: index + 1, + message: messageForDep(forbidden.dep), + }); + } + } + }); + }); +} + +function createFacadeLineChecker(importPrefix) { + let inPubUseBlock = false; + const escapedPrefix = escapeRegex(importPrefix); + const singleReexportPattern = new RegExp( + `^pub use ${escapedPrefix}(?:::[A-Za-z_][A-Za-z0-9_]*)*(?:::\\*)?;$`, + ); + const blockItemPattern = /^[A-Za-z_][A-Za-z0-9_]*(?:,\s*[A-Za-z_][A-Za-z0-9_]*)*,?$/; + const blockStart = `pub use ${importPrefix}::{`; + + const checker = (line) => { + const trimmed = line.trim(); + if ( + trimmed === '' || + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.startsWith('*/') + ) { + return true; + } + + if (inPubUseBlock) { + if (trimmed === '};') { + inPubUseBlock = false; + return true; + } + return blockItemPattern.test(trimmed); + } + + if (singleReexportPattern.test(trimmed)) { + return true; + } + + if (trimmed.startsWith(blockStart)) { + if (trimmed.endsWith('};')) { + return true; + } + if (trimmed.endsWith('{')) { + inPubUseBlock = true; + return true; + } + } + + return false; + }; + + checker.isComplete = () => !inPubUseBlock; + return checker; +} + +function checkFacadeOnlyFile(repoPath, importPrefix, reason) { + const path = repoPathToFsPath(repoPath); + const acceptsLine = createFacadeLineChecker(importPrefix); + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + if (!acceptsLine(line)) { + failures.push({ + path, + line: index + 1, + message: reason, + }); + } + }); + + if (!acceptsLine.isComplete()) { + failures.push({ + path, + line: lines.length, + message: `${reason}; unterminated pub use block`, + }); + } +} + +function checkForbiddenContent(repoPath, patterns) { + const path = repoPathToFsPath(repoPath); + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + for (const pattern of patterns) { + if (pattern.regex.test(line)) { + failures.push({ + path, + line: index + 1, + message: pattern.message, + }); + } + } + }); +} + +function checkRequiredContent(repoPath, patterns, reason) { + const path = repoPathToFsPath(repoPath); + const text = readText(path); + for (const pattern of patterns) { + if (!pattern.regex.test(text)) { + failures.push({ + path, + line: 1, + message: `${reason}; ${pattern.message}`, + }); + } + } +} + +function checkForbiddenContentUnder(repoDir, patterns, reason) { + const dir = repoPathToFsPath(repoDir); + walkFiles(dir, (path) => { + if (!path.endsWith('.rs')) { + return; + } + const repoPath = toRepoPath(path); + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + for (const pattern of patterns) { + if (pattern.allowPaths?.includes(repoPath)) { + continue; + } + if (pattern.regex.test(line)) { + failures.push({ + path, + line: index + 1, + message: `${reason}; ${pattern.message}`, + }); + } + } + }); + }); +} + +export function runCoreBoundaryCheck() { + failures.length = 0; + + if (process.env.BITFUN_BOUNDARY_CHECK_SELF_TEST === '1') { + runManifestParserSelfTest({ + isManifestDependencyDeclaration, + parseManifestDependencies, + manifestDependencyDisablesDefaultFeatures, + parseManifestDependencyFeatureNames, + productCoreFeatureAssemblyRules, + coreProductFullFeatureAssemblyRule, + collectProductCoreDependencyManifestPaths, + ownerCrateFeatureAssemblyRules, + parseManifestFeatures, + optionalDependencyFeatureOwnerRules, + lightweightBoundaryRules, + dependencyProfileRules, + noCoreDependencyCrates, + requiredContentRules, + forbiddenContentRules, + forbiddenContentUnderRules, + facadeOnlyFiles, + forbiddenRuleTextForPath, + regexSourceContainsContract, + createFacadeLineChecker, + escapeRegex, + }); + console.log('Core boundary check self-test passed.'); + return; + } + + checkCrateLayoutRules(); + + for (const crateName of noCoreDependencyCrates) { + const crateDir = crateDirForName(crateName); + checkCargoManifest(crateDir); + checkRustImports(crateDir); + } + + for (const rule of lightweightBoundaryRules) { + const crateDir = crateDirForName(rule.crateName); + const messageForDep = (dep) => `${rule.reason}; forbidden dependency: ${dep}`; + checkForbiddenManifestDeps(crateDir, rule.forbiddenDeps, messageForDep); + checkForbiddenRustImports(crateDir, rule.forbiddenDeps, messageForDep); + } + + for (const rule of dependencyProfileRules) { + const crateDir = crateDirForName(rule.crateName); + const messageForDep = (dep) => + `${rule.reason}; ${rule.profileName} forbids non-optional dependency: ${dep}`; + checkForbiddenNonOptionalManifestDeps(crateDir, rule.forbiddenNonOptionalDeps, messageForDep); + } + + for (const rule of optionalDependencyFeatureOwnerRules) { + const crateDir = crateDirForName(rule.crateName); + checkOptionalDependencyFeatureOwners(crateDir, rule); + } + + for (const rule of productCoreFeatureAssemblyRules) { + checkProductCoreFeatureAssembly(rule); + } + checkProductCoreFeatureAssemblyCoverage(); + checkCoreDefaultProductFullFeature(); + checkCoreProductFullFeatureAssembly(coreProductFullFeatureAssemblyRule); + for (const rule of ownerCrateFeatureAssemblyRules) { + checkOwnerCrateFeatureAssembly(rule); + } + + for (const facade of facadeOnlyFiles) { + checkFacadeOnlyFile(facade.path, facade.importPrefix, facade.reason); + } + + for (const rule of forbiddenContentRules) { + checkForbiddenContent(rule.path, rule.patterns); + } + + for (const rule of forbiddenContentUnderRules) { + checkForbiddenContentUnder(rule.path, rule.patterns, rule.reason); + } + + for (const rule of requiredContentRules) { + checkRequiredContent(rule.path, rule.patterns, rule.reason); + } + + if (failures.length > 0) { + console.error('Core boundary check failed.'); + for (const failure of failures) { + console.error(`${toRepoPath(failure.path)}:${failure.line}: ${failure.message}`); + } + process.exit(1); + } + + console.log('Core boundary check passed.'); +} diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs new file mode 100644 index 0000000000..21fcbabd7d --- /dev/null +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -0,0 +1,50 @@ +// Physical crate layout rules. Package names remain stable; this file only +// owns where workspace crates live under src/crates. + +export const crateLayoutRules = [ + { crateName: 'core-types', layer: 'contracts', path: 'src/crates/contracts/core-types' }, + { crateName: 'events', layer: 'contracts', path: 'src/crates/contracts/events' }, + { crateName: 'product-domains', layer: 'contracts', path: 'src/crates/contracts/product-domains' }, + { crateName: 'runtime-ports', layer: 'contracts', path: 'src/crates/contracts/runtime-ports' }, + + { crateName: 'agent-runtime', layer: 'execution', path: 'src/crates/execution/agent-runtime' }, + { crateName: 'agent-stream', layer: 'execution', path: 'src/crates/execution/agent-stream' }, + { crateName: 'agent-tools', layer: 'execution', path: 'src/crates/execution/tool-contracts' }, + { crateName: 'harness', layer: 'execution', path: 'src/crates/execution/harness' }, + { crateName: 'runtime-services', layer: 'execution', path: 'src/crates/execution/runtime-services' }, + { crateName: 'tool-packs', layer: 'execution', path: 'src/crates/execution/tool-provider-groups' }, + { crateName: 'tool-runtime', layer: 'execution', path: 'src/crates/execution/tool-execution' }, + + { crateName: 'product-capabilities', layer: 'assembly', path: 'src/crates/assembly/product-capabilities' }, + + { crateName: 'services-core', layer: 'services', path: 'src/crates/services/services-core' }, + { crateName: 'services-integrations', layer: 'services', path: 'src/crates/services/services-integrations' }, + { crateName: 'terminal', layer: 'services', path: 'src/crates/services/terminal' }, + + { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, + { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, + { crateName: 'api-layer', layer: 'adapters', path: 'src/crates/adapters/api-layer' }, + { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, + { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, + + { crateName: 'core', layer: 'assembly', path: 'src/crates/assembly/core' }, +]; + +export const crateLayoutLayerNames = [ + 'interfaces', + 'assembly', + 'adapters', + 'services', + 'execution', + 'contracts', +]; + +const crateLayoutByName = new Map(crateLayoutRules.map((rule) => [rule.crateName, rule])); + +export function crateLayoutRuleForName(crateName) { + return crateLayoutByName.get(crateName); +} + +export function cratePathForName(crateName) { + return crateLayoutRuleForName(crateName)?.path; +} diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs new file mode 100644 index 0000000000..aadc9b3729 --- /dev/null +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -0,0 +1,470 @@ +// Boundary rules for crate dependencies and lightweight profiles. + +export const noCoreDependencyCrates = [ + 'core-types', + 'events', + 'ai-adapters', + 'agent-stream', + 'agent-runtime', + 'harness', + 'product-capabilities', + 'runtime-ports', + 'runtime-services', + 'services-core', + 'services-integrations', + 'agent-tools', + 'tool-packs', + 'product-domains', + 'terminal', + 'tool-runtime', + 'transport', + 'api-layer', + 'webdriver', +]; + +export const lightweightBoundaryRules = [ + { + crateName: 'core-types', + reason: 'core-types must stay low-level DTO-only', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-events', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-runtime-ports', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'runtime-ports', + reason: 'runtime-ports must stay DTO/trait-only', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'runtime-services', + reason: 'runtime-services must stay a typed service assembly contract without concrete runtime implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'agent-runtime', + reason: 'agent-runtime must own portable runtime decisions without concrete service or product implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'harness', + reason: + 'harness must own workflow contracts without concrete service, product, or platform implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'product-capabilities', + reason: + 'product-capabilities must own product capability assembly facts without concrete runtime implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-tools', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'agent-tools', + reason: 'agent-tools must not depend on concrete service or product runtime implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, +]; + +export const dependencyProfileRules = [ + { + crateName: 'core', + profileName: 'no-default runtime-surface-light profile', + reason: + 'bitfun-core no-default profile must not force product/runtime integration dependencies', + forbiddenNonOptionalDeps: [ + 'aes', + 'aes-gcm', + 'bitfun-product-capabilities', + 'bitfun-product-domains', + 'bitfun-relay-server', + 'bitfun-tool-packs', + 'chrono-tz', + 'cron', + 'dashmap', + 'eventsource-stream', + 'filetime', + 'flate2', + 'fs2', + 'git2', + 'glob', + 'globset', + 'hostname', + 'image', + 'include_dir', + 'indexmap', + 'local-ip-address', + 'mac_address', + 'md5', + 'qrcode', + 'rand', + 'rmcp', + 'russh', + 'sse-stream', + 'similar', + 'tool-runtime', + 'tokio-tungstenite', + 'x25519-dalek', + ], + }, + { + crateName: 'core-types', + profileName: 'default DTO profile', + reason: 'core-types default profile must stay DTO-only', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-events', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-runtime-ports', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'runtime-ports', + profileName: 'default ports profile', + reason: 'runtime-ports default profile must stay trait/DTO-only', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'runtime-services', + profileName: 'default runtime service assembly profile', + reason: 'runtime-services default profile must not compile concrete service or product runtime implementations', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'agent-runtime', + profileName: 'default agent runtime decision profile', + reason: 'agent-runtime default profile must not compile concrete services or product surfaces', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'agent-tools', + profileName: 'tool contract-only profile', + reason: 'agent-tools must stay a lightweight tool contract crate', + forbiddenNonOptionalDeps: [ + 'bitfun-ai-adapters', + 'bitfun-product-capabilities', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'tauri', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'product-capabilities', + profileName: 'default product capability profile', + reason: 'product-capabilities default profile must stay assembly-fact only', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-tools', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'product-domains', + profileName: 'default product domain profile', + reason: 'product-domains default profile must not compile runtime/platform helpers', + forbiddenNonOptionalDeps: [ + 'dirs', + 'log', + 'sha2', + 'which', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'tauri', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'services-integrations', + profileName: 'default integrations profile', + reason: 'services-integrations default profile must not compile feature-gated integrations', + forbiddenNonOptionalDeps: [ + 'aes-gcm', + 'anyhow', + 'async-trait', + 'base64', + 'bitfun-product-domains', + 'bitfun-runtime-ports', + 'bitfun-services-core', + 'chrono', + 'dunce', + 'futures', + 'git2', + 'notify', + 'rand', + 'reqwest', + 'rmcp', + 'sha2', + 'sse-stream', + 'thiserror', + 'tokio-util', + 'tokio-tungstenite', + 'uuid', + 'bitfun-relay-server', + ], + }, +]; diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs new file mode 100644 index 0000000000..6286790258 --- /dev/null +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -0,0 +1,187 @@ +// Boundary rules for feature assembly and optional dependency ownership. + +export const optionalDependencyFeatureOwnerRules = [ + { + crateName: 'core', + reason: + 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', + dependencies: [ + { depName: 'aes', ownerFeatures: ['service-integrations'] }, + { depName: 'aes-gcm', ownerFeatures: ['service-integrations'] }, + { depName: 'axum', ownerFeatures: ['service-integrations'] }, + { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime'] }, + { depName: 'bitfun-product-capabilities', ownerFeatures: ['product-capabilities'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, + { depName: 'bitfun-relay-server', ownerFeatures: ['service-integrations'] }, + { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, + { depName: 'chrono-tz', ownerFeatures: ['product-full'] }, + { depName: 'cron', ownerFeatures: ['product-full'] }, + { depName: 'dashmap', ownerFeatures: ['product-full'] }, + { depName: 'eventsource-stream', ownerFeatures: ['product-full'] }, + { depName: 'filetime', ownerFeatures: ['product-full'] }, + { depName: 'flate2', ownerFeatures: ['product-full'] }, + { depName: 'fs2', ownerFeatures: ['product-full'] }, + { depName: 'git2', ownerFeatures: ['service-integrations'] }, + { depName: 'glob', ownerFeatures: ['product-full'] }, + { depName: 'globset', ownerFeatures: ['product-full'] }, + { depName: 'image', ownerFeatures: ['service-integrations'] }, + { depName: 'include_dir', ownerFeatures: ['product-full'] }, + { depName: 'indexmap', ownerFeatures: ['product-full'] }, + { depName: 'local-ip-address', ownerFeatures: ['service-integrations'] }, + { depName: 'md5', ownerFeatures: ['product-full', 'service-integrations'] }, + { depName: 'rand', ownerFeatures: ['service-integrations'] }, + { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'service-integrations'] }, + { depName: 'rmcp', ownerFeatures: ['service-integrations'] }, + { depName: 'russh', ownerFeatures: ['ssh-remote'] }, + { depName: 'similar', ownerFeatures: ['product-full'] }, + { depName: 'sse-stream', ownerFeatures: ['service-integrations'] }, + { depName: 'tokio-tungstenite', ownerFeatures: ['service-integrations'] }, + { depName: 'tower-http', ownerFeatures: ['service-integrations'] }, + { depName: 'tool-runtime', ownerFeatures: ['product-full'] }, + ], + }, + { + crateName: 'services-integrations', + reason: + 'services-integrations optional runtime dependencies must stay owned by explicit integration features', + dependencies: [ + { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'anyhow', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { + depName: 'async-trait', + ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete', 'workspace-search'], + }, + { + depName: 'base64', + ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'], + }, + { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['function-agents', 'miniapp-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect'] }, + { + depName: 'bitfun-services-core', + ownerFeatures: ['git', 'mcp', 'miniapp-runtime', 'workspace-search'], + }, + { depName: 'chrono', ownerFeatures: ['git', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'dirs', ownerFeatures: ['miniapp-runtime', 'remote-ssh-concrete'] }, + { depName: 'dunce', ownerFeatures: ['remote-ssh', 'workspace-search'] }, + { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect'] }, + { depName: 'git2', ownerFeatures: ['git'] }, + { depName: 'hostname', ownerFeatures: ['remote-connect'] }, + { depName: 'image', ownerFeatures: ['remote-connect'] }, + { depName: 'mac_address', ownerFeatures: ['remote-connect'] }, + { depName: 'notify', ownerFeatures: ['file-watch'] }, + { depName: 'qrcode', ownerFeatures: ['remote-connect'] }, + { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'reqwest', ownerFeatures: ['mcp', 'miniapp-runtime'] }, + { depName: 'rmcp', ownerFeatures: ['mcp'] }, + { depName: 'russh', ownerFeatures: ['remote-ssh-concrete'] }, + { depName: 'russh-keys', ownerFeatures: ['remote-ssh-concrete'] }, + { depName: 'russh-sftp', ownerFeatures: ['remote-ssh-concrete'] }, + { depName: 'rustls', ownerFeatures: ['remote-connect'] }, + { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, + { depName: 'schannel', ownerFeatures: ['remote-connect'] }, + { depName: 'sha2', ownerFeatures: ['remote-connect', 'remote-ssh'] }, + { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, + { depName: 'sse-stream', ownerFeatures: ['mcp'] }, + { depName: 'ssh_config', ownerFeatures: ['remote-ssh-concrete', 'ssh_config'] }, + { depName: 'terminal-core', ownerFeatures: ['remote-ssh-concrete'] }, + { depName: 'thiserror', ownerFeatures: ['git', 'remote-ssh-concrete', 'workspace-search'] }, + { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect'] }, + { depName: 'tokio-util', ownerFeatures: ['remote-ssh'] }, + { depName: 'urlencoding', ownerFeatures: ['remote-connect'] }, + { depName: 'uuid', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'which', ownerFeatures: ['miniapp-runtime', 'workspace-search'] }, + { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, + ], + }, + { + crateName: 'product-domains', + reason: + 'product-domains optional runtime dependencies must stay owned by explicit product-domain features', + dependencies: [ + { depName: 'dirs', ownerFeatures: ['miniapp'] }, + { depName: 'log', ownerFeatures: ['function-agents'] }, + { depName: 'sha2', ownerFeatures: ['miniapp'] }, + { depName: 'which', ownerFeatures: ['miniapp'] }, + ], + }, +]; + +export const productCoreFeatureAssemblyRules = [ + { + manifestPath: 'src/apps/desktop/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'desktop must explicitly assemble the full bitfun-core product runtime', + }, + { + manifestPath: 'src/apps/cli/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'CLI must explicitly assemble the full bitfun-core product runtime', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'ACP must explicitly assemble the full bitfun-core product runtime', + }, +]; + +export const productCoreFeatureAssemblyScanRoots = [ + 'src/apps', + 'src/crates/interfaces/acp', +]; + +export const coreProductFullFeatureAssemblyRule = { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'product-full', + requiredFeatureRefs: [ + 'ssh-remote', + 'product-capabilities', + 'product-domains', + 'service-integrations', + 'tool-packs', + ], + reason: 'bitfun-core product-full must explicitly assemble current owner feature groups', +}; + +export const ownerCrateFeatureAssemblyRules = [ + { + manifestPath: 'src/crates/execution/tool-provider-groups/Cargo.toml', + reason: 'tool-packs must keep product feature groups explicit and default-light', + requiredProductFullFeatures: [ + 'basic', + 'git', + 'mcp', + 'browser-web', + 'computer-use', + 'image-analysis', + 'miniapp', + 'agent-control', + ], + }, + { + manifestPath: 'src/crates/services/services-integrations/Cargo.toml', + reason: 'services-integrations must keep integration feature groups explicit and default-light', + requiredProductFullFeatures: [ + 'announcement', + 'deep-research', + 'file-watch', + 'function-agents', + 'git', + 'miniapp-runtime', + 'mcp', + 'remote-connect', + 'remote-ssh', + 'remote-ssh-concrete', + 'workspace-search', + ], + }, + { + manifestPath: 'src/crates/contracts/product-domains/Cargo.toml', + reason: 'product-domains must keep product domain feature groups explicit and default-light', + requiredProductFullFeatures: ['miniapp', 'function-agents'], + }, +]; diff --git a/scripts/core-boundaries/rules/source-rules.mjs b/scripts/core-boundaries/rules/source-rules.mjs new file mode 100644 index 0000000000..e3e319429d --- /dev/null +++ b/scripts/core-boundaries/rules/source-rules.mjs @@ -0,0 +1,8 @@ +// Source boundary rule entrypoint. Keep detailed rules in focused modules. + +export { facadeOnlyFiles } from './source/facade-rules.mjs'; +export { + forbiddenContentRules, + forbiddenContentUnderRules, +} from './source/forbidden-rules.mjs'; +export { requiredContentRules } from './source/required-rules.mjs'; diff --git a/scripts/core-boundaries/rules/source/facade-rules.mjs b/scripts/core-boundaries/rules/source/facade-rules.mjs new file mode 100644 index 0000000000..512aefa2f3 --- /dev/null +++ b/scripts/core-boundaries/rules/source/facade-rules.mjs @@ -0,0 +1,114 @@ +// Boundary rules for source ownership, facades, and required owner content. + +export const facadeOnlyFiles = [ + { + path: 'src/crates/assembly/core/src/infrastructure/filesystem/mod.rs', + importPrefix: 'bitfun_services_core::filesystem', + reason: 'core filesystem infrastructure facade must only re-export the services-core owner crate', + }, + { + path: 'src/crates/assembly/core/src/infrastructure/events/event_system.rs', + importPrefix: 'bitfun_runtime_services::backend_events', + reason: 'core backend event system facade must only re-export the runtime-services owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/filesystem/listing.rs', + importPrefix: 'bitfun_services_core::filesystem', + reason: 'core filesystem listing facade must only re-export the services-core owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/filesystem/types.rs', + importPrefix: 'bitfun_services_core::filesystem', + reason: 'core filesystem DTO facade must only re-export the services-core owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/git/git_service.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git service facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/git/git_types.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git types facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/git/git_utils.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git utils facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/git/graph.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git graph facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/types.rs', + importPrefix: 'bitfun_services_integrations::remote_ssh', + reason: 'core remote SSH types facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/manager.rs', + importPrefix: 'bitfun_services_integrations::remote_ssh::manager', + reason: 'core remote SSH manager facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/remote_fs.rs', + importPrefix: 'bitfun_services_integrations::remote_ssh::remote_fs', + reason: 'core remote SSH filesystem facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/remote_terminal.rs', + importPrefix: 'bitfun_services_integrations::remote_ssh::remote_terminal', + reason: 'core remote SSH terminal facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/tool_info.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP tool info facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/tool_name.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP tool name facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/protocol/types.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP protocol types facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/protocol/transport.rs', + importPrefix: 'bitfun_services_integrations::mcp::protocol', + reason: 'core MCP stdio transport facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/protocol/transport_remote.rs', + importPrefix: 'bitfun_services_integrations::mcp::protocol', + reason: 'core MCP remote transport facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/server/connection.rs', + importPrefix: 'bitfun_services_integrations::mcp::server', + reason: 'core MCP connection facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/config/location.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP config location facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/adapter/resource.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP resource adapter facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/mcp/adapter/prompt.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP prompt adapter facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/assembly/core/src/service/announcement/types.rs', + importPrefix: 'bitfun_services_integrations::announcement', + reason: 'core announcement types facade must only re-export the integrations owner crate', + }, +]; diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs new file mode 100644 index 0000000000..548b9ad387 --- /dev/null +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -0,0 +1,3143 @@ +// Boundary rules for source ownership, facades, and required owner content. + +export const forbiddenContentRules = [ + { + path: 'src/crates/contracts/core-types/src/ai.rs', + patterns: [ + { + regex: /\bresolve_request_url\b/, + message: + 'core-types may own AI DTOs, but provider URL resolution belongs in adapter or assembly compatibility owners', + }, + { + regex: /\b(?:chat\/completions|v1\/messages|streamGenerateContent)\b/, + message: + 'core-types must not encode provider endpoint paths; keep protocol URL behavior above contracts', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/product_assembly.rs', + patterns: [ + { + regex: /\bpub struct CoreRuntimeServicesProvider\b/, + message: + 'core product_assembly must remain a compatibility facade; move core-specific runtime service providers to product runtime adapters', + }, + { + regex: /\bimpl RuntimeServicesProvider for CoreRuntimeServicesProvider\b/, + message: + 'core product_assembly must not own runtime service provider registration; use the product runtime adapter path', + }, + { + regex: /\bCoreSessionStorePort\b/, + message: + 'core product_assembly must not bind concrete session store adapters directly; use the product runtime adapter path', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/harness.rs', + patterns: [ + { + regex: /\bproduct_assembly_plan_for_profile\b/, + message: + 'core agentic harness facade must not rebuild product assembly plans; use bitfun-product-capabilities harness registry entrypoints', + }, + { + regex: /\bfn product_harness_registry_for_profile\b/, + message: + 'core agentic harness facade must not own profile-scoped harness registry construction', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/persistence/session_branch.rs', + patterns: [ + { + regex: /\bfn\s+estimate_turn_message_count\b/, + message: + 'session branch metadata counting belongs in services-core session lineage owner, not core persistence', + }, + { + regex: /\bfn\s+strip_child_session_metadata\b/, + message: + 'branch child-metadata cleanup belongs in services-core session lineage owner, not core persistence', + }, + { + regex: /\bfn\s+build_branch_custom_metadata\b/, + message: + 'branch custom metadata shaping belongs in services-core session lineage owner, not core persistence', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/persistence/manager.rs', + patterns: [ + { + regex: /\bfn\s+build_session_relationship\b/, + message: + 'session relationship reconstruction belongs in services-core session metadata owner, not core persistence manager', + }, + { + regex: /\bSessionMetadata\s*\{\s*session_id\s*:/, + message: + 'session metadata field assembly belongs in services-core session metadata owner, not core persistence manager', + }, + { + regex: /\bmetadata\.deep_review_cache\s*=\s*Some\s*\(/, + message: + 'DeepReview cache metadata mutation belongs in services-core session metadata owner, not core persistence manager', + }, + { + regex: /\bstatic\s+SESSION_INDEX_LOCKS\b/, + message: + 'session metadata index locking belongs in services-core SessionMetadataStore, not core persistence manager', + }, + { + regex: /\bfn\s+scan_session_metadata_dirs\b/, + message: + 'session metadata directory scanning belongs in services-core SessionMetadataStore, not core persistence manager', + }, + { + regex: /\bfn\s+count_session_metadata_dirs\b/, + message: + 'session metadata directory counting belongs in services-core SessionMetadataStore, not core persistence manager', + }, + { + regex: /\bfn\s+rebuild_index_locked\b/, + message: + 'session metadata index rebuilding belongs in services-core SessionMetadataStore, not core persistence manager', + }, + { + regex: /\bfn\s+upsert_index_entry_locked\b/, + message: + 'session metadata index upsert belongs in services-core SessionMetadataStore, not core persistence manager', + }, + { + regex: /\bfn\s+remove_index_entry_locked\b/, + message: + 'session metadata index removal belongs in services-core SessionMetadataStore, not core persistence manager', + }, + { + regex: /\bget_session_index_lock\b/, + message: + 'session metadata index lock access belongs in services-core SessionMetadataStore, not core persistence manager', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/session_manager.rs', + patterns: [ + { + regex: /\bfn\s+extract_subagent_relationship\b/, + message: + 'subagent relationship extraction belongs in services-core session lineage owner, not core session manager', + }, + { + regex: /\bmetadata\.custom_metadata\s*=\s*Some\s*\(\s*match\b/, + message: + 'session custom-metadata merge rules belong in services-core session metadata owner, not core session manager', + }, + { + regex: /\bmetadata\.relationship\s*=\s*Some\s*\(\s*relationship\s*\)/, + message: + 'session relationship mutation belongs in services-core session metadata owner, not core session manager', + }, + { + regex: /\bmetadata\.deep_review_run_manifest\s*=\s*deep_review_run_manifest\b/, + message: + 'DeepReview run-manifest metadata mutation belongs in services-core session metadata owner, not core session manager', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/workspace_runtime/service.rs', + patterns: [ + { + regex: /\bfn\s+merge_session_directory\b/, + message: + 'legacy session directory merge belongs in services-core session migration owner, not core workspace runtime', + }, + { + regex: /\bfn\s+merge_session_metadata_file\b/, + message: + 'legacy session metadata conflict resolution belongs in services-core session migration owner, not core workspace runtime', + }, + { + regex: /\bSessionMetadataStore::new\b/, + message: + 'legacy session index rebuild belongs in services-core session migration owner, not core workspace runtime', + }, + { + regex: /\bfn\s+copy_dir_recursive\b/, + message: + 'legacy path copy fallback belongs in services-core session migration owner, not core workspace runtime', + }, + { + regex: /\bfn\s+files_are_equal\b/, + message: + 'legacy path conflict comparison belongs in services-core session migration owner, not core workspace runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service_agent_runtime.rs', + patterns: [ + { + regex: /\bself\.scheduler\s*\.\s*submit\b/, + message: + 'remote dialog runtime host must submit through AgentRuntime dialog lifecycle port, not direct DialogScheduler', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs', + patterns: [ + { + regex: /\bsubmit_with_prepended_messages\b/, + message: + 'SessionMessage must submit through AgentRuntime dialog lifecycle port, not direct DialogScheduler', + }, + { + regex: /\bcoordinator\s*\.\s*resolve_session_workspace_path\b/, + message: + 'SessionMessage target workspace resolution must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\bresolve_session_workspace_path\s*\(\s*&?target_session_id\b/, + message: + 'SessionMessage target workspace resolution must use AgentSessionWorkspaceRequest, not legacy direct session id calls', + }, + { + regex: /\bcoordinator\s*\.\s*list_sessions\b/, + message: + 'SessionMessage target session lookup must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\blist_sessions\s*\(\s*(?:Path::new|workspace_path)\b/, + message: + 'SessionMessage target session lookup must use AgentSessionListRequest, not legacy path arguments', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs', + patterns: [ + { + regex: /\bcancel_active_turn_for_session_from_requester\b/, + message: + 'SessionControl requester-aware cancellation must flow through AgentRuntime cancellation port, not direct DialogScheduler', + }, + { + regex: /\bcoordinator\s*\.\s*resolve_session_workspace_path\b/, + message: + 'SessionControl workspace resolution must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\bresolve_session_workspace_path\s*\(\s*session_id\b/, + message: + 'SessionControl workspace resolution must use AgentSessionWorkspaceRequest, not legacy direct session id calls', + }, + { + regex: /\bcoordinator\s*\.\s*list_sessions\b/, + message: + 'SessionControl session listing must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\blist_sessions\s*\(\s*(?:Path::new|workspace_path)\b/, + message: + 'SessionControl session listing must use AgentSessionListRequest, not legacy path arguments', + }, + { + regex: /\bcoordinator\s*\.\s*delete_session\b/, + message: + 'SessionControl session deletion must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\bdelete_session\s*\(\s*(?:Path::new|workspace_path)\b/, + message: + 'SessionControl session deletion must use AgentSessionDeleteRequest, not legacy path arguments', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/cron/service.rs', + patterns: [ + { + regex: /\bsubmit_with_prepended_messages\b/, + message: + 'Cron scheduled jobs must submit through AgentRuntime dialog lifecycle port, not direct DialogScheduler', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs', + patterns: [ + { + regex: /\bcoordinator\s*\.\s*resolve_session_workspace_path\b/, + message: + 'CronTool target workspace resolution must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\bresolve_session_workspace_path\s*\(\s*&?session_id\b/, + message: + 'CronTool target workspace resolution must use AgentSessionWorkspaceRequest, not legacy direct session id calls', + }, + { + regex: /\bcoordinator\s*\.\s*list_sessions\b/, + message: + 'CronTool target session lookup must flow through AgentRuntime session-management port, not direct coordinator access', + }, + { + regex: /\blist_sessions\s*\(\s*(?:Path::new|workspace_path)\b/, + message: + 'CronTool target session lookup must use AgentSessionListRequest, not legacy path arguments', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs', + patterns: [ + { + regex: /\bscheduler\s*\.\s*deliver_background_result\b/, + message: + 'Bash background delivery must flow through AgentRuntime lifecycle delivery port, not direct DialogScheduler', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', + patterns: [ + { + regex: /\bscheduler\s*\.\s*deliver_thread_goal_(?:resumed|objective_updated)\b/, + message: + 'Coordinator thread-goal delivery must flow through AgentRuntime lifecycle delivery port, not direct DialogScheduler', + }, + { + regex: /\bscheduler\s*\.\s*deliver_background_result\b/, + message: + 'Coordinator background result delivery must flow through AgentRuntime lifecycle delivery port, not direct DialogScheduler', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/deep_review_policy.rs', + patterns: [ + { + regex: /\bstatic GLOBAL_DEEP_REVIEW_BUDGET_TRACKER\b/, + message: + 'core DeepReview policy facade must not re-own runtime budget state; use bitfun-agent-runtime::deep_review', + }, + { + regex: /\bstatic GLOBAL_DEEP_REVIEW_QUEUE_CONTROL_TRACKER\b/, + message: + 'core DeepReview policy facade must not re-own queue control state; use bitfun-agent-runtime::deep_review', + }, + { + regex: /\bpub struct DeepReviewExecutionPolicy\b/, + message: + 'core DeepReview policy facade must not redefine execution policy; use bitfun-agent-runtime::deep_review', + }, + { + regex: /\bpub fn record_deep_review_task_budget\b/, + message: + 'core DeepReview policy facade must not re-own task budget recording; use bitfun-agent-runtime::deep_review', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/deep_review/report.rs', + patterns: [ + { + regex: /\bfn fill_deep_review_packet_metadata\b/, + message: + 'core DeepReview report must not re-own packet metadata enrichment; use bitfun-agent-runtime::deep_review::report', + }, + { + regex: /\bfn deep_review_cache_from_completed_reviewers\b/, + message: + 'core DeepReview report must not re-own cache update logic; use bitfun-agent-runtime::deep_review::report', + }, + { + regex: /\bmetadata\.deep_review_cache\s*=\s*Some\s*\(/, + message: + 'DeepReview cache metadata mutation belongs in services-core session metadata owner, not core report bridge', + }, + { + regex: /\bstruct DeepReviewCacheUpdate\b/, + message: + 'core DeepReview report must not re-own cache update DTO; use bitfun-agent-runtime::deep_review::report', + }, + { + regex: /"kind": "concurrency_limited"/, + message: + 'core DeepReview report must not re-own runtime tracker reliability signal shaping; use bitfun-agent-runtime::deep_review::report', + }, + { + regex: /DeepReview runtime diagnostics:/, + message: + 'core DeepReview report must not re-own runtime diagnostics log formatting; use bitfun-agent-runtime::deep_review::diagnostics', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs', + patterns: [ + { + regex: /\bfn string_for_any_key\b/, + message: + 'core DeepReview task adapter must not re-own manifest key normalization; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bfn deep_review_packet_id_for_cache\b/, + message: + 'core DeepReview task adapter must not re-own packet id inference; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bfn ensure_deep_review_retry_coverage\b/, + message: + 'core DeepReview task adapter must not re-own retry coverage validation; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bfn provider_capacity_queue_wait_seconds_for_attempt\b/, + message: + 'core DeepReview task adapter must not re-own provider capacity backoff; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bclassify_deep_review_capacity_error\b/, + message: + 'core DeepReview task adapter must not directly classify provider or local capacity errors; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bDeepReviewCapacityFailFastReason::DeterministicProviderError\b/, + message: + 'core DeepReview task adapter must not re-own provider capacity category fallback; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bfn provider_capacity_wait_can_wake_on_active_reviewer_release\b/, + message: + 'core DeepReview task adapter must not re-own provider capacity queue wake policy; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bqueue_expired_without_active_reviewer\b/, + message: + 'core DeepReview task adapter must not re-own reviewer admission queue expiry policy; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bstruct QueueWaitTimer\b/, + message: + 'core DeepReview task adapter must not re-own queue wait timing; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /runtime_task_execution::decide_provider_capacity_queue_step/, + message: + 'core DeepReview task adapter must use the runtime provider queue state machine instead of direct step decisions', + }, + { + regex: /runtime_task_execution::decide_blocked_reviewer_admission_queue_step/, + message: + 'core DeepReview task adapter must use the runtime reviewer admission state machine instead of direct step decisions', + }, + { + regex: /\bcontrol_snapshot\.(?:cancelled|paused|skip_optional)\b/, + message: + 'core DeepReview task adapter must not re-own queue control decision priority; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\bfn prompt_with_deep_review_retry_scope\b/, + message: + 'core DeepReview task adapter must not re-own retry prompt shaping; use bitfun-agent-runtime::deep_review::task_execution', + }, + { + regex: /\b/, + message: + 'core MiniApp worker pool facade must not own worker pool state; use bitfun-services-integrations::miniapp::worker_pool', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/miniapp/storage.rs', + patterns: [ + { + regex: /\btokio::fs\b/, + message: + 'core MiniApp storage facade must not own filesystem IO; use bitfun-services-integrations::miniapp::storage', + }, + { + regex: /\bMiniAppStorageLayout\b/, + message: + 'core MiniApp storage facade must not own storage layout logic; use bitfun-services-integrations::miniapp::storage', + }, + { + regex: /\bbuild_package_json\b/, + message: + 'core MiniApp storage facade must not own package-json storage assembly; use bitfun-services-integrations::miniapp::storage', + }, + { + regex: /\bparse_npm_dependencies\b/, + message: + 'core MiniApp storage facade must not own package-json dependency parsing; use bitfun-services-integrations::miniapp::storage', + }, + { + regex: /\bDRAFTS_CLEANUP_MARKER\b/, + message: + 'core MiniApp storage facade must not own draft cleanup marker IO; use bitfun-services-integrations::miniapp::storage', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/function_agents/port_adapters.rs', + patterns: [ + { + regex: /\bCoreFunctionAgentGitService\b/, + message: + 'core function-agent port adapters must not re-own Git concrete snapshots; use bitfun-services-integrations::function_agents', + }, + { + regex: /\bgit_stdout_lenient\b/, + message: + 'core function-agent port adapters must not re-own lenient Git process fallback; use bitfun-services-integrations::function_agents', + }, + { + regex: /\bGitService::get_status\b/, + message: + 'core function-agent port adapters must not re-own Git status snapshots; use bitfun-services-integrations::function_agents', + }, + { + regex: /\bcreate_command\("git"\)/, + message: + 'core function-agent port adapters must not spawn Git concrete commands; use bitfun-services-integrations::function_agents', + }, + ], + }, + { + path: 'src/crates/assembly/product-capabilities/src/lib.rs', + patterns: [ + { + regex: /\bpub struct HarnessProviderDescriptor\b/, + message: + 'product-capabilities must not redefine provider-neutral harness descriptors; use bitfun-harness', + }, + { + regex: /\bfn build_harness_registry_from_descriptors\b/, + message: + 'product-capabilities must not own descriptor registry construction; use bitfun-harness', + }, + { + regex: /\bpub enum ProductCapabilityBuildError\b/, + message: + 'product-capabilities must not redefine tool provider group selection errors; use bitfun-tool-packs', + }, + { + regex: /\bproduct_tool_provider_group_plan\(\)\b/, + message: + 'product-capabilities must not scan product tool provider plans locally; use bitfun-tool-packs selector', + }, + { + regex: /\bdefault_product_tool_provider_group_plan\b/, + message: + 'product-capabilities must expose product assembly, not a separate default tool-provider plan shortcut', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/filesystem/service.rs', + patterns: [ + { + regex: /\btokio::fs::/, + message: + 'core filesystem service must not own async local filesystem IO; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bstd::fs::/, + message: + 'core filesystem service must not own sync local filesystem IO; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bignore::WalkBuilder\b/, + message: + 'core filesystem service must not own local file walking/search implementation; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bsha2::/, + message: + 'core filesystem service must not own editor-sync hashing implementation; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bbase64::/, + message: + 'core filesystem service must not own binary file encoding implementation; use bitfun-services-core filesystem primitives', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/search/service.rs', + patterns: [ + { + regex: /\bManagedClient\b/, + message: + 'core workspace-search facade must not own flashgrep daemon clients; use bitfun-services-integrations::workspace_search', + }, + { + regex: /\bRepoSession\b/, + message: + 'core workspace-search facade must not own flashgrep repo sessions; use bitfun-services-integrations::workspace_search', + }, + { + regex: /\bwith_scan_fallback\b/, + message: + 'core workspace-search facade must not own scan fallback policy; use bitfun-services-integrations::workspace_search', + }, + { + regex: /\bconvert_hits_to_file_search_results\b/, + message: + 'core workspace-search facade must not own hit conversion; use bitfun-services-integrations::workspace_search', + }, + { + regex: /\bsplit_preview\b/, + message: + 'core workspace-search facade must not own preview mapping; use bitfun-services-integrations::workspace_search', + }, + { + regex: /\bdunce::canonicalize\b/, + message: + 'core workspace-search facade must not own local search path normalization; use bitfun-services-integrations::workspace_search', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/search/mod.rs', + patterns: [ + { + regex: /\bbitfun_services_integrations::workspace_search::flashgrep\b/, + message: + 'core must not import flashgrep internals; use the remote workspace-search stdio facade instead', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/search/service.rs', + patterns: [ + { + regex: + /\b(?:owner::flashgrep|workspace_search::flashgrep|bitfun_services_integrations::workspace_search::flashgrep)\b/, + message: + 'core workspace search facade must not depend on flashgrep internals; use stable workspace-search config and DTO APIs', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/search/remote.rs', + patterns: [ + { + regex: /\bconst\s+REMOTE_FLASHGREP_INSTALL_DIR\b/, + message: + 'core remote workspace search must not own remote flashgrep install facts; use bitfun-services-integrations::remote_ssh::workspace_search', + }, + { + regex: /\bconst\s+REMOTE_(?:OS|ARCHITECTURE)_PROBES\b/, + message: + 'core remote workspace search must not own remote probe facts; use bitfun-services-integrations::remote_ssh::workspace_search', + }, + { + regex: /\bstruct\s+LocalFlashgrepBundle\b/, + message: + 'core remote workspace search must not own local remote-search bundle DTOs; use bitfun-services-integrations::remote_ssh::workspace_search', + }, + { + regex: + /\bfn\s+(?:build_remote_scope|normalize_remote_scope_path|remote_flashgrep_install_dir|parse_remote_architecture_output|parse_remote_os_output|local_flashgrep_bundle_for_arch|remote_stdio_search_mode|should_retry_remote_scan_fallback_as_files_with_matches|join_remote_path|shell_escape)\b/, + message: + 'core remote workspace search must not re-own provider-neutral remote search strategy helpers', + }, + { + regex: /\b(?:RemoteStdioRepoSession|RemoteStdioDaemonClient|RemoteSearchContext|REMOTE_STDIO_SESSIONS|REMOTE_SEARCH_CONTEXTS)\b/, + message: + 'core remote workspace search must not own remote flashgrep session/context lifecycle; use bitfun-services-integrations::remote_ssh::workspace_search', + }, + { + regex: /\bfn\s+(?:ensure_remote_search_context|convert_stdio_search_results|remote_stdio_session_key|remote_search_context_key|schedule_remote_stdio_session_release)\b/, + message: + 'core remote workspace search must not re-own remote search concrete lifecycle helpers', + }, + { + regex: + /\b(?:ProtocolClient|drain_content_length_messages|log_flashgrep_stderr_line_with_context|FLASHGREP_LOG_TARGET)\b/, + message: + 'core remote workspace search must not depend on flashgrep protocol internals; use RemoteWorkspaceSearchStdioProtocol', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/workspace_search/mod.rs', + patterns: [ + { + regex: /\bpub\s+mod\s+flashgrep\b/, + message: + 'workspace_search must not publicly expose flashgrep protocol internals', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs', + patterns: [ + { + regex: + /\bpub\s+(?:const|struct|fn)\s+(?:REMOTE_OS_PROBES|REMOTE_ARCHITECTURE_PROBES|LocalFlashgrepBundle|build_remote_scope|remote_flashgrep_install_dir|remote_workspace_search_storage_root|looks_like_linux_workspace_root|parse_remote_architecture_output|parse_remote_os_output|local_flashgrep_bundle_for_arch|remote_stdio_search_mode|should_retry_remote_scan_fallback_as_files_with_matches|join_remote_path|shell_escape)\b/, + message: + 'remote workspace-search helper APIs must stay crate-internal; expose only reviewed provider/service contracts', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/miniapp/runtime_detect.rs', + patterns: [ + { + regex: /\bCoreMiniAppRuntimeProbe\b/, + message: + 'core MiniApp runtime_detect must remain a compatibility facade; concrete probe owner is product-domains', + }, + { + regex: /\bwhich::which\b/, + message: + 'core MiniApp runtime_detect must not own PATH lookup; use product-domain runtime detection', + }, + { + regex: /\bcreate_command\b/, + message: + 'core MiniApp runtime_detect must not own version process execution; use product-domain runtime detection', + }, + { + regex: /\bstd::fs::read_dir\b/, + message: + 'core MiniApp runtime_detect must not own version-manager directory scanning; use product-domain runtime detection', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs', + patterns: [ + { + regex: /\bpub\s+enum\s+UserContextSection\b/, + message: + 'core prompt builder must not own user-context section facts; use bitfun-agent-runtime prompt contracts', + }, + { + regex: /\bpub\s+struct\s+UserContextPolicy\b/, + message: + 'core prompt builder must not own user-context policy facts; use bitfun-agent-runtime prompt contracts', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/mod.rs', + patterns: [ + { + regex: /\bpub const SHARED_CODING_MODE_PROMPT_TEMPLATE\b/, + message: + 'core agent mode module must not own shared coding-mode prompt facts; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub const SHARED_CODING_MODE_CONFIG_PROFILE_ID\b/, + message: + 'core agent mode module must not own shared coding-mode config profile facts; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub const SHARED_CODING_MODE_IDS\b/, + message: + 'core agent mode module must not own shared coding-mode membership facts; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn resolve_mode_config_profile_id\b/, + message: + 'core agent mode module must not own mode config profile resolution; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn mode_config_profile_member_mode_ids\b/, + message: + 'core agent mode module must not own mode config profile membership; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn mode_config_profile_label\b/, + message: + 'core agent mode module must not own mode config profile labels; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn shared_coding_mode_user_context_policy\b/, + message: + 'core agent mode module must not own shared coding-mode context policy; use bitfun-agent-runtime agents', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/query.rs', + patterns: [ + { + regex: /"agentic"\s*=>\s*0[\s\S]*"Cowork"\s*=>\s*1/, + message: + 'core agent registry query must not own mode presentation order; use bitfun-agent-runtime agents', + }, + { + regex: /\bfn subagent_source_rank\b/, + message: + 'core agent registry query must not own subagent source presentation rank; use bitfun-agent-runtime agents', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/types.rs', + patterns: [ + { + regex: /\bpub enum SubAgentSource\b/, + message: + 'core agent registry must not own subagent source DTOs; use bitfun-agent-runtime agents', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/prompt_cache.rs', + patterns: [ + { + regex: /\bpub const PROMPT_CACHE_SCHEMA_VERSION\b/, + message: + 'core prompt cache must not own prompt-cache schema facts; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub struct PromptCachePolicy\b/, + message: + 'core prompt cache must not own prompt-cache policy; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub struct SessionPromptCache\b/, + message: + 'core prompt cache must not own prompt-cache DTOs; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub enum PromptCacheScope\b/, + message: + 'core prompt cache must not own prompt-cache invalidation scope; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub struct SessionPromptCacheStore\b/, + message: + 'core prompt cache must not own in-memory prompt-cache store; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub enum PromptCacheLookup\b/, + message: + 'core prompt cache must not own prompt-cache lookup outcomes; use bitfun-agent-runtime prompt_cache', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/file_read_state.rs', + patterns: [ + { + regex: /\bpub struct FileReadState\b/, + message: + 'core file_read_state must not own file-read state DTOs; use bitfun-agent-runtime file_read_state', + }, + { + regex: /\bpub struct FileReadStateStore\b/, + message: + 'core file_read_state must not own in-memory file-read state store; use bitfun-agent-runtime file_read_state', + }, + { + regex: /\bDashMap\b/, + message: + 'core file_read_state must not own file-read state storage maps; use bitfun-agent-runtime file_read_state', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/evidence_ledger.rs', + patterns: [ + { + regex: /\bpub enum EvidenceLedgerTargetKind\b/, + message: + 'core evidence_ledger must not own evidence ledger DTOs; use bitfun-agent-runtime evidence_ledger', + }, + { + regex: /\bpub struct EvidenceLedgerEvent\b/, + message: + 'core evidence_ledger must not own evidence ledger events; use bitfun-agent-runtime evidence_ledger', + }, + { + regex: /\bpub struct SessionEvidenceLedger\b/, + message: + 'core evidence_ledger must not own evidence ledger store; use bitfun-agent-runtime evidence_ledger', + }, + { + regex: /\bimpl From for CompressionContract\b/, + message: + 'core evidence_ledger must not own compression contract projection; use bitfun-agent-runtime evidence_ledger', + }, + { + regex: /\buuid::Uuid::new_v4\b/, + message: + 'core evidence_ledger must not own evidence ledger event id generation; use bitfun-agent-runtime evidence_ledger', + }, + { + regex: /\bDashMap\b/, + message: + 'core evidence_ledger must not own evidence ledger storage maps; use bitfun-agent-runtime evidence_ledger', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs', + patterns: [ + { + regex: /\bimpl From for EvidenceLedgerCheckpoint\b/, + message: + 'core tool context runtime must not own checkpoint evidence projection; use bitfun-agent-runtime evidence_ledger', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/user_input_manager.rs', + patterns: [ + { + regex: /\bpub struct UserInputManager\b/, + message: + 'core user_input_manager must not own user-input channel state; use bitfun-agent-runtime user_questions', + }, + { + regex: /\boneshot::Sender\b/, + message: + 'core user_input_manager must not own user-input wait channels; use bitfun-agent-runtime user_questions', + }, + { + regex: /\bDashMap\b/, + message: + 'core user_input_manager must not own user-input channel storage; use bitfun-agent-runtime user_questions', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + patterns: [ + { + regex: /\bpub enum ConfirmationResponse\b/, + message: + 'core tool pipeline must not own confirmation channel responses; use bitfun-agent-runtime tool_confirmation', + }, + { + regex: /\boneshot::Sender<\s*ConfirmationResponse\s*>/, + message: + 'core tool pipeline must not own confirmation wait-channel storage; use bitfun-agent-runtime tool_confirmation', + }, + { + regex: /\bArc>\b/, + message: + 'core tool pipeline must not own cancellation token storage; use tool-runtime pipeline', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/round_executor.rs', + patterns: [ + { + regex: /\bArc>\b/, + message: + 'core round executor must not own dialog-turn cancellation token storage; use bitfun-agent-runtime turn_cancellation', + }, + { + regex: /\bcancellation_tokens:\s*Arc\s*"idle"/, + message: + 'core event types must not own session-state wire labels; use bitfun-agent-runtime events', + }, + { + regex: /SessionState::Processing\s*\{[^}]*\}\s*=>\s*"processing"/, + message: + 'core event types must not own session-state wire labels; use bitfun-agent-runtime events', + }, + { + regex: /SessionState::Error\s*\{[^}]*\}\s*=>\s*"error"/, + message: + 'core event types must not own session-state wire labels; use bitfun-agent-runtime events', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + patterns: [ + { + regex: /\bconst\s+MAX_QUEUE_DEPTH\b/, + message: + 'core scheduler must not own dialog queue capacity; use bitfun-agent-runtime scheduler', + }, + { + regex: /\bstd::collections::VecDeque\b/, + message: + 'core scheduler must not own dialog queue storage; use bitfun-agent-runtime scheduler', + }, + { + regex: /\bdashmap::DashMap\b/, + message: + 'core scheduler must not own scheduler state maps; use bitfun-agent-runtime scheduler stores', + }, + { + regex: /\bstruct\s+ActiveTurn\b/, + message: + 'core scheduler must not own active-turn facts; use bitfun-agent-runtime scheduler', + }, + { + regex: /\bfn\s+format_agent_session_reply\b/, + message: + 'core scheduler must not own agent-session reply text assembly; use bitfun-agent-runtime scheduler', + }, + { + regex: /automated reply to a previous SessionMessage call/, + message: + 'core scheduler must not own agent-session reply reminder text; use bitfun-agent-runtime scheduler', + }, + { + regex: /RoundInjectionKind::UserSteering/, + message: + 'core scheduler must not own steering injection construction; use bitfun-agent-runtime scheduler', + }, + { + regex: /RoundInjectionTarget::ExactTurn/, + message: + 'core scheduler must not own steering exact-turn targeting; use bitfun-agent-runtime scheduler', + }, + { + regex: /RoundInjectionKind::ThreadGoalObjectiveUpdated/, + message: + 'core scheduler must not own thread-goal background injection construction; use bitfun-agent-runtime scheduler', + }, + { + regex: /RoundInjectionKind::BackgroundResult/, + message: + 'core scheduler must not own background result injection construction; use bitfun-agent-runtime scheduler', + }, + { + regex: /RoundInjectionTarget::CurrentRunningTurn/, + message: + 'core scheduler must not own current-turn background injection targeting; use bitfun-agent-runtime scheduler', + }, + { + regex: /\bfn\s+turn_outcome_kind\s*\(/, + message: + 'core scheduler must not own turn-outcome event facts; use bitfun-agent-runtime events', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/framework.rs', + patterns: [ + { + regex: /\bpub struct DynamicMcpToolInfo\b/, + message: 'core tool framework must not redefine DynamicMcpToolInfo; use bitfun-agent-tools', + }, + { + regex: /\bpub struct DynamicToolInfo\b/, + message: 'core tool framework must not redefine DynamicToolInfo; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolRenderOptions\b/, + message: 'core tool framework must not redefine ToolRenderOptions; use bitfun-agent-tools', + }, + { + regex: /\bpub enum ToolPathBackend\b/, + message: 'core tool framework must not redefine ToolPathBackend; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolPathResolution\b/, + message: 'core tool framework must not redefine ToolPathResolution; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolContextFacts\b/, + message: 'core tool framework must not redefine ToolContextFacts; use bitfun-agent-tools', + }, + { + regex: /\bpub enum ToolWorkspaceKind\b/, + message: 'core tool framework must not redefine ToolWorkspaceKind; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolUseContext\b/, + message: + 'core tool framework must not own ToolUseContext; re-export it from tool_context_runtime', + }, + { + regex: /\bcall_with_tool_runtime_hooks\b/, + message: + 'core tool framework must not wire runtime hooks directly; delegate through tool_context_runtime', + }, + { + regex: /\bdeep_review_shared_context_measurement_snapshot\b/, + message: + 'core tool framework must not own runtime hook regressions; keep them in tool_context_runtime', + }, + { + regex: /\bget_global_coordinator\b/, + message: + 'core tool framework must not own runtime checkpoint coordination; keep it in tool_context_runtime', + }, + { + regex: /\bGitService\b/, + message: + 'core tool framework must not own git-backed checkpoint runtime; keep it in tool_context_runtime', + }, + { + regex: /\bget_workspace_runtime_service_arc\b/, + message: + 'core tool framework must not own workspace runtime lookup; keep it in tool_context_runtime', + }, + { + regex: /\bremote_workspace_runtime_root\b/, + message: + 'core tool framework must not own remote runtime-root lookup; keep it in tool_context_runtime', + }, + { + regex: /\bget_path_manager_arc\b/, + message: + 'core tool framework must not own host runtime-root lookup; keep it in tool_context_runtime', + }, + { + regex: /\bpost_call_hooks::record_successful_tool_call\b/, + message: + 'core tool framework must not own post-call runtime hooks; keep them in tool_context_runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'tool pipeline must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + { + regex: /\bfn serialize_result_for_assistant\b/, + message: + 'core tool pipeline must not own provider-neutral assistant result rendering; use bitfun-agent-tools', + }, + { + regex: /\bconst TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES\b/, + message: + 'core tool pipeline must not own tool error argument preview limits; use bitfun-agent-tools', + }, + { + regex: /\bfn truncate_arguments_preview\b/, + message: + 'core tool pipeline must not own tool error argument preview rendering; use bitfun-agent-tools', + }, + { + regex: /\bfn truncate_raw_arguments_preview\b/, + message: + 'core tool pipeline must not own raw tool argument preview rendering; use bitfun-agent-tools', + }, + { + regex: /\bconst USER_STEERING_INTERRUPTED_MESSAGE\b/, + message: + 'core tool pipeline must not own steering-interrupted result presentation; use bitfun-agent-tools', + }, + { + regex: /\bfn build_truncation_recovery_notice\b/, + message: + 'core tool pipeline must not own truncation recovery notice policy; use bitfun-agent-tools', + }, + { + regex: /\bfn is_write_like_tool_name\b/, + message: + 'core tool pipeline must not own write-like truncation classification; use bitfun-agent-tools', + }, + { + regex: /\bstruct\s+ToolBatch\b/, + message: + 'core tool pipeline must not own portable batching DTOs; use tool-runtime::pipeline', + }, + { + regex: /\bfn\s+partition_tool_batches\b/, + message: + 'core tool pipeline must not own portable batching strategy; use tool-runtime::pipeline', + }, + { + regex: /Duration::from_millis\(100\s*\*\s*attempts/, + message: + 'core tool pipeline must not own retry backoff policy; use tool-runtime::pipeline', + }, + { + regex: /\blet\s+mut\s+cancelled_count\b/, + message: + 'core tool pipeline must not own dialog-turn cancellation summary policy; use tool-runtime::pipeline', + }, + { + regex: /ToolConfirmationOutcome::(?:Rejected|ChannelClosed|Timeout)/, + message: + 'core tool pipeline must not own confirmation wait-result mapping; use bitfun-agent-runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs', + patterns: [ + { + regex: /\bstats\.(?:queued|waiting|running|streaming|awaiting_confirmation|completed|failed|cancelled)\s*\+=/, + message: + 'core tool state manager must not own provider-neutral state counting; use tool-runtime::pipeline', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs', + patterns: [ + { + regex: /remote_workspace_git_metadata_unavailable|workspace_unavailable|git_status_unavailable:/, + message: + 'core tool context must not own light-checkpoint summary policy; use bitfun-agent-runtime::checkpoint', + }, + { + regex: /format!\(\s*"staged=\{\}, unstaged=\{\}, untracked=\{\}"/, + message: + 'core tool context must not own local git dirty-state checkpoint formatting; use bitfun-agent-runtime::checkpoint', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs', + patterns: [ + { + regex: /\bFrontMatterMarkdown\b/, + message: + 'core custom subagent facade must not own markdown front-matter IO; use bitfun-agent-runtime', + }, + { + regex: /\bserde_yaml::Mapping\b/, + message: + 'core custom subagent facade must not own markdown metadata serialization; use bitfun-agent-runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/custom.rs', + patterns: [ + { + regex: /\bCustomSubagentLoader\b/, + message: + 'core custom subagent registry must not restore the old loader owner; use bitfun-agent-runtime discovery report', + }, + { + regex: /\bread_dir\b/, + message: + 'core custom subagent registry must not own directory scanning; use bitfun-agent-runtime discovery report', + }, + { + regex: /\.extension\(\)\.is_some_and\(\|ext\|\s*ext\s*==\s*"md"\)/, + message: + 'core custom subagent registry must not own markdown file discovery; use bitfun-agent-runtime discovery report', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs', + patterns: [ + { + regex: /\bstruct\s+DelegationPolicy\b/, + message: + 'core subagent runtime must not redefine DelegationPolicy; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+SubagentContextMode\b/, + message: + 'core subagent runtime must not redefine SubagentContextMode; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', + patterns: [ + { + regex: /\benum\s+DialogTriggerSource\b/, + message: + 'core coordinator must not redefine DialogTriggerSource; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + patterns: [ + { + regex: /\benum\s+DialogQueuePriority\b/, + message: + 'core scheduler must not redefine DialogQueuePriority; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+DialogSubmissionPolicy\b/, + message: + 'core scheduler must not redefine DialogSubmissionPolicy; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+DialogSubmitOutcome\b/, + message: + 'core scheduler must not redefine DialogSubmitOutcome; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+AgentSessionReplyRoute\b/, + message: + 'core scheduler must not redefine AgentSessionReplyRoute; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+DialogSteerOutcome\b/, + message: + 'core scheduler must not redefine DialogSteerOutcome; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/round_preempt.rs', + patterns: [ + { + regex: /\bstruct\s+RoundInjection\b/, + message: + 'core round-boundary runtime must not redefine RoundInjection; use bitfun-runtime-ports', + }, + { + regex: /\btrait\s+DialogRoundInjectionSource\b/, + message: + 'core round-boundary runtime must not redefine DialogRoundInjectionSource; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+RoundInjectionKind\b/, + message: + 'core round-boundary runtime must not redefine RoundInjectionKind; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+RoundInjectionTarget\b/, + message: + 'core round-boundary runtime must not redefine RoundInjectionTarget; use bitfun-runtime-ports', + }, + { + regex: /\bpub\s+struct\s+SessionRoundInjectionBuffer\b/, + message: + 'core round-boundary runtime must not own round injection buffer; use bitfun-agent-runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/goal_mode/mod.rs', + patterns: [ + { + regex: /\bconst\s+GOAL_MODE_METADATA_KEY\b/, + message: 'core goal mode types must not redefine GOAL_MODE_METADATA_KEY; use bitfun-runtime-ports', + }, + { + regex: /\bconst\s+MAX_GOAL_CONTINUATIONS\b/, + message: 'core goal mode types must not redefine MAX_GOAL_CONTINUATIONS; use bitfun-runtime-ports', + }, + { + regex: /\bconst\s+MAX_CONTEXT_SUMMARY_CHARS\b/, + message: 'core goal mode types must not redefine MAX_CONTEXT_SUMMARY_CHARS; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+ThreadGoal\b/, + message: 'core goal mode types must not redefine ThreadGoal; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+ThreadGoalStatus\b/, + message: 'core goal mode types must not redefine ThreadGoalStatus; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+GoalGenerationResult\b/, + message: 'core goal mode types must not redefine GoalGenerationResult; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+ThreadGoalToolResponse\b/, + message: 'core goal mode types must not redefine ThreadGoalToolResponse; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+GoalActivationResult\b/, + message: 'core goal mode types must not redefine GoalActivationResult; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+GoalContinuationPlan\b/, + message: 'core goal mode types must not redefine GoalContinuationPlan; use bitfun-runtime-ports', + }, + { + regex: /\bpub\s+struct\s+ThreadGoalRuntime\b/, + message: + 'core goal mode must not own thread goal runtime accounting; use bitfun-agent-runtime', + }, + { + regex: /\bfn\s+build_thread_goal_continuation_plan\b/, + message: + 'core goal mode must not own thread goal continuation planning; use bitfun-agent-runtime', + }, + { + regex: /\bfn\s+goal_tool_response\b/, + message: + 'core goal mode must not own thread goal tool response assembly; use bitfun-agent-runtime', + }, + { + regex: /\bfn\s+billable_tokens_from_counts\b/, + message: + 'core goal mode must not own thread goal token accounting policy; use bitfun-agent-runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/core/message.rs', + patterns: [ + { + regex: /\bstruct\s+CompressionContract\b/, + message: 'core message model must not redefine CompressionContract; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+CompressionContractItem\b/, + message: 'core message model must not redefine CompressionContractItem; use bitfun-runtime-ports', + }, + { + regex: /\bfn\s+render_contract_items\b/, + message: 'core message model must not own compression contract rendering; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/workspace/manager.rs', + patterns: [ + { + regex: /\bstruct\s+RelatedPath\b/, + message: 'core workspace manager must not redefine RelatedPath; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'file read-state runtime must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'tool-result storage runtime must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'post-call hooks must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_adapter.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'tool adapter must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'product tool runtime must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'product tool runtime catalog must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'GetToolSpec adapter must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs', + patterns: [ + { + regex: /framework::(?:\{[^}]*\bToolUseContext\b[^}]*\}|\bToolUseContext\b)/, + message: + 'manifest resolver must import ToolUseContext from tool_context_runtime, not the framework re-export', + }, + { + regex: /\bContextualToolManifest\b/, + message: + 'manifest resolver must stay a compatibility facade; contextual manifest conversion belongs in product_runtime/catalog', + }, + { + regex: /\bContextualVisibleTools\b/, + message: + 'manifest resolver must stay a compatibility facade; contextual visible-tool conversion belongs in product_runtime/catalog', + }, + { + regex: /\bToolManifestDefinition\b/, + message: + 'manifest resolver must not own manifest DTO conversion; use product_runtime/catalog', + }, + { + regex: /\bfn\s+to_core_tool_definition\b/, + message: + 'manifest resolver must not own ToolDefinition conversion; use product_runtime/catalog', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/workspace.rs', + patterns: [ + { + regex: /\bpub\s+trait\s+WorkspaceFileSystem\b/, + message: + 'workspace file-system contract must be owned by bitfun-runtime-ports; keep only concrete adapters or re-exports in core', + }, + { + regex: /\bpub\s+trait\s+WorkspaceShell\b/, + message: + 'workspace shell contract must be owned by bitfun-runtime-ports; keep only concrete adapters or re-exports in core', + }, + { + regex: /\bpub\s+struct\s+WorkspaceServices\b/, + message: + 'workspace service bundle contract must be owned by bitfun-runtime-ports; keep only concrete adapters or re-exports in core', + }, + { + regex: /\bpub\s+struct\s+WorkspaceCommandOptions\b/, + message: + 'workspace command contract must be owned by bitfun-runtime-ports; keep only concrete adapters or re-exports in core', + }, + { + regex: /\bpub\s+struct\s+WorkspaceCommandResult\b/, + message: + 'workspace command result contract must be owned by bitfun-runtime-ports; keep only concrete adapters or re-exports in core', + }, + { + regex: /\bpub\s+struct\s+WorkspaceDirEntry\b/, + message: + 'workspace directory entry contract must be owned by bitfun-runtime-ports; keep only concrete adapters or re-exports in core', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/execution_engine.rs', + patterns: [ + { + regex: /\bGetToolSpecLoadObservation\b/, + message: + 'execution engine must not own collapsed-tool unlock observation details; use product_runtime unlock state owner', + }, + { + regex: /\bcollect_loaded_collapsed_tool_names\b/, + message: + 'execution engine must not call generic collapsed-tool collector directly; use product_runtime unlock state owner', + }, + { + regex: /\bfn\s+collect_unlocked_collapsed_tools\b/, + message: + 'execution engine must not own collapsed-tool unlock collection; use product_runtime unlock state owner', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/miniapp/manager.rs', + patterns: [ + { + regex: /\bbuild_runtime_state\b/, + message: + 'core MiniApp manager must not build runtime state directly; use product-domain lifecycle helpers', + }, + { + regex: /\bbuild_source_revision\b/, + message: + 'core MiniApp manager must not build source revisions directly; use product-domain lifecycle helpers', + }, + { + regex: /\bbuild_deps_revision\b/, + message: + 'core MiniApp manager must not build dependency revisions directly; use product-domain lifecycle helpers', + }, + { + regex: /\bapp\.version\s*\+=\s*1\b/, + message: + 'core MiniApp manager must not own version increments for lifecycle transitions; use product-domain lifecycle helpers', + }, + { + regex: /\bapp\.runtime\s*=/, + message: + 'core MiniApp manager must not own runtime-state replacement for lifecycle transitions; use product-domain lifecycle helpers', + }, + { + regex: /\bbuild_created_app\b/, + message: + 'core MiniApp manager must not own create workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bapply_update_patch\b/, + message: + 'core MiniApp manager must not own update workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bprepare_draft_app\b/, + message: + 'core MiniApp manager must not own draft creation workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bapply_draft_source_sync_result\b/, + message: + 'core MiniApp manager must not own draft source-sync workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bapply_draft_permission_update_result\b/, + message: + 'core MiniApp manager must not own draft permission workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bapply_draft_to_active\b/, + message: + 'core MiniApp manager must not own apply-draft workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bapply_draft_customization_metadata\b/, + message: + 'core MiniApp manager must not own draft customization workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bmark_builtin_update_available_metadata\b/, + message: + 'core MiniApp manager must not own built-in update workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bdecline_builtin_update_metadata\b/, + message: + 'core MiniApp manager must not own built-in update decline workflow assembly; use MiniAppRuntimeFacade', + }, + { + regex: /\bprepare_imported_meta\b/, + message: + 'core MiniApp manager must not own import metadata rehome planning; use product-domain import bundle plan', + }, + { + regex: /\bbuild_import_fallbacks\b/, + message: + 'core MiniApp manager must not own import fallback planning; use product-domain import bundle plan', + }, + { + regex: /\bbuild_import_bundle_plan\b/, + message: + 'core MiniApp manager must not own import bundle planning; use MiniAppRuntimeFacade', + }, + { + regex: /\bread_import_meta_json\b/, + message: + 'core MiniApp manager must not own import metadata IO; use MiniAppRuntimeFacade import ports', + }, + { + regex: /\bwrite_import_bundle\b/, + message: + 'core MiniApp manager must not own import bundle IO; use MiniAppRuntimeFacade import ports', + }, + { + regex: /\bworkspace_dir_string\b/, + message: + 'core MiniApp manager must not own compile workspace path adaptation; use MiniAppCompileRequest', + }, + { + regex: /\bresolve_policy\s*\(/, + message: + 'core MiniApp manager must not own permission policy path adaptation; use MiniAppPermissionPolicyRequest', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/restrictions.rs', + patterns: [ + { + regex: /\bpub enum ToolPathOperation\b/, + message: 'core tool restrictions must not redefine ToolPathOperation; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolPathPolicy\b/, + message: 'core tool restrictions must not redefine ToolPathPolicy; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolRuntimeRestrictions\b/, + message: + 'core tool restrictions must not redefine ToolRuntimeRestrictions; use bitfun-agent-tools', + }, + { + regex: /\bfn\s+normalize_absolute_posix_path\b/, + message: + 'core tool restrictions must not redefine remote POSIX path normalization; use bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/workspace_paths.rs', + patterns: [ + { + regex: /\bpub const BITFUN_RUNTIME_URI_PREFIX\b/, + message: + 'core workspace path facade must not redefine the runtime URI prefix; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ParsedBitFunRuntimeUri\b/, + message: + 'core workspace path facade must not redefine ParsedBitFunRuntimeUri; use bitfun-agent-tools', + }, + { + regex: /\bfn\s+posix_normalize_components\b/, + message: + 'core workspace path facade must not redefine remote POSIX path normalization; use bitfun-agent-tools', + }, + { + regex: /Component::ParentDir/, + message: + 'core workspace path facade must not redefine host path normalization; use bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/registry.rs', + patterns: [ + { + regex: /\bstruct DynamicToolMetadata\b/, + message: + 'core tool registry must not own dynamic tool metadata storage; use bitfun-agent-tools ToolRegistry', + }, + { + regex: /\btools\s*:\s*IndexMap\b/, + message: + 'core tool registry must not own the generic tool map; use bitfun-agent-tools ToolRegistry', + }, + { + regex: /\bdynamic_tools\s*:\s*IndexMap\b/, + message: + 'core tool registry must not own the dynamic tool map; use bitfun-agent-tools ToolRegistry', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs', + patterns: [ + { + regex: /\bnormalize_string\b/, + message: + 'core file read-state runtime must delegate pure freshness normalization to bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs', + patterns: [ + { + regex: /\bfn\s+generate_preview\b/, + message: + 'core tool result storage must delegate pure preview generation to bitfun-agent-tools', + }, + { + regex: /\bfn\s+build_persisted_output_message\b/, + message: + 'core tool result storage must delegate persisted-output rendering to bitfun-agent-tools', + }, + { + regex: /\bfn\s+select_candidates_to_persist\b/, + message: + 'core tool result storage must delegate round-budget selection to bitfun-agent-tools', + }, + { + regex: /\bstruct\s+ToolResultStoragePolicy\b/, + message: + 'core tool result storage must use the provider-neutral storage policy from bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/server/process.rs', + patterns: [ + { + regex: /\bpub enum MCPServerType\b/, + message: 'core MCP server process runtime must not redefine MCPServerType; use the integrations contract', + }, + { + regex: /\bpub enum MCPServerStatus\b/, + message: 'core MCP server process runtime must not redefine MCPServerStatus; use the integrations contract', + }, + { + regex: /\bfn is_auth_error\b/, + message: 'core MCP server process runtime must not own auth error classification; use the integrations helper', + }, + { + regex: /\bconst AUTHORIZATION_KEYS\b/, + message: 'core MCP server process runtime must not own remote authorization key constants; use the integrations helper', + }, + { + regex: /\bcontains_key\("Authorization"\)/, + message: 'core MCP server process runtime must not inline legacy authorization header fallback; use the integrations helper', + }, + { + regex: /\bprocess_manager::create_tokio_command\b/, + message: 'core MCP server process facade must not spawn MCP child processes; use the integrations owner crate', + }, + { + regex: /\bMCPTransport::start_receive_loop\b/, + message: 'core MCP server process facade must not own stdio receive lifecycle; use the integrations owner crate', + }, + { + regex: /\bMCPConnection::new_remote\b/, + message: 'core MCP server process facade must not own remote transport lifecycle; use the integrations owner crate', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/server/manager/mod.rs', + patterns: [ + { + regex: /\benum ListChangedKind\b/, + message: 'core MCP server manager must not own list-changed classification; use the integrations helper', + }, + { + regex: /\bresource_catalog_cache\b/, + message: 'core MCP server manager must not own resource catalog cache state; use the integrations owner crate', + }, + { + regex: /\bprompt_catalog_cache\b/, + message: 'core MCP server manager must not own prompt catalog cache state; use the integrations owner crate', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/server/manager/reconnect.rs', + patterns: [ + { + regex: /\bfn compute_backoff_delay\b/, + message: 'core MCP reconnect runtime must not own backoff policy math; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/server/manager/interaction.rs', + patterns: [ + { + regex: /\bfn detect_list_changed_kind\b/, + message: 'core MCP interaction runtime must not own list-changed classification; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/adapter/tool.rs', + patterns: [ + { + regex: /\bfn behavior_hints\b/, + message: 'core MCP tool adapter must not own dynamic tool behavior hint rendering; use the integrations helper', + }, + { + regex: /\bfn truncate_for_assistant\b/, + message: 'core MCP tool adapter must not own result truncation rendering; use the integrations helper', + }, + { + regex: /\bMCPToolResultContent\b/, + message: 'core MCP tool adapter must not own MCP result content rendering; use the integrations helper', + }, + { + regex: /Tool '\{\}' from MCP server/, + message: 'core MCP tool adapter must not own dynamic descriptor text; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/adapter/context.rs', + patterns: [ + { + regex: /\bpub struct ContextEnhancerConfig\b/, + message: 'core MCP context provider must not own enhancer config; use the integrations helper', + }, + { + regex: /\bpub struct ContextEnhancer\b/, + message: 'core MCP context provider must not own resource selection logic; use the integrations helper', + }, + { + regex: /\bpartial_cmp\b/, + message: 'core MCP context provider must not own resource ranking logic; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/function_agents/git-func-agent/commit_generator.rs', + patterns: [ + { + regex: /\bGitService::get_status\b/, + message: + 'Git function-agent commit generator must use CoreProductDomainRuntime for Git adapter wiring', + }, + { + regex: /\bAIAnalysisService::new_with_agent_config\b/, + message: + 'Git function-agent commit generator must use CoreProductDomainRuntime for AI adapter wiring', + }, + { + regex: /\bto_string_lossy\b/, + message: + 'Git function-agent commit generator must preserve PathBuf paths when routing through the facade', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', + patterns: [ + { + regex: /\bAIWorkStateService::new_with_agent_config\b/, + message: + 'Startchat work-state analyzer must use CoreProductDomainRuntime for AI adapter wiring', + }, + { + regex: /\bcreate_command\("git"\)/, + message: + 'Startchat work-state analyzer must use CoreProductDomainRuntime for Git adapter wiring', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/server/config.rs', + patterns: [ + { + regex: /\bpub enum MCPServerTransport\b/, + message: 'core MCP server config facade must not redefine MCPServerTransport; use the integrations contract', + }, + { + regex: /\bpub struct MCPServerOAuthConfig\b/, + message: 'core MCP server config facade must not redefine OAuth config; use the integrations contract', + }, + { + regex: /\bpub struct MCPServerXaaConfig\b/, + message: 'core MCP server config facade must not redefine XAA config; use the integrations contract', + }, + { + regex: /\bpub struct MCPServerConfig\b/, + message: 'core MCP server config facade must not redefine server config; use the integrations contract', + }, + { + regex: /\bfn default_true\b/, + message: 'core MCP server config facade must not redefine config serde defaults; use the integrations contract', + }, + { + regex: /\bpub fn resolved_transport\b/, + message: 'core MCP server config facade must not redefine transport defaults; use the integrations contract', + }, + { + regex: /\bpub fn validate\b/, + message: 'core MCP server config facade must not redefine config validation; use the integrations contract', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/config/cursor_format.rs', + patterns: [ + { + regex: /\bfn parse_source\b/, + message: 'core MCP cursor facade must not redefine source parsing; use the integrations contract', + }, + { + regex: /\bfn parse_transport\b/, + message: 'core MCP cursor facade must not redefine transport parsing; use the integrations contract', + }, + { + regex: /\bfn parse_legacy_type\b/, + message: 'core MCP cursor facade must not redefine legacy type parsing; use the integrations contract', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/config/json_config.rs', + patterns: [ + { + regex: /\bfn normalize_source\b/, + message: 'core MCP JSON config facade must not redefine source normalization; use the integrations helper', + }, + { + regex: /\bfn normalize_transport\b/, + message: 'core MCP JSON config facade must not redefine transport normalization; use the integrations helper', + }, + { + regex: /\bfn normalize_legacy_type\b/, + message: 'core MCP JSON config facade must not redefine legacy type normalization; use the integrations helper', + }, + { + regex: /\bconfig_value\.get\("mcpServers"\)\.is_none\(\)/, + message: 'core MCP JSON config facade must not inline save validation; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/config/service.rs', + patterns: [ + { + regex: /\bconst AUTHORIZATION_KEYS\b/, + message: 'core MCP config service facade must not own authorization key constants; use the integrations helper', + }, + { + regex: /\bfn config_signature\b/, + message: 'core MCP config service facade must not own merge signatures; use the integrations helper', + }, + { + regex: /\bfn precedence\b/, + message: 'core MCP config service facade must not own merge precedence; use the integrations helper', + }, + { + regex: /\bfn config_authorization_from_map\b/, + message: 'core MCP config service facade must not own authorization extraction; use the integrations helper', + }, + { + regex: /\bBTreeMap\b/, + message: 'core MCP config service facade must not rebuild stable merge signatures; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/auth.rs', + patterns: [ + { + regex: /\bstruct VaultFile\b/, + message: 'core MCP auth facade must not own OAuth vault storage; use the integrations owner crate', + }, + { + regex: /\bconst NONCE_LEN\b/, + message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', + }, + { + regex: /\bfn encrypt_value\b/, + message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', + }, + { + regex: /\bfn decrypt_value\b/, + message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', + }, + { + regex: /\bAuthorizationManager::new\b/, + message: 'core MCP auth facade must not assemble OAuth authorization manager internals; use the integrations owner crate', + }, + { + regex: /\bOAuthState::new\b/, + message: 'core MCP auth facade must not assemble OAuth authorization state internals; use the integrations owner crate', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/protocol/transport_remote.rs', + patterns: [ + { + regex: /\bfn normalize_authorization_value\b/, + message: 'core MCP remote transport must not redefine authorization normalization; use the integrations helper', + }, + { + regex: /starts_with\("bearer "\)/, + message: 'core MCP remote transport must not inline bearer normalization; use the integrations helper', + }, + { + regex: /\bfn build_client_info\b/, + message: 'core MCP remote transport must not own client capability construction; use the integrations helper', + }, + { + regex: /\bClientCapabilities::builder\b/, + message: 'core MCP remote transport must not inline client capability construction; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?initialize_result\b/, + message: 'core MCP remote transport must not own rmcp initialize mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?tool\b/, + message: 'core MCP remote transport must not own rmcp tool mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?resource\b/, + message: 'core MCP remote transport must not own rmcp resource mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?resource_content\b/, + message: 'core MCP remote transport must not own rmcp resource content mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?prompt\b/, + message: 'core MCP remote transport must not own rmcp prompt mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?prompt_message\b/, + message: 'core MCP remote transport must not own rmcp prompt message mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?tool_result\b/, + message: 'core MCP remote transport must not own rmcp tool result mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?content_block\b/, + message: 'core MCP remote transport must not own rmcp content block mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?icons\b/, + message: 'core MCP remote transport must not own rmcp icon mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?annotations\b/, + message: 'core MCP remote transport must not own rmcp annotation mapping; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mcp/protocol/jsonrpc.rs', + patterns: [ + { + regex: /\bfn serialize_params\b/, + message: 'core MCP jsonrpc facade must not redefine request parameter serialization; use the integrations contract', + }, + { + regex: /\bpub fn create_initialize_request\b/, + message: 'core MCP jsonrpc facade must not redefine initialize request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_resources_list_request\b/, + message: 'core MCP jsonrpc facade must not redefine resources/list request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_resources_read_request\b/, + message: 'core MCP jsonrpc facade must not redefine resources/read request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_prompts_list_request\b/, + message: 'core MCP jsonrpc facade must not redefine prompts/list request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_prompts_get_request\b/, + message: 'core MCP jsonrpc facade must not redefine prompts/get request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_tools_list_request\b/, + message: 'core MCP jsonrpc facade must not redefine tools/list request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_tools_call_request\b/, + message: 'core MCP jsonrpc facade must not redefine tools/call request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_ping_request\b/, + message: 'core MCP jsonrpc facade must not redefine ping request builders; use the integrations contract', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs', + patterns: [ + { + regex: /\bpub const LOCAL_WORKSPACE_SSH_HOST\b/, + message: 'core remote SSH workspace runtime must not redefine LOCAL_WORKSPACE_SSH_HOST; use the integrations contract', + }, + { + regex: /\bpub fn normalize_remote_workspace_path\b/, + message: 'core remote SSH workspace runtime must not redefine remote path normalization; use the integrations contract', + }, + { + regex: /\bpub fn sanitize_ssh_connection_id_for_local_dir\b/, + message: 'core remote SSH workspace runtime must not redefine SSH connection id sanitization; use the integrations contract', + }, + { + regex: /\bpub fn sanitize_remote_mirror_path_component\b/, + message: 'core remote SSH workspace runtime must not redefine remote mirror path sanitization; use the integrations contract', + }, + { + regex: /\bpub fn sanitize_ssh_hostname_for_mirror\b/, + message: 'core remote SSH workspace runtime must not redefine SSH hostname mirror sanitization; use the integrations contract', + }, + { + regex: /\bpub fn remote_root_to_mirror_subpath\b/, + message: 'core remote SSH workspace runtime must not redefine remote mirror subpath mapping; use the integrations contract', + }, + { + regex: /\bpub fn workspace_logical_key\b/, + message: 'core remote SSH workspace runtime must not redefine workspace logical keys; use the integrations contract', + }, + { + regex: /\bpub fn local_workspace_stable_storage_id\b/, + message: 'core remote SSH workspace runtime must not redefine local workspace stable ids; use the integrations contract', + }, + { + regex: /\bpub fn remote_workspace_stable_id\b/, + message: 'core remote SSH workspace runtime must not redefine remote workspace stable ids; use the integrations contract', + }, + { + regex: /\bpub fn unresolved_remote_session_storage_key\b/, + message: 'core remote SSH workspace runtime must not redefine unresolved session keys; use the integrations contract', + }, + { + regex: /\bstruct RegisteredRemoteWorkspace\b/, + message: 'core remote SSH workspace runtime must not own workspace registrations; use the integrations registry', + }, + { + regex: /\bpub struct RemoteWorkspaceEntry\b/, + message: 'core remote SSH workspace runtime must not redefine workspace entries; use the integrations registry', + }, + { + regex: /\bpub struct RemoteWorkspaceState\b/, + message: 'core remote SSH workspace runtime must not redefine legacy workspace state; use the integrations registry', + }, + { + regex: /\bregistration_matches_path\b/, + message: 'core remote SSH workspace runtime must not own path-to-registration matching; use the integrations registry', + }, + { + regex: /\bdunce::canonicalize\b/, + message: 'core remote SSH workspace runtime must not own local root canonicalization; use the integrations path helper', + }, + { + regex: /\bfn path_buf_to_stable_local_root_string\b/, + message: 'core remote SSH workspace runtime must not own local root string normalization; use the integrations path helper', + }, + { + regex: /join\("_unresolved"\)/, + message: 'core remote SSH workspace runtime must not own unresolved session path layout; use the integrations path helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/mod.rs', + patterns: [ + { + regex: /\bpub\s+mod\s+device\s*;/, + message: + 'core remote-connect root must not own device module implementation; use the services-integrations owner', + }, + { + regex: /\bpub\s+mod\s+encryption\s*;/, + message: + 'core remote-connect root must not own encryption module implementation; use the services-integrations owner', + }, + { + regex: /\bpub\s+mod\s+pairing\s*;/, + message: + 'core remote-connect root must not own pairing module implementation; use the services-integrations owner', + }, + { + regex: /\bpub\s+mod\s+qr_generator\s*;/, + message: + 'core remote-connect root must not own QR module implementation; use the services-integrations owner', + }, + { + regex: /\bpub\s+mod\s+relay_client\s*;/, + message: + 'core remote-connect root must not own relay client module implementation; use the services-integrations owner', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/remote_server.rs', + patterns: [ + { + regex: /\bpub\(crate\) struct CoreRemoteDialogRuntimeHost\b/, + message: + 'remote_server must not own concrete remote dialog runtime host; keep it in service_agent_runtime', + }, + { + regex: /\bpub\(crate\) struct CoreRemoteCancelRuntimeHost\b/, + message: + 'remote_server must not own concrete remote cancel runtime host; keep it in service_agent_runtime', + }, + { + regex: /\bpub\(crate\) struct CoreRemoteWorkspaceFileRuntimeHost\b/, + message: + 'remote_server must not own concrete remote workspace file runtime host; keep it in service_agent_runtime', + }, + { + regex: /\bstruct CoreRemoteSessionTrackerHost\b/, + message: + 'remote_server must not own concrete remote tracker host; keep it in service_agent_runtime', + }, + { + regex: /\basync fn resolve_session_model_id\b/, + message: + 'remote_server must not own remote session model resolution; keep it in service_agent_runtime', + }, + { + regex: /\basync fn load_remote_model_catalog\b/, + message: + 'remote_server must not own remote model catalog loading; keep it in service_agent_runtime', + }, + { + regex: /\bget_global_config_service\b/, + message: + 'remote_server must not own remote model config access; route it through service_agent_runtime', + }, + { + regex: /\bfn compress_data_url_for_mobile\b/, + message: + 'remote_server must not own remote chat thumbnail compression; keep it in service_agent_runtime', + }, + { + regex: /\bfn turns_to_chat_messages\b/, + message: + 'remote_server must not own persisted turn to remote chat conversion; keep it in service_agent_runtime', + }, + { + regex: /\basync fn load_chat_messages_from_conversation_persistence\b/, + message: + 'remote_server must not own remote chat history persistence loading; keep it in service_agent_runtime', + }, + { + regex: /\bfn strip_user_input_tags\b/, + message: + 'remote_server must not own remote user input display cleanup; keep it in service_agent_runtime', + }, + { + regex: /\bpub struct ImageAttachment\b/, + message: 'core remote-connect server must not redefine image attachment wire DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ChatImageAttachment\b/, + message: 'core remote-connect server must not redefine chat image wire DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ChatMessage\b/, + message: 'core remote-connect server must not redefine chat message wire DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ChatMessageItem\b/, + message: 'core remote-connect server must not redefine chat message item DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteToolStatus\b/, + message: 'core remote-connect server must not redefine remote tool status DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ActiveTurnSnapshot\b/, + message: 'core remote-connect server must not redefine active turn snapshot DTOs; use the integrations contract', + }, + { + regex: /\bpub struct SessionInfo\b/, + message: 'core remote-connect server must not redefine session info DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteDefaultModelsConfig\b/, + message: 'core remote-connect server must not redefine remote model default DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteModelConfig\b/, + message: 'core remote-connect server must not redefine remote model DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteModelCatalog\b/, + message: 'core remote-connect server must not redefine remote model catalog DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteModelCatalogPollDelta\b/, + message: 'core remote-connect server must not redefine remote model catalog poll delta; use the integrations contract', + }, + { + regex: /\bpub enum RemoteCommand\b/, + message: 'core remote-connect server must not redefine remote command wire DTOs; use the integrations contract', + }, + { + regex: /\bpub enum RemoteResponse\b/, + message: 'core remote-connect server must not redefine remote response wire DTOs; use the integrations contract', + }, + { + regex: /\bstruct TrackerState\b/, + message: 'core remote-connect server must not own tracker state; use the integrations tracker', + }, + { + regex: /\bpub enum TrackerEvent\b/, + message: 'core remote-connect server must not redefine tracker events; use the integrations tracker', + }, + { + regex: /\bpub struct RemoteSessionStateTracker\b/, + message: 'core remote-connect server must not own tracker state; use the integrations tracker', + }, + { + regex: /\bDashMap\b/, + message: 'core remote-connect server must not own tracker storage; use the integrations registry', + }, + { + regex: /\bfn make_slim_params\b/, + message: 'core remote-connect server must not own remote tool preview slimming; use the integrations helper', + }, + { + regex: /\bmatch mobile_type\b/, + message: 'core remote-connect server must not own remote agent type alias mapping; use the integrations helper', + }, + { + regex: /\bfn resolve_remote_cancel_decision\b/, + message: 'core remote-connect server must not own cancel decision policy; use the integrations helper', + }, + { + regex: /\benum RemoteCancelDecision\b/, + message: 'core remote-connect server must not own cancel decision types; use the integrations contract', + }, + { + regex: /\bstruct RemoteCancelTaskRequest\b/, + message: 'core remote-connect server must not own cancel task request contracts; use the integrations contract', + }, + { + regex: /\btrait RemoteCancelRuntimeHost\b/, + message: 'core remote-connect server must not own cancel runtime host contracts; use the integrations contract', + }, + { + regex: /\bfn cancel_remote_task\b/, + message: 'core remote-connect server must not own cancel orchestration; use the integrations helper', + }, + { + regex: /\bfn remote_session_restore_target\b/, + message: 'core remote-connect server must not own restore-target policy; use the integrations helper', + }, + { + regex: /\bfn resolve_remote_execution_image_contexts\b/, + message: 'core remote-connect server must not own image-context preference policy; use the integrations helper', + }, + { + regex: /\btrait RemoteImageContextAdapter\b/, + message: 'core remote-connect server must not own image-context adapter contracts; use the integrations contract', + }, + { + regex: /\bconst MAX_SIZE\b/, + message: 'core remote-connect server must not own remote file max-read policy; use the integrations helper', + }, + { + regex: /\bconst MAX_CHUNK\b/, + message: 'core remote-connect server must not own remote file chunk policy; use the integrations helper', + }, + { + regex: /unwrap_or\("file"\)/, + message: 'core remote-connect server must not own remote file display-name fallback; use the integrations helper', + }, + { + regex: /\bresolve_workspace_path\b/, + message: 'core remote-connect server must not own workspace file path resolution; use the integrations helper', + }, + { + regex: /\bdetect_mime_type\b/, + message: 'core remote-connect server must not own workspace file MIME detection; use the integrations helper', + }, + { + regex: /\bread_workspace_file\b/, + message: 'core remote-connect server must not own workspace file read helpers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file\b/, + message: 'core remote-connect server must not redefine remote workspace full-file readers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file_chunk\b/, + message: 'core remote-connect server must not redefine remote workspace chunk readers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file_info\b/, + message: 'core remote-connect server must not redefine remote workspace file-info readers; use the integrations helper', + }, + { + regex: /\bfn remote_file_content_response\b/, + message: 'core remote-connect server must not own remote file content response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_file_chunk_response\b/, + message: 'core remote-connect server must not own remote file chunk response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_file_info_response\b/, + message: 'core remote-connect server must not own remote file-info response assembly; use the integrations helper', + }, + { + regex: /\bfn handle_remote_workspace_file_command\b/, + message: 'core remote-connect server must not own remote file command orchestration; use the integrations helper', + }, + { + regex: /general_purpose::STANDARD\.encode/, + message: 'core remote-connect server must not own remote response base64 wrapping; use the integrations helper', + }, + { + regex: /\bfn remote_dialog_submit_response\b/, + message: 'core remote-connect server must not own remote dialog response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_task_cancel_response\b/, + message: 'core remote-connect server must not own remote cancel response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_interaction_accepted_response\b/, + message: 'core remote-connect server must not own remote interaction response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_answer_question_response\b/, + message: 'core remote-connect server must not own remote answer response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_workspace_info_response\b/, + message: 'core remote-connect server must not own workspace-info response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_recent_workspaces_response\b/, + message: 'core remote-connect server must not own recent-workspaces response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_assistant_list_response\b/, + message: 'core remote-connect server must not own assistant-list response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_workspace_updated_response\b/, + message: 'core remote-connect server must not own workspace-updated response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_assistant_updated_response\b/, + message: 'core remote-connect server must not own assistant-updated response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_session_info\b/, + message: 'core remote-connect server must not own session response facts assembly; use the integrations helper', + }, + { + regex: /\bfn remote_session_list_response\b/, + message: 'core remote-connect server must not own session-list response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_initial_sync_response\b/, + message: 'core remote-connect server must not own initial-sync response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_session_created_response\b/, + message: 'core remote-connect server must not own session-created response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_session_model_updated_response\b/, + message: 'core remote-connect server must not own session-model response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_messages_response\b/, + message: 'core remote-connect server must not own remote messages response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_session_deleted_response\b/, + message: 'core remote-connect server must not own session-deleted response assembly; use the integrations helper', + }, + { + regex: /\bfn should_send_remote_model_catalog\b/, + message: 'core remote-connect server must not own poll model-catalog policy; use the integrations helper', + }, + { + regex: /\bfn remote_model_catalog_poll_delta\b/, + message: 'core remote-connect server must not own poll model-catalog delta policy; use the integrations helper', + }, + { + regex: /\bfn remote_no_change_poll_response\b/, + message: 'core remote-connect server must not own no-change poll response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_snapshot_poll_response\b/, + message: 'core remote-connect server must not own streaming poll response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_persisted_poll_response\b/, + message: 'core remote-connect server must not own persisted poll response assembly; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/bot/mod.rs', + patterns: [ + { + regex: /\bfn strip_workspace_path_prefix\b/, + message: 'core remote-connect bot facade must not own workspace path prefix stripping; use the integrations helper', + }, + { + regex: /\bfn is_absolute_workspace_path\b/, + message: 'core remote-connect bot facade must not own workspace path absolute detection; use the integrations helper', + }, + { + regex: /\bmatch ext\.as_str\(\)/, + message: 'core remote-connect bot facade must not own workspace file MIME mapping; use the integrations helper', + }, + { + regex: /\btokio::fs::read\(&abs_path\)/, + message: 'core remote-connect bot facade must not own workspace file reads; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs', + patterns: [ + { + regex: /\bcreate_session_with_workspace_and_creator\b/, + message: + 'SessionControl must not bypass the service/agent runtime owner when creating sessions', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs', + patterns: [ + { + regex: /\bcreate_session_with_workspace_and_creator\b/, + message: + 'SessionMessage must not bypass the service/agent runtime owner when creating sessions', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/announcement/state_store.rs', + patterns: [ + { + regex: /\btokio::fs\b/, + message: 'core announcement state store facade must not own filesystem persistence; use the integrations state store', + }, + { + regex: /\bserde_json::to_string_pretty\b/, + message: 'core announcement state store facade must not own state serialization; use the integrations state store', + }, + { + regex: /\bserde_json::from_str\b/, + message: 'core announcement state store facade must not own state deserialization; use the integrations state store', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs', + reason: + 'BashTool must stay as terminal/session/checkpoint glue and must not re-own reusable shell execution helpers', + patterns: [ + { + regex: /\bconst\s+MAX_OUTPUT_LENGTH\b/, + message: 'Bash output rendering budget is owned by tool-runtime::shell', + }, + { + regex: /\bconst\s+BANNED_COMMANDS\b/, + message: 'Bash banned-command policy is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+detect_osascript_keystroke_non_ascii\b/, + message: 'Bash osascript keystroke guard is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+detect_osascript_im_app\b/, + message: 'Bash IM AppleScript guard is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+truncate_output_preserving_tail\b/, + message: 'Bash output truncation is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+command_for_working_directory\b/, + message: 'Bash working-directory command wrapping is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+render_result\b/, + message: 'Bash local result rendering is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+render_remote_result\b/, + message: 'Bash remote result rendering is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+format_background_command_delivery_text\b/, + message: 'Bash background-result delivery text is owned by tool-runtime::shell', + }, + { + regex: /\bfn\s+format_background_command_error_text\b/, + message: 'Bash background-result error text is owned by tool-runtime::shell', + }, + ], + }, +]; + +export const forbiddenContentUnderRules = [ + { + path: 'src/crates/assembly/core/src', + reason: + 'core must use runtime-ports as the owner path for portable subagent contracts', + patterns: [ + { + regex: + /crate::agentic::subagent_runtime(?:::|\s*::|::\{)(?:[^;\n]*\b(?:DelegationPolicy|SubagentContextMode)\b)/, + message: + 'DelegationPolicy and SubagentContextMode must be imported from bitfun-runtime-ports, not the core compatibility re-export', + }, + ], + }, + { + path: 'src/crates/contracts/product-domains/src', + reason: + 'product-domains must not own IO/process/Git/AI/platform runtime behavior without an approved port/provider migration', + patterns: [ + { + regex: /\bCommand::new\(/, + allowPaths: ['src/crates/contracts/product-domains/src/miniapp/runtime.rs'], + message: + 'product-domains must not spawn processes outside the reviewed MiniApp runtime detector owner', + }, + { + regex: /\bprocess_manager::/, + message: + 'product-domains must not use the core process manager; keep process execution in core/adapters', + }, + { + regex: /\btokio::process::/, + message: 'product-domains must not own async process execution', + }, + { + regex: /\btokio::fs::/, + message: + 'product-domains must not own async storage IO; storage runtime belongs in services-integrations', + }, + { + regex: /\bGitService::/, + message: 'product-domains must not call concrete Git services; use reviewed ports/adapters', + }, + { + regex: /\b(?:AIService|AiService)::/, + message: 'product-domains must not call concrete AI services; use reviewed ports/adapters', + }, + { + regex: /\breqwest::/, + message: 'product-domains must not own network clients', + }, + { + regex: /\bgit2::/, + message: 'product-domains must not own libgit2 runtime integration', + }, + { + regex: /\brmcp::/, + message: 'product-domains must not own MCP runtime integration', + }, + { + regex: /\btauri::/, + message: 'product-domains must not depend on Tauri platform APIs', + }, + { + regex: /\bAppHandle\b/, + message: 'product-domains must not own desktop platform handles', + }, + { + regex: /\bstd::net::/, + message: 'product-domains must not own network sockets', + }, + { + regex: /\b(?:TcpStream|UdpSocket)\b/, + message: 'product-domains must not own network sockets', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src', + reason: + 'agent-tools may own pure tool manifest contracts, but not product manifest runtime or GetToolSpec execution without an approved provider migration', + patterns: [ + { + regex: /\bGetToolSpecTool\b/, + message: 'GetToolSpec implementation stays in core product tool runtime', + }, + { + regex: /\bmanifest_resolver\b/, + message: 'tool manifest resolution stays in core product tool runtime', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', + }, + { + regex: /\bToolUseContext\b/, + message: 'ToolUseContext stays in core until a portable context port is reviewed', + }, + ], + }, + { + path: 'src/crates/execution/tool-provider-groups/src', + reason: + 'tool-packs may own provider group plans, but not product tool manifest/exposure or GetToolSpec runtime', + patterns: [ + { + regex: /\bGetToolSpecTool\b/, + message: 'GetToolSpec implementation stays in core product tool runtime', + }, + { + regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, + message: 'GetToolSpec manifest insertion stays in core product tool runtime', + }, + { + regex: /\bmanifest_resolver\b/, + message: 'tool manifest resolution stays in core product tool runtime', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', + }, + { + regex: /\bToolExposure\b/, + message: 'expanded/collapsed exposure policy stays in core until provider migration', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations', + reason: + 'GetToolSpec concrete adapter belongs in the product tool runtime owner, not the generic concrete-tool implementations module', + patterns: [ + { + regex: /\bpub(?:\(crate\))? struct GetToolSpecTool\b/, + message: 'move GetToolSpecTool into core product_runtime owner', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline', + reason: + 'core pipeline must delegate deterministic tool execution admission policy to bitfun-agent-tools', + patterns: [ + { + regex: /\bvalidate_tool_allowed_by_list\s*\(/, + message: 'allowed-list admission must stay behind validate_tool_execution_admission', + }, + { + regex: /\bruntime_tool_restrictions\s*\.\s*ensure_tool_allowed\s*\(/, + message: 'runtime-restriction admission must stay behind validate_tool_execution_admission', + }, + { + regex: /\bvalidate_collapsed_tool_usage\s*\(/, + message: 'collapsed-tool admission must stay behind validate_tool_execution_admission', + }, + ], + }, +]; diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs new file mode 100644 index 0000000000..e8e49d20f8 --- /dev/null +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -0,0 +1,7889 @@ +// Boundary rules for source ownership, facades, and required owner content. + +export const requiredContentRules = [ + { + path: 'src/crates/execution/runtime-services/src/lib.rs', + reason: + 'runtime-services must own typed runtime service assembly and capability validation contracts', + patterns: [ + { + regex: /\bpub struct RuntimeServices\b/, + message: 'missing runtime services assembly container', + }, + { + regex: /\bpub struct RuntimeServicesBuilder\b/, + message: 'missing runtime services builder', + }, + { + regex: /\bpub struct CapabilityAvailability\b/, + message: 'missing capability availability contract', + }, + { + regex: /\bpub trait RuntimeServicesProvider\b/, + message: 'missing runtime services provider contract', + }, + { + regex: /\bpub struct RuntimeServicesRegistry\b/, + message: 'missing runtime services registry', + }, + { + regex: /\bCapabilityMismatch\b/, + message: 'missing typed capability mismatch error', + }, + { + regex: /\brequire_capability\b/, + message: 'missing typed capability requirement check', + }, + ], + }, + { + path: 'src/crates/execution/runtime-services/src/backend_events.rs', + reason: + 'runtime-services must own backend event delivery while core keeps only a compatibility facade', + patterns: [ + { + regex: /\bpub enum BackendEvent\b/, + message: 'missing backend event contract', + }, + { + regex: /\bpub struct BackendEventSystem\b/, + message: 'missing backend event system owner', + }, + { + regex: /\bpub fn event_name\b/, + message: 'missing stable backend event-name mapping', + }, + { + regex: /\bpub async fn emit\b/, + message: 'missing backend event emitter path', + }, + { + regex: /\bget_global_event_system\b/, + message: 'missing global backend event compatibility entry', + }, + { + regex: /\bbackend_event_names_remain_stable\b/, + message: 'missing backend event name regression', + }, + ], + }, + { + path: 'src/crates/execution/runtime-services/tests/runtime_services_contracts.rs', + reason: + 'runtime-services must keep behavior-equivalence contracts for required services, optional capabilities, registry assembly, and remote port exposure', + patterns: [ + { + regex: /\bbuilder_requires_mandatory_runtime_services\b/, + message: 'missing mandatory runtime services regression', + }, + { + regex: + /\bfake_provider_registers_required_and_remote_services_through_registry\b/, + message: 'missing provider registry assembly regression', + }, + { + regex: + /\bmissing_optional_capability_returns_typed_unsupported_error\b/, + message: 'missing optional capability unsupported regression', + }, + { + regex: + /\bcapability_availability_reports_optional_service_status_without_side_effects\b/, + message: 'missing capability availability regression', + }, + { + regex: /\bbuilder_rejects_port_registered_under_the_wrong_capability\b/, + message: 'missing capability mismatch regression', + }, + { + regex: /\bregistered_remote_ports_expose_owner_contract_methods\b/, + message: 'missing remote port owner contract regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/runtime.rs', + reason: + 'agent-runtime must expose a narrow port-backed SDK facade without depending on core, apps, or concrete service managers', + patterns: [ + { + regex: /\bpub struct AgentRuntime\b/, + message: 'missing agent runtime facade type', + }, + { + regex: /\bpub struct AgentRuntimeBuilder\b/, + message: 'missing agent runtime builder', + }, + { + regex: /\bAgentSubmissionPort\b/, + message: 'missing agent submission port dependency', + }, + { + regex: /\bAgentDialogTurnPort\b/, + message: 'missing agent dialog turn lifecycle port dependency', + }, + { + regex: /\bwith_dialog_turn_port\b/, + message: 'missing agent dialog turn lifecycle builder hook', + }, + { + regex: /\bsubmit_dialog_turn\b/, + message: 'missing agent dialog turn lifecycle entrypoint', + }, + { + regex: /\bAgentLifecycleDeliveryPort\b/, + message: 'missing agent lifecycle delivery port dependency', + }, + { + regex: /\bwith_lifecycle_delivery_port\b/, + message: 'missing agent lifecycle delivery builder hook', + }, + { + regex: /\bdeliver_background_result\b/, + message: 'missing background result lifecycle delivery entrypoint', + }, + { + regex: /\bdeliver_thread_goal\b/, + message: 'missing thread-goal lifecycle delivery entrypoint', + }, + { + regex: /\bAgentTurnCancellationPort\b/, + message: 'missing agent turn cancellation port dependency', + }, + { + regex: /\bAgentSessionManagementPort\b/, + message: 'missing agent session management port dependency', + }, + { + regex: /\bwith_session_management_port\b/, + message: 'missing agent session management builder hook', + }, + { + regex: /\bMissingSessionManagementPort\b/, + message: 'missing agent session management missing-port guard', + }, + { + regex: /\blist_sessions\b/, + message: 'missing agent session list entrypoint', + }, + { + regex: /\bdelete_session\b/, + message: 'missing agent session delete entrypoint', + }, + { + regex: /\bresolve_session_workspace_path\b/, + message: 'missing agent session workspace resolution entrypoint', + }, + { + regex: /\bsession_management_delegates_to_registered_port\b/, + message: 'missing agent session management port delegation regression', + }, + { + regex: /\bRuntimeServices\b/, + message: 'missing typed runtime services injection', + }, + { + regex: /\bRuntimeEventEnvelope\b/, + message: 'missing runtime event envelope contract', + }, + { + regex: /\bpub struct AgentEventStream\b/, + message: 'missing agent runtime event stream contract', + }, + { + regex: /\bpub fn with_event_stream\b/, + message: 'missing agent runtime event stream builder hook', + }, + { + regex: /\bpub enum SessionSelector\b/, + message: 'missing session selector contract', + }, + { + regex: /\bpub struct AgentRunRequest\b/, + message: 'missing agent run request contract', + }, + { + regex: /\bpub struct AgentRunHandle\b/, + message: 'missing agent run handle contract', + }, + { + regex: /\bpub async fn run\b/, + message: 'missing agent runtime run entrypoint', + }, + { + regex: /\bpub async fn publish_event\b/, + message: 'missing explicit runtime event publish entrypoint', + }, + { + regex: /\bpublish_event_uses_runtime_services_event_sink\b/, + message: 'missing runtime services event sink regression', + }, + { + regex: /\brun_handle_exposes_configured_agent_event_stream\b/, + message: 'missing agent runtime event stream regression', + }, + { + regex: /\bport_errors_remain_typed\b/, + message: 'missing typed port error regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/prompt.rs', + reason: + 'agent-runtime must own prompt-loop facts that do not require concrete workspace or product IO', + patterns: [ + { + regex: /\bpub enum UserContextSection\b/, + message: 'missing agent-runtime user-context section contract', + }, + { + regex: /\bpub struct UserContextPolicy\b/, + message: 'missing agent-runtime user-context policy contract', + }, + { + regex: /\bpub struct ToolListingSections\b/, + message: 'missing agent-runtime tool-listing section contract', + }, + { + regex: /\bpub struct PrependedPromptReminders\b/, + message: 'missing agent-runtime prepended-reminder contract', + }, + { + regex: /\bpub struct PromptEnvironmentFacts\b/, + message: 'missing agent-runtime prompt environment facts contract', + }, + { + regex: /\bpub fn render_prompt_environment_info\b/, + message: 'missing agent-runtime prompt environment renderer', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/prompt_cache.rs', + reason: + 'agent-runtime must own prompt-cache policy, identities, DTOs, scope keys, and in-memory runtime store', + patterns: [ + { + regex: /\bpub const PROMPT_CACHE_SCHEMA_VERSION\b/, + message: 'missing agent-runtime prompt-cache schema fact', + }, + { + regex: /\bpub struct PromptCachePolicy\b/, + message: 'missing agent-runtime prompt-cache policy', + }, + { + regex: /\bpub fn prompt_cache_scope_key\b/, + message: 'missing agent-runtime prompt-cache scope-key helper', + }, + { + regex: /\bpub struct SessionPromptCacheStore\b/, + message: 'missing agent-runtime in-memory prompt-cache store', + }, + { + regex: /\bpub enum PromptCacheLookup\b/, + message: 'missing agent-runtime prompt-cache lookup contract', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs', + reason: + 'agent-runtime must own turn skill/agent snapshot DTOs, diff rendering, listing sections, and in-memory runtime store', + patterns: [ + { + regex: /\bpub struct SkillSnapshotEntry\b/, + message: 'missing agent-runtime skill snapshot DTO', + }, + { + regex: /\bpub struct AgentSnapshotEntry\b/, + message: 'missing agent-runtime agent snapshot DTO', + }, + { + regex: /\bpub struct TurnSkillAgentSnapshot\b/, + message: 'missing agent-runtime turn skill/agent snapshot DTO', + }, + { + regex: /\bpub struct SkillAgentDiff\b/, + message: 'missing agent-runtime skill/agent diff contract', + }, + { + regex: /\bpub fn diff_skill_agent_snapshot\b/, + message: 'missing agent-runtime skill/agent diff owner', + }, + { + regex: /\bpub fn build_skill_agent_tool_listing_sections_from_snapshot\b/, + message: 'missing agent-runtime tool listing section owner', + }, + { + regex: /\bpub struct TurnSkillAgentSnapshotStore\b/, + message: 'missing agent-runtime turn skill/agent snapshot store', + }, + { + regex: /\bskill_agent_diff_renders_changed_added_and_removed_entries\b/, + message: 'missing agent-runtime skill/agent diff rendering regression', + }, + { + regex: /\blatest_snapshot_at_or_before_returns_nearest_sparse_snapshot\b/, + message: 'missing agent-runtime sparse turn snapshot store regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/file_read_state.rs', + reason: + 'agent-runtime must own provider-neutral file-read state facts and session-scoped in-memory store', + patterns: [ + { + regex: /\bpub struct FileReadState\b/, + message: 'missing agent-runtime file-read state DTO', + }, + { + regex: /\bpub fn is_full_file_read\b/, + message: 'missing agent-runtime file-read completeness policy', + }, + { + regex: /\bpub struct FileReadStateStore\b/, + message: 'missing agent-runtime file-read state store', + }, + { + regex: /\bfile_read_state_accepts_nonempty_whole_file\b/, + message: 'missing agent-runtime file-read completeness regression', + }, + { + regex: /\bfile_read_state_store_scopes_entries_by_session\b/, + message: 'missing agent-runtime file-read state session scoping regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/evidence_ledger.rs', + reason: + 'agent-runtime must own provider-neutral session evidence ledger state and compression contract projection', + patterns: [ + { + regex: /\bpub enum EvidenceLedgerTargetKind\b/, + message: 'missing agent-runtime evidence ledger target-kind DTO', + }, + { + regex: /\bpub enum EvidenceLedgerEventStatus\b/, + message: 'missing agent-runtime evidence ledger event status DTO', + }, + { + regex: /\bpub struct EvidenceLedgerEvent\b/, + message: 'missing agent-runtime evidence ledger event DTO', + }, + { + regex: /\bpub struct EvidenceLedgerSummary\b/, + message: 'missing agent-runtime evidence ledger summary DTO', + }, + { + regex: /\bpub struct SessionEvidenceLedger\b/, + message: 'missing agent-runtime session evidence ledger store', + }, + { + regex: /\bimpl From for CompressionContract\b/, + message: 'missing agent-runtime evidence ledger compression contract projection', + }, + { + regex: /\bimpl From for EvidenceLedgerCheckpoint\b/, + message: 'missing agent-runtime checkpoint evidence projection', + }, + { + regex: /\bledger_reads_events_scoped_by_session_and_turn\b/, + message: 'missing agent-runtime evidence ledger session/turn scoping regression', + }, + { + regex: /\bcheckpoint_from_light_checkpoint_preserves_recovery_boundary_metadata\b/, + message: 'missing agent-runtime checkpoint evidence projection regression', + }, + { + regex: /\bsummary_projects_into_compression_contract\b/, + message: 'missing agent-runtime evidence ledger compression contract regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/turn_cancellation.rs', + reason: + 'agent-runtime must own provider-neutral dialog-turn cancellation token lifecycle state', + patterns: [ + { + regex: /\bpub struct DialogTurnCancellationTokenStore\b/, + message: 'missing dialog-turn cancellation token store', + }, + { + regex: /\bpub fn get_or_insert_new\b/, + message: 'missing dialog-turn cancellation token creation/reuse owner', + }, + { + regex: /\bpub fn is_cancelled\b/, + message: 'missing dialog-turn cancellation state query', + }, + { + regex: /\bturn_cancellation_store_reuses_existing_token\b/, + message: 'missing dialog-turn cancellation token reuse regression', + }, + { + regex: /\bturn_cancellation_store_cancels_registered_token\b/, + message: 'missing dialog-turn cancellation token cancel regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/mod.rs', + reason: + 'agent-runtime must own provider-neutral DeepReview policy, manifest, budget, queue, report, and shared-context runtime state', + patterns: [ + { + regex: /\bpub mod budget\b/, + message: 'missing DeepReview budget owner module', + }, + { + regex: /\bpub mod manifest\b/, + message: 'missing DeepReview manifest owner module', + }, + { + regex: /\bpub mod report\b/, + message: 'missing DeepReview report owner module', + }, + { + regex: /\bpub mod task_execution\b/, + message: 'missing DeepReview task execution owner module', + }, + { + regex: /\bpub use runtime_state::\*/, + message: 'missing DeepReview runtime state exports', + }, + { + regex: /\bDeepReviewCacheUpdate\b/, + message: 'missing DeepReview report cache update export', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/task_execution.rs', + reason: + 'agent-runtime DeepReview task execution owner must keep provider-neutral packet matching, retry validation, capacity timing, and capacity-skipped presentation out of core', + patterns: [ + { + regex: /\bpub fn deep_review_packet_id_for_cache\b/, + message: 'missing DeepReview packet id cache owner function', + }, + { + regex: /\bpub fn ensure_deep_review_retry_coverage\b/, + message: 'missing DeepReview bounded retry coverage owner function', + }, + { + regex: /\bpub fn provider_capacity_queue_wait_seconds_for_attempt\b/, + message: 'missing DeepReview provider capacity backoff owner function', + }, + { + regex: /\bpub fn capacity_decision_for_provider_error_facts\b/, + message: 'missing DeepReview provider capacity error decision owner function', + }, + { + regex: /\bpub fn local_reviewer_capacity_queue_decision\b/, + message: 'missing DeepReview local reviewer capacity decision owner function', + }, + { + regex: /\bpub fn decide_provider_capacity_queue_step\b/, + message: 'missing DeepReview provider capacity queue step owner function', + }, + { + regex: /\bpub struct DeepReviewProviderCapacityQueueRuntime\b/, + message: 'missing DeepReview provider capacity queue runtime owner', + }, + { + regex: /\bpub struct DeepReviewProviderCapacityRetryRuntime\b/, + message: 'missing DeepReview provider capacity retry runtime owner', + }, + { + regex: /\bpub enum DeepReviewProviderCapacityRetryDecision\b/, + message: 'missing DeepReview provider capacity retry decision owner', + }, + { + regex: /\bpub fn decide_blocked_reviewer_admission_queue_step\b/, + message: 'missing DeepReview reviewer admission queue step owner function', + }, + { + regex: /\bpub struct DeepReviewReviewerAdmissionQueueRuntime\b/, + message: 'missing DeepReview reviewer admission queue runtime owner', + }, + { + regex: /\bstruct QueueWaitTimer\b/, + message: 'missing DeepReview queue wait timing owner', + }, + { + regex: /\bpub fn deep_review_task_completion_result\b/, + message: 'missing DeepReview task completion result presentation owner function', + }, + { + regex: /\bpub fn deep_review_cancelled_reviewer_result\b/, + message: 'missing DeepReview cancelled reviewer result presentation owner function', + }, + { + regex: /\bpub fn should_emit_deep_review_retry_guidance\b/, + message: 'missing DeepReview retry guidance emission owner function', + }, + { + regex: /\bpub fn deep_review_retry_guidance\b/, + message: 'missing DeepReview retry guidance presentation owner function', + }, + { + regex: /\bpub fn auto_retry_suppression_reason\b/, + message: 'missing DeepReview auto-retry suppression reason owner function', + }, + { + regex: /\bpub fn ensure_deep_review_auto_retry_allowed\b/, + message: 'missing DeepReview auto-retry admission owner function', + }, + { + regex: /\bpub fn capacity_skip_result_for_local_queue_outcome\b/, + message: 'missing DeepReview local capacity-skipped presentation owner function', + }, + { + regex: /\bpub fn capacity_skip_result_for_provider_queue_outcome\b/, + message: 'missing DeepReview provider capacity-skipped presentation owner function', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/diagnostics.rs', + reason: + 'agent-runtime DeepReview diagnostics owner must keep provider-neutral runtime diagnostics log shaping out of core', + patterns: [ + { + regex: /\bpub fn deep_review_runtime_diagnostics_log_line\b/, + message: 'missing DeepReview runtime diagnostics log owner function', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/report.rs', + reason: + 'agent-runtime DeepReview report owner must keep provider-neutral packet metadata, reliability signals, and cache update logic out of core', + patterns: [ + { + regex: /\bpub fn fill_deep_review_packet_metadata\b/, + message: 'missing DeepReview packet metadata owner function', + }, + { + regex: /\bpub fn fill_deep_review_reliability_signals\b/, + message: 'missing DeepReview reliability signal owner function', + }, + { + regex: /\bpub fn fill_deep_review_runtime_tracker_signal\b/, + message: 'missing DeepReview runtime tracker reliability signal owner function', + }, + { + regex: /\bpub fn fill_deep_review_cache_update_signals\b/, + message: 'missing DeepReview cache update reliability signal owner function', + }, + { + regex: /\bpub fn deep_review_cache_from_completed_reviewers\b/, + message: 'missing DeepReview cache update owner function', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/deep_review_policy_contracts.rs', + reason: + 'agent-runtime DeepReview owner must keep behavior-equivalence contracts for policy, queue state, tool context, report enrichment, and cache updates', + patterns: [ + { + regex: /\bdeep_review_policy_owner_exposes_execution_policy_and_manifest_gate\b/, + message: 'missing DeepReview policy/manifest owner regression', + }, + { + regex: /\bdeep_review_runtime_owner_tracks_budget_queue_and_shared_context\b/, + message: 'missing DeepReview budget/queue/shared-context regression', + }, + { + regex: /\bdeep_review_report_owner_enriches_packet_reliability_and_cache_facts\b/, + message: 'missing DeepReview report/cache owner regression', + }, + { + regex: /\bdeep_review_task_execution_owner_preserves_packet_retry_and_queue_contracts\b/, + message: 'missing DeepReview task execution owner regression', + }, + { + regex: /\bcapacity_decision_for_provider_error_facts\b/, + message: 'missing DeepReview provider capacity error decision regression', + }, + { + regex: /\bdecide_provider_capacity_queue_step\b/, + message: 'missing DeepReview provider queue step decision regression', + }, + { + regex: /\bdecide_blocked_reviewer_admission_queue_step\b/, + message: 'missing DeepReview blocked admission queue decision regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/prompt_cache_contracts.rs', + reason: + 'agent-runtime prompt-cache owner must keep behavior-equivalence contracts for cache identity, expiry, invalidation, and scope-key shape', + patterns: [ + { + regex: /\bprompt_cache_policy_keeps_existing_default_persistence_ttl\b/, + message: 'missing prompt-cache default TTL regression', + }, + { + regex: /\bprompt_cache_lookup_preserves_identity_and_expiry_semantics\b/, + message: 'missing prompt-cache identity/expiry regression', + }, + { + regex: /\bprompt_cache_scope_key_preserves_legacy_mode_switch_shape\b/, + message: 'missing prompt-cache scope-key shape regression', + }, + ], + }, + { + path: 'src/crates/execution/harness/src/lib.rs', + reason: + 'harness must own provider-neutral harness descriptors and descriptor registry wiring without concrete execution', + patterns: [ + { + regex: /\bpub struct HarnessProviderDescriptor\b/, + message: 'missing provider-neutral harness provider descriptor', + }, + { + regex: /\bpub fn build_descriptor_harness_registry\b/, + message: 'missing descriptor harness registry builder', + }, + { + regex: /\bDescriptorHarnessProvider::legacy_facade\b/, + message: 'missing legacy-facade descriptor adapter', + }, + ], + }, + { + path: 'src/crates/assembly/product-capabilities/src/lib.rs', + reason: + 'product-capabilities must select harness descriptors from the harness owner instead of owning descriptor construction', + patterns: [ + { + regex: /\bHarnessProviderDescriptor\b/, + message: 'missing harness descriptor selection in product capability packs', + }, + { + regex: /\bbuild_descriptor_harness_registry\b/, + message: 'missing harness-owned descriptor registry assembly delegation', + }, + { + regex: /\bProductCapabilityAssembly\b/, + message: 'missing product capability assembly owner', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/agents.rs', + reason: + 'agent-runtime must own shared mode config profile facts that are runtime-visible and product-neutral', + patterns: [ + { + regex: /\bpub const SHARED_CODING_MODE_PROMPT_TEMPLATE\b/, + message: 'missing shared coding-mode prompt template fact', + }, + { + regex: /\bpub const SHARED_CODING_MODE_CONFIG_PROFILE_ID\b/, + message: 'missing shared coding-mode config profile id', + }, + { + regex: /\bpub fn resolve_mode_config_profile_id\b/, + message: 'missing mode config profile resolver', + }, + { + regex: /\bpub fn mode_config_profile_member_mode_ids\b/, + message: 'missing mode config profile member lookup', + }, + { + regex: /\bpub fn mode_presentation_rank\b/, + message: 'missing mode presentation rank', + }, + { + regex: /\bpub fn shared_coding_mode_user_context_policy\b/, + message: 'missing shared coding-mode user-context policy', + }, + { + regex: /\bpub enum SubagentListScope\b/, + message: 'missing subagent query list-scope contract', + }, + { + regex: /\bpub struct SubagentQueryContext\b/, + message: 'missing subagent query context contract', + }, + { + regex: /\bpub struct SubagentVisibilityPolicy\b/, + message: 'missing subagent visibility policy contract', + }, + { + regex: /\bpub enum SubagentStateReason\b/, + message: 'missing subagent state reason contract', + }, + { + regex: /\bpub struct SubagentOverrideLayers\b/, + message: 'missing subagent override-layer contract', + }, + { + regex: /\bpub fn resolve_subagent_default_enabled\b/, + message: 'missing subagent default-enabled decision helper', + }, + { + regex: /\bpub fn resolve_subagent_availability\b/, + message: 'missing subagent availability decision helper', + }, + { + regex: /\bpub enum SubAgentSource\b/, + message: 'missing subagent source DTO', + }, + { + regex: /\bpub const fn subagent_source_kind\b/, + message: 'missing subagent source runtime-kind mapping', + }, + { + regex: /\bpub const fn subagent_source_presentation_rank\b/, + message: 'missing subagent source presentation rank', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/agent_registry_contracts.rs', + reason: + 'agent-runtime agent registry owner must keep behavior-equivalence contracts for visibility, availability, shared mode config, and source ordering', + patterns: [ + { + regex: + /\bvisibility_policy_supports_public_restricted_hidden_and_denied_parents\b/, + message: 'missing subagent visibility policy regression', + }, + { + regex: + /\bavailability_preserves_builtin_project_and_user_override_layering\b/, + message: 'missing subagent availability layering regression', + }, + { + regex: + /\bdefault_enabled_uses_visibility_only_for_builtin_subagents\b/, + message: 'missing subagent default-enabled regression', + }, + { + regex: /\bshared_coding_modes_resolve_to_the_same_config_profile\b/, + message: 'missing shared coding-mode profile regression', + }, + { + regex: + /\bsubagent_source_contract_preserves_runtime_kind_and_presentation_order\b/, + message: 'missing subagent source ordering regression', + }, + { + regex: + /\bmode_presentation_and_shared_context_policy_match_existing_mode_contract\b/, + message: 'missing mode presentation/context-policy regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/custom_subagent.rs', + reason: + 'agent-runtime must own custom subagent portable schema defaults, discovery, and markdown front-matter IO', + patterns: [ + { + regex: /\bpub enum CustomSubagentKind\b/, + message: 'missing custom subagent source-kind contract', + }, + { + regex: /\bpub struct CustomSubagentDiscoveryRoots\b/, + message: 'missing custom subagent discovery root contract', + }, + { + regex: /\bpub struct CustomSubagentLoadReport\b/, + message: 'missing custom subagent load report contract', + }, + { + regex: /\bpub struct CustomSubagentDefinition\b/, + message: 'missing custom subagent definition schema', + }, + { + regex: /\bpub enum CustomSubagentDefinitionError\b/, + message: 'missing custom subagent definition validation errors', + }, + { + regex: /\bDEFAULT_CUSTOM_SUBAGENT_TOOLS\b/, + message: 'missing custom subagent default tools contract', + }, + { + regex: /\bpub fn custom_subagent_tools_from_front_matter\b/, + message: 'missing custom subagent tools front-matter parser', + }, + { + regex: /\bpub fn custom_subagent_tools_to_front_matter\b/, + message: 'missing custom subagent tools front-matter serializer', + }, + { + regex: /\bpub const fn custom_subagent_readonly_should_save\b/, + message: 'missing custom subagent readonly save decision', + }, + { + regex: /\bpub const fn custom_subagent_review_should_save\b/, + message: 'missing custom subagent review save decision', + }, + { + regex: /\bpub fn custom_subagent_model_should_save\b/, + message: 'missing custom subagent model save decision', + }, + { + regex: /\bpub fn custom_subagent_read_markdown_file\b/, + message: 'missing custom subagent markdown file reader', + }, + { + regex: /\bpub fn custom_subagent_save_markdown_parts\b/, + message: 'missing custom subagent markdown file writer', + }, + { + regex: /\bpub fn custom_subagent_possible_dirs\b/, + message: 'missing custom subagent directory discovery owner', + }, + { + regex: /\bpub fn load_custom_subagent_definitions\b/, + message: 'missing custom subagent definition loading owner', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/custom_subagent_discovery_contracts.rs', + reason: + 'agent-runtime custom subagent discovery owner must keep behavior-equivalence contracts for directory priority, deduplication, and load errors', + patterns: [ + { + regex: + /\bcustom_subagent_discovery_preserves_directory_priority_and_deduplication\b/, + message: 'missing custom subagent discovery priority/dedup regression', + }, + { + regex: + /\bcustom_subagent_discovery_reports_parse_errors_without_dropping_valid_files\b/, + message: 'missing custom subagent discovery parse-error regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/custom_subagent_contracts.rs', + reason: + 'agent-runtime custom subagent owner must keep behavior-equivalence contracts for defaults and front-matter serialization decisions', + patterns: [ + { + regex: /\bcustom_subagent_defaults_match_existing_front_matter_contract\b/, + message: 'missing custom subagent default regression', + }, + { + regex: /\bcustom_subagent_tool_front_matter_keeps_existing_comma_format\b/, + message: 'missing custom subagent tools comma-format regression', + }, + { + regex: /\bcustom_subagent_default_fields_are_omitted_when_saved\b/, + message: 'missing custom subagent default omission regression', + }, + { + regex: /\bcustom_subagent_definition_from_front_matter_preserves_schema_and_defaults\b/, + message: 'missing custom subagent definition schema/default regression', + }, + { + regex: /\bcustom_subagent_definition_reports_legacy_missing_field_errors\b/, + message: 'missing custom subagent missing-field regression', + }, + { + regex: /\bcustom_subagent_markdown_io_preserves_legacy_front_matter_shape\b/, + message: 'missing custom subagent markdown IO regression', + }, + { + regex: /\bcustom_subagent_markdown_parse_errors_match_legacy_prefixes\b/, + message: 'missing custom subagent markdown parse-error regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', + reason: + 'agent-runtime must own portable post-call hook routing decisions while concrete hook execution stays in the owning runtime', + patterns: [ + { + regex: /\bpub enum PostCallHookKind\b/, + message: 'missing post-call hook kind contract', + }, + { + regex: /\bpub const fn successful_tool_post_call_hooks\b/, + message: 'missing successful tool post-call hook routing decision', + }, + { + regex: /\bpub trait SuccessfulToolPostCallHookExecutor\b/, + message: 'missing successful tool post-call hook executor contract', + }, + { + regex: /\bpub fn run_successful_tool_post_call_hooks\b/, + message: 'missing successful tool post-call hook executor runner', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/post_call_hook_contracts.rs', + reason: + 'agent-runtime post-call hook owner must keep behavior-equivalence contracts for successful tool-call hook routing', + patterns: [ + { + regex: /\bsuccessful_tool_call_routes_to_shared_context_measurement_hook\b/, + message: 'missing successful tool post-call hook routing regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs', + reason: + 'agent-runtime post-call hook owner must keep concrete-executor routing behavior-equivalence contracts', + patterns: [ + { + regex: /\bsuccessful_tool_post_call_executor_runs_deep_review_measurement_route\b/, + message: 'missing successful tool post-call executor regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/tool_confirmation.rs', + reason: + 'agent-runtime must own portable tool confirmation planning, failure mapping, and wait-channel lifecycle state', + patterns: [ + { + regex: /\bpub struct ToolConfirmationRequestFacts\b/, + message: 'missing tool confirmation request facts', + }, + { + regex: /\bpub enum ToolConfirmationPlan\b/, + message: 'missing tool confirmation plan contract', + }, + { + regex: /\bpub enum ToolConfirmationOutcome\b/, + message: 'missing tool confirmation outcome contract', + }, + { + regex: /\bpub enum ToolConfirmationWaitResult\b/, + message: 'missing tool confirmation wait-result contract', + }, + { + regex: /\bpub enum ToolConfirmationResponse\b/, + message: 'missing tool confirmation channel response', + }, + { + regex: /\bpub enum ConfirmationFailureKind\b/, + message: 'missing tool confirmation failure kind', + }, + { + regex: /\bpub struct ToolConfirmationChannelStore\b/, + message: 'missing tool confirmation channel store', + }, + { + regex: /\bpub fn resolve_tool_confirmation_plan\b/, + message: 'missing tool confirmation plan resolver', + }, + { + regex: /\bpub fn resolve_confirmation_failure\b/, + message: 'missing tool confirmation failure resolver', + }, + { + regex: /\bpub fn resolve_confirmation_wait_result\b/, + message: 'missing tool confirmation wait-result resolver', + }, + { + regex: /\bconfirmation_channel_store_delivers_confirmation_once\b/, + message: 'missing confirmation channel delivery regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/checkpoint.rs', + reason: + 'agent-runtime must own provider-neutral light-checkpoint summary policy while core keeps concrete Git/session IO', + patterns: [ + { + regex: /\bpub struct LightCheckpoint\b/, + message: 'missing light checkpoint DTO', + }, + { + regex: /\bpub enum LightCheckpointWorkspaceFacts\b/, + message: 'missing light checkpoint workspace facts', + }, + { + regex: /\bpub struct GitStatusCheckpointFacts\b/, + message: 'missing git status checkpoint facts', + }, + { + regex: /\bpub fn build_light_checkpoint\b/, + message: 'missing light checkpoint builder', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs', + reason: + 'agent-runtime tool confirmation owner must keep behavior-equivalence contracts for legacy permission planning and failures', + patterns: [ + { + regex: /\bconfirmation_plan_requires_permission_only_when_both_flags_are_true\b/, + message: 'missing tool confirmation gate regression', + }, + { + regex: /\bconfirmation_plan_preserves_legacy_no_timeout_one_year_deadline\b/, + message: 'missing tool confirmation no-timeout regression', + }, + { + regex: /\bconfirmation_failure_mapping_preserves_legacy_reasons_and_errors\b/, + message: 'missing tool confirmation failure mapping regression', + }, + { + regex: /\bconfirmation_wait_result_mapping_preserves_legacy_timeout_and_rejection\b/, + message: 'missing tool confirmation wait-result mapping regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/scheduler.rs', + reason: + 'agent-runtime scheduler owner must keep portable queue, background delivery, steering, reply, and round injection decisions outside concrete core session IO', + patterns: [ + { + regex: /\bpub const DEFAULT_MAX_DIALOG_QUEUE_DEPTH\b/, + message: 'missing dialog queue depth contract', + }, + { + regex: /\bpub struct ActiveDialogTurn\b/, + message: 'missing active dialog turn owner', + }, + { + regex: /\bpub struct ActiveDialogTurnStore\b/, + message: 'missing active dialog turn store owner', + }, + { + regex: /\bpub enum AgentSessionReplyAction\b/, + message: 'missing agent-session reply action contract', + }, + { + regex: /\bpub struct AgentSessionReplyPlan\b/, + message: 'missing agent-session reply plan contract', + }, + { + regex: /\bpub struct BackgroundDeliveryFacts\b/, + message: 'missing background delivery facts contract', + }, + { + regex: /\bpub enum BackgroundDeliveryAction\b/, + message: 'missing background delivery action contract', + }, + { + regex: /\bpub enum BackgroundInjectionKind\b/, + message: 'missing background injection kind contract', + }, + { + regex: /\bpub struct DialogReplySuppressionSet\b/, + message: 'missing dialog reply suppression set owner', + }, + { + regex: /\bpub enum DialogSteeringAction\b/, + message: 'missing dialog steering action contract', + }, + { + regex: /\bpub struct DialogTurnQueue\b/, + message: 'missing dialog turn queue owner', + }, + { + regex: /\bpub struct SessionAbortFlags\b/, + message: 'missing session abort flags owner', + }, + { + regex: /\bpub fn resolve_agent_session_reply_action\b/, + message: 'missing agent-session reply action resolver', + }, + { + regex: /\bpub const fn resolve_background_delivery_action\b/, + message: 'missing background delivery action resolver', + }, + { + regex: /\bpub fn resolve_background_delivery_injection\b/, + message: 'missing background delivery injection resolver', + }, + { + regex: /\bpub fn resolve_dialog_steering_action\b/, + message: 'missing dialog steering action resolver', + }, + { + regex: /\bfollow_up_submission_policy\b/, + message: 'missing background follow-up submission policy helper', + }, + { + regex: /\bSubmitAgentSessionFollowUp\b/, + message: 'missing agent-session follow-up action variant', + }, + { + regex: /\bInjectIntoRunningTurn\b/, + message: 'missing running-turn injection action variant', + }, + { + regex: /\bpub struct SessionRoundInjectionBuffer\b/, + message: 'missing session round injection buffer owner', + }, + { + regex: /\bpub enum TurnOutcome\b/, + message: 'missing turn outcome contract', + }, + { + regex: /\bpub enum TurnOutcomeQueueAction\b/, + message: 'missing turn outcome queue action contract', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/scheduler_contracts.rs', + reason: + 'agent-runtime scheduler owner must keep behavior-equivalence contracts for background delivery, queueing, reply suppression, steering, round injection, and turn outcomes', + patterns: [ + { + regex: /\bbackground_delivery_injects_when_session_is_processing\b/, + message: 'missing background delivery processing regression', + }, + { + regex: + /\bbackground_delivery_starts_agent_session_follow_up_when_session_is_not_processing\b/, + message: 'missing background delivery follow-up regression', + }, + { + regex: + /\bbackground_delivery_follow_up_uses_agent_session_source_semantics\b/, + message: 'missing background follow-up source semantics regression', + }, + { + regex: /\bbackground_delivery_injection_does_not_expose_follow_up_policy\b/, + message: 'missing background injection policy regression', + }, + { + regex: + /\bbackground_delivery_injection_builds_thread_goal_current_turn_message\b/, + message: 'missing thread-goal current-turn injection regression', + }, + { + regex: + /\bbackground_delivery_injection_builds_background_result_with_display_fallback\b/, + message: 'missing background result display fallback regression', + }, + { + regex: /\bdialog_turn_queue_preserves_priority_order_and_fifo_within_priority\b/, + message: 'missing dialog queue ordering regression', + }, + { + regex: + /\bdialog_turn_queue_rejects_overflow_and_preserves_current_error_shape\b/, + message: 'missing dialog queue overflow regression', + }, + { + regex: + /\bdialog_turn_queue_clear_and_requeue_front_preserve_scheduler_recovery_contract\b/, + message: 'missing dialog queue clear/requeue recovery regression', + }, + { + regex: + /\bdialog_turn_queue_requeued_turn_keeps_original_priority_for_later_ordering\b/, + message: 'missing dialog queue requeue regression', + }, + { + regex: /\bactive_dialog_turn_owns_agent_session_reply_suppression_facts\b/, + message: 'missing active dialog turn suppression facts regression', + }, + { + regex: + /\bactive_dialog_turn_store_owns_suppression_key_resolution_and_removal\b/, + message: 'missing active dialog turn store regression', + }, + { + regex: /\breply_suppression_set_marks_takes_and_clears_turn_keys\b/, + message: 'missing reply suppression set regression', + }, + { + regex: /\bsession_abort_flags_are_session_scoped\b/, + message: 'missing session abort flags regression', + }, + { + regex: + /\bagent_session_reply_action_forwards_completed_outcome_with_legacy_reminder_text\b/, + message: 'missing agent-session completed reply regression', + }, + { + regex: + /\bagent_session_reply_action_suppresses_cancelled_auto_reply_when_requested\b/, + message: 'missing agent-session cancelled reply suppression regression', + }, + { + regex: /\bagent_session_reply_action_ignores_non_agent_session_turns\b/, + message: 'missing non-agent-session reply suppression regression', + }, + { + regex: /\bdialog_steering_action_buffers_exact_running_turn_with_display_fallback\b/, + message: 'missing dialog steering buffer regression', + }, + { + regex: /\bdialog_steering_action_rejects_when_target_turn_is_not_running\b/, + message: 'missing dialog steering reject regression', + }, + { + regex: /\bround_injection_buffer_drains_only_messages_for_the_active_turn\b/, + message: 'missing round injection buffer regression', + }, + { + regex: /\bturn_outcome_status_reply_and_queue_policy_are_portable\b/, + message: 'missing turn outcome queue policy regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/thread_goal.rs', + reason: + 'agent-runtime must own persisted thread-goal runtime decisions, set/update planning, continuation decisions, and tool response shaping', + patterns: [ + { + regex: /\bpub struct ThreadGoalRuntime\b/, + message: 'missing thread-goal runtime owner', + }, + { + regex: /\bpub struct SetThreadGoalRequest\b/, + message: 'missing set-thread-goal request contract', + }, + { + regex: /\bpub fn build_set_thread_goal_result\b/, + message: 'missing set-thread-goal result builder', + }, + { + regex: /\bcontinuation_after_turn\b/, + message: 'missing thread-goal continuation-after-turn decision', + }, + { + regex: /\bpub struct ThreadGoalContinuationOutcome\b/, + message: 'missing thread-goal continuation outcome contract', + }, + { + regex: /\bpub fn goal_tool_response\b/, + message: 'missing thread-goal tool response helper', + }, + { + regex: /\bpub fn should_skip_goal_for_turn\b/, + message: 'missing thread-goal skip-turn decision', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/thread_goal_contracts.rs', + reason: + 'agent-runtime thread-goal owner must keep behavior-equivalence contracts for goal creation, continuation limits, budget reporting, and wire response shape', + patterns: [ + { + regex: /\bset_thread_goal_creates_new_active_goal_with_trimmed_objective\b/, + message: 'missing set-thread-goal creation regression', + }, + { + regex: /\bcontinuation_outcome_increments_active_goal_and_builds_plan\b/, + message: 'missing thread-goal continuation increment regression', + }, + { + regex: /\bcontinuation_outcome_marks_active_goal_blocked_at_limit\b/, + message: 'missing thread-goal blocked-at-limit regression', + }, + { + regex: + /\bcontinuation_outcome_reports_budget_limit_once_when_tokens_cross_budget\b/, + message: 'missing thread-goal budget-limit regression', + }, + { + regex: /\bprompt_and_tool_response_contracts_match_thread_goal_wire_shape\b/, + message: 'missing thread-goal prompt/tool response wire-shape regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/prompt_contracts.rs', + reason: + 'agent-runtime prompt owner must keep behavior-equivalence contracts for user context and reminder ordering', + patterns: [ + { + regex: /\buser_context_policy_preserves_order_and_deduplicates_sections\b/, + message: 'missing user-context policy order regression', + }, + { + regex: /\btool_listing_sections_render_only_present_sections\b/, + message: 'missing tool-listing rendering regression', + }, + { + regex: /\bprepended_prompt_reminders_keep_runtime_injection_order\b/, + message: 'missing prompt reminder ordering regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/events.rs', + reason: + 'agent-runtime must own runtime event facts that do not require concrete scheduler or session IO', + patterns: [ + { + regex: /\bpub enum FinishReason\b/, + message: 'missing agent-runtime finish-reason event fact', + }, + { + regex: /\bpub const fn session_state_label\b/, + message: 'missing agent-runtime session-state label fact', + }, + { + regex: /\bpub fn turn_outcome_kind\b/, + message: 'missing agent-runtime turn-outcome event fact', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/events_contracts.rs', + reason: + 'agent-runtime event owner must keep behavior-equivalence contracts for event wire labels', + patterns: [ + { + regex: /\bfinish_reason_display_preserves_wire_labels\b/, + message: 'missing finish-reason wire-label regression', + }, + { + regex: /\bsession_state_labels_match_existing_event_wire_values\b/, + message: 'missing session-state label regression', + }, + { + regex: /\bturn_outcome_kind_matches_existing_reply_policy_contract\b/, + message: 'missing turn-outcome event fact regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/scheduled_job.rs', + reason: + 'agent-runtime must own scheduled-job portable lifecycle state and transition decisions without concrete cron storage, schedule parsing, or session dispatch', + patterns: [ + { + regex: /\bpub struct ScheduledJobRuntimeState\b/, + message: 'missing scheduled-job runtime state owner', + }, + { + regex: /\bpub enum ScheduledJobRunStatus\b/, + message: 'missing scheduled-job run status owner', + }, + { + regex: /\bDEFAULT_SCHEDULED_JOB_RETRY_DELAY_MS\b/, + message: 'missing scheduled-job retry delay contract', + }, + { + regex: /\bpub fn mark_manual_trigger\b/, + message: 'missing manual trigger transition', + }, + { + regex: /\bpub fn apply_due_scheduled_trigger\b/, + message: 'missing due scheduled trigger transition', + }, + { + regex: /\bpub fn mark_enqueued\b/, + message: 'missing enqueue success transition', + }, + { + regex: /\bpub fn mark_enqueue_failed\b/, + message: 'missing enqueue failure transition', + }, + { + regex: /\bpub fn recover_interrupted_turn_after_restart\b/, + message: 'missing restart recovery transition', + }, + { + regex: /\bpub fn pending_is_due\b/, + message: 'missing scheduled-job pending due decision', + }, + { + regex: /\bpub fn next_wakeup_at_ms\b/, + message: 'missing scheduled-job wakeup decision', + }, + { + regex: /\bpub fn clear_pending_trigger\b/, + message: 'missing scheduled-job pending trigger clear transition', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/scheduled_job_contracts.rs', + reason: + 'agent-runtime scheduled-job owner must keep behavior-equivalence contracts for wire shape, retry, coalescing, one-shot, missing-session, and restart recovery semantics', + patterns: [ + { + regex: /\bmanual_trigger_coalesces_existing_pending_run\b/, + message: 'missing manual trigger coalescing regression', + }, + { + regex: /\bdue_scheduled_trigger_coalesces_when_active_or_pending\b/, + message: 'missing scheduled trigger coalescing regression', + }, + { + regex: /\bpending_wakeup_prefers_retry_time_when_present\b/, + message: 'missing retry wakeup regression', + }, + { + regex: /\bdisabled_and_config_clear_remove_pending_retry_without_touching_history\b/, + message: 'missing disabled/config clear regression', + }, + { + regex: /\benqueue_success_sets_active_turn_and_disables_one_shot_next_run\b/, + message: 'missing enqueue success one-shot regression', + }, + { + regex: /\benqueue_failure_preserves_retry_and_missing_session_disable_semantics\b/, + message: 'missing enqueue failure regression', + }, + { + regex: /\brestart_recovery_marks_active_turn_error\b/, + message: 'missing restart recovery regression', + }, + { + regex: /\bserde_shape_preserves_legacy_cron_state_wire_contract\b/, + message: 'missing legacy cron state wire-shape regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/cron/types.rs', + reason: + 'core cron types must preserve old import and wire paths while bitfun-agent-runtime owns scheduled-job runtime state', + patterns: [ + { + regex: /ScheduledJobRuntimeState as CronJobState/, + message: 'missing scheduled-job state compatibility alias', + }, + { + regex: /ScheduledJobRunStatus as CronJobRunStatus/, + message: 'missing scheduled-job status compatibility alias', + }, + { + regex: /DEFAULT_SCHEDULED_JOB_RETRY_DELAY_MS/, + message: 'missing scheduled-job retry delay compatibility constant', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/cron/service.rs', + reason: + 'core cron service may own concrete storage and schedule parsing, while scheduled-job state and dialog submission flow through agent-runtime owners', + patterns: [ + { + regex: /\bmark_manual_trigger\b/, + message: 'missing manual trigger owner delegation', + }, + { + regex: /\bapply_due_scheduled_trigger\b/, + message: 'missing scheduled trigger owner delegation', + }, + { + regex: /\bmark_enqueued\b/, + message: 'missing enqueue success owner delegation', + }, + { + regex: /\bmark_enqueue_failed\b/, + message: 'missing enqueue failure owner delegation', + }, + { + regex: /\brecover_interrupted_turn_after_restart\b/, + message: 'missing restart recovery owner delegation', + }, + { + regex: /\bpending_is_due\b/, + message: 'missing pending due owner delegation', + }, + { + regex: /\bnext_wakeup_at_ms\b/, + message: 'missing wakeup owner delegation', + }, + { + regex: /\bclear_pending_trigger\b/, + message: 'missing pending trigger clear owner delegation', + }, + { + regex: /\bScheduledJobEnqueueFailureAction\b/, + message: 'missing enqueue failure action owner delegation', + }, + { + regex: /\bCoreServiceAgentRuntime::agent_runtime_with_dialog_turns\b/, + message: 'missing scheduled-job dialog lifecycle owner binding', + }, + { + regex: /\bAgentDialogTurnRequest\b/, + message: 'missing scheduled-job dialog lifecycle request', + }, + { + regex: /\bAgentDialogPrependedReminder\b/, + message: 'missing scheduled-job portable prepended reminder', + }, + { + regex: /\bsubmit_dialog_turn\b/, + message: 'missing scheduled-job dialog lifecycle submission', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs', + reason: + 'CronTool must resolve and validate target agent sessions through the service/agent runtime owner before scheduling jobs', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime::agent_runtime\b/, + message: 'missing service/agent runtime owner routing', + }, + { + regex: /\bAgentSessionListRequest\b/, + message: 'missing port-backed cron target session list request', + }, + { + regex: /\bAgentSessionWorkspaceRequest\b/, + message: 'missing port-backed cron target workspace request', + }, + { + regex: /\blist_sessions\b/, + message: 'missing port-backed cron target session list call', + }, + { + regex: /\bresolve_session_workspace_path\b/, + message: 'missing port-backed cron target workspace resolution call', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/deep_review_policy.rs', + reason: + 'core DeepReview policy path must stay a compatibility facade over agent-runtime while core keeps product config loading', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::deep_review::/, + message: 'missing DeepReview agent-runtime compatibility re-export', + }, + { + regex: /\bload_default_deep_review_policy\b/, + message: 'missing DeepReview product config loading bridge', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs', + reason: + 'core DeepReview task adapter must delegate provider-neutral packet, retry, backoff, and skipped-result shaping to agent-runtime while retaining core-only event/state side effects', + patterns: [ + { + regex: /runtime_task_execution::capacity_skip_result_for_local_queue_outcome/, + message: 'missing DeepReview local capacity-skip runtime delegation', + }, + { + regex: /runtime_task_execution::capacity_skip_result_for_provider_queue_outcome/, + message: 'missing DeepReview provider capacity-skip runtime delegation', + }, + { + regex: /DeepReviewProviderCapacityRetryRuntime/, + message: 'missing DeepReview provider capacity retry runtime re-export', + }, + { + regex: /DeepReviewProviderCapacityRetryDecision/, + message: 'missing DeepReview provider capacity retry decision re-export', + }, + { + regex: /runtime_task_execution::capacity_decision_for_provider_error_facts/, + message: 'missing DeepReview provider capacity error decision delegation', + }, + { + regex: /runtime_task_execution::local_reviewer_capacity_queue_decision/, + message: 'missing DeepReview local reviewer capacity decision delegation', + }, + { + regex: /runtime_task_execution::DeepReviewProviderCapacityQueueRuntime::start/, + message: 'missing DeepReview provider capacity queue runtime delegation', + }, + { + regex: /runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntime::start/, + message: 'missing DeepReview reviewer admission queue runtime delegation', + }, + { + regex: /runtime_task_execution::deep_review_task_completion_result/, + message: 'missing DeepReview task completion result runtime delegation', + }, + { + regex: /runtime_task_execution::deep_review_cancelled_reviewer_result/, + message: 'missing DeepReview cancelled reviewer result runtime delegation', + }, + { + regex: /runtime_task_execution::should_emit_deep_review_retry_guidance/, + message: 'missing DeepReview retry guidance emission runtime delegation', + }, + { + regex: /runtime_task_execution::deep_review_retry_guidance/, + message: 'missing DeepReview retry guidance presentation runtime delegation', + }, + { + regex: /runtime_task_execution::auto_retry_suppression_reason/, + message: 'missing DeepReview auto-retry suppression reason runtime delegation', + }, + { + regex: /runtime_task_execution::ensure_deep_review_auto_retry_allowed/, + message: 'missing DeepReview auto-retry admission runtime delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs', + reason: + 'TaskTool must keep DeepReview retry/result presentation as a facade call instead of re-owning provider-neutral policy text or data shaping', + patterns: [ + { + regex: /deep_review_task_adapter::should_emit_deep_review_retry_guidance/, + message: 'missing TaskTool DeepReview retry guidance emission facade call', + }, + { + regex: /deep_review_task_adapter::deep_review_retry_guidance/, + message: 'missing TaskTool DeepReview retry guidance facade call', + }, + { + regex: /deep_review_task_adapter::auto_retry_suppression_reason/, + message: 'missing TaskTool DeepReview auto-retry suppression facade call', + }, + { + regex: /deep_review_task_adapter::ensure_deep_review_auto_retry_allowed/, + message: 'missing TaskTool DeepReview auto-retry admission facade call', + }, + { + regex: /deep_review_task_adapter::deep_review_task_completion_result/, + message: 'missing TaskTool DeepReview completion result facade call', + }, + { + regex: /deep_review_task_adapter::deep_review_cancelled_reviewer_result/, + message: 'missing TaskTool DeepReview cancelled reviewer result facade call', + }, + { + regex: /DeepReviewProviderCapacityRetryRuntime::default/, + message: 'missing TaskTool DeepReview provider retry runtime state owner usage', + }, + { + regex: /DeepReviewProviderCapacityRetryDecision::WaitForCapacity/, + message: 'missing TaskTool DeepReview provider retry runtime wait decision usage', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_research.rs', + reason: + 'agent-runtime must own provider-neutral DeepResearch citation renumbering without core session or filesystem IO dependencies', + patterns: [ + { + regex: /\bpub fn renumber_research_report\b/, + message: 'missing DeepResearch citation renumber runtime owner', + }, + { + regex: /\bpub struct ResearchCitationRenumberOutput\b/, + message: 'missing DeepResearch citation renumber output contract', + }, + { + regex: /\bpub struct ResearchCitationDisplayMapEntry\b/, + message: 'missing DeepResearch display-map entry contract', + }, + { + regex: /\brejected_index_rows_dropped\b/, + message: 'missing rejected citation index cleanup telemetry', + }, + { + regex: /\bpub fn should_post_process_research_report\b/, + message: 'missing DeepResearch post-process gate owner', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', + reason: + 'agent-runtime must keep behavior-equivalence contracts for DeepResearch citation renumbering', + patterns: [ + { + regex: /\bdeep_research_citation_renumber_owner_preserves_report_and_display_map_contracts\b/, + message: 'missing DeepResearch citation renumber behavior contract', + }, + { + regex: /\bdeep_research_citation_renumber_owner_is_idempotent_without_citations\b/, + message: 'missing DeepResearch citation renumber idempotence contract', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/deep_review/report.rs', + reason: + 'core DeepReview report path must delegate provider-neutral enrichment and cache updates to agent-runtime while retaining core-only session IO', + patterns: [ + { + regex: /runtime_report::fill_deep_review_reliability_signals/, + message: 'missing DeepReview runtime report enrichment delegation', + }, + { + regex: /runtime_report::fill_deep_review_runtime_tracker_signal/, + message: 'missing DeepReview runtime tracker reliability signal delegation', + }, + { + regex: /fill_deep_review_cache_update_signals/, + message: 'missing DeepReview cache update reliability signal delegation', + }, + { + regex: /runtime_diagnostics::deep_review_runtime_diagnostics_log_line/, + message: 'missing DeepReview runtime diagnostics log delegation', + }, + { + regex: /deep_review_cache_from_completed_reviewers/, + message: 'missing DeepReview cache update compatibility re-export', + }, + { + regex: /\bpersist_deep_review_cache\b/, + message: 'missing core session metadata persistence bridge', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs', + reason: + 'core custom subagent path must stay a compatibility facade over agent-runtime schema/default and markdown IO decisions', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::custom_subagent::CustomSubagentKind/, + message: 'missing custom subagent kind compatibility re-export', + }, + { + regex: /\bCustomSubagentDefinition::new\b/, + message: 'missing custom subagent definition construction delegation', + }, + { + regex: /\bcustom_subagent_read_markdown_file\b/, + message: 'missing custom subagent markdown read delegation', + }, + { + regex: /\bcustom_subagent_save_markdown_parts\b/, + message: 'missing custom subagent markdown save delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/custom.rs', + reason: + 'core custom subagent registry must delegate portable discovery/loading to agent-runtime while retaining validation and registry writes', + patterns: [ + { + regex: /\bload_custom_subagent_definitions\b/, + message: 'missing custom subagent runtime load delegation', + }, + { + regex: /\bCustomSubagentDiscoveryRoots\b/, + message: 'missing custom subagent runtime discovery root adapter', + }, + { + regex: /\bCustomSubagent::from_definition\b/, + message: 'missing custom subagent runtime definition adapter', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs', + reason: + 'core post-call hooks must delegate portable hook routing to agent-runtime while retaining concrete hook execution', + patterns: [ + { + regex: /\brun_successful_tool_post_call_hooks\b/, + message: 'missing post-call hook executor runner delegation', + }, + { + regex: /\bSuccessfulToolPostCallHookExecutor\b/, + message: 'missing post-call hook executor implementation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + reason: + 'core tool pipeline must delegate portable confirmation planning, failure mapping, and channel ownership to agent-runtime while retaining state/event/tool execution wiring', + patterns: [ + { + regex: /\bresolve_tool_confirmation_plan\b/, + message: 'missing tool confirmation plan delegation', + }, + { + regex: /\bresolve_confirmation_failure\b/, + message: 'missing tool confirmation failure mapping delegation', + }, + { + regex: /\bresolve_confirmation_wait_result\b/, + message: 'missing tool confirmation wait-result mapping delegation', + }, + { + regex: /\bToolConfirmationPlan::Await\b/, + message: 'missing tool confirmation await-plan handling', + }, + { + regex: /\bToolConfirmationChannelStore\b/, + message: 'missing tool confirmation channel owner delegation', + }, + { + regex: /\bToolCancellationTokenStore\b/, + message: 'missing tool cancellation token owner delegation', + }, + { + regex: /\bshould_retry_tool_attempt\b/, + message: 'missing tool-runtime retry decision delegation', + }, + { + regex: /\bretry_delay_ms\b/, + message: 'missing tool-runtime retry backoff delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/types.rs', + reason: + 'core execution types must preserve legacy import path while agent-runtime owns finish-reason event facts', + patterns: [ + { + regex: /bitfun_agent_runtime::events::FinishReason/, + message: 'missing finish-reason compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/events/types.rs', + reason: + 'core event types must preserve legacy import path while agent-runtime owns session-state labels', + patterns: [ + { + regex: /bitfun_agent_runtime::events::session_state_label/, + message: 'missing session-state label owner delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs', + reason: + 'core prompt_builder user_context path must stay a compatibility facade over agent-runtime', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::prompt::\{UserContextPolicy, UserContextSection\};/, + message: 'missing agent-runtime user-context compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/prompt_cache.rs', + reason: + 'core prompt_cache path must stay a compatibility facade over agent-runtime', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::prompt_cache::\*;/, + message: 'missing agent-runtime prompt-cache compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/round_executor.rs', + reason: + 'core round executor must delegate dialog-turn cancellation token storage to agent-runtime while retaining concrete model streaming and events', + patterns: [ + { + regex: /\bDialogTurnCancellationTokenStore\b/, + message: 'missing dialog-turn cancellation token store delegation', + }, + { + regex: /\bget_or_insert_new\b/, + message: 'missing dialog-turn cancellation token creation/reuse delegation', + }, + { + regex: /\bis_cancelled\b/, + message: 'missing dialog-turn cancellation state delegation', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/user_questions.rs', + reason: + 'agent-runtime must own user-question contracts and user-input wait-channel lifecycle state', + patterns: [ + { + regex: /\bpub struct AskUserQuestionInput\b/, + message: 'missing AskUserQuestion input contract', + }, + { + regex: /\bpub struct UserInputResponse\b/, + message: 'missing user input response contract', + }, + { + regex: /\bpub struct UserInputManager\b/, + message: 'missing user input manager owner', + }, + { + regex: /\bpub fn get_user_input_manager\b/, + message: 'missing user input manager global entry', + }, + { + regex: /\bvalidate_ask_user_question_input\b/, + message: 'missing AskUserQuestion validation owner', + }, + { + regex: /\buser_input_manager_delivers_answer_and_clears_channel\b/, + message: 'missing user input manager answer regression', + }, + { + regex: /\buser_input_manager_cancel_closes_receiver\b/, + message: 'missing user input manager cancel regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/file_read_state.rs', + reason: + 'core file_read_state path must stay a compatibility facade over agent-runtime', + patterns: [ + { + regex: + /pub use bitfun_agent_runtime::file_read_state::\{FileReadState, FileReadStateStore\};/, + message: 'missing agent-runtime file-read state compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/evidence_ledger.rs', + reason: + 'core evidence_ledger path must stay a compatibility facade over agent-runtime', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::evidence_ledger::\*;/, + message: 'missing agent-runtime evidence ledger compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs', + reason: + 'core turn_skill_agent_snapshot_store path must stay a compatibility facade over agent-runtime', + patterns: [ + { + regex: + /pub use bitfun_agent_runtime::skill_agent_snapshot::TurnSkillAgentSnapshotStore;/, + message: 'missing agent-runtime turn skill/agent snapshot store compatibility re-export', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/session/mod.rs', + reason: + 'services-core session owner must expose lineage, branch, metadata mutation, and metadata store rules through the session boundary', + patterns: [ + { + regex: /\bmod lineage;/, + message: 'missing services-core session lineage module', + }, + { + regex: /\bmod metadata_store;/, + message: 'missing services-core session metadata store module', + }, + { + regex: /\bmod migration;/, + message: 'missing services-core session migration module', + }, + { + regex: /\bapply_session_lineage\b/, + message: 'missing session lineage owner re-export', + }, + { + regex: /\bbuild_branched_session_metadata\b/, + message: 'missing branch metadata owner re-export', + }, + { + regex: /\bSessionBranchRequest\b/, + message: 'missing branch request compatibility owner re-export', + }, + { + regex: /\bmerge_session_custom_metadata\b/, + message: 'missing custom metadata mutation owner re-export', + }, + { + regex: /\bbuild_session_metadata\b/, + message: 'missing session metadata construction owner re-export', + }, + { + regex: /\bSessionMetadataBuildFacts\b/, + message: 'missing session metadata construction facts re-export', + }, + { + regex: /\bSessionMetadataStore\b/, + message: 'missing session metadata store owner re-export', + }, + { + regex: /\bmerge_legacy_session_store\b/, + message: 'missing legacy session-store migration owner re-export', + }, + { + regex: /\bset_deep_review_cache\b/, + message: 'missing DeepReview cache metadata mutation owner re-export', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/session/migration.rs', + reason: + 'services-core must own legacy session-store merge, metadata selection, and index rebuild behavior', + patterns: [ + { + regex: /\bpub async fn merge_legacy_session_store\b/, + message: 'missing legacy session-store merge owner', + }, + { + regex: /\bfn merge_session_metadata_file\b/, + message: 'missing metadata merge conflict resolver', + }, + { + regex: /\bSessionMetadataStore::new\s*\(\s*sessions_dir\s*\)/, + message: 'missing services-core session index rebuild delegation', + }, + { + regex: /\bmetadata_file_count\b/, + message: 'missing metadata-file count regression coverage', + }, + { + regex: + /\bmerge_legacy_session_store_preserves_newer_metadata_and_rebuilds_visible_index\b/, + message: 'missing legacy session-store merge regression', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/session/metadata.rs', + reason: + 'services-core session metadata owner must own construction and field mutation rules while metadata_store owns file/index IO', + patterns: [ + { + regex: /\bpub fn merge_session_custom_metadata\b/, + message: 'missing custom metadata merge owner', + }, + { + regex: /\bpub struct SessionMetadataBuildFacts\b/, + message: 'missing session metadata construction facts owner', + }, + { + regex: /\bpub fn build_session_metadata\b/, + message: 'missing session metadata construction owner', + }, + { + regex: /\bpub fn set_session_relationship\b/, + message: 'missing session relationship mutation owner', + }, + { + regex: /\bpub fn set_deep_review_run_manifest\b/, + message: 'missing DeepReview manifest metadata mutation owner', + }, + { + regex: /\bpub fn set_deep_review_cache\b/, + message: 'missing DeepReview cache metadata mutation owner', + }, + { + regex: /\bmerge_custom_metadata_shallow_merges_object_patch\b/, + message: 'missing custom metadata merge regression', + }, + { + regex: /\bdeep_review_cache_mutation_preserves_manifest_and_relationship\b/, + message: 'missing DeepReview cache mutation regression', + }, + { + regex: /\bbuild_session_metadata_preserves_existing_fields_and_legacy_relationship\b/, + message: 'missing session metadata construction regression', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/session/metadata_store.rs', + reason: + 'services-core session metadata store must own provider-neutral metadata file and index IO under a resolved sessions root', + patterns: [ + { + regex: /\bpub struct SessionMetadataStore\b/, + message: 'missing session metadata store owner', + }, + { + regex: /\bpub enum SessionMetadataStoreError\b/, + message: 'missing session metadata store error boundary', + }, + { + regex: /\bSessionStorageLayout\b/, + message: 'metadata store must reuse the session storage layout owner', + }, + { + regex: /\bpub async fn list_metadata\b/, + message: 'missing session metadata list owner entrypoint', + }, + { + regex: /\bpub async fn list_metadata_page\b/, + message: 'missing session metadata page owner entrypoint', + }, + { + regex: /\bpub async fn list_metadata_including_internal\b/, + message: 'missing internal session metadata list owner entrypoint', + }, + { + regex: /\bpub async fn rebuild_index\b/, + message: 'missing session metadata index rebuild owner entrypoint', + }, + { + regex: /\bpub async fn save_metadata\b/, + message: 'missing session metadata save owner entrypoint', + }, + { + regex: /\bpub async fn load_metadata\b/, + message: 'missing session metadata load owner entrypoint', + }, + { + regex: /\bpub async fn delete_session_dir_and_index\b/, + message: 'missing session metadata delete/index owner entrypoint', + }, + { + regex: /\bmetadata_store_saves_visible_metadata_and_updates_index\b/, + message: 'missing metadata store visible-index regression', + }, + { + regex: /\bmetadata_store_rebuilds_stale_index_entries\b/, + message: 'missing metadata store stale-index regression', + }, + { + regex: /\bmetadata_store_rebuild_index_counts_hidden_metadata_files\b/, + message: 'missing metadata store hidden-count rebuild regression', + }, + { + regex: /\bmetadata_store_delete_session_updates_visible_index\b/, + message: 'missing metadata store delete-index regression', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/session/lineage.rs', + reason: + 'services-core must own provider-neutral session lineage, subagent cascade, and branch metadata shaping rules without core IO', + patterns: [ + { + regex: /\bpub struct SessionBranchRequest\b/, + message: 'missing session branch request owner type', + }, + { + regex: /\bpub struct SessionBranchResult\b/, + message: 'missing session branch result owner type', + }, + { + regex: /\bpub struct BranchSessionMetadataFacts\b/, + message: 'missing branch metadata facts contract', + }, + { + regex: /\bpub fn apply_session_lineage\b/, + message: 'missing lineage metadata mutation owner', + }, + { + regex: /\bpub fn build_branched_session_metadata\b/, + message: 'missing branch metadata shaping owner', + }, + { + regex: /\bpub fn collect_hidden_subagent_cascade\b/, + message: 'missing hidden subagent cascade owner', + }, + { + regex: /\bfn extract_subagent_relationship\b/, + message: 'missing legacy/structured subagent relationship resolver', + }, + { + regex: /\bapply_session_lineage_sets_relationship_and_removes_legacy_projection\b/, + message: 'missing lineage cleanup regression', + }, + { + regex: /\bbuild_branched_session_metadata_resets_child_state_and_counts_turns\b/, + message: 'missing branch metadata shaping regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/persistence/session_branch.rs', + reason: + 'core session branch persistence must keep IO orchestration and old import compatibility while services-core owns branch metadata shaping', + patterns: [ + { + regex: /pub use bitfun_services_core::session::\{SessionBranchRequest,\s*SessionBranchResult\};/, + message: 'missing session branch compatibility re-export', + }, + { + regex: /\bbuild_branched_session_metadata\b/, + message: 'missing services-core branch metadata delegation', + }, + { + regex: /\bBranchSessionMetadataFacts\b/, + message: 'missing branch metadata facts delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/mod.rs', + reason: + 'core agent mode module must keep old import paths while agent-runtime owns shared mode profile facts', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::agents::\{[\s\S]*mode_presentation_rank[\s\S]*resolve_mode_config_profile_id[\s\S]*shared_coding_mode_user_context_policy[\s\S]*SHARED_CODING_MODE_PROMPT_TEMPLATE[\s\S]*\};/, + message: 'missing agent-runtime shared mode profile compatibility re-export', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/filesystem/mod.rs', + reason: + 'services-core filesystem owner must expose local filesystem primitives behind a single module boundary', + patterns: [ + { + regex: /mod error;/, + message: 'filesystem owner must expose its error boundary', + }, + { + regex: /mod operations;/, + message: 'filesystem owner must expose local file operation primitives', + }, + { + regex: /mod tree;/, + message: 'filesystem owner must expose local file tree/search primitives', + }, + { + regex: /pub use error::\{FileSystemError, FileSystemResult\};/, + message: 'filesystem owner must re-export the unified filesystem error type', + }, + { + regex: /pub use service::FileSystemService;/, + message: 'filesystem owner must keep the consolidated service facade', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/filesystem/service.rs', + reason: + 'core filesystem service may keep remote-workspace overlay and BitFunError compatibility, but local filesystem owner must remain services-core', + patterns: [ + { + regex: /lookup_remote_connection_with_hint/, + message: 'core filesystem wrapper must preserve remote workspace connection disambiguation', + }, + { + regex: /get_remote_workspace_manager/, + message: 'core filesystem wrapper must preserve existing remote file service lookup', + }, + { + regex: /map_filesystem_error/, + message: 'core filesystem wrapper must map services-core errors at the compatibility boundary', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/persistence/manager.rs', + reason: + 'core persistence manager must keep workspace identity, runtime preflight, state/turn/prompt-cache IO while services-core owns session metadata store CRUD/index rebuild IO and construction rules', + patterns: [ + { + regex: /\bbuild_persisted_session_metadata\s*\(\s*SessionMetadataBuildFacts\s*\{/, + message: 'missing services-core session metadata construction delegation', + }, + { + regex: /\bsession_metadata_store\s*\(\s*&self,\s*workspace_path:\s*&Path\s*\)\s*->\s*SessionMetadataStore\b/, + message: 'missing services-core session metadata store adapter', + }, + { + regex: /\.list_metadata\(\)/, + message: 'missing metadata list delegation to services-core store', + }, + { + regex: /\.list_metadata_page\(cursor,\s*limit\)/, + message: 'missing metadata page delegation to services-core store', + }, + { + regex: /\.list_metadata_including_internal\(\)/, + message: 'missing internal metadata list delegation to services-core store', + }, + { + regex: /\.save_metadata\(metadata\)/, + message: 'missing metadata save delegation to services-core store', + }, + { + regex: /\.load_metadata\(session_id\)/, + message: 'missing metadata load delegation to services-core store', + }, + { + regex: /\.delete_session_dir_and_index\(session_id\)/, + message: 'missing metadata delete delegation to services-core store', + }, + { + regex: /\bensure_runtime_for_write\(workspace_path\)\.await\?/, + message: 'missing runtime preflight before metadata write', + }, + { + regex: /\bresolve_workspace_session_identity\b/, + message: 'missing workspace identity resolution before metadata construction', + }, + { + regex: /\bLOCAL_WORKSPACE_SSH_HOST\b/, + message: 'missing local workspace hostname compatibility fallback', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/session_manager.rs', + reason: + 'core session manager must keep concrete session IO while services-core owns metadata mutation rules and forked Task prompt-cache baselines remain protected', + patterns: [ + { + regex: /\bapply_session_lineage\(metadata, relationship\)/, + message: 'missing services-core session lineage delegation', + }, + { + regex: /\bmerge_session_custom_metadata_value\(metadata, patch\)/, + message: 'missing services-core custom metadata delegation', + }, + { + regex: /\bupdate_persisted_session_metadata\b/, + message: 'missing shared session metadata update facade', + }, + { + regex: /\bcollect_hidden_subagent_cascade_ids\(/, + message: 'missing services-core hidden subagent cascade delegation', + }, + { + regex: /\bpub async fn clone_prompt_cache\b/, + message: 'missing prompt cache clone runtime entry point', + }, + { + regex: /\bpub async fn start_dialog_turn_with_existing_context\b/, + message: 'missing existing-context dialog turn entry point', + }, + { + regex: /\bstart_dialog_turn_with_existing_context_persists_turn_and_snapshot\b/, + message: 'missing existing-context dialog turn persistence regression', + }, + { + regex: /\bclone_prompt_cache_copies_runtime_and_persisted_entries\b/, + message: 'missing prompt cache clone runtime/disk regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + reason: + 'core tool pipeline must preserve latest-main truncation behavior through agent-tools delegation and keep per-tool denial behavior until tool runtime ownership migrates', + patterns: [ + { + regex: /\bbuild_tool_call_truncation_recovery_notice\b/, + message: 'missing tool-call truncation recovery notice owner delegation', + }, + { + regex: /\btruncation_notice_for_interactive_tools_does_not_claim_file_write\b/, + message: 'missing interactive-tool truncation recovery regression', + }, + { + regex: /\btruncation_notice_for_write_tools_keeps_write_continuation_guidance\b/, + message: 'missing write-tool truncation recovery regression', + }, + { + regex: /\bdenied_tool_messages\b/, + message: 'missing per-tool denial message propagation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/restrictions.rs', + reason: + 'core tool restrictions facade must preserve per-tool denial messages while runtime restrictions live in agent-tools', + patterns: [ + { + regex: /\bdenied_tool_messages\b/, + message: 'missing per-tool denial message field propagation', + }, + { + regex: /\bcustom_deny_message_overrides_generic_runtime_error\b/, + message: 'missing custom deny message regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs', + reason: + 'core tool-result storage must keep explicit file flush until runtime artifact ownership migrates', + patterns: [ + { + regex: /\basync fn write_once\b/, + message: 'missing single-write persistence helper', + }, + { + regex: /file\.flush\(\)\.await/, + message: 'missing explicit persisted tool-result flush', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/shell/mod.rs', + reason: + 'tool-runtime must own reusable Bash shell execution policy, rendering, and background-result text helpers', + patterns: [ + { + regex: /\bpub fn banned_shell_command\b/, + message: 'missing Bash banned-command policy owner', + }, + { + regex: /\bpub fn detect_osascript_keystroke_non_ascii\b/, + message: 'missing Bash osascript keystroke guard owner', + }, + { + regex: /\bpub fn detect_osascript_im_app\b/, + message: 'missing Bash IM AppleScript guard owner', + }, + { + regex: /\bpub fn command_for_working_directory\b/, + message: 'missing Bash working-directory command wrapper owner', + }, + { + regex: /\bpub fn bash_noninteractive_env\b/, + message: 'missing Bash noninteractive environment owner', + }, + { + regex: /\bpub fn render_local_shell_result\b/, + message: 'missing local shell result rendering owner', + }, + { + regex: /\bpub fn render_remote_shell_result\b/, + message: 'missing remote shell result rendering owner', + }, + { + regex: /\bpub fn format_background_command_delivery_text\b/, + message: 'missing background command delivery text owner', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/tests/tool_io_contracts.rs', + reason: + 'tool-runtime shell owner must keep focused behavior-equivalence contracts for Bash execution helpers', + patterns: [ + { + regex: /\bbash_shell_owner_preserves_command_wrapping_and_env\b/, + message: 'missing Bash command/env owner regression', + }, + { + regex: /\bbash_shell_owner_preserves_guard_and_result_rendering\b/, + message: 'missing Bash guard/rendering owner regression', + }, + { + regex: /\bbash_shell_owner_preserves_background_delivery_texts\b/, + message: 'missing Bash background-result text owner regression', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/pipeline.rs', + reason: + 'tool-runtime must own provider-neutral tool batching, retry, state counting, and cancellation policy while core keeps concrete execution wiring', + patterns: [ + { + regex: /\bpub struct ToolBatch\b/, + message: 'missing tool batch DTO', + }, + { + regex: /\bpub fn partition_tool_batches\b/, + message: 'missing tool batching policy', + }, + { + regex: /\bpub enum ToolExecutionErrorClass\b/, + message: 'missing tool retry error class', + }, + { + regex: /\bpub struct ToolRetryAttemptFacts\b/, + message: 'missing tool retry attempt facts', + }, + { + regex: /\bpub fn should_retry_tool_attempt\b/, + message: 'missing tool retry decision policy', + }, + { + regex: /\bpub fn retry_delay_ms\b/, + message: 'missing tool retry backoff policy', + }, + { + regex: /\bpub enum ToolTaskStateKind\b/, + message: 'missing provider-neutral tool state kind owner', + }, + { + regex: /\bpub fn should_cancel_tool_state\b/, + message: 'missing tool cancellation state policy', + }, + { + regex: /\bpub fn summarize_dialog_turn_cancellation\b/, + message: 'missing dialog-turn cancellation summary policy', + }, + { + regex: /\bpub struct ToolCancellationTokenStore\b/, + message: 'missing tool cancellation token store owner', + }, + { + regex: /\bpub fn count_tool_states\b/, + message: 'missing tool state counting policy', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs', + reason: + 'tool-runtime pipeline owner must keep behavior-equivalence contracts for batching and retry policy', + patterns: [ + { + regex: /\bpartitions_consecutive_concurrency_safe_tools_into_parallel_batches\b/, + message: 'missing tool batching regression', + }, + { + regex: /\bretry_policy_preserves_attempt_limit_and_error_class_contract\b/, + message: 'missing tool retry policy regression', + }, + { + regex: /\bcancellation_policy_preserves_cancellable_and_terminal_state_contract\b/, + message: 'missing tool cancellation policy regression', + }, + { + regex: /\bdialog_turn_cancellation_summary_counts_cancelled_and_skipped_tasks\b/, + message: 'missing dialog-turn cancellation summary regression', + }, + { + regex: /\bcancellation_token_store_cancels_and_removes_tokens\b/, + message: 'missing cancellation token store regression', + }, + { + regex: /\bstate_counts_preserve_pipeline_stats_contract\b/, + message: 'missing tool state-count regression', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/mcp/server/connection.rs', + reason: + 'services-integrations MCP connection must keep initialize-scoped timeout and channel-close cleanup until MCP owner migration is reviewed', + patterns: [ + { + regex: /\bsend_request_with_id\b/, + message: 'missing stable local JSON-RPC request id path', + }, + { + regex: /\binitialize_timeout\b/, + message: 'missing initialize-scoped timeout', + }, + { + regex: /notifications\/initialized/, + message: 'missing MCP initialized notification', + }, + { + regex: /\bpending\.clear\(\)/, + message: 'missing pending request waiter drain on channel close', + }, + { + regex: /\blocal_tool_calls_do_not_inherit_initialize_timeout\b/, + message: 'missing local tool request timeout-scope regression', + }, + { + regex: /\blocal_initialize_uses_initialize_timeout\b/, + message: 'missing local initialize timeout regression', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/mcp/protocol/transport.rs', + reason: + 'services-integrations MCP local transport must keep explicit request ids and stdin flush semantics', + patterns: [ + { + regex: /\bpub async fn send_request_with_id\b/, + message: 'missing explicit JSON-RPC request id send path', + }, + { + regex: /\.flush\(\)\s*\.await/, + message: 'missing local MCP stdin flush', + }, + ], + }, + { + path: 'src/crates/assembly/core/Cargo.toml', + reason: + 'bitfun-core product-full must explicitly aggregate owner crate feature groups instead of forcing them through dependency declarations', + patterns: [ + { + regex: + /bitfun-tool-packs = \{ path = "\.\.\/\.\.\/execution\/tool-provider-groups", default-features = false, optional = true \}/, + message: 'bitfun-tool-packs dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: + /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", default-features = false, features = \["remote-ssh"\] \}/, + message: + 'bitfun-services-integrations dependency may keep remote workspace identity but must not force workspace-search or product-full outside the core feature graph', + }, + { + regex: + /bitfun-ai-adapters = \{ path = "\.\.\/\.\.\/adapters\/ai-adapters", optional = true \}/, + message: 'bitfun-ai-adapters dependency must stay optional for no-default core builds', + }, + { + regex: /"dep:bitfun-ai-adapters"/, + message: 'core ai-adapter-runtime feature must explicitly enable the optional dependency', + }, + { + regex: /product-full = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, + message: 'core product-full assembly must explicitly opt into AI adapter runtime', + }, + { + regex: /product-domains = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, + message: 'core product-domain facade must explicitly opt into AI adapter runtime while concrete AI adapters remain optional', + }, + { + regex: /product-domains = \[[^\]]*"bitfun-services-integrations\/function-agents"[^\]]*\]/, + message: 'core product-domain facade must enable the function-agent service owner feature it imports', + }, + { + regex: /product-domains = \[[^\]]*"bitfun-services-integrations\/miniapp-runtime"[^\]]*\]/, + message: 'core product-domain facade must enable the MiniApp service owner feature it imports', + }, + { + regex: + /bitfun-product-domains = \{ path = "\.\.\/\.\.\/contracts\/product-domains", default-features = false, optional = true \}/, + message: + 'bitfun-product-domains dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: + /bitfun-product-capabilities = \{ path = "\.\.\/product-capabilities", default-features = false, optional = true \}/, + message: + 'bitfun-product-capabilities dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: /"dep:bitfun-tool-packs"/, + message: 'core tool-packs feature must explicitly enable the optional dependency', + }, + { + regex: /"bitfun-tool-packs\/product-full"/, + message: 'core product-full must explicitly enable tool pack product features', + }, + { + regex: /"bitfun-services-integrations\/product-full"/, + message: 'core product-full must explicitly enable integration product features', + }, + { + regex: /"dep:bitfun-product-domains"/, + message: 'core product-domains feature must explicitly enable the optional dependency', + }, + { + regex: /"dep:bitfun-product-capabilities"/, + message: + 'core product-capabilities feature must explicitly enable the optional dependency', + }, + { + regex: /"bitfun-product-domains\/product-full"/, + message: 'core product-full must explicitly enable product-domain features', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/lib.rs', + reason: + 'no-default bitfun-core must keep product runtime surfaces behind explicit features', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod agentic\b/s, + message: 'agentic runtime must stay behind product-full for no-default builds', + }, + { + regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod function_agents\b/s, + message: 'function-agent product domain facade must stay behind product-domains', + }, + { + regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod miniapp\b/s, + message: 'MiniApp product domain facade must stay behind product-domains', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub\(crate\) mod service_agent_runtime\b/s, + message: 'service agent runtime owner assembly must stay behind service-integrations', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/infrastructure/mod.rs', + reason: 'concrete AI adapter runtime and debug ingest HTTP server must stay out of no-default core builds', + patterns: [ + { + regex: /#\[cfg\(feature = "ai-adapter-runtime"\)\]\s*pub mod ai\b/s, + message: 'AI client runtime must stay behind ai-adapter-runtime', + }, + { + regex: /#\[cfg\(feature = "ai-adapter-runtime"\)\]\s*pub mod cli_credentials\b/s, + message: 'AI CLI credential runtime must stay behind ai-adapter-runtime', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod debug_log\b/s, + message: 'debug ingest HTTP server must stay behind product-full', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/util/types/ai.rs', + reason: 'legacy AI implementation DTO re-exports must not force AI adapters into no-default core builds', + patterns: [ + { + regex: /pub use bitfun_core_types::\{ConnectionTestMessageCode, ConnectionTestResult, RemoteModelInfo\};/s, + message: 'stable AI DTOs must be re-exported from core-types', + }, + { + regex: /#\[cfg\(feature = "ai-adapter-runtime"\)\]\s*pub use bitfun_ai_adapters::types::\{GeminiResponse, GeminiUsage\};/s, + message: 'legacy Gemini implementation DTOs must stay behind ai-adapter-runtime', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/mod.rs', + reason: + 'service integration and agent-runtime surfaces must not compile in no-default core builds', + patterns: [ + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod git\b/s, + message: 'git service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod mcp\b/s, + message: 'MCP service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod remote_connect\b/s, + message: 'remote-connect service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod review_platform\b/s, + message: 'review platform facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod search\b/s, + message: 'workspace search facade must stay behind product-full', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub use search::/s, + message: 'workspace search exports must stay behind product-full', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod snapshot\b/s, + message: 'snapshot service must stay behind product-full until tool-runtime ownership is split', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/config/mod.rs', + reason: + 'mode config canonicalization depends on product agent/tool registries and must stay out of no-default builds', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod mode_config_canonicalizer\b/s, + message: 'mode config canonicalizer must stay behind product-full', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/workspace/manager.rs', + reason: + 'workspace metadata may omit git worktree enrichment when service integrations are disabled', + patterns: [ + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*use crate::service::git::GitService\b/s, + message: 'GitService import must stay gated for no-default builds', + }, + { + regex: /#\[cfg\(not\(feature = "service-integrations"\)\)\]\s*\{\s*let _ = workspace_root;\s*return None;\s*\}/s, + message: 'no-default worktree enrichment fallback must remain explicit', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/workspace_runtime/service.rs', + reason: + 'workspace runtime binding helpers may depend on agentic runtime only in full product builds and must delegate legacy session-store migration to services-core', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*use crate::agentic::WorkspaceBinding\b/s, + message: 'WorkspaceBinding import must stay gated for no-default builds', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub async fn ensure_runtime_for_workspace_binding\b/s, + message: 'WorkspaceBinding runtime helper must stay behind product-full', + }, + { + regex: /\bmerge_legacy_session_store\b/, + message: 'workspace runtime session merge must delegate to services-core', + }, + { + regex: /\bmove_legacy_path\b/, + message: 'workspace runtime legacy path movement must delegate to services-core', + }, + { + regex: /\bsession_store_migration_error\b/, + message: 'workspace runtime must keep BitFunError compatibility mapping at the facade boundary', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/background_command_output.rs', + reason: + 'tool-runtime must own reusable background exec-command output capture state, cursors, retention, and lifecycle metadata', + patterns: [ + { + regex: /\bpub struct BackgroundCommandOutputCapture\b/, + message: 'missing background command output capture owner', + }, + { + regex: /\bpub enum BackgroundCommandOutputStatus\b/, + message: 'missing background command output status contract', + }, + { + regex: /\bpub struct BackgroundCommandOutputMetadata\b/, + message: 'missing background command output metadata contract', + }, + { + regex: /\bpub fn background_command_output_capture\b/, + message: 'missing background command output global entry', + }, + { + regex: /\bBACKGROUND_COMMAND_OUTPUT_CAPTURE_LIMIT_BYTES\b/, + message: 'missing background command output retention limit', + }, + { + regex: /\bbackground_command_output_reads_snapshot_then_incremental_chunks\b/, + message: 'missing background command output snapshot/incremental regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/mod.rs', + reason: + 'core remote SSH compatibility facade must keep service-backed SSH surfaces behind the ssh-remote feature while preserving lightweight workspace identity helpers', + patterns: [ + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod disabled\b/s, + message: 'missing disabled remote SSH runtime surface for no-default builds', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod manager\b/s, + message: 'remote SSH manager must stay gated behind ssh-remote', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_fs\b/s, + message: 'remote SSH filesystem runtime must stay gated behind ssh-remote', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_terminal\b/s, + message: 'remote SSH terminal runtime must stay gated behind ssh-remote', + }, + { + regex: /\bpub mod workspace_state\b/, + message: 'remote workspace identity helpers must remain available without ssh-remote', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/disabled.rs', + reason: + 'no-default core builds must expose explicit unsupported remote SSH stubs instead of compiling russh-backed runtime code', + patterns: [ + { + regex: /Remote SSH support is disabled; enable the `ssh-remote` feature/, + message: 'missing explicit disabled remote SSH diagnostic', + }, + { + regex: /\bpub struct SSHConnectionManager\b/, + message: 'missing disabled SSH manager compatibility surface', + }, + { + regex: /\bpub struct RemoteFileService\b/, + message: 'missing disabled remote file compatibility surface', + }, + { + regex: /\bpub struct RemoteTerminalManager\b/, + message: 'missing disabled remote terminal compatibility surface', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/mod.rs', + reason: + 'services-integrations remote_ssh must own concrete SSH/SFTP/PTY runtime behind the remote-ssh-concrete feature while keeping lightweight path/type contracts separate', + patterns: [ + { + regex: /#\[cfg\(feature = "remote-ssh-concrete"\)\]\s*pub mod manager\b/s, + message: 'missing concrete SSH manager owner module', + }, + { + regex: /#\[cfg\(feature = "remote-ssh-concrete"\)\]\s*mod remote_exec\b/s, + message: 'missing concrete remote exec owner module', + }, + { + regex: /#\[cfg\(feature = "remote-ssh-concrete"\)\]\s*pub mod remote_fs\b/s, + message: 'missing concrete remote filesystem owner module', + }, + { + regex: /#\[cfg\(feature = "remote-ssh-concrete"\)\]\s*pub mod remote_terminal\b/s, + message: 'missing concrete remote terminal owner module', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/manager.rs', + reason: + 'services-integrations remote_ssh manager owns russh connection, known-host, saved connection, SFTP, PTY channel, and port-forward concrete behavior', + patterns: [ + { + regex: /\bpub struct SSHConnectionManager\b/, + message: 'missing SSH connection manager owner', + }, + { + regex: /\brussh::client::connect_stream\b/, + message: 'missing russh connection owner path', + }, + { + regex: /\bSftpSession\b/, + message: 'missing SFTP session owner path', + }, + { + regex: /\bprunes_password_connection_without_vault_entry\b/, + message: 'missing saved credential pruning regression', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs', + reason: + 'services-integrations remote_ssh remote_exec owns model-facing remote shell process lifecycle and stdin/control semantics', + patterns: [ + { + regex: /\bpub struct RemoteExecProcessManager\b/, + message: 'missing remote exec process manager owner', + }, + { + regex: /\bGLOBAL_REMOTE_EXEC_MANAGER\b/, + message: 'missing global remote exec manager compatibility owner', + }, + { + regex: /\bremote_exec_session_ids_match_local_test_baseline\b/, + message: 'missing remote exec session-id compatibility regression', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs', + reason: + 'services-integrations remote_ssh remote_fs owns SFTP-backed remote filesystem operations', + patterns: [ + { + regex: /\bpub struct RemoteFileService\b/, + message: 'missing remote filesystem service owner', + }, + { + regex: /\bsftp_read\b/, + message: 'missing SFTP read owner path', + }, + { + regex: /\bsftp_write\b/, + message: 'missing SFTP write owner path', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs', + reason: + 'services-integrations remote_ssh remote_terminal owns remote PTY lifecycle, output broadcast, write, resize, and close behavior', + patterns: [ + { + regex: /\bpub struct RemoteTerminalManager\b/, + message: 'missing remote terminal manager owner', + }, + { + regex: /\benum PtyCommand\b/, + message: 'missing remote PTY command owner', + }, + { + regex: /\bchannel\.window_change\b/, + message: 'missing remote PTY resize owner path', + }, + ], + }, + { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', + reason: + 'runtime-ports must keep remote and subagent runtime boundary contracts DTO/trait-only', + patterns: [ + { + regex: /\bpub trait AgentTurnCancellationPort\b/, + message: 'missing turn cancellation port contract', + }, + { + regex: /\bpub trait RemoteControlStatePort\b/, + message: 'missing remote control state port contract', + }, + { + regex: /\bpub trait RuntimeEventSink\b/, + message: 'missing runtime event sink contract', + }, + { + regex: /\bpub struct AgentSessionCreateResult\b[\s\S]*\bpub session_name: String\b/, + message: 'agent session create result must return the persisted session name', + }, + { + regex: /\bpub struct RemoteWorkspaceFacts\b/, + message: 'missing remote workspace facts contract', + }, + { + regex: /\bpub trait RemoteWorkspaceRuntimeHost\b/, + message: 'missing remote workspace runtime host contract', + }, + { + regex: /\bpub trait RemoteWorkspacePort\b/, + message: 'missing remote workspace service port contract', + }, + { + regex: /\bpub trait RemoteWorkspaceFileRuntimeHost\b/, + message: 'missing remote workspace file runtime host contract', + }, + { + regex: /\bpub trait RemoteProjectionPort\b/, + message: 'missing remote projection service port contract', + }, + { + regex: /\bpub trait RemoteInitialSyncRuntimeHost\b/, + message: 'missing remote initial sync runtime host contract', + }, + { + regex: /\bremote_workspace_contracts_preserve_workspace_and_session_facts\b/, + message: 'missing remote workspace contract regression', + }, + { + regex: /\bremote_projection_contract_preserves_file_chunk_identity\b/, + message: 'missing remote projection contract regression', + }, + { + regex: /\bpub trait WorkspaceFileSystem\b/, + message: 'missing workspace file-system port contract', + }, + { + regex: /\bpub trait WorkspaceShell\b/, + message: 'missing workspace shell port contract', + }, + { + regex: /\bpub struct WorkspaceServices\b/, + message: 'missing workspace services bundle contract', + }, + { + regex: /\bpub struct WorkspaceCommandOptions\b/, + message: 'missing workspace command options contract', + }, + { + regex: /\bpub struct WorkspaceCommandResult\b/, + message: 'missing workspace command result contract', + }, + { + regex: /\bpub struct WorkspaceDirEntry\b/, + message: 'missing workspace dir-entry contract', + }, + { + regex: /\bworkspace_services_contract_is_runtime_port_owned\b/, + message: 'missing workspace service ownership regression', + }, + { + regex: /\bpub fn remote_image\b/, + message: 'missing remote image attachment helper contract', + }, + { + regex: /\bpub struct AgentDialogTurnRequest\b/, + message: 'missing agent dialog turn lifecycle request contract', + }, + { + regex: /\bpub struct AgentDialogPrependedReminder\b/, + message: 'missing agent dialog prepended reminder contract', + }, + { + regex: /\bpub trait AgentDialogTurnPort\b/, + message: 'missing agent dialog turn lifecycle port contract', + }, + { + regex: /\bpub struct AgentBackgroundResultRequest\b/, + message: 'missing background result lifecycle request contract', + }, + { + regex: /\bpub enum AgentThreadGoalDeliveryKind\b/, + message: 'missing thread-goal lifecycle delivery kind contract', + }, + { + regex: /\bpub struct AgentThreadGoalDeliveryRequest\b/, + message: 'missing thread-goal lifecycle delivery request contract', + }, + { + regex: /\bpub trait AgentLifecycleDeliveryPort\b/, + message: 'missing agent lifecycle delivery port contract', + }, + { + regex: /\brequester_session_id\b/, + message: 'missing requester-aware turn cancellation contract', + }, + { + regex: /\bpub struct AgentSessionListRequest\b/, + message: 'missing agent session list request contract', + }, + { + regex: /\bpub struct AgentSessionSummary\b/, + message: 'missing agent session summary contract', + }, + { + regex: /\bpub struct AgentSessionDeleteRequest\b/, + message: 'missing agent session delete request contract', + }, + { + regex: /\bpub struct AgentSessionWorkspaceRequest\b/, + message: 'missing agent session workspace request contract', + }, + { + regex: /\bpub trait AgentSessionManagementPort\b/, + message: 'missing agent session management port contract', + }, + { + regex: /\bagent_session_management_contracts_serialize_stable_shape\b/, + message: 'missing agent session management serialization regression', + }, + { + regex: /\bagent_dialog_turn_request_serializes_lifecycle_contract\b/, + message: 'missing agent dialog turn lifecycle request regression', + }, + { + regex: /\bagent_background_result_request_serializes_lifecycle_contract\b/, + message: 'missing background result lifecycle request regression', + }, + { + regex: /\bagent_thread_goal_delivery_request_serializes_lifecycle_contract\b/, + message: 'missing thread-goal lifecycle request regression', + }, + { + regex: /\bpub type DialogTriggerSource = AgentSubmissionSource\b/, + message: 'missing dialog trigger source compatibility contract', + }, + { + regex: /\bdialog_trigger_source_reuses_agent_submission_source_contract\b/, + message: 'missing dialog trigger source alias regression', + }, + { + regex: /\bpub enum DialogQueuePriority\b/, + message: 'missing dialog queue priority contract', + }, + { + regex: /\bpub struct DialogSubmissionPolicy\b/, + message: 'missing dialog submission policy contract', + }, + { + regex: /\bdialog_submission_policy_preserves_current_surface_queue_defaults\b/, + message: 'missing dialog submission policy regression', + }, + { + regex: /\bpub enum DialogSubmitOutcome\b/, + message: 'missing dialog submit outcome contract', + }, + { + regex: /\bdialog_submit_outcome_preserves_started_and_queued_fields\b/, + message: 'missing dialog submit outcome regression', + }, + { + regex: /\bpub enum DialogSessionStateFact\b/, + message: 'missing dialog session state fact contract', + }, + { + regex: /\bpub struct DialogSubmitQueueFacts\b/, + message: 'missing dialog submit queue facts contract', + }, + { + regex: /\bpub enum DialogSubmitQueueAction\b/, + message: 'missing dialog submit queue action contract', + }, + { + regex: /\bpub const fn resolve_dialog_submit_queue_action\b/, + message: 'missing dialog submit queue action resolver', + }, + { + regex: /\bdialog_submit_queue_action_preserves_current_scheduler_routing_policy\b/, + message: 'missing dialog submit queue action regression', + }, + { + regex: /\bpub fn should_suppress_agent_session_cancelled_reply\b/, + message: 'missing agent-session cancel suppression contract', + }, + { + regex: /\bpub enum DialogTurnOutcomeKind\b/, + message: 'missing dialog turn outcome kind contract', + }, + { + regex: /\bpub const fn should_skip_agent_session_reply\b/, + message: 'missing agent-session reply skip contract', + }, + { + regex: /\bagent_session_reply_decisions_preserve_cancel_suppression_boundary\b/, + message: 'missing agent-session reply decision regression', + }, + { + regex: /\bpub struct AgentSessionReplyRoute\b/, + message: 'missing agent session reply route contract', + }, + { + regex: /\bagent_session_reply_route_keeps_requester_fields\b/, + message: 'missing agent session reply route regression', + }, + { + regex: /\bpub enum DialogSteerOutcome\b/, + message: 'missing dialog steer outcome contract', + }, + { + regex: /\bdialog_steer_outcome_preserves_buffered_fields\b/, + message: 'missing dialog steer outcome regression', + }, + { + regex: /\bpub enum RoundInjectionKind\b/, + message: 'missing round injection kind contract', + }, + { + regex: /\bpub enum RoundInjectionTarget\b/, + message: 'missing round injection target contract', + }, + { + regex: /\bpub struct RoundInjection\b/, + message: 'missing round injection message contract', + }, + { + regex: /\bpub trait DialogRoundInjectionSource\b/, + message: 'missing dialog round injection source contract', + }, + { + regex: /\bround_injection_contract_keeps_kind_and_target_identity\b/, + message: 'missing round injection contract regression', + }, + { + regex: /\bround_injection_source_contract_drains_portable_injections\b/, + message: 'missing round injection source contract regression', + }, + { + regex: /\bpub enum ThreadGoalStatus\b/, + message: 'missing thread goal status contract', + }, + { + regex: /\bpub struct ThreadGoal\b/, + message: 'missing thread goal contract', + }, + { + regex: /\bpub struct SetThreadGoalResult\b/, + message: 'missing set thread goal result contract', + }, + { + regex: /\bpub struct ThreadGoalContinuationPlan\b/, + message: 'missing thread goal continuation plan contract', + }, + { + regex: /\bpub struct ThreadGoalToolResponse\b/, + message: 'missing thread goal tool response contract', + }, + { + regex: /\bthread_goal_active_status_includes_budget_limited\b/, + message: 'missing thread goal status contract regression', + }, + { + regex: /\bthread_goal_tool_response_serializes_optional_fields\b/, + message: 'missing thread goal tool response wire-shape regression', + }, + { + regex: /\bpub struct CompressionContract\b/, + message: 'missing compression contract', + }, + { + regex: /\bpub struct CompressionContractItem\b/, + message: 'missing compression contract item', + }, + { + regex: /\bcompression_contract_renders_model_visible_fields\b/, + message: 'missing compression contract rendering regression', + }, + { + regex: /\bpub struct RelatedPath\b/, + message: 'missing related path request-context contract', + }, + { + regex: /\brelated_path_serializes_as_request_context_fact\b/, + message: 'missing related path serialization regression', + }, + { + regex: /\bpub struct DelegationPolicy\b/, + message: 'missing delegation policy contract', + }, + { + regex: /\bpub enum SubagentContextMode\b/, + message: 'missing subagent context mode contract', + }, + { + regex: /\bdelegation_policy_child_blocks_recursive_spawn_without_losing_depth\b/, + message: 'missing delegation policy contract regression', + }, + { + regex: /\bsubagent_context_mode_preserves_fork_wire_value\b/, + message: 'missing subagent context mode contract regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs', + reason: + 'core subagent runtime must preserve legacy import path while runtime-ports owns portable subagent contracts', + patterns: [ + { + regex: /pub\(crate\) use bitfun_runtime_ports::\{DelegationPolicy, SubagentContextMode\};/, + message: 'missing core compatibility re-export for subagent runtime contracts', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/framework.rs', + reason: + 'agent-tools may own pure and generic prompt-visible tool contracts and provider-neutral execution gate policy without owning product registry or concrete execution', + patterns: [ + { + regex: /\bpub const GET_TOOL_SPEC_TOOL_NAME\b/, + message: 'missing shared GetToolSpec manifest name contract', + }, + { + regex: /\bpub enum ToolExposure\b/, + message: 'missing lightweight tool exposure contract', + }, + { + regex: /\bpub struct ToolManifestPolicyTool\b/, + message: 'missing pure tool manifest policy input contract', + }, + { + regex: /\bpub fn resolve_tool_manifest_policy\b/, + message: 'missing pure tool manifest policy resolver', + }, + { + regex: /\bfn default_exposure\b/, + message: 'missing generic tool exposure contract', + }, + { + regex: /\bpub fn build_tool_manifest_policy_tools\b/, + message: 'missing registry snapshot to manifest policy input helper', + }, + { + regex: /\bpub fn build_collapsed_tool_stub_definition\b/, + message: 'missing collapsed-tool prompt stub contract', + }, + { + regex: /\bpub enum PromptVisibleToolManifestItem\b/, + message: 'missing prompt-visible manifest item contract', + }, + { + regex: /\bpub fn build_prompt_visible_tool_manifest_definitions\b/, + message: 'missing prompt-visible manifest definition builder', + }, + { + regex: /\bpub trait ContextualToolManifestItem\b/, + message: 'missing generic contextual manifest item adapter contract', + }, + { + regex: /\bpub trait ToolCatalogSnapshotProvider\b/, + message: 'missing generic tool catalog snapshot provider contract', + }, + { + regex: /\bpub trait GetToolSpecCatalogProvider\b/, + message: 'missing generic GetToolSpec catalog provider contract', + }, + { + regex: /\bpub struct ContextualVisibleTools\b/, + message: 'missing generic contextual visible-tools result contract', + }, + { + regex: /\bpub struct ContextualToolManifest\b/, + message: 'missing generic contextual tool manifest result contract', + }, + { + regex: /\bpub async fn resolve_contextual_visible_tools\b/, + message: 'missing generic contextual visible-tools resolver', + }, + { + regex: /\bpub async fn resolve_contextual_tool_manifest\b/, + message: 'missing generic contextual tool manifest resolver', + }, + { + regex: /\bpub async fn resolve_contextual_visible_tools_from_provider\b/, + message: 'missing provider-backed contextual visible-tools resolver', + }, + { + regex: /\bpub async fn resolve_contextual_tool_manifest_from_provider\b/, + message: 'missing provider-backed contextual manifest resolver', + }, + { + regex: /\bpub async fn build_get_tool_spec_catalog_description_from_provider\b/, + message: 'missing provider-backed GetToolSpec catalog description builder', + }, + { + regex: /\bpub async fn resolve_get_tool_spec_detail_from_provider\b/, + message: 'missing provider-backed GetToolSpec detail resolver', + }, + { + regex: /\bpub fn build_get_tool_spec_description\b/, + message: 'missing pure GetToolSpec prompt description contract', + }, + { + regex: /\bpub struct GetToolSpecCollapsedToolSummary\b/, + message: 'missing pure GetToolSpec collapsed catalog summary', + }, + { + regex: /\bpub struct GetToolSpecDetail\b/, + message: 'missing pure GetToolSpec detail contract', + }, + { + regex: /\bpub fn summarize_get_tool_spec_collapsed_tools\b/, + message: 'missing pure GetToolSpec collapsed summary helper', + }, + { + regex: /\bpub async fn resolve_get_tool_spec_detail\b/, + message: 'missing generic GetToolSpec detail resolver', + }, + { + regex: /\bpub fn build_get_tool_spec_catalog_description\b/, + message: 'missing pure GetToolSpec catalog description builder', + }, + { + regex: /\bpub fn get_tool_spec_input_schema\b/, + message: 'missing pure GetToolSpec input schema contract', + }, + { + regex: /\bpub fn get_tool_spec_short_description\b/, + message: 'missing pure GetToolSpec short description contract', + }, + { + regex: /\bpub fn render_get_tool_spec_tool_use_message\b/, + message: 'missing pure GetToolSpec tool-use message renderer', + }, + { + regex: /\bpub fn get_tool_spec_is_readonly\b/, + message: 'missing pure GetToolSpec readonly metadata contract', + }, + { + regex: /\bpub fn get_tool_spec_is_concurrency_safe\b/, + message: 'missing pure GetToolSpec concurrency metadata contract', + }, + { + regex: /\bpub fn get_tool_spec_needs_permissions\b/, + message: 'missing pure GetToolSpec permission metadata contract', + }, + { + regex: /\bpub fn validate_get_tool_spec_input\b/, + message: 'missing pure GetToolSpec input validation contract', + }, + { + regex: /\bpub fn build_get_tool_spec_assistant_detail\b/, + message: 'missing pure GetToolSpec assistant detail rendering contract', + }, + { + regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, + message: 'missing pure GetToolSpec duplicate-load result assembly contract', + }, + { + regex: /\bpub fn build_get_tool_spec_detail_result\b/, + message: 'missing pure GetToolSpec detail result assembly contract', + }, + { + regex: /\bpub enum GetToolSpecExecutionPlan\b/, + message: 'missing pure GetToolSpec execution plan contract', + }, + { + regex: /\bpub enum GetToolSpecExecutionError\b/, + message: 'missing pure GetToolSpec execution error contract', + }, + { + regex: /\bpub fn resolve_get_tool_spec_execution_plan\b/, + message: 'missing pure GetToolSpec execution plan resolver', + }, + { + regex: /\bpub async fn resolve_get_tool_spec_execution_result_from_provider\b/, + message: 'missing provider-backed GetToolSpec execution result resolver', + }, + { + regex: /\bpub struct GetToolSpecRuntime\b/, + message: 'missing provider-backed GetToolSpec runtime facade', + }, + { + regex: /\bpub async fn call_results\b/, + message: 'missing provider-backed GetToolSpec Tool-result vector adapter facade', + }, + { + regex: /\bpub struct GetToolSpecLoadObservation\b/, + message: 'missing pure GetToolSpec load observation contract', + }, + { + regex: /\bpub fn collect_loaded_collapsed_tool_names\b/, + message: 'missing pure collapsed-tool load collection contract', + }, + { + regex: /\bpub enum CollapsedToolUsageError\b/, + message: 'missing collapsed-tool execution gate error contract', + }, + { + regex: /\bpub enum ToolExecutionAccessError\b/, + message: 'missing tool execution allowed-list gate error contract', + }, + { + regex: /\bpub fn validate_tool_allowed_by_list\b/, + message: 'missing tool execution allowed-list gate policy', + }, + { + regex: /\bpub fn validate_collapsed_tool_usage\b/, + message: 'missing collapsed-tool execution gate policy', + }, + { + regex: /\bpub fn is_tool_path_allowed_by_resolved_roots\b/, + message: 'missing provider-neutral path policy root matcher', + }, + { + regex: /\bpub fn build_tool_path_policy_denial_message\b/, + message: 'missing provider-neutral path policy denial message', + }, + { + regex: /\bpub fn resolve_tool_path_with_context\b/, + message: 'missing provider-neutral tool path resolution owner', + }, + { + regex: /\bpub fn tool_path_is_effectively_absolute\b/, + message: 'missing provider-neutral tool path absolute check', + }, + { + regex: /\bpub fn build_tool_runtime_artifact_reference\b/, + message: 'missing provider-neutral runtime artifact reference builder', + }, + { + regex: /\bpub fn build_tool_session_runtime_artifact_reference\b/, + message: 'missing provider-neutral session runtime artifact reference builder', + }, + { + regex: /\bpub fn sort_tool_manifest_definitions\b/, + message: 'missing prompt-visible manifest ordering helper', + }, + { + regex: /\bpub struct StaticToolProviderGroup\b/, + message: 'missing generic static provider group container', + }, + { + regex: /\bpub trait StaticToolProviderPlan\b/, + message: 'missing provider-neutral static tool provider plan contract', + }, + { + regex: /\bpub trait StaticToolProviderFactory\b/, + message: 'missing provider-neutral static tool factory contract', + }, + { + regex: /\bpub enum StaticToolMaterializationError\b/, + message: 'missing provider-neutral static tool materialization error', + }, + { + regex: /\bpub fn materialize_static_tool_provider_groups\b/, + message: 'missing provider-neutral static tool materializer', + }, + { + regex: /\bpub struct ToolRuntimeAssembly\b/, + message: 'missing generic tool runtime assembly owner', + }, + { + regex: /\bpub type ToolDecoratorRef\b/, + message: 'missing generic tool decorator reference contract', + }, + { + regex: /\bpub trait SnapshotToolWrapper\b/, + message: 'missing generic snapshot wrapper port', + }, + { + regex: /\bpub struct SnapshotToolDecorator\b/, + message: 'missing generic snapshot decorator adapter', + }, + { + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static-provider runtime assembly helper', + }, + { + regex: /\bcreate_registry_from_static_provider_plans\b/, + message: 'missing generic static-provider plan-to-registry assembly helper', + }, + { + regex: /\bpub fn is_tool_collapsed\b/, + message: 'missing generic collapsed-tool registry query', + }, + { + regex: /\bpub fn get_collapsed_tool_names\b/, + message: 'missing generic collapsed-tool registry catalog query', + }, + { + regex: /\bpub async fn resolve_readonly_enabled_tools\b/, + message: 'missing generic readonly enabled tool filter', + }, + { + regex: /\bpub struct ToolCatalogRuntime\b/, + message: 'missing provider-backed tool catalog runtime facade', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/file_guidance.rs', + reason: 'agent-tools owns provider-neutral file tool guidance marker contracts', + patterns: [ + { + regex: /\bpub const FILE_TOOL_GUIDANCE_PREFIX\b/, + message: 'missing file tool guidance marker prefix', + }, + { + regex: /\bpub fn file_tool_guidance_message\b/, + message: 'missing file tool guidance message helper', + }, + { + regex: /\bpub fn is_file_tool_guidance_message\b/, + message: 'missing file tool guidance classifier', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/file_read_freshness.rs', + reason: 'agent-tools owns pure file-read freshness policy for Read/Edit/Write guardrails', + patterns: [ + { + regex: /\bpub struct FileReadFreshnessFacts\b/, + message: 'missing file-read freshness facts contract', + }, + { + regex: /\bpub fn normalize_tool_file_content\b/, + message: 'missing provider-neutral file content normalization helper', + }, + { + regex: /\bpub fn file_read_facts_content_matches\b/, + message: 'missing file-read content equivalence helper', + }, + { + regex: /\bpub fn file_read_facts_are_fresh\b/, + message: 'missing file-read freshness policy helper', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/tool_result_storage.rs', + reason: + 'agent-tools owns pure oversized tool-result storage policy and rendering without session IO', + patterns: [ + { + regex: /\bpub struct ToolResultStoragePolicy\b/, + message: 'missing provider-neutral tool result storage policy', + }, + { + regex: /\bpub struct PersistedToolOutput\b/, + message: 'missing persisted tool output render contract', + }, + { + regex: /\bpub struct ToolResultPersistenceCandidate\b/, + message: 'missing provider-neutral persistence candidate contract', + }, + { + regex: /\bpub fn select_tool_result_indices_for_persistence\b/, + message: 'missing round-budget persistence candidate selector', + }, + { + regex: /\bpub fn sanitize_tool_result_file_component\b/, + message: 'missing tool-result file component sanitizer', + }, + { + regex: /\bpub fn generate_tool_result_preview\b/, + message: 'missing tool-result preview generator', + }, + { + regex: /\bpub fn count_tool_result_lines\b/, + message: 'missing tool-result line counter', + }, + { + regex: /\bpub fn build_persisted_tool_output_message\b/, + message: 'missing persisted-output message renderer', + }, + { + regex: /\bpub fn tool_result_is_persisted_output\b/, + message: 'missing persisted-output classifier', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/tool_execution_presentation.rs', + reason: + 'agent-tools owns provider-neutral tool execution result and error presentation helpers', + patterns: [ + { + regex: /\bpub const TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES\b/, + message: 'missing tool error argument preview limit contract', + }, + { + regex: /\bpub const USER_STEERING_INTERRUPTED_MESSAGE\b/, + message: 'missing steering-interrupted assistant message contract', + }, + { + regex: /\bpub struct ToolExecutionErrorPresentation\b/, + message: 'missing tool execution error presentation DTO', + }, + { + regex: /\bpub fn render_tool_result_for_assistant\b/, + message: 'missing tool result assistant rendering helper', + }, + { + regex: /\bpub fn truncate_tool_arguments_preview\b/, + message: 'missing structured tool argument preview helper', + }, + { + regex: /\bpub fn truncate_raw_tool_arguments_preview\b/, + message: 'missing raw tool argument preview helper', + }, + { + regex: /\bpub fn build_tool_execution_error_presentation\b/, + message: 'missing tool execution error presentation helper', + }, + { + regex: /\bpub fn build_user_steering_interrupted_presentation\b/, + message: 'missing steering-interrupted presentation helper', + }, + { + regex: /\bpub fn build_invalid_tool_call_error_message\b/, + message: 'missing invalid tool call error message helper', + }, + { + regex: /\bpub fn is_write_like_tool_name\b/, + message: 'missing write-like tool classification helper', + }, + { + regex: /\bpub fn build_tool_call_truncation_recovery_notice\b/, + message: 'missing truncation recovery notice helper', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', + reason: + 'core must keep current coordinator port adapters and attachment guard until remote runtime migration is reviewed', + patterns: [ + { + regex: /impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator/, + message: 'missing agent submission port adapter', + }, + { + regex: /impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator/, + message: 'missing session transcript reader adapter', + }, + { + regex: /impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator/, + message: 'missing turn cancellation port adapter', + }, + { + regex: /impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinator/, + message: 'missing session management port adapter', + }, + { + regex: /\bruntime_session_summary\b/, + message: 'missing session summary adapter helper', + }, + { + regex: /\bAgentSessionSummary\b/, + message: 'missing runtime session summary contract binding', + }, + { + regex: /impl bitfun_runtime_ports::RemoteControlStatePort for ConversationCoordinator/, + message: 'missing remote control state port adapter', + }, + { + regex: /agent submission port does not yet accept generic attachments/, + message: 'missing generic attachment guard on agent submission port', + }, + { + regex: /pub use bitfun_runtime_ports::DialogTriggerSource;/, + message: 'missing dialog trigger source compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler must preserve legacy submission policy import path while runtime-ports owns portable dialog policy contracts', + patterns: [ + { + regex: + /pub use bitfun_runtime_ports::\{[\s\S]*AgentSessionReplyRoute[\s\S]*DialogQueuePriority[\s\S]*DialogSteerOutcome[\s\S]*DialogSubmissionPolicy[\s\S]*DialogSubmitOutcome[\s\S]*\};/, + message: 'missing dialog submission policy compatibility re-export', + }, + { + regex: + /use bitfun_runtime_ports::\{(?=[\s\S]*DialogSessionStateFact)(?=[\s\S]*DialogSubmitQueueAction)(?=[\s\S]*DialogSubmitQueueFacts)(?=[\s\S]*resolve_dialog_submit_queue_action)[\s\S]*\};/, + message: 'missing dialog scheduler decision contract import', + }, + { + regex: + /use bitfun_agent_runtime::scheduler::\{(?=[\s\S]*ActiveDialogTurn)(?=[\s\S]*ActiveDialogTurnStore)(?=[\s\S]*AgentSessionReplyAction)(?=[\s\S]*AgentSessionReplyPlan)(?=[\s\S]*BackgroundDeliveryAction)(?=[\s\S]*BackgroundDeliveryFacts)(?=[\s\S]*BackgroundInjectionKind)(?=[\s\S]*DialogReplySuppressionSet)(?=[\s\S]*DialogSteeringAction)(?=[\s\S]*DialogTurnQueue)(?=[\s\S]*SessionAbortFlags)(?=[\s\S]*resolve_agent_session_reply_action)(?=[\s\S]*resolve_background_delivery_action)(?=[\s\S]*resolve_background_delivery_injection)(?=[\s\S]*resolve_dialog_steering_action)[\s\S]*\};/, + message: 'missing agent-runtime scheduler owner imports', + }, + { + regex: /\bBackgroundResult\b/, + message: 'missing background-result injection owner delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/round_preempt.rs', + reason: + 'core round-boundary runtime must preserve legacy import paths while runtime-ports owns portable contracts and agent-runtime owns injection state', + patterns: [ + { + regex: + /pub use bitfun_agent_runtime::scheduler::\{[\s\S]*DialogRoundInjectionInterrupt[\s\S]*SessionRoundInjectionBuffer[\s\S]*\};/, + message: 'missing agent-runtime round-boundary state compatibility re-export', + }, + { + regex: + /pub use bitfun_runtime_ports::\{[\s\S]*DialogRoundInjectionSource[\s\S]*RoundInjection[\s\S]*RoundInjectionKind[\s\S]*RoundInjectionTarget[\s\S]*\};/, + message: 'missing round injection compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/goal_mode/mod.rs', + reason: + 'core goal mode must preserve legacy import paths while runtime-ports owns portable contracts and agent-runtime owns runtime decisions', + patterns: [ + { + regex: + /pub use bitfun_runtime_ports::\{[\s\S]*SetThreadGoalResult[\s\S]*ThreadGoal[\s\S]*ThreadGoalContinuationPlan[\s\S]*ThreadGoalStatus[\s\S]*ThreadGoalToolResponse[\s\S]*GOAL_MODE_METADATA_KEY[\s\S]*MAX_CONTEXT_SUMMARY_CHARS[\s\S]*MAX_THREAD_GOAL_OBJECTIVE_CHARS[\s\S]*THREAD_GOAL_METADATA_KEY[\s\S]*\};/, + message: 'missing thread goal compatibility re-export', + }, + { + regex: + /pub use bitfun_agent_runtime::thread_goal::\{[\s\S]*build_thread_goal_continuation_plan[\s\S]*goal_tool_response[\s\S]*should_skip_goal_for_turn[\s\S]*ThreadGoalRuntime[\s\S]*\};/, + message: 'missing thread goal runtime owner compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/core/message.rs', + reason: + 'core message model must preserve legacy compression contract import path while runtime-ports owns portable compaction facts', + patterns: [ + { + regex: /pub use bitfun_runtime_ports::\{CompressionContract, CompressionContractItem\};/, + message: 'missing compression contract compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/workspace/manager.rs', + reason: + 'core workspace manager must preserve legacy related-path import path while runtime-ports owns portable request-context facts', + patterns: [ + { + regex: /pub use bitfun_runtime_ports::RelatedPath;/, + message: 'missing related path compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service_agent_runtime.rs', + reason: + 'core service/agent runtime owner must centralize concrete remote-connect and agent runtime port bindings without moving runtime behavior', + patterns: [ + { + regex: /\bpub\(crate\) struct CoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner type', + }, + { + regex: /\bfn remote_dialog_host\b/, + message: 'missing remote dialog host owner factory', + }, + { + regex: /\bfn remote_cancel_host\b/, + message: 'missing remote cancel host owner factory', + }, + { + regex: /\bfn remote_image_context\b/, + message: 'missing remote image context owner adapter', + }, + { + regex: /\bfn load_remote_model_catalog\b/, + message: 'missing remote model catalog owner adapter', + }, + { + regex: /\bRemoteModelCatalogFacts\b/, + message: 'missing remote model catalog fact projection', + }, + { + regex: /\bRemoteModelCapabilityFact\b/, + message: 'missing remote model capability fact projection', + }, + { + regex: /\bRemoteReasoningModeFact\b/, + message: 'missing remote reasoning mode fact projection', + }, + { + regex: /\bbuild_remote_model_catalog\b/, + message: 'missing remote model catalog assembly delegation', + }, + { + regex: /\bfn update_remote_session_model\b/, + message: 'missing remote session model update owner adapter', + }, + { + regex: /\bfn normalize_remote_session_model_id\b/, + message: 'missing remote session model id normalization regression hook', + }, + { + regex: /\bnormalize_remote_session_model_id_contract\b/, + message: 'missing remote session model id owner delegation', + }, + { + regex: /\bfn normalize_remote_model_selection\b/, + message: 'missing remote model selection normalization regression hook', + }, + { + regex: /\bnormalize_remote_model_selection_contract\b/, + message: 'missing remote model selection owner delegation', + }, + { + regex: /\bfn remote_chat_messages_from_turns\b/, + message: 'missing remote chat history conversion owner adapter', + }, + { + regex: /\bRemoteDialogSchedulerOutcomeFact\b/, + message: 'missing remote dialog scheduler outcome fact projection', + }, + { + regex: /\bremote_dialog_submit_outcome_from_scheduler\b/, + message: 'missing remote dialog submit outcome assembly delegation', + }, + { + regex: /\bRemoteChatHistoryTurn\b/, + message: 'missing remote chat history owner DTO projection', + }, + { + regex: /\bbuild_remote_chat_messages\b/, + message: 'missing remote chat history assembly delegation', + }, + { + regex: /\bfn strip_remote_user_input_tags\b/, + message: 'missing remote user input display cleanup owner adapter', + }, + { + regex: /\bfn compress_remote_chat_data_url_for_mobile\b/, + message: 'missing remote chat thumbnail compression owner adapter', + }, + { + regex: /\bfn load_remote_chat_messages\b/, + message: 'missing remote chat history persistence owner adapter', + }, + { + regex: /\bfn agent_runtime\b/, + message: 'missing agent runtime owner binding', + }, + { + regex: /\bfn agent_runtime_with_dialog_turns\b/, + message: 'missing agent runtime dialog lifecycle owner binding', + }, + { + regex: /\bfn agent_runtime_with_lifecycle_delivery\b/, + message: 'missing agent runtime lifecycle delivery owner binding', + }, + { + regex: /\bfn agent_runtime_with_scheduler_ports\b/, + message: 'missing scheduler lifecycle runtime port binding', + }, + { + regex: /\bfn global_agent_runtime_with_lifecycle_delivery\b/, + message: 'missing global lifecycle delivery runtime binding', + }, + { + regex: /\bwith_lifecycle_delivery_port\b/, + message: 'missing lifecycle delivery builder registration', + }, + { + regex: /\bagent_input_attachment_from_image_context\b/, + message: 'missing remote image to lifecycle attachment adapter', + }, + { + regex: /\bAgentDialogTurnRequest\b/, + message: 'missing dialog lifecycle request binding', + }, + { + regex: /\bsubmit_dialog_turn\b/, + message: 'missing dialog lifecycle submit delegation', + }, + { + regex: /\bAgentRuntimeBuilder\b/, + message: 'missing agent runtime builder binding', + }, + { + regex: /\bfn remote_control_state_port\b/, + message: 'missing remote control state port owner binding', + }, + { + regex: /\bCoreRemoteDialogRuntimeHost\b/, + message: 'missing core remote dialog host binding', + }, + { + regex: /\bCoreRemoteCancelRuntimeHost\b/, + message: 'missing core remote cancel host binding', + }, + { + regex: /pub\(crate\) struct CoreRemoteCancelRuntimeHost\s*\{[\s\S]*\bruntime:\s*AgentRuntime\b/, + message: 'missing remote cancel host runtime field', + }, + { + regex: /\bCoreServiceAgentRuntime::agent_runtime_with_scheduler_ports\b/, + message: 'missing remote cancel scheduler-backed runtime binding', + }, + { + regex: /\bCoreRemoteWorkspaceFileRuntimeHost\b/, + message: 'missing core remote workspace file host binding', + }, + { + regex: /\bCoreRemoteSessionTrackerHost\b/, + message: 'missing core remote session tracker host binding', + }, + { + regex: /\bCoreRemoteWorkspaceRuntimeHost\b/, + message: 'missing core remote workspace runtime host binding', + }, + { + regex: /\bCoreRemoteSessionRuntimeHost\b/, + message: 'missing core remote session runtime host binding', + }, + { + regex: /pub\(crate\) struct CoreRemoteSessionRuntimeHost\s*\{[\s\S]*\bruntime:\s*AgentRuntime\b/, + message: 'missing remote session host runtime field', + }, + { + regex: /\bCoreRemotePollRuntimeHost\b/, + message: 'missing core remote poll runtime host binding', + }, + { + regex: /\bCoreRemoteInteractionRuntimeHost\b/, + message: 'missing core remote interaction runtime host binding', + }, + { + regex: /\bRemoteExecutionDispatcher\b/, + message: 'missing remote execution dispatcher binding', + }, + { + regex: /\bimpl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost\b/, + message: 'missing remote dialog host adapter implementation in runtime owner', + }, + { + regex: /\bimpl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost\b/, + message: 'missing remote cancel host adapter implementation in runtime owner', + }, + { + regex: /\bimpl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost\b/, + message: 'missing remote workspace file host adapter implementation in runtime owner', + }, + { + regex: /\bimpl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost\b/, + message: 'missing remote tracker host adapter implementation in runtime owner', + }, + { + regex: /\bImageContextData\b/, + message: 'missing core image context binding', + }, + { + regex: /\bRemoteImageContextAdapter\b/, + message: 'missing remote image context adapter implementation', + }, + { + regex: /\bAgentSubmissionPort\b/, + message: 'missing agent submission port binding', + }, + { + regex: /\bAgentDialogTurnPort\b/, + message: 'missing agent dialog turn port binding', + }, + { + regex: /\bAgentTurnCancellationPort\b/, + message: 'missing agent turn cancellation port contract guard', + }, + { + regex: /\bAgentSessionManagementPort\b/, + message: 'missing agent session management port contract guard', + }, + { + regex: /\bwith_session_management_port\b/, + message: 'missing agent session management runtime binding', + }, + { + regex: /\bRemoteControlStatePort\b/, + message: 'missing remote control state port contract guard', + }, + { + regex: /\bSessionTranscriptReader\b/, + message: 'missing session transcript reader contract guard', + }, + { + regex: /\bcore_service_agent_runtime_owner_keeps_coordinator_port_contracts\b/, + message: 'missing coordinator runtime port contract regression', + }, + { + regex: /\bcore_service_agent_runtime_owner_normalizes_remote_session_model_ids\b/, + message: 'missing remote session model id normalization regression', + }, + { + regex: /\bcore_service_agent_runtime_owner_normalizes_remote_model_selection_aliases\b/, + message: 'missing remote model selection alias regression', + }, + { + regex: /\bcore_service_agent_runtime_owner_preserves_remote_chat_history_shape\b/, + message: 'missing remote chat history conversion regression', + }, + { + regex: /\bcore_service_agent_runtime_owner_skips_in_progress_remote_assistant_history\b/, + message: 'missing in-progress remote assistant history regression', + }, + { + regex: /\bcore_service_agent_runtime_owner_maps_image_context_to_lifecycle_attachment\b/, + message: 'missing remote image lifecycle attachment regression', + }, + { + regex: /\bcore_service_agent_runtime_owner_keeps_scheduler_lifecycle_port_contracts\b/, + message: 'missing scheduler lifecycle port contract regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs', + reason: + 'SessionControl must route session create, list, delete, workspace resolution, and cancellation through the service/agent runtime owner while preserving existing behavior', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime::agent_runtime\b/, + message: 'missing service/agent runtime owner routing', + }, + { + regex: /\bCoreServiceAgentRuntime::agent_runtime_with_scheduler_ports\b/, + message: 'missing scheduler cancellation runtime owner routing', + }, + { + regex: /\bAgentSessionCreateRequest\b/, + message: 'missing port-backed agent session creation request', + }, + { + regex: /\bAgentSessionListRequest\b/, + message: 'missing port-backed agent session list request', + }, + { + regex: /\bAgentSessionDeleteRequest\b/, + message: 'missing port-backed agent session delete request', + }, + { + regex: /\bAgentSessionWorkspaceRequest\b/, + message: 'missing port-backed session workspace request', + }, + { + regex: /\blist_sessions\b/, + message: 'missing port-backed agent session list call', + }, + { + regex: /\bdelete_session\b/, + message: 'missing port-backed agent session delete call', + }, + { + regex: /\bresolve_session_workspace_path\b/, + message: 'missing port-backed session workspace resolution call', + }, + { + regex: /"createdBy"/, + message: 'missing creator metadata propagation', + }, + { + regex: /\bAgentTurnCancellationRequest\b/, + message: 'missing port-backed cancellation request', + }, + { + regex: /\brequester_session_id\b/, + message: 'missing requester-aware cancellation propagation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs', + reason: + 'SessionMessage must create, resolve, validate, and submit target agent sessions through the service/agent runtime lifecycle owner', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime::agent_runtime_with_dialog_turns\b/, + message: 'missing service/agent runtime lifecycle owner routing', + }, + { + regex: /\bAgentSessionCreateRequest\b/, + message: 'missing port-backed agent session creation request', + }, + { + regex: /\bAgentSessionListRequest\b/, + message: 'missing port-backed target session list request', + }, + { + regex: /\bAgentSessionWorkspaceRequest\b/, + message: 'missing port-backed target session workspace request', + }, + { + regex: /\blist_sessions\b/, + message: 'missing port-backed target session list call', + }, + { + regex: /\bresolve_session_workspace_path\b/, + message: 'missing port-backed target session workspace resolution call', + }, + { + regex: /"createdBy"/, + message: 'missing creator metadata propagation', + }, + { + regex: /\bAgentDialogTurnRequest\b/, + message: 'missing port-backed dialog turn request', + }, + { + regex: /\bAgentDialogPrependedReminder\b/, + message: 'missing portable prepended reminder request', + }, + { + regex: /\bsubmit_dialog_turn\b/, + message: 'missing dialog lifecycle submission', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_connect.rs', + reason: + 'services-integrations must own remote-connect wire/response assembly and preserve remote owner compatibility re-exports', + patterns: [ + { + regex: /\bpub mod device\b/, + message: 'missing remote-connect device owner module', + }, + { + regex: /\bpub mod encryption\b/, + message: 'missing remote-connect encryption owner module', + }, + { + regex: /\bpub mod pairing\b/, + message: 'missing remote-connect pairing owner module', + }, + { + regex: /\bpub mod qr_generator\b/, + message: 'missing remote-connect QR owner module', + }, + { + regex: /\bpub mod relay_client\b/, + message: 'missing remote-connect relay client owner module', + }, + { + regex: /\bpub use device::DeviceIdentity\b/, + message: 'missing remote-connect device compatibility export', + }, + { + regex: /\bpub use encryption::\{decrypt_from_base64, encrypt_to_base64, KeyPair\}/, + message: 'missing remote-connect encryption compatibility export', + }, + { + regex: /pub use pairing::\{[\s\S]*\bPairingChallenge\b[\s\S]*\bPairingProtocol\b[\s\S]*\bPairingResponse\b[\s\S]*\bPairingState\b[\s\S]*\bQrPayload\b[\s\S]*\}/, + message: 'missing remote-connect pairing compatibility export', + }, + { + regex: /\bpub use qr_generator::QrGenerator\b/, + message: 'missing remote-connect QR compatibility export', + }, + { + regex: /pub use relay_client::\{[\s\S]*\bConnectionState\b[\s\S]*\bRelayClient\b[\s\S]*\bRelayEvent\b[\s\S]*\bRelayMessage\b[\s\S]*\}/, + message: 'missing remote-connect relay compatibility export', + }, + { + regex: /\bpub struct RemoteSessionStateTracker\b/, + message: 'missing remote session state tracker owner', + }, + { + regex: /\bpub enum TrackerEvent\b/, + message: 'missing remote tracker event owner', + }, + { + regex: /\bpub trait RemoteSessionTrackerHost\b/, + message: 'missing remote tracker host port', + }, + { + regex: /\bpub struct RemoteSessionTrackerRegistry\b/, + message: 'missing remote tracker registry owner', + }, + { + regex: /\bpub fn make_slim_tool_params\b/, + message: 'missing remote tool preview slimming helper', + }, + { + regex: /\bfn handle_agentic_event\b/, + message: 'missing tracker event reducer', + }, + { + regex: /\bpub fn resolve_remote_agent_type\b/, + message: 'missing remote agent type helper', + }, + { + regex: /\bpub struct RemoteImageContext\b/, + message: 'missing portable remote image context contract', + }, + { + regex: /\bpub trait RemoteImageContextAdapter\b/, + message: 'missing remote image context adapter contract', + }, + { + regex: /\bpub fn build_remote_image_contexts\b/, + message: 'missing legacy remote image context builder', + }, + { + regex: /\bpub fn resolve_remote_execution_image_contexts\b/, + message: 'missing remote image context preference helper', + }, + { + regex: /\bpub fn remote_session_restore_target\b/, + message: 'missing remote restore-target helper', + }, + { + regex: /\bpub enum RemoteCancelDecision\b/, + message: 'missing remote cancel decision contract', + }, + { + regex: /\bpub fn resolve_remote_cancel_decision\b/, + message: 'missing remote cancel decision resolver', + }, + { + regex: /\bpub struct RemoteCancelTaskRequest\b/, + message: 'missing remote cancel task request contract', + }, + { + regex: /\bpub trait RemoteCancelRuntimeHost\b/, + message: 'missing remote cancel runtime host port', + }, + { + regex: /\bpub async fn cancel_remote_task\b/, + message: 'missing remote cancel orchestration owner', + }, + { + regex: /\bpub trait RemoteDialogRuntimeHost\b/, + message: 'missing remote dialog runtime host port', + }, + { + regex: /\bpub async fn submit_remote_dialog\b/, + message: 'missing remote dialog orchestration owner', + }, + { + regex: /\bpub struct RemoteChatHistoryTurn\b/, + message: 'missing remote chat history turn DTO', + }, + { + regex: /\bpub struct RemoteChatHistoryRound\b/, + message: 'missing remote chat history round DTO', + }, + { + regex: /\bpub struct RemoteChatHistoryToolItem\b/, + message: 'missing remote chat history tool item DTO', + }, + { + regex: /\bpub fn build_remote_chat_messages\b/, + message: 'missing remote chat history assembly owner', + }, + { + regex: /\bpub const REMOTE_FILE_MAX_READ_BYTES\b/, + message: 'missing remote file max-read policy', + }, + { + regex: /\bpub const REMOTE_FILE_MAX_CHUNK_BYTES\b/, + message: 'missing remote file chunk policy', + }, + { + regex: /\bpub fn resolve_remote_file_chunk_range\b/, + message: 'missing remote file chunk range helper', + }, + { + regex: /\bpub fn remote_file_display_name\b/, + message: 'missing remote file display-name fallback', + }, + { + regex: /\bpub fn resolve_remote_workspace_path\b/, + message: 'missing remote workspace path resolver', + }, + { + regex: /\bpub fn detect_remote_mime_type\b/, + message: 'missing remote MIME detector', + }, + { + regex: /\bpub async fn read_remote_workspace_file\b/, + message: 'missing remote workspace full-file reader', + }, + { + regex: /\bpub async fn read_remote_workspace_file_chunk\b/, + message: 'missing remote workspace chunk reader', + }, + { + regex: /\bpub async fn read_remote_workspace_file_info\b/, + message: 'missing remote workspace file-info reader', + }, + { + regex: /\bRemoteWorkspaceFileRuntimeHost\b/, + message: 'missing remote workspace file runtime host contract', + }, + { + regex: /\bRemoteWorkspaceRuntimeHost\b/, + message: 'missing remote workspace runtime host contract', + }, + { + regex: /\bRemoteInitialSyncRuntimeHost\b/, + message: 'missing remote initial-sync runtime host contract', + }, + { + regex: /\bRemoteSessionRuntimeHost\b/, + message: 'missing remote session runtime host contract', + }, + { + regex: /\bRemotePollRuntimeHost\b/, + message: 'missing remote poll runtime host contract', + }, + { + regex: /\bRemoteInteractionRuntimeHost\b/, + message: 'missing remote interaction runtime host contract', + }, + { + regex: /\bpub async fn handle_remote_workspace_file_command\b/, + message: 'missing remote workspace file command owner handler', + }, + { + regex: /\bpub async fn handle_remote_workspace_command\b/, + message: 'missing remote workspace command owner handler', + }, + { + regex: /\bpub async fn generate_remote_initial_sync\b/, + message: 'missing remote initial-sync owner handler', + }, + { + regex: /\bpub async fn handle_remote_session_command\b/, + message: 'missing remote session command owner handler', + }, + { + regex: /\bpub async fn handle_remote_poll_command\b/, + message: 'missing remote poll command owner handler', + }, + { + regex: /\bpub async fn handle_remote_interaction_command\b/, + message: 'missing remote interaction command owner handler', + }, + { + regex: /\bpub trait RemoteCommandRuntimeHost\b/, + message: 'missing remote command runtime host contract', + }, + { + regex: /\bpub async fn handle_remote_command\b/, + message: 'missing remote command routing owner', + }, + { + regex: /\bpub fn remote_file_content_response\b/, + message: 'missing remote file content response assembly helper', + }, + { + regex: /\bpub fn remote_file_chunk_response\b/, + message: 'missing remote file chunk response assembly helper', + }, + { + regex: /\bpub fn remote_file_info_response\b/, + message: 'missing remote file-info response assembly helper', + }, + { + regex: /\bpub fn remote_dialog_submit_response\b/, + message: 'missing remote dialog response assembly helper', + }, + { + regex: /\bpub fn remote_task_cancel_response\b/, + message: 'missing remote task cancel response assembly helper', + }, + { + regex: /\bpub fn remote_interaction_accepted_response\b/, + message: 'missing remote interaction response assembly helper', + }, + { + regex: /\bpub fn remote_answer_question_response\b/, + message: 'missing remote answer response assembly helper', + }, + { + regex: /\bRemoteWorkspaceFacts\b/, + message: 'missing remote workspace response facts DTO', + }, + { + regex: /\bRemoteSessionMetadata\b/, + message: 'missing remote session response metadata DTO', + }, + { + regex: /\bpub fn remote_workspace_info_response\b/, + message: 'missing remote workspace-info response assembly helper', + }, + { + regex: /\bpub fn remote_recent_workspaces_response\b/, + message: 'missing remote recent-workspaces response assembly helper', + }, + { + regex: /\bpub fn remote_assistant_list_response\b/, + message: 'missing remote assistant-list response assembly helper', + }, + { + regex: /\bpub fn remote_session_info\b/, + message: 'missing remote session response facts helper', + }, + { + regex: /\bpub fn remote_session_list_response\b/, + message: 'missing remote session-list response assembly helper', + }, + { + regex: /\bpub fn remote_initial_sync_response\b/, + message: 'missing remote initial-sync response assembly helper', + }, + { + regex: /\bpub fn remote_messages_response\b/, + message: 'missing remote messages response assembly helper', + }, + { + regex: /\bpub struct RemoteDefaultModelsConfig\b/, + message: 'missing remote model default DTO', + }, + { + regex: /\bpub struct RemoteModelConfig\b/, + message: 'missing remote model DTO', + }, + { + regex: /\bpub struct RemoteModelCatalog\b/, + message: 'missing remote model catalog DTO', + }, + { + regex: /\bpub enum RemoteModelCapabilityFact\b/, + message: 'missing remote model capability owner fact', + }, + { + regex: /\bpub enum RemoteReasoningModeFact\b/, + message: 'missing remote reasoning mode owner fact', + }, + { + regex: /\bpub struct RemoteModelFacts\b/, + message: 'missing remote model owner facts', + }, + { + regex: /\bpub struct RemoteModelCatalogFacts\b/, + message: 'missing remote model catalog owner facts', + }, + { + regex: /\bpub fn build_remote_model_catalog\b/, + message: 'missing remote model catalog assembly owner', + }, + { + regex: /\bpub struct RemoteModelCatalogPollDelta\b/, + message: 'missing remote model catalog poll delta', + }, + { + regex: /\bpub fn normalize_remote_session_model_id\b/, + message: 'missing remote session model normalization policy', + }, + { + regex: /\bpub fn normalize_remote_model_selection\b/, + message: 'missing remote model selection policy', + }, + { + regex: /\bpub fn remote_model_selection_needs_config\b/, + message: 'missing remote model selection config-gate policy', + }, + { + regex: /\bpub enum RemoteCommand\b/, + message: 'missing remote command wire contract', + }, + { + regex: /\bpub enum RemoteDialogSchedulerOutcomeFact\b/, + message: 'missing remote dialog scheduler outcome fact', + }, + { + regex: /\bpub fn remote_dialog_submit_outcome_from_scheduler\b/, + message: 'missing remote dialog submit outcome assembly owner', + }, + { + regex: /\bpub enum RemoteResponse\b/, + message: 'missing remote response wire contract', + }, + { + regex: /\bpub fn should_send_remote_model_catalog\b/, + message: 'missing remote model catalog poll policy', + }, + { + regex: /\bpub fn remote_model_catalog_poll_delta\b/, + message: 'missing remote model catalog poll delta helper', + }, + { + regex: /\bpub fn remote_no_change_poll_response\b/, + message: 'missing remote no-change poll response helper', + }, + { + regex: /\bpub fn remote_snapshot_poll_response\b/, + message: 'missing remote snapshot poll response helper', + }, + { + regex: /\bpub fn remote_persisted_poll_response\b/, + message: 'missing remote persisted poll response helper', + }, + { + regex: /\bremote_workspace_handler_preserves_response_shapes\b/, + message: 'missing remote workspace command handler regression', + }, + { + regex: /\bremote_session_handler_preserves_list_and_create_policy\b/, + message: 'missing remote session command handler regression', + }, + { + regex: /\bremote_session_handler_removes_tracker_after_delete_success\b/, + message: 'missing remote session delete tracker cleanup regression', + }, + { + regex: /\bremote_poll_handler_preserves_missing_workspace_error\b/, + message: 'missing remote poll missing-workspace regression', + }, + { + regex: /\bremote_interaction_handler_preserves_default_reject_reason\b/, + message: 'missing remote interaction default reject regression', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/tests/remote_connect_contracts.rs', + reason: 'remote-connect owner crate must keep focused behavior contracts', + patterns: [ + { + regex: /\bremote_connect_pairing_primitives_live_in_services_owner\b/, + message: 'missing remote-connect pairing/encryption owner contract test', + }, + { + regex: /\bremote_connect_qr_and_relay_primitives_live_in_services_owner\b/, + message: 'missing remote-connect QR/relay owner contract test', + }, + { + regex: /\bremote_connect_command_wire_shape_lives_in_owner_contract\b/, + message: 'missing remote command wire contract test', + }, + { + regex: /\bremote_connect_response_wire_shape_lives_in_owner_contract\b/, + message: 'missing remote response wire contract test', + }, + { + regex: /\bremote_connect_model_catalog_delta_preserves_poll_invalidation_policy\b/, + message: 'missing remote model catalog delta contract test', + }, + { + regex: /\bremote_connect_model_catalog_builder_preserves_config_shape\b/, + message: 'missing remote model catalog builder contract test', + }, + { + regex: /\bremote_connect_model_selection_policy_owns_alias_and_config_reference_rules\b/, + message: 'missing remote model selection policy contract test', + }, + { + regex: /\bremote_connect_poll_helpers_preserve_delta_and_completion_policy\b/, + message: 'missing remote poll helper contract test', + }, + { + regex: /\bremote_connect_image_context_policy_preserves_legacy_fallback_shape\b/, + message: 'missing legacy image context fallback test', + }, + { + regex: /\bremote_connect_image_context_policy_prefers_explicit_contexts\b/, + message: 'missing explicit image context preference test', + }, + { + regex: /\bremote_connect_image_context_adapter_owns_portable_conversion_shape\b/, + message: 'missing image context adapter contract test', + }, + { + regex: /\bremote_connect_cancel_and_restore_policy_preserve_runtime_decisions\b/, + message: 'missing cancel/restore policy test', + }, + { + regex: /\bremote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order\b/, + message: 'missing dialog runtime order test', + }, + { + regex: /\bremote_connect_dialog_runtime_preserves_explicit_turn_without_restore\b/, + message: 'missing dialog explicit-turn test', + }, + { + regex: /\bremote_connect_dialog_submit_outcome_builder_preserves_scheduler_shape\b/, + message: 'missing remote dialog outcome builder contract test', + }, + { + regex: /\bremote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance\b/, + message: 'missing restore failure tolerance test', + }, + { + regex: /\bremote_chat_history_assembly_preserves_message_shape_and_item_order\b/, + message: 'missing remote chat history assembly shape/order test', + }, + { + regex: /\bremote_chat_history_assembly_skips_in_progress_assistant_history\b/, + message: 'missing remote chat history in-progress guard test', + }, + { + regex: /\bremote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges\b/, + message: 'missing remote file transfer policy test', + }, + { + regex: /\bremote_connect_file_transfer_policy_preserves_name_fallback\b/, + message: 'missing remote file display-name test', + }, + { + regex: /\bremote_connect_file_path_resolution_stays_within_workspace_root\b/, + message: 'missing remote file path resolution test', + }, + { + regex: /\bremote_connect_file_read_helpers_preserve_current_wire_inputs\b/, + message: 'missing remote full-read helper test', + }, + { + regex: /\bremote_connect_file_chunk_and_info_helpers_preserve_response_facts\b/, + message: 'missing remote chunk/info helper test', + }, + { + regex: /\bremote_connect_file_response_assembly_owns_base64_wire_shape\b/, + message: 'missing remote file response assembly contract test', + }, + { + regex: /\bremote_connect_file_command_handler_owns_owner_flow_and_uses_host_root\b/, + message: 'missing remote file command handler owner-flow test', + }, + { + regex: /\bremote_connect_execution_response_helpers_preserve_wire_shape\b/, + message: 'missing remote execution response helper contract test', + }, + { + regex: /\bremote_connect_command_owner_routes_send_message_and_prefers_explicit_images\b/, + message: 'missing remote command routing image/source regression', + }, + { + regex: /\bremote_connect_command_owner_preserves_cancel_and_group_routing\b/, + message: 'missing remote command routing group/cancel regression', + }, + { + regex: /\bremote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes\b/, + message: 'missing tracker completion contract test', + }, + { + regex: /\bremote_connect_tracker_registry_owns_lifecycle_without_core_state\b/, + message: 'missing tracker registry owner test', + }, + { + regex: /\bremote_connect_tracker_ignores_unrelated_direct_session_events\b/, + message: 'missing tracker unrelated-event guard test', + }, + { + regex: /\bremote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings\b/, + message: 'missing remote tool preview slimming test', + }, + { + regex: /\bremote_connect_cancel_runtime_restores_missing_session_before_cancel\b/, + message: 'missing remote cancel restore/order regression', + }, + { + regex: /\bremote_connect_cancel_runtime_preserves_stale_and_idle_errors_without_restore\b/, + message: 'missing remote cancel stale/idle regression', + }, + { + regex: /\bremote_connect_cancel_runtime_preserves_restore_failure_error\b/, + message: 'missing remote cancel restore failure regression', + }, + { + regex: /\bremote_connect_workspace_response_helpers_own_wire_shape\b/, + message: 'missing remote workspace response assembly regression', + }, + { + regex: /\bremote_connect_session_response_helpers_own_pagination_and_timestamps\b/, + message: 'missing remote session response assembly regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/mod.rs', + reason: + 'core remote-connect root keeps compatibility re-exports while services-integrations owns device, pairing, encryption, QR, and relay primitives', + patterns: [ + { + regex: /\bpub mod device\s*\{[\s\S]*bitfun_services_integrations::remote_connect::device::\*/m, + message: 'missing device compatibility re-export module', + }, + { + regex: /\bpub mod encryption\s*\{[\s\S]*bitfun_services_integrations::remote_connect::encryption::\*/m, + message: 'missing encryption compatibility re-export module', + }, + { + regex: /\bpub mod pairing\s*\{[\s\S]*bitfun_services_integrations::remote_connect::pairing::\*/m, + message: 'missing pairing compatibility re-export module', + }, + { + regex: /\bpub mod qr_generator\s*\{[\s\S]*bitfun_services_integrations::remote_connect::qr_generator::\*/m, + message: 'missing QR compatibility re-export module', + }, + { + regex: /\bpub mod relay_client\s*\{[\s\S]*bitfun_services_integrations::remote_connect::relay_client::\*/m, + message: 'missing relay client compatibility re-export module', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/remote_server.rs', + reason: + 'core remote-connect server must remain a product runtime adapter around integrations-owned contracts', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner routing', + }, + { + regex: /\bsubmit_remote_dialog\b/, + message: 'missing remote dialog owner orchestration delegation', + }, + { + regex: /\bcancel_remote_task\b/, + message: 'missing remote cancel owner orchestration delegation', + }, + { + regex: /\bhandle_remote_workspace_file_command\b/, + message: 'missing remote file command owner delegation', + }, + { + regex: /\bRemoteCommandRuntimeHost\b/, + message: 'missing remote command runtime host adapter', + }, + { + regex: /\bhandle_remote_command\b/, + message: 'missing remote command routing owner delegation', + }, + { + regex: /\bhandle_remote_interaction_command\b/, + message: 'missing remote interaction command owner orchestration delegation', + }, + { + regex: /\bgenerate_remote_initial_sync\b/, + message: 'missing remote initial-sync owner orchestration delegation', + }, + { + regex: /\bhandle_remote_workspace_command\b/, + message: 'missing remote workspace command owner orchestration delegation', + }, + { + regex: /\bhandle_remote_session_command\b/, + message: 'missing remote session command owner orchestration delegation', + }, + { + regex: /\bhandle_remote_poll_command\b/, + message: 'missing remote poll command owner orchestration delegation', + }, + { + regex: /\bhandle_remote_interaction_command\b/, + message: 'missing remote interaction command owner orchestration delegation', + }, + { + regex: /\bremote_image_context\b/, + message: 'missing image context adapter contract delegation', + }, + { + regex: /\bcore_service_agent_runtime_owner_maps_remote_image_context\b/, + message: 'missing core service/agent image-context owner regression', + }, + { + regex: /\bremote_execution_prefers_unified_image_contexts_over_legacy_images\b/, + message: 'missing unified image context preference regression', + }, + { + regex: /\bremote_execution_falls_back_to_legacy_images_as_image_contexts\b/, + message: 'missing legacy image context fallback regression', + }, + { + regex: /\bremote_cancel_decision_preserves_current_turn_boundaries\b/, + message: 'missing remote cancel boundary regression', + }, + { + regex: /\bremote_restore_target_only_restores_cold_sessions_with_workspace_binding\b/, + message: 'missing remote restore target regression', + }, + { + regex: /\bremote_command_snapshot_covers_execution_poll_and_cancel_surfaces\b/, + message: 'missing remote command snapshot regression', + }, + { + regex: /\bremote_response_snapshot_preserves_active_turn_and_result_shapes\b/, + message: 'missing remote response snapshot regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs', + reason: + 'remote-connect bot must route concrete agent runtime port bindings through the core service/agent runtime owner', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner routing', + }, + { + regex: /\bagent_runtime\b/, + message: 'missing agent runtime owner binding', + }, + { + regex: /\bbuild_remote_session_create_request\b/, + message: 'missing integrations-owned remote session create request builder', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler keeps remote queue policy semantics until agent-runtime migration is reviewed', + patterns: [ + { + regex: /\bremote_queue_policy_preserves_confirmation_boundary\b/, + message: 'missing remote queue policy regression', + }, + { + regex: /\bimpl AgentDialogTurnPort for DialogScheduler\b/, + message: 'missing dialog lifecycle port implementation', + }, + { + regex: /\bimpl AgentLifecycleDeliveryPort for DialogScheduler\b/, + message: 'missing lifecycle delivery port implementation', + }, + { + regex: /\bimpl AgentTurnCancellationPort for DialogScheduler\b/, + message: 'missing requester-aware cancellation port implementation', + }, + { + regex: /\bAgentBackgroundResultRequest\b/, + message: 'missing background result lifecycle request adapter', + }, + { + regex: /\bAgentThreadGoalDeliveryRequest\b/, + message: 'missing thread-goal lifecycle request adapter', + }, + { + regex: /\bAgentThreadGoalDeliveryKind::ObjectiveUpdated\b/, + message: 'missing thread-goal objective-updated lifecycle adapter', + }, + { + regex: /\bcancel_active_turn_for_session_from_requester\b/, + message: 'missing requester-aware cancellation adapter', + }, + { + regex: /\bagent_dialog_turn_image_contexts\b/, + message: 'missing dialog lifecycle image attachment adapter', + }, + { + regex: /\bagent_dialog_turn_prepended_messages\b/, + message: 'missing dialog lifecycle prepended reminder adapter', + }, + { + regex: /\bagent_dialog_turn_attachments_preserve_remote_image_context\b/, + message: 'missing dialog lifecycle image attachment preservation regression', + }, + { + regex: /\bagent_dialog_turn_attachments_reject_unknown_kind\b/, + message: 'missing dialog lifecycle attachment validation regression', + }, + { + regex: /\bagent_dialog_turn_prepended_reminders_preserve_session_message_kind\b/, + message: 'missing dialog lifecycle prepended reminder preservation regression', + }, + { + regex: /\bagent_dialog_turn_prepended_reminders_reject_unknown_kind\b/, + message: 'missing dialog lifecycle prepended reminder validation regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/registry.rs', + reason: + 'core registry must stay a compatibility container that delegates product tool runtime assembly through the core owner module', + patterns: [ + { + regex: /\bfrom_inner\b/, + message: 'missing generic agent-tools registry adapter hook', + }, + { + regex: /\bProductToolDecoratorRef\b/, + message: 'missing product decorator ref alias using agent-tools contract', + }, + { + regex: /\bProductToolRuntime\b/, + message: 'missing product tool runtime owner delegation', + }, + { + regex: /\bget_collapsed_tool_names\b/, + message: 'missing collapsed-tool catalog owner', + }, + { + regex: /\bresolve_product_readonly_enabled_tools\b/, + message: 'missing product tool catalog readonly facade delegation', + }, + { + regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, + message: 'missing collapsed-tool manifest migration baseline', + }, + { + regex: /\binner\.is_tool_collapsed\b/, + message: 'missing collapsed exposure lookup delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime.rs', + reason: + 'core product tool runtime owner delegates generic registry assembly and only wires product plan, decorator, and compatibility facade', + patterns: [ + { + regex: /\bProductToolRuntime\b/, + message: 'missing core product tool runtime owner', + }, + { + regex: /\bSnapshotToolDecorator\b/, + message: 'missing generic snapshot decorator injection', + }, + { + regex: /\bcreate_product_tool_registry_from_plan\b/, + message: 'missing product registry assembly adapter delegation', + }, + { + regex: /\bproduct_assembly_plan_for_profile\b/, + message: 'missing product assembly plan provider group plan delegation', + }, + { + regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, + message: 'missing product runtime owner registry equivalence regression', + }, + { + regex: /\bproduct_tool_runtime_registry_preserves_provider_plan_order\b/, + message: 'missing product tool provider plan-to-registry order regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs', + reason: + 'product runtime materialization must keep only concrete tool construction and product plan adapter while delegating generic registry assembly to agent-tools', + patterns: [ + { + regex: /\bProductConcreteToolFactory\b/, + message: 'missing product concrete tool factory adapter', + }, + { + regex: /\bimpl StaticToolProviderFactory for ProductConcreteToolFactory\b/, + message: 'missing concrete tool factory implementation', + }, + { + regex: /\bProductToolProviderPlanAdapter\b/, + message: 'missing product provider plan adapter', + }, + { + regex: /\bimpl StaticToolProviderPlan for ProductToolProviderPlanAdapter\b/, + message: 'missing product provider plan adapter contract', + }, + { + regex: /\bcreate_registry_from_static_provider_plans\b/, + message: 'missing generic agent-tools plan-to-registry delegation', + }, + { + regex: /\bcreate_product_tool_registry_from_plan\b/, + message: 'missing product registry creation adapter', + }, + { + regex: /\bmaterialize_tool\b/, + message: 'missing concrete tool materialization boundary', + }, + { + regex: /\bGetToolSpecTool::new\(\)/, + message: 'missing GetToolSpec registration anchor', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/snapshot.rs', + reason: + 'product runtime snapshot wrapper must stay isolated from registry and catalog ownership', + patterns: [ + { + regex: /\bProductSnapshotToolWrapper\b/, + message: 'missing core product snapshot wrapper adapter', + }, + { + regex: /\bimpl SnapshotToolWrapper for ProductSnapshotToolWrapper\b/, + message: 'missing generic snapshot wrapper implementation', + }, + { + regex: /\bwrap_tool_for_snapshot_tracking\b/, + message: 'missing snapshot wrapper boundary', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs', + reason: + 'product runtime catalog owner keeps manifest, snapshot, readonly, and GetToolSpec product facades explicit', + patterns: [ + { + regex: /\bProductToolCatalogProvider\b/, + message: 'missing core product tool catalog provider owner', + }, + { + regex: /\bimpl ToolCatalogSnapshotProvider for ProductToolCatalogProvider\b/, + message: 'missing core tool catalog snapshot provider implementation', + }, + { + regex: /\bimpl GetToolSpecCatalogProvider for ProductToolCatalogProvider\b/, + message: 'missing core product GetToolSpec catalog provider implementation', + }, + { + regex: /\bget_global_tool_registry\b/, + message: 'missing core product registry snapshot access', + }, + { + regex: /\bget_agent_registry\b/, + message: 'missing core agent policy source for contextual catalog', + }, + { + regex: /\bToolCatalogRuntime\b/, + message: 'missing agent-tools product catalog runtime facade delegation', + }, + { + regex: /\bproduct_tool_catalog_runtime\b/, + message: 'missing product catalog runtime factory', + }, + { + regex: /\bGetToolSpecRuntime\b/, + message: 'missing agent-tools GetToolSpec runtime facade delegation', + }, + { + regex: /\bproduct_get_tool_spec_runtime\b/, + message: 'missing product GetToolSpec runtime factory', + }, + { + regex: /\bresolve_product_tool_manifest\b/, + message: 'missing product manifest facade', + }, + { + regex: /\bresolve_product_resolved_tool_manifest\b/, + message: 'missing product resolved manifest compatibility facade', + }, + { + regex: /\bresolve_product_resolved_visible_tools\b/, + message: 'missing product resolved visible-tools compatibility facade', + }, + { + regex: /\bresolve_product_readonly_enabled_tools\b/, + message: 'missing product readonly enabled tools facade', + }, + { + regex: /\bresolve_product_get_tool_spec_results\b/, + message: 'missing product GetToolSpec Tool-result vector facade', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing product runtime collapsed-tool unlock state source', + }, + { + regex: /\bproduct_catalog_provider_default_get_tool_spec_catalog_matches_registry\b/, + message: 'missing product catalog provider collapsed catalog regression', + }, + { + regex: /\bproduct_resolved_manifest_owner_matches_legacy_shape\b/, + message: 'missing product resolved manifest compatibility regression', + }, + { + regex: /\bGetToolSpec requires agent type context\b/, + message: 'missing contextual GetToolSpec catalog validation boundary', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_adapter.rs', + reason: + 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', + patterns: [ + { + regex: /\bimpl ToolRegistryItem for dyn Tool\b/, + message: 'missing core Tool registry adapter', + }, + { + regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, + message: 'missing core ToolUseContext contextual manifest adapter', + }, + { + regex: /\bTool::dynamic_tool_info\b/, + message: 'missing dynamic tool metadata adapter', + }, + { + regex: /\bTool::is_readonly\b/, + message: 'missing readonly metadata adapter', + }, + { + regex: /\bTool::is_enabled\b/, + message: 'missing enabled-state metadata adapter', + }, + { + regex: /\bTool::description_with_context\b/, + message: 'missing context-aware tool description adapter', + }, + { + regex: /\bTool::input_schema_for_model_with_context\b/, + message: 'missing context-aware tool schema adapter', + }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/framework.rs', + reason: 'agent-tools owns portable tool facts plus generic registry and provider contracts', + patterns: [ + { + regex: /\bpub struct ToolContextFacts\b/, + message: 'missing portable tool context facts contract', + }, + { + regex: /\bpub trait PortableToolContextProvider\b/, + message: 'missing portable tool context provider contract', + }, + { + regex: /\bpub enum ToolWorkspaceKind\b/, + message: 'missing portable workspace kind contract', + }, + { + regex: /\bpub trait StaticToolProvider\b/, + message: 'missing static tool provider contract', + }, + { + regex: /\bpub struct ToolRuntimeAssembly\b/, + message: 'missing generic runtime assembly contract', + }, + { + regex: /\bpub type ToolDecoratorRef\b/, + message: 'missing generic decorator ref contract', + }, + { + regex: /\bpub trait SnapshotToolWrapper\b/, + message: 'missing generic snapshot wrapper contract', + }, + { + regex: /\bpub struct SnapshotToolDecorator\b/, + message: 'missing generic snapshot decorator contract', + }, + { + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static-provider assembly helper', + }, + { + regex: /\bcreate_registry_from_static_provider_plans\b/, + message: 'missing generic static-provider plan-to-registry helper', + }, + { + regex: /\bpub fn install_static_provider\b/, + message: 'missing static provider registry installer', + }, + { + regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, + message: 'missing provider-neutral GetToolSpec duplicate-load result helper', + }, + { + regex: /\bpub fn build_get_tool_spec_detail_result\b/, + message: 'missing provider-neutral GetToolSpec detail result helper', + }, + { + regex: /\bpub fn resolve_get_tool_spec_execution_plan\b/, + message: 'missing provider-neutral GetToolSpec execution plan helper', + }, + { + regex: /\bpub async fn resolve_get_tool_spec_execution_result_from_provider\b/, + message: 'missing provider-backed GetToolSpec execution result helper', + }, + { + regex: /\bpub struct GetToolSpecRuntime\b/, + message: 'missing provider-backed GetToolSpec runtime facade', + }, + ], + }, + { + path: 'src/crates/execution/tool-provider-groups/src/lib.rs', + reason: + 'tool-packs must keep its feature-group scaffold explicit without owning concrete tools yet', + patterns: [ + { + regex: /\bpub enum ToolPackFeatureGroup\b/, + message: 'missing tool-pack feature group scaffold', + }, + { + regex: /\bpub fn all_feature_groups\b/, + message: 'missing tool-pack full feature group metadata helper', + }, + { + regex: /\bpub fn enabled_feature_groups\b/, + message: 'missing tool-pack compile-time feature metadata helper', + }, + { + regex: /\bpub struct ToolProviderGroupPlan\b/, + message: 'missing tool-pack provider group plan contract', + }, + { + regex: /\bpub fn product_tool_provider_group_plan\b/, + message: 'missing product tool provider group plan', + }, + { + regex: /\bpub enum ToolProviderGroupPlanSelectionError\b/, + message: 'missing tool provider group plan selection error', + }, + { + regex: /\bpub fn try_product_tool_provider_group_plan_for_ids\b/, + message: 'missing product tool provider group plan selector', + }, + { + regex: /\bproduct_provider_group_plan_selector_rejects_unknown_provider_ids\b/, + message: 'missing provider group selector unknown-id regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs', + reason: + 'core must continue owning manifest resolver wrappers while delegating product catalog access and generic manifest assembly', + patterns: [ + { + regex: /\bpub async fn resolve_tool_manifest\b/, + message: 'missing tool manifest resolver owner', + }, + { + regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, + message: 'missing GetToolSpec manifest insertion anchor', + }, + { + regex: /\bresolve_product_resolved_visible_tools\b/, + message: 'missing core product visible-tools facade delegation', + }, + { + regex: /\bresolve_product_resolved_tool_manifest\b/, + message: 'missing core product manifest facade delegation', + }, + { + regex: /\bcollapsed_tool_names\b/, + message: 'missing collapsed-tool name tracking', + }, + { + regex: /\bmanifest_resolver_facade_preserves_product_owner_output\b/, + message: 'missing manifest resolver facade parity regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs', + reason: + 'product runtime must own the GetToolSpec Tool adapter while delegating generic runtime surface to agent-tools', + patterns: [ + { + regex: /\bpub(?:\(crate\))? struct GetToolSpecTool\b/, + message: 'missing GetToolSpec owner type', + }, + { + regex: /\bresolve_product_get_tool_spec_results\b/, + message: 'missing product GetToolSpec Tool-result vector facade delegation', + }, + { + regex: /\bmap_get_tool_spec_execution_error\b/, + message: 'missing core GetToolSpec execution error mapping boundary', + }, + { + regex: /\bbuild_collapsed_tools_context_section\b/, + message: 'missing core collapsed-tool request-context section renderer', + }, + { + regex: /\bproduct_get_tool_spec_runtime\b/, + message: 'missing product GetToolSpec runtime facade delegation', + }, + { + regex: /\bwith_runtime\b/, + message: 'missing core GetToolSpec static surface facade boundary', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/framework.rs', + reason: + 'core tool framework must keep compatibility re-exports while ToolUseContext is owned by tool_context_runtime', + patterns: [ + { + regex: /\bToolExposure\b/, + message: 'missing ToolExposure compatibility re-export', + }, + { + regex: /\bpub use crate::agentic::tools::tool_context_runtime::ToolUseContext\b/, + message: 'missing ToolUseContext compatibility re-export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs', + reason: + 'core must keep ToolUseContext runtime/service bindings centralized while ToolUseContext and concrete tools remain core-owned', + patterns: [ + { + regex: /\bpub struct ToolUseContext\b/, + message: 'missing ToolUseContext owner type', + }, + { + regex: /\bto_tool_context_facts\b/, + message: 'missing portable ToolUseContext facts projection', + }, + { + regex: /\bimpl PortableToolContextProvider for ToolUseContext\b/, + message: 'missing portable ToolUseContext facts provider impl', + }, + { + regex: /\btool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated\b/, + message: 'missing portable facts runtime-owner leak guard', + }, + { + regex: /customData/, + message: 'missing custom data runtime-only facts guard', + }, + { + regex: /cancellationToken/, + message: 'missing cancellation token runtime-only facts guard', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock state', + }, + { + regex: /\bimpl ToolUseContext\b/, + message: 'missing ToolUseContext runtime binding owner impl', + }, + { + regex: /\brecord_light_checkpoint\b/, + message: 'missing Deep Review checkpoint binding', + }, + { + regex: /\bbuild_runtime_light_checkpoint\b/, + message: 'missing agent-runtime light checkpoint policy delegation', + }, + { + regex: /\bLightCheckpointWorkspaceFacts::LocalWorkspace\b/, + message: 'missing local checkpoint facts delegation', + }, + { + regex: /\bcall_with_tool_runtime_hooks\b/, + message: 'missing tool-call cancellation/post-call hook binding', + }, + { + regex: /\bcall_tool_with_runtime_hooks\b/, + message: 'missing unified Tool::call runtime hook facade', + }, + { + regex: /\bcall_records_deep_review_read_file_measurement_without_touching_result\b/, + message: 'missing Deep Review post-call hook regression in runtime owner', + }, + { + regex: /\bbuild_tool_use_context_for_task\b/, + message: 'missing tool pipeline context materialization binding', + }, + { + regex: /\bbuild_tool_description_context\b/, + message: 'missing tool manifest description context materialization binding', + }, + { + regex: /\bensure_current_workspace_runtime\b/, + message: 'missing workspace runtime ensure binding', + }, + { + regex: /\bresolve_tool_path\b/, + message: 'missing tool path resolution binding', + }, + { + regex: /\benforce_path_operation\b/, + message: 'missing runtime path policy binding', + }, + { + regex: /\bis_tool_path_allowed_by_resolved_roots\b/, + message: 'missing path policy owner delegation to agent-tools', + }, + { + regex: /\bbuild_tool_path_policy_denial_message\b/, + message: 'missing shared path policy denial contract', + }, + { + regex: /\bresolve_tool_path_with_context\b/, + message: 'missing shared tool path resolution owner delegation', + }, + { + regex: /\btool_path_is_effectively_absolute\b/, + message: 'missing shared tool path absolute owner delegation', + }, + { + regex: /\bbuild_tool_runtime_artifact_reference\b/, + message: 'missing runtime artifact reference owner delegation', + }, + { + regex: /\bbuild_tool_session_runtime_artifact_reference\b/, + message: 'missing session runtime artifact reference owner delegation', + }, + { + regex: /\bworkspace_path_resolution_rejects_absolute_paths_outside_remote_workspace\b/, + message: 'missing remote workspace containment regression', + }, + { + regex: /\bruntime_uri_resolution_rejects_different_workspace_scope\b/, + message: 'missing runtime artifact scope regression', + }, + { + regex: /\bpath_policy_allows_only_configured_local_roots\b/, + message: 'missing path policy enforcement regression', + }, + { + regex: /\btool_call_runtime_hook_returns_cancelled_before_impl_completes\b/, + message: 'missing tool-call cancellation regression', + }, + { + regex: /\btool_task_context_materialization_preserves_runtime_fields\b/, + message: 'missing tool task context materialization regression', + }, + { + regex: /\btool_description_context_preserves_manifest_custom_data_shape\b/, + message: 'missing tool description context regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + reason: + 'core must continue carrying collapsed-tool unlock state while delegating provider-neutral execution gate policy to agent-tools', + patterns: [ + { + regex: /\bvalidate_tool_execution_admission\b/, + message: 'missing provider-neutral tool execution admission gate delegation', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock state propagation', + }, + { + regex: /\bpipeline_preserves_core_owned_tool_context_without_portable_runtime_leak\b/, + message: 'missing ToolUseContext runtime boundary regression', + }, + { + regex: /\bGetToolSpec\b/, + message: 'missing GetToolSpec gating contract', + }, + { + regex: /\brender_tool_result_for_assistant\b/, + message: 'missing tool result presentation owner delegation', + }, + { + regex: /\bbuild_tool_execution_error_presentation\b/, + message: 'missing tool execution error presentation owner delegation', + }, + { + regex: /\bbuild_user_steering_interrupted_presentation\b/, + message: 'missing steering-interrupted presentation owner delegation', + }, + { + regex: /\bbuild_invalid_tool_call_error_message\b/, + message: 'missing invalid tool call presentation owner delegation', + }, + { + regex: /\bbuild_tool_call_truncation_recovery_notice\b/, + message: 'missing truncation recovery notice owner delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/execution_engine.rs', + reason: + 'core execution must pass collapsed-tool unlock state through product runtime owner and keep DeepResearch post-turn hooks', + patterns: [ + { + regex: /\bcollect_product_unlocked_collapsed_tools\b/, + message: 'missing product runtime collapsed-tool unlock state handoff', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock propagation into round context', + }, + { + regex: /\bcollapsed_tool_names\b/, + message: 'missing manifest collapsed-tool handoff', + }, + { + regex: /\bGetToolSpec\b/, + message: 'missing GetToolSpec execution contract', + }, + { + regex: /\bshould_post_process_research_report\b/, + message: 'missing DeepResearch post-process runtime gate', + }, + { + regex: /\bbitfun_services_integrations::deep_research::run_for_session_workspace\b/, + message: 'missing DeepResearch report IO owner delegation', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs', + reason: + 'product runtime owns collapsed-tool unlock observation adaptation while preserving generic agent-tools policy', + patterns: [ + { + regex: /\bcollect_product_unlocked_collapsed_tools\b/, + message: 'missing product runtime collapsed-tool unlock collector', + }, + { + regex: /\bGetToolSpecLoadObservation\b/, + message: 'missing GetToolSpec load observation adapter', + }, + { + regex: /\bcollect_loaded_collapsed_tool_names\b/, + message: 'missing generic collapsed-tool load collector delegation', + }, + { + regex: /\bproduct_unlock_state_dedupes_and_filters_runtime_unlocks\b/, + message: 'missing collapsed-tool unlock filtering regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/availability.rs', + reason: + 'core agent registry must adapt config and AgentEntry facts while bitfun-agent-runtime owns mode-scoped subagent availability decisions', + patterns: [ + { + regex: /\bpub fn resolve_availability\b/, + message: 'missing core compatibility availability adapter', + }, + { + regex: /\bpub fn resolve_override_layers\b/, + message: 'missing project/user override layering adapter', + }, + { + regex: /\bresolve_subagent_availability\b/, + message: 'missing agent-runtime availability decision delegation', + }, + { + regex: /\bto_runtime_override_state\b/, + message: 'missing config override to runtime override adapter', + }, + { + regex: /\bAgentSubagentOverrideState\b/, + message: 'missing config override state adapter source', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/types.rs', + reason: + 'core agent registry must preserve legacy DTO fields while bitfun-agent-runtime owns query scope and availability reason contracts', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::agents::\{[\s\S]*SubAgentSource[\s\S]*SubagentListScope[\s\S]*SubagentOverrideState[\s\S]*SubagentQueryContext[\s\S]*SubagentStateReason[\s\S]*\};/, + message: 'missing agent-runtime subagent registry contract re-export', + }, + { + regex: /\bpub struct AgentInfo\b/, + message: 'missing core AgentInfo facade DTO', + }, + { + regex: /\bdefault_enabled\b/, + message: 'missing default availability field', + }, + { + regex: /\beffective_enabled\b/, + message: 'missing effective availability field', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs', + reason: + 'core agent mode definitions must continue exposing Multitask mode until an approved agent-runtime migration preserves mode registration semantics', + patterns: [ + { + regex: /\bmod multitask\b/, + message: 'missing Multitask mode module', + }, + { + regex: /\bpub use multitask::MultitaskMode\b/, + message: 'missing Multitask mode export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs', + reason: + 'core subagent definitions must continue exposing the built-in GeneralPurpose subagent until registry ownership migration has equivalence coverage', + patterns: [ + { + regex: /\bmod general_purpose\b/, + message: 'missing GeneralPurpose subagent module', + }, + { + regex: /\bpub use general_purpose::GeneralPurposeAgent\b/, + message: 'missing GeneralPurpose subagent export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/builtin.rs', + reason: + 'core builtin registry must delegate builtin default model facts to agent-runtime while preserving latest-main compatibility', + patterns: [ + { + regex: /\bbuiltin_agent_specs\(\)/, + message: 'missing builtin agent spec registration source', + }, + { + regex: /\bruntime_agents::default_model_id_for_builtin_agent\(agent_type\)/, + message: 'missing default model delegation to agent-runtime', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/agents.rs', + reason: + 'agent-runtime builtin agent catalog must own latest-main mode/subagent categories, visibility, and default model facts', + patterns: [ + { + regex: /\bbuiltin_agent_definition_specs\(\)/, + message: 'missing builtin agent definition catalog owner', + }, + { + regex: /builtin_agent_spec\(\s*"Multitask",\s*Mode,\s*"auto"/, + message: 'missing Multitask runtime default model mapping', + }, + { + regex: /builtin_agent_spec\(\s*"GeneralPurpose",\s*SubAgent,\s*"fast"/, + message: 'missing GeneralPurpose runtime default model mapping', + }, + { + regex: /SubagentVisibilityPolicy::restricted\(\["Claw",\s*"Team"\]\)/, + message: 'missing ComputerUse restricted visibility mapping', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs', + reason: + 'core Task tool must continue owning fork-aware background subagent launch semantics until a reviewed agent-runtime port preserves delivery behavior', + patterns: [ + { + regex: /\bfork_context\b/, + message: 'missing Task fork_context schema and validation surface', + }, + { + regex: /\bSubagentContextMode::Fork\b/, + message: 'missing forked subagent context mode path', + }, + { + regex: /delegation_policy\(\)\.spawn_child\(\)/, + message: 'missing child delegation policy propagation', + }, + { + regex: /"run_in_background"/, + message: 'missing Task run_in_background schema flag', + }, + { + regex: /\bstart_background_subagent\b/, + message: 'missing background subagent launch path', + }, + { + regex: /\bbackground_task_id\b/, + message: 'missing background task id result contract', + }, + { + regex: /Background \{\} started successfully/, + message: 'missing assistant-visible background start acknowledgement', + }, + { + regex: / [dep.name, dep])); + if (parsedByName.get('reqwest')?.optional !== true) { + throw new Error('dependency profile parser must detect inline optional dependencies'); + } + if (parsedByName.get('dirs')?.optional !== false) { + throw new Error('dependency profile parser must detect non-optional inline dependencies'); + } + if (parsedByName.get('rmcp')?.optional !== true) { + throw new Error('dependency profile parser must detect multiline optional inline dependencies'); + } + if (parsedByName.get('git2')?.optional !== true) { + throw new Error('dependency profile parser must detect optional dependency tables'); + } + if (parsedByName.get('bitfun-cli')?.optional !== false) { + throw new Error('dependency profile parser must detect non-optional target dependency tables'); + } + const parsedCoreDep = parsedByName.get('bitfun-core'); + if (!manifestDependencyDisablesDefaultFeatures(parsedCoreDep)) { + throw new Error('dependency profile parser must detect default-features = false'); + } + if (!parseManifestDependencyFeatureNames(parsedCoreDep).has('product-full')) { + throw new Error('dependency profile parser must detect inline dependency features'); + } + const parsedCoreTableDeps = parseManifestDependencies([ + '[dependencies."bitfun-core"]', + 'path = "../core"', + 'default-features = false', + 'features = [', + ' "product-full",', + ' "ssh-remote",', + ']', + ]); + const parsedCoreTableDep = parsedCoreTableDeps.find((dep) => dep.name === 'bitfun-core'); + if (!manifestDependencyDisablesDefaultFeatures(parsedCoreTableDep)) { + throw new Error('dependency profile parser must detect table default-features = false'); + } + if (!parseManifestDependencyFeatureNames(parsedCoreTableDep).has('ssh-remote')) { + throw new Error('dependency profile parser must detect table dependency features'); + } + if (parsedByName.has('image')) { + throw new Error('dependency profile parser must ignore feature entries named like dependencies'); + } + + const productCoreRulePaths = new Set( + productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath), + ); + for (const manifestPath of [ + 'src/apps/desktop/Cargo.toml', + 'src/apps/cli/Cargo.toml', + 'src/crates/interfaces/acp/Cargo.toml', + ]) { + if (!productCoreRulePaths.has(manifestPath)) { + throw new Error(`product core feature assembly rule must cover ${manifestPath}`); + } + } + for (const rule of productCoreFeatureAssemblyRules) { + if (!rule.requiredFeatures.includes('product-full')) { + throw new Error(`${rule.manifestPath} must require bitfun-core product-full`); + } + } + for (const featureName of [ + 'ssh-remote', + 'product-capabilities', + 'product-domains', + 'service-integrations', + 'tool-packs', + ]) { + if (!coreProductFullFeatureAssemblyRule.requiredFeatureRefs.includes(featureName)) { + throw new Error(`core product-full assembly rule must require ${featureName}`); + } + } + const discoveredProductCoreManifests = collectProductCoreDependencyManifestPaths([ + { + manifestPath: 'src/apps/desktop/Cargo.toml', + text: + '[dependencies]\nbitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] }', + }, + { + manifestPath: 'src/apps/server/Cargo.toml', + text: '[dependencies]\naxum = { workspace = true }', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + text: '[dependencies."bitfun-core"]\npath = "../../assembly/core"\ndefault-features = false\nfeatures = ["product-full"]', + }, + ]); + if (discoveredProductCoreManifests.join(',') !== 'src/apps/desktop/Cargo.toml,src/crates/interfaces/acp/Cargo.toml') { + throw new Error('product core dependency scanner must discover only manifests that depend on bitfun-core'); + } + const ownerFeatureRulePaths = new Set( + ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), + ); + for (const manifestPath of [ + 'src/crates/execution/tool-provider-groups/Cargo.toml', + 'src/crates/services/services-integrations/Cargo.toml', + 'src/crates/contracts/product-domains/Cargo.toml', + ]) { + if (!ownerFeatureRulePaths.has(manifestPath)) { + throw new Error(`owner crate feature assembly rule must cover ${manifestPath}`); + } + } + for (const rule of ownerCrateFeatureAssemblyRules) { + const declaredFeatures = new Set(rule.requiredProductFullFeatures); + if (declaredFeatures.size !== rule.requiredProductFullFeatures.length) { + throw new Error(`${rule.manifestPath} product-full guard must not duplicate feature groups`); + } + if (rule.requiredProductFullFeatures.some((featureName) => featureName.startsWith('dep:'))) { + throw new Error(`${rule.manifestPath} product-full guard must track owner feature groups only`); + } + } + + const parsedFeatures = parseManifestFeatures([ + '[package]', + 'name = "example"', + '[features]', + 'default = ["product-full"]', + 'product-full = [', + ' "dep:tool-runtime",', + ' "service-integrations",', + ']', + 'service-integrations = ["dep:git2", "dep:rmcp"]', + 'ssh-remote = [', + ' "bitfun-services-integrations/remote-ssh-concrete",', + ' "russh",', + ']', + '[dependencies]', + 'git2 = { workspace = true, optional = true }', + ]); + if (!parsedFeatures.get('default')?.refs.includes('product-full')) { + throw new Error('feature parser must detect inline feature references'); + } + if (!parsedFeatures.get('product-full')?.refs.includes('dep:tool-runtime')) { + throw new Error('feature parser must detect multiline dependency feature references'); + } + if (!parsedFeatures.get('service-integrations')?.refs.includes('dep:rmcp')) { + throw new Error('feature parser must detect inline dependency feature references'); + } + if (!parsedFeatures.get('ssh-remote')?.refs.includes('russh')) { + throw new Error('feature parser must detect implicit optional dependency feature references'); + } + + const acceptsGitFacadeLine = createFacadeLineChecker('bitfun_services_integrations::git'); + const facadePositiveCases = [ + '', + '//! Compatibility facade.', + 'pub use bitfun_services_integrations::git::GitService;', + 'pub use bitfun_services_integrations::git::types::*;', + 'pub use bitfun_services_integrations::git::{', + ' build_git_graph, build_git_graph_for_branch,', + '};', + 'pub use bitfun_services_integrations::git::{build_git_graph, build_git_graph_for_branch};', + ]; + for (const line of facadePositiveCases) { + if (!acceptsGitFacadeLine(line)) { + throw new Error(`facade parser rejected allowed line: ${line}`); + } + } + + const rejectsGitImplementationLine = createFacadeLineChecker('bitfun_services_integrations::git'); + const facadeNegativeCases = [ + 'pub mod service;', + 'use bitfun_services_integrations::git::GitService;', + 'fn parse_git_status() {}', + ]; + for (const line of facadeNegativeCases) { + if (rejectsGitImplementationLine(line)) { + throw new Error(`facade parser accepted implementation line: ${line}`); + } + } + + const cliBoundaryDeps = ['bitfun-cli', 'ratatui', 'crossterm', 'arboard', 'syntect-tui']; + for (const rule of lightweightBoundaryRules) { + for (const dep of cliBoundaryDeps) { + if (!rule.forbiddenDeps.includes(dep)) { + throw new Error( + `lightweight boundary rule for ${rule.crateName} must forbid CLI-only dependency: ${dep}`, + ); + } + } + } + + const agentToolsRule = lightweightBoundaryRules.find((rule) => rule.crateName === 'agent-tools'); + if (!agentToolsRule?.forbiddenDeps.includes('bitfun-ai-adapters')) { + throw new Error('agent-tools lightweight boundary must forbid bitfun-ai-adapters'); + } + const coreToolFrameworkRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/framework.rs', + ); + if (!coreToolFrameworkRuleText) { + throw new Error('missing core tool framework boundary rule'); + } + const coreToolFrameworkContracts = [ + 'DynamicMcpToolInfo', + 'DynamicToolInfo', + 'ToolRenderOptions', + 'ToolPathBackend', + 'ToolPathResolution', + 'get_global_coordinator', + 'GitService', + 'get_workspace_runtime_service_arc', + 'remote_workspace_runtime_root', + 'get_path_manager_arc', + 'post_call_hooks::record_successful_tool_call', + ]; + for (const contract of coreToolFrameworkContracts) { + if (!coreToolFrameworkRuleText.includes(contract)) { + throw new Error(`core tool framework boundary rule must forbid contract: ${contract}`); + } + } + const coreToolRestrictionRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/restrictions.rs', + ); + if (!coreToolRestrictionRuleText) { + throw new Error('missing core tool restrictions boundary rule'); + } + const coreToolRestrictionContracts = [ + 'ToolPathOperation', + 'ToolPathPolicy', + 'ToolRuntimeRestrictions', + 'normalize_absolute_posix_path', + ]; + for (const contract of coreToolRestrictionContracts) { + if (!coreToolRestrictionRuleText.includes(contract)) { + throw new Error(`core tool restrictions boundary rule must forbid contract: ${contract}`); + } + } + const coreDeepReviewTaskAdapterRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs', + ); + if (!coreDeepReviewTaskAdapterRuleText) { + throw new Error('missing core DeepReview task adapter boundary rule'); + } + const coreDeepReviewTaskAdapterContracts = [ + 'QueueWaitTimer', + 'decide_provider_capacity_queue_step', + 'decide_blocked_reviewer_admission_queue_step', + ' rule.path === 'src/crates/execution/tool-contracts/src/framework.rs', + ); + if (!agentToolsFrameworkRule) { + throw new Error('missing agent-tools framework boundary rule'); + } + const agentToolsFrameworkContracts = [ + 'is_tool_path_allowed_by_resolved_roots', + 'build_tool_path_policy_denial_message', + 'resolve_tool_path_with_context', + 'tool_path_is_effectively_absolute', + 'build_tool_runtime_artifact_reference', + 'build_tool_session_runtime_artifact_reference', + ]; + const agentToolsFrameworkRuleText = agentToolsFrameworkRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of agentToolsFrameworkContracts) { + if (!agentToolsFrameworkRuleText.includes(contract)) { + throw new Error(`agent-tools framework boundary rule must require contract: ${contract}`); + } + } + const coreWorkspacePathRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/workspace_paths.rs', + ); + if (!coreWorkspacePathRuleText) { + throw new Error('missing core workspace path boundary rule'); + } + const coreWorkspacePathContracts = [ + 'BITFUN_RUNTIME_URI_PREFIX', + 'ParsedBitFunRuntimeUri', + 'posix_normalize_components', + 'Component::ParentDir', + ]; + for (const contract of coreWorkspacePathContracts) { + if (!coreWorkspacePathRuleText.includes(contract)) { + throw new Error(`core workspace path boundary rule must forbid contract: ${contract}`); + } + } + const coreToolRegistryRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/registry.rs', + ); + if (!coreToolRegistryRuleText) { + throw new Error('missing core tool registry boundary rule'); + } + const coreToolRegistryContracts = [ + 'DynamicToolMetadata', + 'tools\\s*:\\s*IndexMap', + 'dynamic_tools\\s*:\\s*IndexMap', + ]; + for (const contract of coreToolRegistryContracts) { + if (!coreToolRegistryRuleText.includes(contract)) { + throw new Error(`core tool registry boundary rule must forbid contract: ${contract}`); + } + } + const coreSubagentRuntimeRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs', + ); + if (!coreSubagentRuntimeRuleText) { + throw new Error('missing core subagent runtime boundary rule'); + } + for (const contract of ['DelegationPolicy', 'SubagentContextMode']) { + if (!coreSubagentRuntimeRuleText.includes(contract)) { + throw new Error(`core subagent runtime boundary rule must forbid contract: ${contract}`); + } + } + const coreCoordinatorRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', + ); + if (!coreCoordinatorRuleText) { + throw new Error('missing core coordinator boundary rule'); + } + if (!coreCoordinatorRuleText.includes('DialogTriggerSource')) { + throw new Error('core coordinator boundary rule must forbid DialogTriggerSource redefinition'); + } + const coreSchedulerRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + ); + if (!coreSchedulerRuleText) { + throw new Error('missing core scheduler boundary rule'); + } + for (const contract of [ + 'DialogQueuePriority', + 'DialogSubmissionPolicy', + 'DialogSubmitOutcome', + 'AgentSessionReplyRoute', + 'DialogSteerOutcome', + ]) { + if (!coreSchedulerRuleText.includes(contract)) { + throw new Error(`core scheduler boundary rule must forbid contract: ${contract}`); + } + } + const coreRoundPreemptRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/round_preempt.rs', + ); + if (!coreRoundPreemptRuleText) { + throw new Error('missing core round preempt boundary rule'); + } + for (const contract of [ + 'RoundInjection', + 'DialogRoundInjectionSource', + 'RoundInjectionKind', + 'RoundInjectionTarget', + ]) { + if (!coreRoundPreemptRuleText.includes(contract)) { + throw new Error(`core round preempt boundary rule must forbid contract: ${contract}`); + } + } + const coreGoalModeTypesRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/goal_mode/mod.rs', + ); + if (!coreGoalModeTypesRuleText) { + throw new Error('missing core goal mode types boundary rule'); + } + for (const contract of [ + 'GOAL_MODE_METADATA_KEY', + 'MAX_GOAL_CONTINUATIONS', + 'ThreadGoal', + 'ThreadGoalStatus', + 'ThreadGoalToolResponse', + 'ThreadGoalRuntime', + 'build_thread_goal_continuation_plan', + 'goal_tool_response', + ]) { + if (!coreGoalModeTypesRuleText.includes(contract)) { + throw new Error(`core goal mode types boundary rule must forbid contract: ${contract}`); + } + } + const coreMessageRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/core/message.rs', + ); + if (!coreMessageRuleText) { + throw new Error('missing core message boundary rule'); + } + for (const contract of ['CompressionContract', 'CompressionContractItem']) { + if (!coreMessageRuleText.includes(contract)) { + throw new Error(`core message boundary rule must forbid contract: ${contract}`); + } + } + const coreWorkspaceRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/service/workspace/manager.rs', + ); + if (!coreWorkspaceRuleText) { + throw new Error('missing core workspace manager boundary rule'); + } + if (!coreWorkspaceRuleText.includes('RelatedPath')) { + throw new Error('core workspace manager boundary rule must forbid contract: RelatedPath'); + } + const coreSubagentRuntimeOwnerPathRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src', + ); + if (!coreSubagentRuntimeOwnerPathRule) { + throw new Error('missing core subagent runtime owner-path boundary rule'); + } + const coreSubagentRuntimeOwnerPathRuleText = coreSubagentRuntimeOwnerPathRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of ['DelegationPolicy', 'SubagentContextMode']) { + if (!coreSubagentRuntimeOwnerPathRuleText.includes(contract)) { + throw new Error( + `core subagent runtime owner-path rule must forbid compatibility import: ${contract}`, + ); + } + } + + const productDomainProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'product-domains', + ); + for (const dep of ['dirs', 'log', 'sha2', 'which']) { + if (!productDomainProfile?.forbiddenNonOptionalDeps.includes(dep)) { + throw new Error(`product-domains default profile must forbid non-optional ${dep}`); + } + } + const servicesIntegrationsDefaultProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'services-integrations', + ); + if (!servicesIntegrationsDefaultProfile?.forbiddenNonOptionalDeps.includes('uuid')) { + throw new Error('services-integrations default profile must forbid non-optional uuid'); + } + const coreProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core'); + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + if (!coreProfile?.forbiddenNonOptionalDeps.includes(dep)) { + throw new Error(`core no-default profile must forbid non-optional ${dep}`); + } + } + const coreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'core', + ); + const coreOptionalOwnerDeps = new Set( + coreOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [], + ); + const coreFullyMigratedDeps = new Set(['hostname', 'mac_address', 'qrcode', 'x25519-dalek']); + for (const dep of coreProfile?.forbiddenNonOptionalDeps ?? []) { + if (coreFullyMigratedDeps.has(dep)) { + continue; + } + if (!coreOptionalOwnerDeps.has(dep)) { + throw new Error(`core optional dependency owner rule must cover forbidden dependency ${dep}`); + } + } + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + if (!coreOptionalOwnerDeps.has(dep)) { + throw new Error(`core optional dependency owner rule must cover ${dep}`); + } + } + const coreGit2Owner = coreOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === 'git2', + ); + if (!coreGit2Owner?.ownerFeatures.includes('service-integrations')) { + throw new Error('core optional dependency owner rule must keep git2 under service-integrations'); + } + const servicesOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'services-integrations', + ); + for (const dep of [ + 'bitfun-runtime-ports', + 'git2', + 'hostname', + 'mac_address', + 'notify', + 'qrcode', + 'rmcp', + 'tokio-tungstenite', + 'x25519-dalek', + ]) { + if (!servicesOptionalOwnerRule?.dependencies.some((dependency) => dependency.depName === dep)) { + throw new Error(`services-integrations optional dependency owner rule must cover ${dep}`); + } + } + const productDomainsOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'product-domains', + ); + for (const dep of ['dirs', 'log', 'sha2']) { + if (!productDomainsOptionalOwnerRule?.dependencies.some((dependency) => dependency.depName === dep)) { + throw new Error(`product-domains optional dependency owner rule must cover ${dep}`); + } + } + const productDomainRuntimeRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src', + ); + if (!productDomainRuntimeRule) { + throw new Error('missing product-domains runtime-owner boundary rule'); + } + const productDomainRuntimeContracts = [ + 'Command::new\\(', + 'process_manager::', + 'GitService::', + 'reqwest::', + 'tauri::', + ]; + const productDomainRuntimeRuleText = productDomainRuntimeRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of productDomainRuntimeContracts) { + if (!productDomainRuntimeRuleText.includes(contract)) { + throw new Error(`product-domains runtime boundary rule must forbid: ${contract}`); + } + } + const productDomainCommandRule = productDomainRuntimeRule.patterns.find((pattern) => + pattern.regex.source.includes('Command::new'), + ); + if ( + !productDomainCommandRule?.allowPaths?.includes( + 'src/crates/contracts/product-domains/src/miniapp/runtime.rs', + ) + ) { + throw new Error('product-domains Command::new exception must stay scoped to MiniApp runtime detection'); + } + const coreTypesProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core-types'); + if (!coreTypesProfile?.forbiddenNonOptionalDeps.includes('bitfun-ai-adapters')) { + throw new Error('core-types dependency profile must forbid ai-adapter dependencies'); + } + const coreTypesAiRuleText = forbiddenRuleTextForPath( + 'src/crates/contracts/core-types/src/ai.rs', + ); + for (const contract of ['resolve_request_url', 'chat\\/completions', 'v1\\/messages']) { + if (!coreTypesAiRuleText.includes(contract)) { + throw new Error(`core-types AI DTO boundary rule must forbid: ${contract}`); + } + } + const runtimePortsProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'runtime-ports', + ); + if (!runtimePortsProfile?.forbiddenNonOptionalDeps.includes('bitfun-services-core')) { + throw new Error('runtime-ports dependency profile must forbid service implementations'); + } + const runtimeServicesRule = lightweightBoundaryRules.find( + (rule) => rule.crateName === 'runtime-services', + ); + if (!runtimeServicesRule?.forbiddenDeps.includes('bitfun-core')) { + throw new Error('runtime-services lightweight boundary must forbid bitfun-core'); + } + if (!runtimeServicesRule?.forbiddenDeps.includes('bitfun-services-integrations')) { + throw new Error('runtime-services lightweight boundary must forbid concrete service integrations'); + } + const runtimeServicesProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'runtime-services', + ); + if (!runtimeServicesProfile?.forbiddenNonOptionalDeps.includes('tool-runtime')) { + throw new Error('runtime-services dependency profile must forbid tool runtime implementations'); + } + const agentRuntimeRule = lightweightBoundaryRules.find( + (rule) => rule.crateName === 'agent-runtime', + ); + if (!agentRuntimeRule?.forbiddenDeps.includes('bitfun-core')) { + throw new Error('agent-runtime lightweight boundary must forbid bitfun-core'); + } + if (!agentRuntimeRule?.forbiddenDeps.includes('bitfun-services-integrations')) { + throw new Error('agent-runtime lightweight boundary must forbid concrete service integrations'); + } + const agentRuntimeProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'agent-runtime', + ); + if (!agentRuntimeProfile?.forbiddenNonOptionalDeps.includes('tauri')) { + throw new Error('agent-runtime dependency profile must forbid product surface dependencies'); + } + const productCapabilitiesRule = lightweightBoundaryRules.find( + (rule) => rule.crateName === 'product-capabilities', + ); + if (!productCapabilitiesRule?.forbiddenDeps.includes('bitfun-core')) { + throw new Error('product-capabilities lightweight boundary must forbid bitfun-core'); + } + if (!productCapabilitiesRule?.forbiddenDeps.includes('bitfun-product-domains')) { + throw new Error( + 'product-capabilities lightweight boundary must forbid product-domain implementations', + ); + } + if (!productCapabilitiesRule?.forbiddenDeps.includes('tool-runtime')) { + throw new Error('product-capabilities lightweight boundary must forbid tool-runtime'); + } + const productCapabilitiesProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'product-capabilities', + ); + if (!productCapabilitiesProfile?.forbiddenNonOptionalDeps.includes('bitfun-core')) { + throw new Error('product-capabilities dependency profile must forbid bitfun-core'); + } + const agentToolsManifestRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/execution/tool-contracts/src', + ); + if (!agentToolsManifestRule) { + throw new Error('missing agent-tools manifest-owner boundary rule'); + } + const agentToolsRuntimeForbiddenContracts = [ + 'GetToolSpecTool', + 'manifest_resolver', + 'unlocked_collapsed_tools', + 'ToolUseContext', + ]; + const agentToolsManifestRuleText = agentToolsManifestRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of agentToolsRuntimeForbiddenContracts) { + if (!agentToolsManifestRuleText.includes(contract)) { + throw new Error(`agent-tools manifest boundary rule must forbid: ${contract}`); + } + } + const toolPacksManifestRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/execution/tool-provider-groups/src', + ); + if (!toolPacksManifestRule) { + throw new Error('missing tool-packs manifest-owner boundary rule'); + } + const toolPacksManifestRuleText = toolPacksManifestRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + const toolPacksManifestContracts = [ + 'GetToolSpecTool', + 'GET_TOOL_SPEC_TOOL_NAME', + 'manifest_resolver', + 'unlocked_collapsed_tools', + 'ToolExposure', + ]; + for (const contract of toolPacksManifestContracts) { + if (!toolPacksManifestRuleText.includes(contract)) { + throw new Error(`tool-packs manifest boundary rule must forbid: ${contract}`); + } + } + const serviceAgentRuntimeRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/service_agent_runtime.rs', + ); + if (!serviceAgentRuntimeRuleText.includes('self\\.scheduler')) { + throw new Error('service agent runtime boundary rule must forbid direct scheduler submit'); + } + const sessionMessageRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs', + ); + if (!sessionMessageRuleText.includes('submit_with_prepended_messages')) { + throw new Error('SessionMessage boundary rule must forbid direct scheduler submit'); + } + const sessionMessageLegacySessionAccessContracts = [ + 'resolve_session_workspace_path', + 'list_sessions', + ]; + for (const contract of sessionMessageLegacySessionAccessContracts) { + if (!sessionMessageRuleText.includes(contract)) { + throw new Error(`SessionMessage boundary rule must forbid direct coordinator ${contract}`); + } + } + const sessionControlForbiddenRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs', + ); + if (!sessionControlForbiddenRuleText.includes('cancel_active_turn_for_session_from_requester')) { + throw new Error('SessionControl boundary rule must forbid direct scheduler cancellation'); + } + const sessionControlLegacySessionAccessContracts = [ + 'resolve_session_workspace_path', + 'list_sessions', + 'delete_session', + ]; + for (const contract of sessionControlLegacySessionAccessContracts) { + if (!sessionControlForbiddenRuleText.includes(contract)) { + throw new Error(`SessionControl boundary rule must forbid direct coordinator ${contract}`); + } + } + const cronServiceRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/service/cron/service.rs', + ); + if (!cronServiceRuleText.includes('submit_with_prepended_messages')) { + throw new Error('cron service boundary rule must forbid direct scheduler submit'); + } + const cronToolRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs', + ); + const cronToolLegacySessionAccessContracts = [ + 'resolve_session_workspace_path', + 'list_sessions', + ]; + for (const contract of cronToolLegacySessionAccessContracts) { + if (!cronToolRuleText.includes(contract)) { + throw new Error(`CronTool boundary rule must forbid direct coordinator ${contract}`); + } + } + const bashToolRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs', + ); + if (!bashToolRuleText.includes('scheduler') || !bashToolRuleText.includes('deliver_background_result')) { + throw new Error('Bash boundary rule must forbid direct scheduler background delivery'); + } + const coordinatorRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', + ); + if ( + !coordinatorRuleText.includes('scheduler') || + !coordinatorRuleText.includes('deliver_thread_goal_') || + !coordinatorRuleText.includes('deliver_background_result') + ) { + throw new Error('Coordinator boundary rule must forbid direct scheduler lifecycle delivery'); + } + const coreFileReadStateRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/session/file_read_state.rs', + ); + for (const contract of ['FileReadState', 'FileReadStateStore', 'DashMap']) { + if (!coreFileReadStateRuleText.includes(contract)) { + throw new Error(`core file_read_state boundary rule must forbid ${contract}`); + } + } + const coreEvidenceLedgerRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/session/evidence_ledger.rs', + ); + for (const contract of [ + 'EvidenceLedgerTargetKind', + 'EvidenceLedgerEvent', + 'SessionEvidenceLedger', + 'CompressionContract', + 'uuid::Uuid::new_v4', + 'DashMap', + ]) { + if (!coreEvidenceLedgerRuleText.includes(contract)) { + throw new Error(`core evidence_ledger boundary rule must forbid ${contract}`); + } + } + const coreToolContextRuntimeRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs', + ); + if (!coreToolContextRuntimeRuleText.includes('LightCheckpoint')) { + throw new Error( + 'core tool_context_runtime boundary rule must forbid checkpoint evidence projection', + ); + } + const coreUserInputManagerRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/user_input_manager.rs', + ); + for (const contract of ['UserInputManager', 'oneshot::Sender', 'DashMap']) { + if (!coreUserInputManagerRuleText.includes(contract)) { + throw new Error(`core user_input_manager boundary rule must forbid ${contract}`); + } + } + const coreToolPipelineRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + ); + for (const contract of ['ConfirmationResponse', 'oneshot::Sender', 'CancellationToken']) { + if (!coreToolPipelineRuleText.includes(contract)) { + throw new Error(`core tool_pipeline boundary rule must forbid ${contract}`); + } + } + const coreRoundExecutorRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/execution/round_executor.rs', + ); + for (const contract of ['CancellationToken', 'DashMap']) { + if (!coreRoundExecutorRuleText.includes(contract)) { + throw new Error(`core round_executor boundary rule must forbid ${contract}`); + } + } + const coreBackgroundCommandOutputRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/implementations/exec_command/background_command_output.rs', + ); + for (const contract of [ + 'BackgroundCommandOutputCapture', + 'BackgroundCommandOutputStatus', + 'VecDeque', + 'mpsc::UnboundedSender', + 'tokio::spawn', + ]) { + if (!coreBackgroundCommandOutputRuleText.includes(contract)) { + throw new Error(`core background_command_output boundary rule must forbid ${contract}`); + } + } + const coreSkillAgentSnapshotRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs', + ); + for (const contract of [ + 'SkillSnapshotEntry', + 'AgentSnapshotEntry', + 'TurnSkillAgentSnapshot', + 'SkillAgentDiff', + 'diff_skill_agent_snapshot', + 'render_titled_skill_entries', + ]) { + if (!coreSkillAgentSnapshotRuleText.includes(contract)) { + throw new Error(`core skill_agent_snapshot boundary rule must forbid ${contract}`); + } + } + const coreTurnSkillAgentSnapshotStoreRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs', + ); + for (const contract of ['TurnSkillAgentSnapshotStore', 'DashMap', 'BTreeMap']) { + if (!coreTurnSkillAgentSnapshotStoreRuleText.includes(contract)) { + throw new Error(`core turn_skill_agent_snapshot_store boundary rule must forbid ${contract}`); + } + } + + const requiredContentContracts = [ + { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', + contracts: [ + 'AgentDialogTurnRequest', + 'AgentDialogPrependedReminder', + 'AgentDialogTurnPort', + 'AgentBackgroundResultRequest', + 'AgentThreadGoalDeliveryKind', + 'AgentThreadGoalDeliveryRequest', + 'AgentLifecycleDeliveryPort', + 'agent_dialog_turn_request_serializes_lifecycle_contract', + 'agent_background_result_request_serializes_lifecycle_contract', + 'agent_thread_goal_delivery_request_serializes_lifecycle_contract', + 'AgentTurnCancellationPort', + 'requester_session_id', + 'AgentSessionListRequest', + 'AgentSessionSummary', + 'AgentSessionDeleteRequest', + 'AgentSessionWorkspaceRequest', + 'AgentSessionManagementPort', + 'agent_session_management_contracts_serialize_stable_shape', + 'RemoteControlStatePort', + 'RuntimeEventSink', + 'AgentSessionCreateResult', + 'session_name', + 'RemoteWorkspaceFacts', + 'RemoteWorkspaceRuntimeHost', + 'RemoteWorkspacePort', + 'RemoteWorkspaceFileRuntimeHost', + 'RemoteProjectionPort', + 'RemoteInitialSyncRuntimeHost', + 'remote_workspace_contracts_preserve_workspace_and_session_facts', + 'remote_projection_contract_preserves_file_chunk_identity', + 'remote_image', + 'DialogTriggerSource', + 'dialog_trigger_source_reuses_agent_submission_source_contract', + 'DialogQueuePriority', + 'DialogSubmissionPolicy', + 'dialog_submission_policy_preserves_current_surface_queue_defaults', + 'DialogSubmitOutcome', + 'dialog_submit_outcome_preserves_started_and_queued_fields', + 'DialogSessionStateFact', + 'DialogSubmitQueueFacts', + 'DialogSubmitQueueAction', + 'resolve_dialog_submit_queue_action', + 'dialog_submit_queue_action_preserves_current_scheduler_routing_policy', + 'should_suppress_agent_session_cancelled_reply', + 'DialogTurnOutcomeKind', + 'should_skip_agent_session_reply', + 'agent_session_reply_decisions_preserve_cancel_suppression_boundary', + 'AgentSessionReplyRoute', + 'agent_session_reply_route_keeps_requester_fields', + 'DialogSteerOutcome', + 'dialog_steer_outcome_preserves_buffered_fields', + 'RoundInjectionKind', + 'RoundInjectionTarget', + 'RoundInjection', + 'DialogRoundInjectionSource', + 'round_injection_contract_keeps_kind_and_target_identity', + 'round_injection_source_contract_drains_portable_injections', + 'ThreadGoalStatus', + 'ThreadGoal', + 'SetThreadGoalResult', + 'ThreadGoalContinuationPlan', + 'ThreadGoalToolResponse', + 'thread_goal_active_status_includes_budget_limited', + 'thread_goal_tool_response_serializes_optional_fields', + 'CompressionContract', + 'CompressionContractItem', + 'compression_contract_renders_model_visible_fields', + 'RelatedPath', + 'related_path_serializes_as_request_context_fact', + 'WorkspaceFileSystem', + 'WorkspaceShell', + 'WorkspaceServices', + 'WorkspaceCommandOptions', + 'WorkspaceCommandResult', + 'WorkspaceDirEntry', + 'workspace_services_contract_is_runtime_port_owned', + 'DelegationPolicy', + 'SubagentContextMode', + 'delegation_policy_child_blocks_recursive_spawn_without_losing_depth', + 'subagent_context_mode_preserves_fork_wire_value', + ], + }, + { + path: 'src/crates/execution/runtime-services/src/lib.rs', + contracts: [ + 'RuntimeServices', + 'RuntimeServicesBuilder', + 'CapabilityAvailability', + 'RuntimeServicesProvider', + 'RuntimeServicesRegistry', + 'CapabilityMismatch', + 'require_capability', + ], + }, + { + path: 'src/crates/execution/runtime-services/src/backend_events.rs', + contracts: [ + 'BackendEvent', + 'BackendEventSystem', + 'event_name', + 'emit', + 'get_global_event_system', + 'backend_event_names_remain_stable', + ], + }, + { + path: 'src/crates/execution/runtime-services/tests/runtime_services_contracts.rs', + contracts: [ + 'builder_requires_mandatory_runtime_services', + 'fake_provider_registers_required_and_remote_services_through_registry', + 'missing_optional_capability_returns_typed_unsupported_error', + 'capability_availability_reports_optional_service_status_without_side_effects', + 'builder_rejects_port_registered_under_the_wrong_capability', + 'registered_remote_ports_expose_owner_contract_methods', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/runtime.rs', + contracts: [ + 'AgentRuntime', + 'AgentRuntimeBuilder', + 'AgentSubmissionPort', + 'AgentDialogTurnPort', + 'with_dialog_turn_port', + 'submit_dialog_turn', + 'AgentLifecycleDeliveryPort', + 'with_lifecycle_delivery_port', + 'deliver_background_result', + 'deliver_thread_goal', + 'AgentTurnCancellationPort', + 'AgentSessionManagementPort', + 'with_session_management_port', + 'MissingSessionManagementPort', + 'list_sessions', + 'delete_session', + 'resolve_session_workspace_path', + 'session_management_delegates_to_registered_port', + 'RuntimeServices', + 'RuntimeEventEnvelope', + 'AgentEventStream', + 'with_event_stream', + 'SessionSelector', + 'AgentRunRequest', + 'AgentRunHandle', + 'run', + 'publish_event', + 'publish_event_uses_runtime_services_event_sink', + 'run_handle_exposes_configured_agent_event_stream', + 'port_errors_remain_typed', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/agents.rs', + contracts: [ + 'SubagentQueryContext', + 'SubagentListScope', + 'SubagentVisibilityPolicy', + 'resolve_subagent_default_enabled', + 'resolve_subagent_availability', + 'SubagentOverrideLayers', + 'SubagentStateReason', + 'SHARED_CODING_MODE_CONFIG_PROFILE_ID', + 'resolve_mode_config_profile_id', + 'mode_config_profile_member_mode_ids', + 'mode_presentation_rank', + 'shared_coding_mode_user_context_policy', + 'SubAgentSource', + 'subagent_source_kind', + 'subagent_source_presentation_rank', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/agent_registry_contracts.rs', + contracts: [ + 'visibility_policy_supports_public_restricted_hidden_and_denied_parents', + 'availability_preserves_builtin_project_and_user_override_layering', + 'default_enabled_uses_visibility_only_for_builtin_subagents', + 'shared_coding_modes_resolve_to_the_same_config_profile', + 'subagent_source_contract_preserves_runtime_kind_and_presentation_order', + 'mode_presentation_and_shared_context_policy_match_existing_mode_contract', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/custom_subagent.rs', + contracts: [ + 'CustomSubagentKind', + 'CustomSubagentDiscoveryRoots', + 'CustomSubagentLoadReport', + 'CustomSubagentDefinition', + 'CustomSubagentDefinitionError', + 'DEFAULT_CUSTOM_SUBAGENT_TOOLS', + 'custom_subagent_tools_from_front_matter', + 'custom_subagent_tools_to_front_matter', + 'custom_subagent_readonly_should_save', + 'custom_subagent_review_should_save', + 'custom_subagent_model_should_save', + 'custom_subagent_read_markdown_file', + 'custom_subagent_save_markdown_parts', + 'custom_subagent_possible_dirs', + 'load_custom_subagent_definitions', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/custom_subagent_discovery_contracts.rs', + contracts: [ + 'custom_subagent_discovery_preserves_directory_priority_and_deduplication', + 'custom_subagent_discovery_reports_parse_errors_without_dropping_valid_files', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/custom_subagent_contracts.rs', + contracts: [ + 'custom_subagent_defaults_match_existing_front_matter_contract', + 'custom_subagent_tool_front_matter_keeps_existing_comma_format', + 'custom_subagent_default_fields_are_omitted_when_saved', + 'custom_subagent_definition_from_front_matter_preserves_schema_and_defaults', + 'custom_subagent_definition_reports_legacy_missing_field_errors', + 'custom_subagent_markdown_io_preserves_legacy_front_matter_shape', + 'custom_subagent_markdown_parse_errors_match_legacy_prefixes', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', + contracts: [ + 'PostCallHookKind', + 'successful_tool_post_call_hooks', + 'SuccessfulToolPostCallHookExecutor', + 'run_successful_tool_post_call_hooks', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/post_call_hook_contracts.rs', + contracts: ['successful_tool_call_routes_to_shared_context_measurement_hook'], + }, + { + path: 'src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs', + contracts: ['successful_tool_post_call_executor_runs_deep_review_measurement_route'], + }, + { + path: 'src/crates/execution/agent-runtime/src/tool_confirmation.rs', + contracts: [ + 'ToolConfirmationRequestFacts', + 'ToolConfirmationPlan', + 'ToolConfirmationOutcome', + 'ToolConfirmationWaitResult', + 'ToolConfirmationResponse', + 'ToolConfirmationChannelStore', + 'ConfirmationFailureKind', + 'resolve_tool_confirmation_plan', + 'resolve_confirmation_failure', + 'resolve_confirmation_wait_result', + 'confirmation_channel_store_delivers_confirmation_once', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/user_questions.rs', + contracts: [ + 'AskUserQuestionInput', + 'UserInputResponse', + 'UserInputManager', + 'get_user_input_manager', + 'validate_ask_user_question_input', + 'user_input_manager_delivers_answer_and_clears_channel', + 'user_input_manager_cancel_closes_receiver', + ], + }, + { + path: 'src/crates/execution/tool-execution/src/background_command_output.rs', + contracts: [ + 'BackgroundCommandOutputCapture', + 'BackgroundCommandOutputStatus', + 'BackgroundCommandOutputMetadata', + 'background_command_output_capture', + 'BACKGROUND_COMMAND_OUTPUT_CAPTURE_LIMIT_BYTES', + 'background_command_output_reads_snapshot_then_incremental_chunks', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs', + contracts: [ + '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', + 'confirmation_wait_result_mapping_preserves_legacy_timeout_and_rejection', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/checkpoint.rs', + contracts: [ + 'LightCheckpoint', + 'LightCheckpointWorkspaceFacts', + 'GitStatusCheckpointFacts', + 'build_light_checkpoint', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/scheduler.rs', + contracts: [ + 'DEFAULT_MAX_DIALOG_QUEUE_DEPTH', + 'ActiveDialogTurn', + 'ActiveDialogTurnStore', + 'AgentSessionReplyAction', + 'AgentSessionReplyPlan', + 'BackgroundDeliveryFacts', + 'BackgroundDeliveryAction', + 'BackgroundInjectionKind', + 'DialogReplySuppressionSet', + 'DialogSteeringAction', + 'DialogTurnQueue', + 'SessionAbortFlags', + 'resolve_agent_session_reply_action', + 'resolve_background_delivery_action', + 'resolve_background_delivery_injection', + 'resolve_dialog_steering_action', + 'follow_up_submission_policy', + 'SubmitAgentSessionFollowUp', + 'InjectIntoRunningTurn', + 'SessionRoundInjectionBuffer', + 'TurnOutcome', + 'TurnOutcomeQueueAction', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/scheduler_contracts.rs', + contracts: [ + 'background_delivery_injects_when_session_is_processing', + 'background_delivery_starts_agent_session_follow_up_when_session_is_not_processing', + 'background_delivery_follow_up_uses_agent_session_source_semantics', + 'background_delivery_injection_does_not_expose_follow_up_policy', + 'background_delivery_injection_builds_thread_goal_current_turn_message', + 'background_delivery_injection_builds_background_result_with_display_fallback', + 'dialog_turn_queue_preserves_priority_order_and_fifo_within_priority', + 'dialog_turn_queue_rejects_overflow_and_preserves_current_error_shape', + 'dialog_turn_queue_clear_and_requeue_front_preserve_scheduler_recovery_contract', + 'dialog_turn_queue_requeued_turn_keeps_original_priority_for_later_ordering', + 'active_dialog_turn_owns_agent_session_reply_suppression_facts', + 'active_dialog_turn_store_owns_suppression_key_resolution_and_removal', + 'reply_suppression_set_marks_takes_and_clears_turn_keys', + 'session_abort_flags_are_session_scoped', + 'agent_session_reply_action_forwards_completed_outcome_with_legacy_reminder_text', + 'agent_session_reply_action_suppresses_cancelled_auto_reply_when_requested', + 'agent_session_reply_action_ignores_non_agent_session_turns', + 'dialog_steering_action_buffers_exact_running_turn_with_display_fallback', + 'dialog_steering_action_rejects_when_target_turn_is_not_running', + 'round_injection_buffer_drains_only_messages_for_the_active_turn', + 'turn_outcome_status_reply_and_queue_policy_are_portable', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/thread_goal.rs', + contracts: [ + 'ThreadGoalRuntime', + 'SetThreadGoalRequest', + 'build_set_thread_goal_result', + 'continuation_after_turn', + 'ThreadGoalContinuationOutcome', + 'goal_tool_response', + 'should_skip_goal_for_turn', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/thread_goal_contracts.rs', + contracts: [ + 'set_thread_goal_creates_new_active_goal_with_trimmed_objective', + 'continuation_outcome_increments_active_goal_and_builds_plan', + 'continuation_outcome_marks_active_goal_blocked_at_limit', + 'continuation_outcome_reports_budget_limit_once_when_tokens_cross_budget', + 'prompt_and_tool_response_contracts_match_thread_goal_wire_shape', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/prompt.rs', + contracts: [ + 'UserContextSection', + 'UserContextPolicy', + 'ToolListingSections', + 'PrependedPromptReminders', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/prompt_cache.rs', + contracts: [ + 'PROMPT_CACHE_SCHEMA_VERSION', + 'PromptCachePolicy', + 'prompt_cache_scope_key', + 'SessionPromptCacheStore', + 'PromptCacheLookup', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs', + contracts: [ + 'SkillSnapshotEntry', + 'AgentSnapshotEntry', + 'TurnSkillAgentSnapshot', + 'SkillAgentDiff', + 'diff_skill_agent_snapshot', + 'build_skill_agent_tool_listing_sections_from_snapshot', + 'TurnSkillAgentSnapshotStore', + 'skill_agent_diff_renders_changed_added_and_removed_entries', + 'latest_snapshot_at_or_before_returns_nearest_sparse_snapshot', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/file_read_state.rs', + contracts: [ + 'FileReadState', + 'is_full_file_read', + 'FileReadStateStore', + 'file_read_state_accepts_nonempty_whole_file', + 'file_read_state_store_scopes_entries_by_session', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/evidence_ledger.rs', + contracts: [ + 'EvidenceLedgerTargetKind', + 'EvidenceLedgerEventStatus', + 'EvidenceLedgerEvent', + 'EvidenceLedgerSummary', + 'SessionEvidenceLedger', + 'impl From for CompressionContract', + 'impl From for EvidenceLedgerCheckpoint', + 'ledger_reads_events_scoped_by_session_and_turn', + 'checkpoint_from_light_checkpoint_preserves_recovery_boundary_metadata', + 'summary_projects_into_compression_contract', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/turn_cancellation.rs', + contracts: [ + 'DialogTurnCancellationTokenStore', + 'get_or_insert_new', + 'is_cancelled', + 'turn_cancellation_store_reuses_existing_token', + 'turn_cancellation_store_cancels_registered_token', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/round_executor.rs', + contracts: ['DialogTurnCancellationTokenStore', 'get_or_insert_new', 'is_cancelled'], + }, + { + path: 'src/crates/execution/agent-runtime/tests/prompt_cache_contracts.rs', + contracts: [ + 'prompt_cache_policy_keeps_existing_default_persistence_ttl', + 'prompt_cache_lookup_preserves_identity_and_expiry_semantics', + 'prompt_cache_scope_key_preserves_legacy_mode_switch_shape', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/prompt_contracts.rs', + contracts: [ + 'user_context_policy_preserves_order_and_deduplicates_sections', + 'tool_listing_sections_render_only_present_sections', + 'prepended_prompt_reminders_keep_runtime_injection_order', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/events.rs', + contracts: ['FinishReason', 'session_state_label', 'turn_outcome_kind'], + }, + { + path: 'src/crates/execution/agent-runtime/tests/events_contracts.rs', + contracts: [ + 'finish_reason_display_preserves_wire_labels', + 'session_state_labels_match_existing_event_wire_values', + 'turn_outcome_kind_matches_existing_reply_policy_contract', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/scheduled_job.rs', + contracts: [ + 'ScheduledJobRuntimeState', + 'ScheduledJobRunStatus', + 'DEFAULT_SCHEDULED_JOB_RETRY_DELAY_MS', + 'mark_manual_trigger', + 'apply_due_scheduled_trigger', + 'mark_enqueued', + 'mark_enqueue_failed', + 'recover_interrupted_turn_after_restart', + 'pending_is_due', + 'next_wakeup_at_ms', + 'clear_pending_trigger', + ], + }, + { + path: 'src/crates/execution/agent-runtime/tests/scheduled_job_contracts.rs', + contracts: [ + 'manual_trigger_coalesces_existing_pending_run', + 'due_scheduled_trigger_coalesces_when_active_or_pending', + 'pending_wakeup_prefers_retry_time_when_present', + 'disabled_and_config_clear_remove_pending_retry_without_touching_history', + 'enqueue_success_sets_active_turn_and_disables_one_shot_next_run', + 'enqueue_failure_preserves_retry_and_missing_session_disable_semantics', + 'restart_recovery_marks_active_turn_error', + 'serde_shape_preserves_legacy_cron_state_wire_contract', + ], + }, + { + path: 'src/crates/assembly/core/src/service/cron/types.rs', + contracts: [ + 'ScheduledJobRuntimeState as CronJobState', + 'ScheduledJobRunStatus as CronJobRunStatus', + 'DEFAULT_SCHEDULED_JOB_RETRY_DELAY_MS', + ], + }, + { + path: 'src/crates/assembly/core/src/service/cron/service.rs', + contracts: [ + 'mark_manual_trigger', + 'apply_due_scheduled_trigger', + 'mark_enqueued', + 'mark_enqueue_failed', + 'recover_interrupted_turn_after_restart', + 'pending_is_due', + 'next_wakeup_at_ms', + 'clear_pending_trigger', + 'ScheduledJobEnqueueFailureAction', + 'CoreServiceAgentRuntime::agent_runtime_with_dialog_turns', + 'AgentDialogTurnRequest', + 'AgentDialogPrependedReminder', + 'submit_dialog_turn', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs', + contracts: [ + 'CoreServiceAgentRuntime::agent_runtime', + 'AgentSessionListRequest', + 'AgentSessionWorkspaceRequest', + 'list_sessions', + 'resolve_session_workspace_path', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/types.rs', + contracts: ['bitfun_agent_runtime::events::FinishReason'], + }, + { + path: 'src/crates/assembly/core/src/agentic/events/types.rs', + contracts: ['bitfun_agent_runtime::events::session_state_label'], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs', + contracts: ['bitfun_agent_runtime::prompt'], + }, + { + path: 'src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs', + contracts: [ + 'bitfun_runtime_ports', + 'DelegationPolicy', + 'SubagentContextMode', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/session/session_manager.rs', + contracts: [ + 'clone_prompt_cache', + 'start_dialog_turn_with_existing_context', + 'start_dialog_turn_with_existing_context_persists_turn_and_snapshot', + 'clone_prompt_cache_copies_runtime_and_persisted_entries', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/persistence/session_branch.rs', + contracts: ['SessionBranchRequest', 'SessionBranchResult', 'build_branched_session_metadata'], + }, + { + path: 'src/crates/services/services-core/src/session/mod.rs', + contracts: [ + 'mod metadata_store', + 'mod migration', + 'SessionMetadataStore', + 'merge_legacy_session_store', + ], + }, + { + path: 'src/crates/services/services-core/src/session/migration.rs', + contracts: [ + 'merge_legacy_session_store', + 'merge_session_metadata_file', + 'SessionMetadataStore::new', + 'metadata_file_count', + 'merge_legacy_session_store_preserves_newer_metadata_and_rebuilds_visible_index', + ], + }, + { + path: 'src/crates/services/services-core/src/session/metadata_store.rs', + contracts: [ + 'SessionMetadataStore', + 'SessionMetadataStoreError', + 'SessionStorageLayout', + 'list_metadata', + 'list_metadata_page', + 'list_metadata_including_internal', + 'rebuild_index', + 'save_metadata', + 'load_metadata', + 'delete_session_dir_and_index', + 'metadata_store_saves_visible_metadata_and_updates_index', + 'metadata_store_rebuilds_stale_index_entries', + 'metadata_store_rebuild_index_counts_hidden_metadata_files', + 'metadata_store_delete_session_updates_visible_index', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/persistence/manager.rs', + contracts: [ + 'SessionMetadataStore', + 'session_metadata_store', + 'list_metadata', + 'list_metadata_page', + 'list_metadata_including_internal', + 'save_metadata', + 'load_metadata', + 'delete_session_dir_and_index', + 'ensure_runtime_for_write', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + contracts: [ + 'resolve_tool_confirmation_plan', + 'resolve_confirmation_failure', + 'resolve_confirmation_wait_result', + 'ToolConfirmationPlan::Await', + 'should_retry_tool_attempt', + 'retry_delay_ms', + 'build_tool_call_truncation_recovery_notice', + 'truncation_notice_for_interactive_tools_does_not_claim_file_write', + 'truncation_notice_for_write_tools_keeps_write_continuation_guidance', + 'denied_tool_messages', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/restrictions.rs', + contracts: ['denied_tool_messages', 'custom_deny_message_overrides_generic_runtime_error'], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs', + contracts: ['write_once', 'file\\.flush\\(\\)\\.await'], + }, + { + path: 'src/crates/execution/tool-execution/src/pipeline.rs', + contracts: [ + 'ToolBatch', + 'partition_tool_batches', + 'ToolExecutionErrorClass', + 'ToolRetryAttemptFacts', + 'should_retry_tool_attempt', + 'retry_delay_ms', + 'ToolTaskStateKind', + 'should_cancel_tool_state', + 'summarize_dialog_turn_cancellation', + 'ToolCancellationTokenStore', + 'count_tool_states', + ], + }, + { + path: 'src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs', + contracts: [ + 'partitions_consecutive_concurrency_safe_tools_into_parallel_batches', + 'retry_policy_preserves_attempt_limit_and_error_class_contract', + 'cancellation_policy_preserves_cancellable_and_terminal_state_contract', + 'dialog_turn_cancellation_summary_counts_cancelled_and_skipped_tasks', + 'cancellation_token_store_cancels_and_removes_tokens', + 'state_counts_preserve_pipeline_stats_contract', + ], + }, + { + path: 'src/crates/services/services-integrations/src/mcp/server/connection.rs', + contracts: [ + 'send_request_with_id', + 'initialize_timeout', + 'notifications/initialized', + 'pending\\.clear\\(\\)', + 'local_tool_calls_do_not_inherit_initialize_timeout', + 'local_initialize_uses_initialize_timeout', + ], + }, + { + path: 'src/crates/services/services-integrations/src/mcp/protocol/transport.rs', + contracts: ['send_request_with_id', '\\.flush\\(\\)\\s*\\.await'], + }, + { + path: 'src/crates/execution/tool-contracts/src/framework.rs', + contracts: [ + 'GET_TOOL_SPEC_TOOL_NAME', + 'ToolExposure', + 'ToolManifestPolicyTool', + 'resolve_tool_manifest_policy', + 'default_exposure', + 'build_tool_manifest_policy_tools', + 'build_collapsed_tool_stub_definition', + 'PromptVisibleToolManifestItem', + 'build_prompt_visible_tool_manifest_definitions', + 'ContextualToolManifestItem', + 'ToolCatalogSnapshotProvider', + 'GetToolSpecCatalogProvider', + 'ContextualVisibleTools', + 'ContextualToolManifest', + 'resolve_contextual_visible_tools', + 'resolve_contextual_tool_manifest', + 'resolve_contextual_visible_tools_from_provider', + 'resolve_contextual_tool_manifest_from_provider', + 'build_get_tool_spec_catalog_description_from_provider', + 'resolve_get_tool_spec_detail_from_provider', + 'build_get_tool_spec_description', + 'GetToolSpecCollapsedToolSummary', + 'GetToolSpecDetail', + 'summarize_get_tool_spec_collapsed_tools', + 'resolve_get_tool_spec_detail', + 'build_get_tool_spec_catalog_description', + 'get_tool_spec_input_schema', + 'get_tool_spec_short_description', + 'render_get_tool_spec_tool_use_message', + 'get_tool_spec_is_readonly', + 'get_tool_spec_is_concurrency_safe', + 'get_tool_spec_needs_permissions', + 'validate_get_tool_spec_input', + 'build_get_tool_spec_assistant_detail', + 'build_get_tool_spec_duplicate_load_result', + 'build_get_tool_spec_detail_result', + 'GetToolSpecExecutionPlan', + 'GetToolSpecExecutionError', + 'resolve_get_tool_spec_execution_plan', + 'resolve_get_tool_spec_execution_result_from_provider', + 'GetToolSpecRuntime', + 'call_results', + 'GetToolSpecLoadObservation', + 'collect_loaded_collapsed_tool_names', + 'CollapsedToolUsageError', + 'ToolExecutionAccessError', + 'validate_tool_allowed_by_list', + 'validate_collapsed_tool_usage', + 'sort_tool_manifest_definitions', + 'is_tool_collapsed', + 'get_collapsed_tool_names', + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/file_guidance.rs', + contracts: [ + 'FILE_TOOL_GUIDANCE_PREFIX', + 'file_tool_guidance_message', + 'is_file_tool_guidance_message', + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/file_read_freshness.rs', + contracts: [ + 'FileReadFreshnessFacts', + 'normalize_tool_file_content', + 'file_read_facts_content_matches', + 'file_read_facts_are_fresh', + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/tool_result_storage.rs', + contracts: [ + 'ToolResultStoragePolicy', + 'PersistedToolOutput', + 'ToolResultPersistenceCandidate', + 'select_tool_result_indices_for_persistence', + 'sanitize_tool_result_file_component', + 'generate_tool_result_preview', + 'count_tool_result_lines', + 'tool_result_is_persisted_output', + 'build_persisted_tool_output_message', + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/tool_execution_presentation.rs', + contracts: [ + 'TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES', + 'USER_STEERING_INTERRUPTED_MESSAGE', + 'ToolExecutionErrorPresentation', + 'render_tool_result_for_assistant', + 'truncate_tool_arguments_preview', + 'truncate_raw_tool_arguments_preview', + 'build_tool_execution_error_presentation', + 'build_user_steering_interrupted_presentation', + 'build_invalid_tool_call_error_message', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', + contracts: [ + 'AgentSubmissionPort', + 'SessionTranscriptReader', + 'AgentTurnCancellationPort', + 'AgentSessionManagementPort', + 'runtime_session_summary', + 'AgentSessionSummary', + 'RemoteControlStatePort', + 'generic attachments', + 'DialogTriggerSource', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + contracts: [ + 'AgentSessionReplyRoute', + 'DialogQueuePriority', + 'DialogSessionStateFact', + 'DialogSteerOutcome', + 'DialogSubmissionPolicy', + 'DialogSubmitOutcome', + 'DialogSubmitQueueAction', + 'DialogSubmitQueueFacts', + 'ActiveDialogTurnStore', + 'AgentSessionReplyAction', + 'AgentSessionReplyPlan', + 'BackgroundInjectionKind', + 'DialogReplySuppressionSet', + 'DialogSteeringAction', + 'DialogTurnQueue', + 'SessionAbortFlags', + 'resolve_agent_session_reply_action', + 'resolve_background_delivery_injection', + 'resolve_dialog_submit_queue_action', + 'resolve_dialog_steering_action', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/round_preempt.rs', + contracts: [ + 'bitfun_agent_runtime', + 'bitfun_runtime_ports', + 'DialogRoundInjectionSource', + 'RoundInjection', + 'RoundInjectionKind', + 'RoundInjectionTarget', + 'SessionRoundInjectionBuffer', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/goal_mode/mod.rs', + contracts: [ + 'bitfun_runtime_ports', + 'SetThreadGoalResult', + 'ThreadGoal', + 'ThreadGoalContinuationPlan', + 'ThreadGoalStatus', + 'ThreadGoalToolResponse', + 'THREAD_GOAL_METADATA_KEY', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/core/message.rs', + contracts: ['bitfun_runtime_ports', 'CompressionContract', 'CompressionContractItem'], + }, + { + path: 'src/crates/assembly/core/src/service/workspace/manager.rs', + contracts: ['bitfun_runtime_ports', 'RelatedPath'], + }, + { + path: 'src/crates/assembly/core/src/service_agent_runtime.rs', + contracts: [ + 'CoreServiceAgentRuntime', + 'remote_dialog_host', + 'remote_cancel_host', + 'remote_image_context', + 'load_remote_model_catalog', + 'RemoteModelCatalogFacts', + 'RemoteModelCapabilityFact', + 'RemoteReasoningModeFact', + 'build_remote_model_catalog', + 'update_remote_session_model', + 'normalize_remote_session_model_id', + 'normalize_remote_session_model_id_contract', + 'normalize_remote_model_selection', + 'normalize_remote_model_selection_contract', + 'remote_chat_messages_from_turns', + 'RemoteDialogSchedulerOutcomeFact', + 'remote_dialog_submit_outcome_from_scheduler', + 'RemoteChatHistoryTurn', + 'build_remote_chat_messages', + 'strip_remote_user_input_tags', + 'compress_remote_chat_data_url_for_mobile', + 'load_remote_chat_messages', + 'agent_runtime', + 'agent_runtime_with_dialog_turns', + 'agent_runtime_with_lifecycle_delivery', + 'agent_runtime_with_scheduler_ports', + 'global_agent_runtime_with_lifecycle_delivery', + 'with_lifecycle_delivery_port', + 'agent_input_attachment_from_image_context', + 'AgentDialogTurnRequest', + 'submit_dialog_turn', + 'AgentRuntimeBuilder', + 'remote_control_state_port', + 'CoreRemoteDialogRuntimeHost', + 'CoreRemoteCancelRuntimeHost', + 'CoreRemoteCancelRuntimeHost\\s*\\{[\\s\\S]*\\bruntime:\\s*AgentRuntime', + 'CoreServiceAgentRuntime::agent_runtime_with_scheduler_ports', + 'CoreRemoteWorkspaceFileRuntimeHost', + 'CoreRemoteWorkspaceRuntimeHost', + 'CoreRemoteSessionRuntimeHost', + 'CoreRemoteSessionRuntimeHost\\s*\\{[\\s\\S]*\\bruntime:\\s*AgentRuntime', + 'CoreRemotePollRuntimeHost', + 'CoreRemoteInteractionRuntimeHost', + 'CoreRemoteSessionTrackerHost', + 'RemoteExecutionDispatcher', + 'ImageContextData', + 'RemoteImageContextAdapter', + 'AgentSubmissionPort', + 'AgentDialogTurnPort', + 'AgentTurnCancellationPort', + 'AgentSessionManagementPort', + 'with_session_management_port', + 'RemoteControlStatePort', + 'SessionTranscriptReader', + 'core_service_agent_runtime_owner_keeps_coordinator_port_contracts', + 'core_service_agent_runtime_owner_normalizes_remote_session_model_ids', + 'core_service_agent_runtime_owner_normalizes_remote_model_selection_aliases', + 'core_service_agent_runtime_owner_preserves_remote_chat_history_shape', + 'core_service_agent_runtime_owner_skips_in_progress_remote_assistant_history', + 'core_service_agent_runtime_owner_maps_image_context_to_lifecycle_attachment', + 'core_service_agent_runtime_owner_keeps_scheduler_lifecycle_port_contracts', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs', + contracts: [ + 'CoreServiceAgentRuntime::agent_runtime', + 'CoreServiceAgentRuntime::agent_runtime_with_scheduler_ports', + 'AgentSessionCreateRequest', + 'AgentSessionListRequest', + 'AgentSessionDeleteRequest', + 'AgentSessionWorkspaceRequest', + 'list_sessions', + 'delete_session', + 'resolve_session_workspace_path', + '"createdBy"', + 'AgentTurnCancellationRequest', + 'requester_session_id', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs', + contracts: [ + 'CoreServiceAgentRuntime::agent_runtime_with_dialog_turns', + 'AgentSessionCreateRequest', + 'AgentSessionListRequest', + 'AgentSessionWorkspaceRequest', + 'list_sessions', + 'resolve_session_workspace_path', + '"createdBy"', + 'AgentDialogTurnRequest', + 'AgentDialogPrependedReminder', + 'submit_dialog_turn', + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_connect.rs', + contracts: [ + 'pub mod device', + 'pub mod encryption', + 'pub mod pairing', + 'pub mod qr_generator', + 'pub mod relay_client', + 'pub use device::DeviceIdentity', + 'pub use encryption::{decrypt_from_base64, encrypt_to_base64, KeyPair}', + 'PairingProtocol', + 'QrPayload', + 'pub use qr_generator::QrGenerator', + 'RelayClient', + 'RelayMessage', + 'RemoteSessionStateTracker', + 'TrackerEvent', + 'RemoteSessionTrackerHost', + 'RemoteSessionTrackerRegistry', + 'make_slim_tool_params', + 'handle_agentic_event', + 'resolve_remote_agent_type', + 'RemoteImageContext', + 'build_remote_image_contexts', + 'resolve_remote_execution_image_contexts', + 'remote_session_restore_target', + 'RemoteCancelDecision', + 'resolve_remote_cancel_decision', + 'RemoteCancelTaskRequest', + 'RemoteCancelRuntimeHost', + 'cancel_remote_task', + 'RemoteChatHistoryTurn', + 'RemoteChatHistoryRound', + 'RemoteChatHistoryToolItem', + 'build_remote_chat_messages', + 'REMOTE_FILE_MAX_READ_BYTES', + 'REMOTE_FILE_MAX_CHUNK_BYTES', + 'resolve_remote_file_chunk_range', + 'remote_file_display_name', + 'RemoteWorkspaceFacts', + 'RemoteSessionMetadata', + 'remote_workspace_info_response', + 'remote_recent_workspaces_response', + 'remote_assistant_list_response', + 'RemoteWorkspaceRuntimeHost', + 'handle_remote_workspace_command', + 'remote_workspace_handler_preserves_response_shapes', + 'RemoteInitialSyncRuntimeHost', + 'generate_remote_initial_sync', + 'remote_session_info', + 'remote_session_list_response', + 'remote_initial_sync_response', + 'remote_messages_response', + 'RemoteSessionRuntimeHost', + 'handle_remote_session_command', + 'remote_session_handler_preserves_list_and_create_policy', + 'remote_session_handler_removes_tracker_after_delete_success', + 'RemotePollRuntimeHost', + 'handle_remote_poll_command', + 'remote_poll_handler_preserves_missing_workspace_error', + 'RemoteInteractionRuntimeHost', + 'handle_remote_interaction_command', + 'remote_interaction_handler_preserves_default_reject_reason', + 'RemoteDefaultModelsConfig', + 'RemoteModelConfig', + 'RemoteModelCatalog', + 'RemoteModelCapabilityFact', + 'RemoteReasoningModeFact', + 'RemoteModelFacts', + 'RemoteModelCatalogFacts', + 'build_remote_model_catalog', + 'RemoteModelCatalogPollDelta', + 'normalize_remote_session_model_id', + 'normalize_remote_model_selection', + 'remote_model_selection_needs_config', + 'RemoteDialogSchedulerOutcomeFact', + 'remote_dialog_submit_outcome_from_scheduler', + 'RemoteCommand', + 'RemoteResponse', + 'should_send_remote_model_catalog', + 'remote_model_catalog_poll_delta', + 'remote_no_change_poll_response', + 'remote_snapshot_poll_response', + 'remote_persisted_poll_response', + ], + }, + { + path: 'src/crates/services/services-integrations/tests/remote_connect_contracts.rs', + contracts: [ + 'remote_connect_pairing_primitives_live_in_services_owner', + 'remote_connect_qr_and_relay_primitives_live_in_services_owner', + 'remote_connect_command_wire_shape_lives_in_owner_contract', + 'remote_connect_response_wire_shape_lives_in_owner_contract', + 'remote_connect_model_catalog_delta_preserves_poll_invalidation_policy', + 'remote_connect_model_catalog_builder_preserves_config_shape', + 'remote_connect_model_selection_policy_owns_alias_and_config_reference_rules', + 'remote_connect_poll_helpers_preserve_delta_and_completion_policy', + 'remote_connect_image_context_policy_preserves_legacy_fallback_shape', + 'remote_connect_image_context_policy_prefers_explicit_contexts', + 'remote_connect_cancel_and_restore_policy_preserve_runtime_decisions', + 'remote_connect_dialog_submit_outcome_builder_preserves_scheduler_shape', + 'remote_chat_history_assembly_preserves_message_shape_and_item_order', + 'remote_chat_history_assembly_skips_in_progress_assistant_history', + 'remote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges', + 'remote_connect_file_transfer_policy_preserves_name_fallback', + 'remote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes', + 'remote_connect_tracker_registry_owns_lifecycle_without_core_state', + 'remote_connect_tracker_ignores_unrelated_direct_session_events', + 'remote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings', + 'remote_connect_workspace_response_helpers_own_wire_shape', + 'remote_connect_session_response_helpers_own_pagination_and_timestamps', + ], + }, + { + path: 'src/crates/assembly/core/src/service/remote_connect/remote_server.rs', + contracts: [ + 'CoreServiceAgentRuntime', + 'remote_image_context', + 'handle_remote_workspace_command', + 'handle_remote_session_command', + 'generate_remote_initial_sync', + 'handle_remote_poll_command', + 'handle_remote_interaction_command', + 'core_service_agent_runtime_owner_maps_remote_image_context', + 'remote_execution_prefers_unified_image_contexts_over_legacy_images', + 'remote_cancel_decision_preserves_current_turn_boundaries', + 'remote_restore_target_only_restores_cold_sessions_with_workspace_binding', + 'remote_command_snapshot_covers_execution_poll_and_cancel_surfaces', + 'remote_response_snapshot_preserves_active_turn_and_result_shapes', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', + contracts: [ + 'remote_queue_policy_preserves_confirmation_boundary', + 'AgentDialogTurnPort', + 'AgentLifecycleDeliveryPort', + 'AgentTurnCancellationPort', + 'AgentBackgroundResultRequest', + 'AgentThreadGoalDeliveryRequest', + 'AgentThreadGoalDeliveryKind::ObjectiveUpdated', + 'cancel_active_turn_for_session_from_requester', + 'agent_dialog_turn_image_contexts', + 'agent_dialog_turn_prepended_messages', + 'agent_dialog_turn_attachments_preserve_remote_image_context', + 'agent_dialog_turn_attachments_reject_unknown_kind', + 'agent_dialog_turn_prepended_reminders_preserve_session_message_kind', + 'agent_dialog_turn_prepended_reminders_reject_unknown_kind', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/registry.rs', + contracts: [ + 'from_inner', + 'ProductToolDecoratorRef', + 'ProductToolRuntime', + 'get_collapsed_tool_names', + 'resolve_product_readonly_enabled_tools', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime.rs', + contracts: [ + 'ProductToolRuntime', + 'SnapshotToolDecorator', + 'create_product_tool_registry_from_plan', + 'product_assembly_plan_for_profile', + 'product_tool_runtime_owner_preserves_registry_contract', + 'product_tool_runtime_registry_preserves_provider_plan_order', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/snapshot.rs', + contracts: [ + 'ProductSnapshotToolWrapper', + 'SnapshotToolWrapper', + 'wrap_tool_for_snapshot_tracking', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs', + contracts: [ + 'ProductToolCatalogProvider', + 'ToolCatalogSnapshotProvider', + 'GetToolSpecCatalogProvider', + 'get_global_tool_registry', + 'get_agent_registry', + 'ToolCatalogRuntime', + 'product_tool_catalog_runtime', + 'GetToolSpecRuntime', + 'product_get_tool_spec_runtime', + 'resolve_product_tool_manifest', + 'resolve_product_readonly_enabled_tools', + 'resolve_product_get_tool_spec_results', + 'unlocked_collapsed_tools', + 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', + 'product_resolved_manifest_owner_matches_legacy_shape', + 'GetToolSpec requires agent type context', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs', + contracts: [ + 'ProductConcreteToolFactory', + 'StaticToolProviderFactory', + 'ProductToolProviderPlanAdapter', + 'StaticToolProviderPlan', + 'create_registry_from_static_provider_plans', + 'create_product_tool_registry_from_plan', + 'materialize_tool', + 'GetToolSpecTool', + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/framework.rs', + contracts: [ + 'ToolContextFacts', + 'PortableToolContextProvider', + 'ToolWorkspaceKind', + 'StaticToolProvider', + 'StaticToolProviderGroup', + 'StaticToolProviderPlan', + 'StaticToolProviderFactory', + 'StaticToolMaterializationError', + 'materialize_static_tool_provider_groups', + 'ToolRuntimeAssembly', + 'create_registry_from_static_provider_plans', + 'ToolCatalogRuntime', + 'ToolDecoratorRef', + 'SnapshotToolWrapper', + 'SnapshotToolDecorator', + 'create_registry_from_static_providers', + 'install_static_provider', + 'resolve_readonly_enabled_tools', + 'build_get_tool_spec_duplicate_load_result', + 'build_get_tool_spec_detail_result', + 'resolve_get_tool_spec_execution_plan', + 'resolve_get_tool_spec_execution_result_from_provider', + 'GetToolSpecRuntime', + 'call_results', + ], + }, + { + path: 'src/crates/execution/tool-provider-groups/src/lib.rs', + contracts: [ + 'ToolPackFeatureGroup', + 'ToolProviderGroupPlan', + 'all_feature_groups', + 'enabled_feature_groups', + 'product_tool_provider_group_plan', + 'ToolProviderGroupPlanSelectionError', + 'try_product_tool_provider_group_plan_for_ids', + 'product_provider_group_plan_selector_rejects_unknown_provider_ids', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_adapter.rs', + contracts: [ + 'ToolRegistryItem', + 'ContextualToolManifestItem', + 'Tool::dynamic_tool_info', + 'Tool::is_readonly', + 'Tool::is_enabled', + 'Tool::description_with_context', + 'Tool::input_schema_for_model_with_context', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs', + contracts: [ + 'resolve_tool_manifest', + 'GET_TOOL_SPEC_TOOL_NAME', + 'resolve_product_resolved_visible_tools', + 'resolve_product_resolved_tool_manifest', + 'collapsed_tool_names', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs', + contracts: [ + 'GetToolSpecTool', + 'build_collapsed_tools_context_section', + 'product_get_tool_spec_runtime', + 'with_runtime', + 'resolve_product_get_tool_spec_results', + 'map_get_tool_spec_execution_error', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/framework.rs', + contracts: [ + 'ToolExposure', + 'ToolUseContext', + 'pub use crate::agentic::tools::tool_context_runtime::ToolUseContext', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs', + contracts: [ + 'pub struct ToolUseContext', + 'to_tool_context_facts', + 'impl PortableToolContextProvider for ToolUseContext', + 'tool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated', + 'customData', + 'cancellationToken', + 'unlocked_collapsed_tools', + 'impl ToolUseContext', + 'record_light_checkpoint', + 'build_runtime_light_checkpoint', + 'LightCheckpointWorkspaceFacts::LocalWorkspace', + 'call_with_tool_runtime_hooks', + 'call_tool_with_runtime_hooks', + 'call_records_deep_review_read_file_measurement_without_touching_result', + 'build_tool_use_context_for_task', + 'build_tool_description_context', + 'ensure_current_workspace_runtime', + 'resolve_tool_path', + 'enforce_path_operation', + 'workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace', + 'runtime_uri_resolution_rejects_different_workspace_scope', + 'path_policy_allows_only_configured_local_roots', + 'tool_call_runtime_hook_returns_cancelled_before_impl_completes', + 'tool_task_context_materialization_preserves_runtime_fields', + 'tool_description_context_preserves_manifest_custom_data_shape', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', + contracts: [ + 'validate_tool_execution_admission', + 'unlocked_collapsed_tools', + 'GetToolSpec', + 'render_tool_result_for_assistant', + 'build_tool_execution_error_presentation', + 'build_user_steering_interrupted_presentation', + 'build_invalid_tool_call_error_message', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/execution/execution_engine.rs', + contracts: [ + 'collect_product_unlocked_collapsed_tools', + 'unlocked_collapsed_tools', + 'collapsed_tool_names', + 'GetToolSpec', + 'should_post_process_research_report', + 'bitfun_services_integrations::deep_research::run_for_session_workspace', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs', + contracts: [ + 'collect_product_unlocked_collapsed_tools', + 'GetToolSpecLoadObservation', + 'collect_loaded_collapsed_tool_names', + 'product_unlock_state_dedupes_and_filters_runtime_unlocks', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/availability.rs', + contracts: [ + 'resolve_availability', + 'resolve_override_layers', + 'AgentSubagentOverrideState', + 'resolve_subagent_availability', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/types.rs', + contracts: ['SubagentQueryContext', 'SubagentListScope', 'default_enabled', 'effective_enabled', 'SubagentStateReason'], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs', + contracts: ['mod multitask', 'MultitaskMode'], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs', + contracts: ['mod general_purpose', 'GeneralPurposeAgent'], + }, + { + path: 'src/crates/assembly/core/src/agentic/agents/registry/builtin.rs', + contracts: ['builtin_agent_specs', 'runtime_agents::default_model_id_for_builtin_agent'], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs', + contracts: [ + 'fork_context', + 'SubagentContextMode::Fork', + 'delegation_policy\\(\\)\\.spawn_child\\(\\)', + 'run_in_background', + 'start_background_subagent', + 'background_task_id', + 'Background \\{\\} started successfully', + ' rule.path === path); + if (matchingRules.length === 0) { + throw new Error(`missing owner content anchor rule for ${path}`); + } + const ruleText = matchingRules + .flatMap((rule) => rule.patterns) + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of contracts) { + if (!regexSourceContainsContract(ruleText, contract)) { + throw new Error(`owner content anchor rule for ${path} must require: ${contract}`); + } + } + } + + const sessionControlRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs', + ); + if (!sessionControlRuleText.includes('create_session_with_workspace_and_creator')) { + throw new Error('SessionControl old create-path boundary rule must cover legacy create path'); + } + + const remoteWorkspaceRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs', + ); + if (!remoteWorkspaceRule) { + throw new Error('missing remote SSH workspace_state boundary rule'); + } + const remoteWorkspaceHelpers = [ + 'LOCAL_WORKSPACE_SSH_HOST', + 'normalize_remote_workspace_path', + 'sanitize_ssh_connection_id_for_local_dir', + 'sanitize_remote_mirror_path_component', + 'sanitize_ssh_hostname_for_mirror', + 'remote_root_to_mirror_subpath', + 'workspace_logical_key', + 'local_workspace_stable_storage_id', + 'remote_workspace_stable_id', + 'unresolved_remote_session_storage_key', + 'RegisteredRemoteWorkspace', + 'RemoteWorkspaceEntry', + 'RemoteWorkspaceState', + 'registration_matches_path', + 'dunce::canonicalize', + 'path_buf_to_stable_local_root_string', + 'join\\("_unresolved"\\)', + ]; + const ruleText = remoteWorkspaceRule.patterns.map((pattern) => pattern.regex.source).join('\n'); + for (const helper of remoteWorkspaceHelpers) { + if (!ruleText.includes(helper)) { + throw new Error(`remote SSH workspace boundary rule must forbid helper: ${helper}`); + } + } + + const announcementStateStoreRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/announcement/state_store.rs', + ); + if (!announcementStateStoreRule) { + throw new Error('missing announcement state store boundary rule'); + } + + const mcpProcessRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/server/process.rs', + ); + if (!mcpProcessRule) { + throw new Error('missing MCP server process boundary rule'); + } + const mcpProcessHelpers = [ + 'MCPServerType', + 'MCPServerStatus', + 'is_auth_error', + 'AUTHORIZATION_KEYS', + 'contains_key\\("Authorization"\\)', + 'process_manager::create_tokio_command', + 'MCPTransport::start_receive_loop', + 'MCPConnection::new_remote', + ]; + const mcpProcessRuleText = mcpProcessRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpProcessHelpers) { + if (!mcpProcessRuleText.includes(helper)) { + throw new Error(`MCP server process boundary rule must forbid helper: ${helper}`); + } + } + + const mcpManagerRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/server/manager/mod.rs', + ); + if (!mcpManagerRule) { + throw new Error('missing MCP server manager boundary rule'); + } + const mcpManagerHelpers = [ + 'ListChangedKind', + 'resource_catalog_cache', + 'prompt_catalog_cache', + ]; + const mcpManagerRuleText = mcpManagerRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpManagerHelpers) { + if (!mcpManagerRuleText.includes(helper)) { + throw new Error(`MCP server manager boundary rule must forbid helper: ${helper}`); + } + } + + const mcpReconnectRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/server/manager/reconnect.rs', + ); + if (!mcpReconnectRule) { + throw new Error('missing MCP reconnect boundary rule'); + } + if ( + !mcpReconnectRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n') + .includes('compute_backoff_delay') + ) { + throw new Error('MCP reconnect boundary rule must forbid compute_backoff_delay'); + } + + const mcpInteractionRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/server/manager/interaction.rs', + ); + if (!mcpInteractionRule) { + throw new Error('missing MCP interaction boundary rule'); + } + if ( + !mcpInteractionRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n') + .includes('detect_list_changed_kind') + ) { + throw new Error('MCP interaction boundary rule must forbid detect_list_changed_kind'); + } + + const mcpToolAdapterRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/adapter/tool.rs', + ); + if (!mcpToolAdapterRule) { + throw new Error('missing MCP tool adapter boundary rule'); + } + const mcpToolAdapterHelpers = [ + 'behavior_hints', + 'truncate_for_assistant', + 'MCPToolResultContent', + 'Tool', + ]; + const mcpToolAdapterRuleText = mcpToolAdapterRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpToolAdapterHelpers) { + if (!mcpToolAdapterRuleText.includes(helper)) { + throw new Error(`MCP tool adapter boundary rule must forbid helper: ${helper}`); + } + } + + const mcpContextAdapterRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/adapter/context.rs', + ); + if (!mcpContextAdapterRule) { + throw new Error('missing MCP context adapter boundary rule'); + } + const mcpContextAdapterHelpers = [ + 'ContextEnhancerConfig', + 'ContextEnhancer', + 'partial_cmp', + ]; + const mcpContextAdapterRuleText = mcpContextAdapterRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpContextAdapterHelpers) { + if (!mcpContextAdapterRuleText.includes(helper)) { + throw new Error(`MCP context adapter boundary rule must forbid helper: ${helper}`); + } + } + + const mcpJsonConfigRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/config/json_config.rs', + ); + if (!mcpJsonConfigRule) { + throw new Error('missing MCP JSON config boundary rule'); + } + const mcpJsonConfigHelpers = [ + 'normalize_source', + 'normalize_transport', + 'normalize_legacy_type', + 'mcpServers', + ]; + const mcpJsonConfigRuleText = mcpJsonConfigRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpJsonConfigHelpers) { + if (!mcpJsonConfigRuleText.includes(helper)) { + throw new Error(`MCP JSON config boundary rule must forbid helper: ${helper}`); + } + } + + const mcpConfigServiceRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/config/service.rs', + ); + if (!mcpConfigServiceRule) { + throw new Error('missing MCP config service boundary rule'); + } + const mcpConfigServiceHelpers = [ + 'AUTHORIZATION_KEYS', + 'config_signature', + 'precedence', + 'config_authorization_from_map', + 'BTreeMap', + ]; + const mcpConfigServiceRuleText = mcpConfigServiceRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpConfigServiceHelpers) { + if (!mcpConfigServiceRuleText.includes(helper)) { + throw new Error(`MCP config service boundary rule must forbid helper: ${helper}`); + } + } + + const mcpAuthRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/auth.rs', + ); + if (!mcpAuthRule) { + throw new Error('missing MCP auth boundary rule'); + } + const mcpAuthHelpers = [ + 'VaultFile', + 'NONCE_LEN', + 'encrypt_value', + 'decrypt_value', + 'AuthorizationManager::new', + 'OAuthState::new', + ]; + const mcpAuthRuleText = mcpAuthRule.patterns.map((pattern) => pattern.regex.source).join('\n'); + for (const helper of mcpAuthHelpers) { + if (!mcpAuthRuleText.includes(escapeRegex(helper))) { + throw new Error(`MCP auth boundary rule must forbid helper: ${helper}`); + } + } + + const mcpRemoteTransportRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/protocol/transport_remote.rs', + ); + if (!mcpRemoteTransportRule) { + throw new Error('missing MCP remote transport boundary rule'); + } + const mcpRemoteTransportHelpers = [ + 'normalize_authorization_value', + 'starts_with\\("bearer "\\)', + 'build_client_info', + 'ClientCapabilities::builder', + 'map_(?:rmcp_)?initialize_result', + 'map_(?:rmcp_)?tool', + 'map_(?:rmcp_)?resource', + 'map_(?:rmcp_)?resource_content', + 'map_(?:rmcp_)?prompt', + 'map_(?:rmcp_)?prompt_message', + 'map_(?:rmcp_)?tool_result', + 'map_(?:rmcp_)?content_block', + 'map_(?:rmcp_)?icons', + 'map_(?:rmcp_)?annotations', + ]; + const mcpRemoteTransportRuleText = mcpRemoteTransportRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpRemoteTransportHelpers) { + if (!mcpRemoteTransportRuleText.includes(helper)) { + throw new Error(`MCP remote transport boundary rule must forbid helper: ${helper}`); + } + } + + const mcpJsonrpcRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/protocol/jsonrpc.rs', + ); + if (!mcpJsonrpcRule) { + throw new Error('missing MCP JSON-RPC boundary rule'); + } + const mcpJsonrpcHelpers = [ + 'serialize_params', + 'create_initialize_request', + 'create_resources_list_request', + 'create_resources_read_request', + 'create_prompts_list_request', + 'create_prompts_get_request', + 'create_tools_list_request', + 'create_tools_call_request', + 'create_ping_request', + ]; + const mcpJsonrpcRuleText = mcpJsonrpcRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpJsonrpcHelpers) { + if (!mcpJsonrpcRuleText.includes(helper)) { + throw new Error(`MCP JSON-RPC boundary rule must forbid helper: ${helper}`); + } + } + + const mcpServerConfigRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/server/config.rs', + ); + if (!mcpServerConfigRule) { + throw new Error('missing MCP server config boundary rule'); + } + const mcpServerConfigContracts = [ + 'MCPServerTransport', + 'MCPServerOAuthConfig', + 'MCPServerXaaConfig', + 'MCPServerConfig', + 'default_true', + 'resolved_transport', + 'validate', + ]; + const mcpServerConfigRuleText = mcpServerConfigRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of mcpServerConfigContracts) { + if (!mcpServerConfigRuleText.includes(contract)) { + throw new Error(`MCP server config boundary rule must forbid contract: ${contract}`); + } + } + + const servicesIntegrationsProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'services-integrations', + ); + for (const dep of ['dunce', 'futures', 'reqwest', 'sse-stream']) { + if (!servicesIntegrationsProfile?.forbiddenNonOptionalDeps.includes(dep)) { + throw new Error(`services-integrations default profile must forbid non-optional ${dep}`); + } + } + + const remoteConnectRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/service/remote_connect/remote_server.rs', + ); + if (!remoteConnectRule) { + throw new Error('missing remote-connect remote_server boundary rule'); + } + const remoteConnectContracts = [ + 'ImageAttachment', + 'ChatImageAttachment', + 'ChatMessage', + 'ChatMessageItem', + 'RemoteToolStatus', + 'ActiveTurnSnapshot', + 'SessionInfo', + 'RemoteDefaultModelsConfig', + 'RemoteModelConfig', + 'RemoteModelCatalog', + 'RemoteModelCatalogPollDelta', + 'RemoteCommand', + 'RemoteResponse', + 'TrackerState', + 'TrackerEvent', + 'RemoteSessionStateTracker', + 'DashMap', + 'make_slim_params', + 'match mobile_type', + 'RemoteCancelDecision', + 'resolve_remote_cancel_decision', + 'RemoteCancelTaskRequest', + 'RemoteCancelRuntimeHost', + 'cancel_remote_task', + 'remote_session_restore_target', + 'resolve_remote_execution_image_contexts', + 'RemoteImageContextAdapter', + 'MAX_SIZE', + 'MAX_CHUNK', + 'unwrap_or\\("file"\\)', + 'resolve_workspace_path', + 'detect_mime_type', + 'read_workspace_file', + 'read_remote_workspace_file', + 'read_remote_workspace_file_chunk', + 'read_remote_workspace_file_info', + 'remote_file_content_response', + 'remote_file_chunk_response', + 'remote_file_info_response', + 'handle_remote_workspace_file_command', + 'general_purpose::STANDARD\\.encode', + 'remote_dialog_submit_response', + 'remote_task_cancel_response', + 'remote_interaction_accepted_response', + 'remote_answer_question_response', + 'remote_workspace_info_response', + 'remote_recent_workspaces_response', + 'remote_assistant_list_response', + 'remote_workspace_updated_response', + 'remote_assistant_updated_response', + 'remote_session_info', + 'remote_session_list_response', + 'remote_initial_sync_response', + 'remote_session_created_response', + 'remote_session_model_updated_response', + 'remote_messages_response', + 'remote_session_deleted_response', + 'should_send_remote_model_catalog', + 'remote_model_catalog_poll_delta', + 'remote_no_change_poll_response', + 'remote_snapshot_poll_response', + 'remote_persisted_poll_response', + ]; + const remoteConnectRuleText = remoteConnectRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of remoteConnectContracts) { + if (!remoteConnectRuleText.includes(contract)) { + throw new Error(`remote-connect boundary rule must forbid contract: ${contract}`); + } + } + + const facadePaths = new Set(facadeOnlyFiles.map((facade) => facade.path)); + for (const path of [ + 'src/crates/assembly/core/src/service/mcp/protocol/transport.rs', + 'src/crates/assembly/core/src/service/mcp/protocol/transport_remote.rs', + 'src/crates/assembly/core/src/service/mcp/server/connection.rs', + ]) { + if (!facadePaths.has(path)) { + throw new Error(`missing MCP runtime facade-only rule for ${path}`); + } + } +} diff --git a/scripts/generate-i18n-contract.mjs b/scripts/generate-i18n-contract.mjs index 8f7ce7b9ac..d4f389d66c 100644 --- a/scripts/generate-i18n-contract.mjs +++ b/scripts/generate-i18n-contract.mjs @@ -20,7 +20,7 @@ const outputs = [ generate: generateInstallerLocaleContract, }, { - path: path.join(root, 'src', 'crates', 'core', 'src', 'service', 'i18n', 'generated_locale_contract.rs'), + path: path.join(root, 'src', 'crates', 'assembly', 'core', 'src', 'service', 'i18n', 'generated_locale_contract.rs'), generate: generateCoreRustLocaleContract, }, { @@ -458,7 +458,7 @@ ${locales.map((locale) => ` GeneratedLocaleContractEntry { short_model_instruction: ${rustString(locale.shortModelInstruction)}, aliases: ${rustStringArray(locale.aliases)}, content_fallbacks: ${rustLocaleArray(locale.contentFallbacks.map((id) => localeMap.get(id)))}, - }`).join(',\n')} + },`).join('\n')} ]; pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ @@ -466,7 +466,7 @@ ${sharedTermEntries.map((entry) => ` GeneratedSharedTermEntry { locale: ${rustLocaleId(entry.locale)}, key: ${rustString(entry.key)}, value: ${rustString(entry.value)}, - }`).join(',\n')} + },`).join('\n')} ]; pub fn generated_locale_entry(id: LocaleId) -> &'static GeneratedLocaleContractEntry { @@ -476,7 +476,9 @@ pub fn generated_locale_entry(id: LocaleId) -> &'static GeneratedLocaleContractE .expect("LocaleId missing from generated locale contract") } -pub fn generated_locale_entry_from_code(code: &str) -> Option<&'static GeneratedLocaleContractEntry> { +pub fn generated_locale_entry_from_code( + code: &str, +) -> Option<&'static GeneratedLocaleContractEntry> { let normalized = code.trim().to_ascii_lowercase(); if normalized.is_empty() { return None; @@ -517,16 +519,31 @@ mod tests { #[test] fn generated_contract_order_matches_runtime_locale_order() { - let generated_ids: Vec<_> = GENERATED_LOCALE_CONTRACT.iter().map(|entry| entry.id).collect(); + let generated_ids: Vec<_> = GENERATED_LOCALE_CONTRACT + .iter() + .map(|entry| entry.id) + .collect(); assert_eq!(generated_ids, LocaleId::all()); } #[test] fn generated_contract_resolves_aliases_like_runtime_locale_contract() { - assert_eq!(generated_locale_entry_from_code("zh-Hant-TW").map(|entry| entry.id), Some(LocaleId::ZhTW)); - assert_eq!(generated_locale_entry_from_code(" ZH-hans-CN ").map(|entry| entry.id), Some(LocaleId::ZhCN)); - assert_eq!(generated_locale_entry_from_code("en").map(|entry| entry.id), Some(LocaleId::EnUS)); - assert_eq!(generated_locale_entry_from_code("fr-FR").map(|entry| entry.id), None); + assert_eq!( + generated_locale_entry_from_code("zh-Hant-TW").map(|entry| entry.id), + Some(LocaleId::ZhTW) + ); + assert_eq!( + generated_locale_entry_from_code(" ZH-hans-CN ").map(|entry| entry.id), + Some(LocaleId::ZhCN) + ); + assert_eq!( + generated_locale_entry_from_code("en").map(|entry| entry.id), + Some(LocaleId::EnUS) + ); + assert_eq!( + generated_locale_entry_from_code("fr-FR").map(|entry| entry.id), + None + ); } #[test] diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs index 2818edb283..06b03a53a6 100644 --- a/scripts/i18n-audit.mjs +++ b/scripts/i18n-audit.mjs @@ -29,7 +29,7 @@ const mobileWebSourceDir = path.join(root, 'src', 'mobile-web', 'src'); const mobileWebMessagesPath = path.join(mobileWebSourceDir, 'i18n', 'messages.ts'); const installerSourceDir = path.join(root, 'BitFun-Installer', 'src'); const installerLocalesDir = path.join(installerSourceDir, 'i18n', 'locales'); -const coreLocalesDir = path.join(root, 'src', 'crates', 'core', 'locales'); +const coreLocalesDir = path.join(root, 'src', 'crates', 'assembly', 'core', 'locales'); const relayHomepageDir = path.join(root, 'src', 'apps', 'relay-server', 'static', 'homepage'); const relayHomepageI18nPath = path.join(relayHomepageDir, 'i18n.json'); const supportedLocales = fs @@ -44,10 +44,12 @@ let errorCount = 0; let warningCount = 0; let auditTypeScript = null; let cliOptions = { reportJsonPath: null }; +let governanceBaselineCache; const reportCategories = [ 'confirmedUnusedKeys', 'dynamicKeyCandidates', 'sharedTermDuplicates', + 'sameTextLocaleInventory', 'l10nQualityCandidates', 'literalDefaultValueFallbacks', 'localeFormatCandidates', @@ -66,6 +68,7 @@ const governanceReport = { confirmedUnusedKeys: [], dynamicKeyCandidates: [], sharedTermDuplicates: [], + sameTextLocaleInventory: [], l10nQualityCandidates: [], literalDefaultValueFallbacks: [], localeFormatCandidates: [], @@ -244,19 +247,113 @@ function hasHanText(value) { return /\p{Script=Han}/u.test(String(value)); } +const zhTwSameTextTerminologySignals = ['了解']; +// Keep this as a review signal, not a converter. The goal is to catch obvious +// Simplified-copy residue without flagging every valid same-writing Han term. +const zhTwSameTextScriptSignals = new Set([ + '这', + '个', + '们', + '为', + '与', + '开', + '关', + '发', + '复', + '后', + '过', + '还', + '进', + '选', + '连', + '远', + '时', + '设', + '数', + '据', + '页', + '项', + '态', + '错', + '误', + '删', + '编', + '导', + '启', + '闭', + '间', + '类', + '显', + '隐', + '径', + '档', + '检', + '测', + '权', + '务', + '动', + '应', + '统', + '议', + '读', + '写', + '话', + '请', + '询', + '详', + '语', + '认', + '让', + '讲', + '试', + '论', + '证', + '评', + '识', + '访', + '调', + '变', + '图', + '标', + '链', + '节', + '览', + '获', +]); + +function getZhTwSameTextSignal(value) { + const text = String(value); + for (const phrase of zhTwSameTextTerminologySignals) { + if (text.includes(phrase)) { + return { type: 'terminology', match: phrase }; + } + } + + for (const character of text) { + if (zhTwSameTextScriptSignals.has(character)) { + return { type: 'script-variant', match: character }; + } + } + + return null; +} + function sortByReportIdentity(left, right) { return JSON.stringify(left).localeCompare(JSON.stringify(right)); } function countEntriesBy(entries, field, options = {}) { const emptyLabel = options.emptyLabel ?? ''; + const includeKeys = options.includeKeys ?? []; + const counts = entries.reduce((acc, entry) => { + const value = entry[field] ?? emptyLabel; + const key = value === '' ? emptyLabel : value; + acc.set(key, (acc.get(key) ?? 0) + 1); + return acc; + }, new Map(includeKeys.map((key) => [key, 0]))); + return Object.fromEntries( - Array.from(entries.reduce((counts, entry) => { - const value = entry[field] ?? emptyLabel; - const key = value === '' ? emptyLabel : value; - counts.set(key, (counts.get(key) ?? 0) + 1); - return counts; - }, new Map()).entries()) + Array.from(counts.entries()) .sort(([left], [right]) => String(left).localeCompare(String(right))), ); } @@ -267,6 +364,9 @@ function finalizeGovernanceReport() { governanceReport.summary.counts[category] = governanceReport[category].length; } + const governanceSurfaceIds = collectGovernanceBudgetSurfaceIds(); + const localeFormatSurfaceIds = collectLocaleFormatSurfaceIds(); + governanceReport.summary.byCategory = { confirmedUnusedKeys: { bySurface: countEntriesBy(governanceReport.confirmedUnusedKeys, 'surface'), @@ -279,12 +379,18 @@ function finalizeGovernanceReport() { sharedTermDuplicates: { byNamespace: countEntriesBy(governanceReport.sharedTermDuplicates, 'namespace', { emptyLabel: '' }), bySharedKey: countEntriesBy(governanceReport.sharedTermDuplicates, 'sharedKey'), - bySurface: countEntriesBy(governanceReport.sharedTermDuplicates, 'surface'), + bySurface: countEntriesBy(governanceReport.sharedTermDuplicates, 'surface', { includeKeys: governanceSurfaceIds }), + }, + sameTextLocaleInventory: { + byAllowlistState: countEntriesBy(governanceReport.sameTextLocaleInventory, 'allowlistState'), + byNamespace: countEntriesBy(governanceReport.sameTextLocaleInventory, 'namespace', { emptyLabel: '' }), + bySignalType: countEntriesBy(governanceReport.sameTextLocaleInventory, 'signalType'), + bySurface: countEntriesBy(governanceReport.sameTextLocaleInventory, 'surface', { includeKeys: governanceSurfaceIds }), }, l10nQualityCandidates: { byComparisonLocale: countEntriesBy(governanceReport.l10nQualityCandidates, 'comparisonLocale'), byNamespace: countEntriesBy(governanceReport.l10nQualityCandidates, 'namespace', { emptyLabel: '' }), - bySurface: countEntriesBy(governanceReport.l10nQualityCandidates, 'surface'), + bySurface: countEntriesBy(governanceReport.l10nQualityCandidates, 'surface', { includeKeys: governanceSurfaceIds }), }, literalDefaultValueFallbacks: { byFile: countEntriesBy(governanceReport.literalDefaultValueFallbacks, 'file'), @@ -292,7 +398,7 @@ function finalizeGovernanceReport() { }, localeFormatCandidates: { byFile: countEntriesBy(governanceReport.localeFormatCandidates, 'file'), - bySurface: countEntriesBy(governanceReport.localeFormatCandidates, 'surface'), + bySurface: countEntriesBy(governanceReport.localeFormatCandidates, 'surface', { includeKeys: localeFormatSurfaceIds }), }, }; } @@ -1009,7 +1115,7 @@ function collectI18nResourceEntries(namespaces) { locale, key, value, - file: `src/crates/core/locales/${locale}.ftl`, + file: `src/crates/assembly/core/locales/${locale}.ftl`, }); } } @@ -1242,8 +1348,13 @@ function readL10nIdenticalAllowlist() { } function readGovernanceBaseline() { + if (governanceBaselineCache !== undefined) { + return governanceBaselineCache; + } + if (!fs.existsSync(governanceBaselinePath)) { reportError('Missing scripts/i18n-governance-baseline.json'); + governanceBaselineCache = null; return null; } @@ -1252,6 +1363,7 @@ function readGovernanceBaseline() { baseline = readJsonFile(governanceBaselinePath); } catch (error) { reportError(`Failed to parse scripts/i18n-governance-baseline.json: ${error.message}`); + governanceBaselineCache = null; return null; } @@ -1260,12 +1372,29 @@ function readGovernanceBaseline() { } if (!isPlainObject(baseline.budgets)) { reportError('scripts/i18n-governance-baseline.json must define a budgets object'); + governanceBaselineCache = null; return null; } + governanceBaselineCache = baseline; return baseline; } +function collectGovernanceBudgetSurfaceIds() { + const baseline = readGovernanceBaseline(); + if (!baseline) return []; + + const surfaces = new Set(); + for (const budget of Object.values(baseline.budgets)) { + if (!isPlainObject(budget?.bySurface)) continue; + for (const surface of Object.keys(budget.bySurface)) { + surfaces.add(surface); + } + } + + return Array.from(surfaces).sort(); +} + function allowlistTargetForGroup(entry, group) { if (entry.surface !== group.surface) return null; if (entry.namespace && entry.namespace !== group.namespace) return null; @@ -1402,6 +1531,35 @@ function collectSharedTermDuplicates(resourceEntries) { } } +function collectSameTextLocaleInventory(resourceGroups, allowedIdenticalMatches) { + for (const group of resourceGroups) { + const simplified = group.valueByLocale.get('zh-CN'); + const traditional = group.valueByLocale.get('zh-TW'); + if (!simplified || !traditional || simplified !== traditional || !hasHanText(traditional)) { + continue; + } + + const signal = getZhTwSameTextSignal(traditional); + const allowlisted = allowedIdenticalMatches.has(l10nIdenticalMatchId(group, 'zh-TW', 'zh-CN')); + + governanceReport.sameTextLocaleInventory.push({ + surface: group.surface, + namespace: group.namespace, + key: group.key, + resourceKey: group.resourceKey, + locale: 'zh-TW', + comparisonLocale: 'zh-CN', + value: traditional, + files: group.files, + reason: 'same-text-locale-pair', + signal, + signalType: signal?.type ?? 'none', + allowlisted, + allowlistState: allowlisted ? 'allowlisted' : 'unreviewed', + }); + } +} + function collectL10nQualityCandidates(resourceGroups, allowedIdenticalMatches) { for (const group of resourceGroups) { const simplified = group.valueByLocale.get('zh-CN'); @@ -1409,6 +1567,10 @@ function collectL10nQualityCandidates(resourceGroups, allowedIdenticalMatches) { if (!simplified || !traditional || simplified !== traditional || !hasHanText(traditional)) { continue; } + const signal = getZhTwSameTextSignal(traditional); + if (!signal) { + continue; + } if (allowedIdenticalMatches.has(l10nIdenticalMatchId(group, 'zh-TW', 'zh-CN'))) { continue; } @@ -1423,6 +1585,7 @@ function collectL10nQualityCandidates(resourceGroups, allowedIdenticalMatches) { value: traditional, files: group.files, reason: 'matches-comparison-locale', + signal, }); } } @@ -1536,6 +1699,7 @@ function auditI18nGovernanceReport(namespaces) { collectConfirmedUnusedKeys(); collectDynamicKeyCandidates(resourceGroups); collectSharedTermDuplicates(resourceEntries); + collectSameTextLocaleInventory(resourceGroups, allowedIdenticalMatches); collectL10nQualityCandidates(resourceGroups, allowedIdenticalMatches); auditGovernanceBaseline(); } @@ -1817,6 +1981,12 @@ function isLiteralFallbackInitializer(ts, initializer) { ts.isTemplateExpression(element) )); } + if (ts.isBinaryExpression(value)) { + return ( + isLiteralFallbackInitializer(ts, value.left) || + isLiteralFallbackInitializer(ts, value.right) + ); + } return false; } @@ -1959,7 +2129,10 @@ function countCjkSourceLines(scanRoot, predicate) { function shouldSkipLocaleFormatSourceScan(file) { const normalized = toPosixPath(path.relative(root, file)); return ( + // Surface i18n owners are the only approved locations for direct Intl usage; + // product code must call their exported formatting helpers instead. normalized === 'src/web-ui/src/infrastructure/i18n/core/I18nService.ts' || + normalized === 'src/mobile-web/src/i18n/I18nProvider.tsx' || normalized.endsWith('/generatedLocaleContract.ts') || normalized.endsWith('.test.ts') || normalized.endsWith('.test.tsx') || @@ -1968,9 +2141,8 @@ function shouldSkipLocaleFormatSourceScan(file) { ); } -function collectLocaleFormatUsageFindings() { - const formatPattern = /\b(?:new\s+)?Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\s*\(|\.\s*toLocale(?:String|DateString|TimeString)\s*\(/g; - const specs = [ +function createLocaleFormatScanSpecs() { + return [ { surface: 'web-ui', root: webSourceDir, @@ -2000,10 +2172,19 @@ function collectLocaleFormatUsageFindings() { }, { surface: 'core-miniapp', - root: path.join(root, 'src', 'crates', 'core', 'src', 'miniapp', 'builtin', 'assets'), + root: path.join(root, 'src', 'crates', 'contracts', 'product-domains', 'src', 'miniapp', 'builtin', 'assets'), predicate: (file) => file.endsWith('.js'), }, ]; +} + +function collectLocaleFormatSurfaceIds() { + return sortedUnique(createLocaleFormatScanSpecs().map((spec) => spec.surface)); +} + +function collectLocaleFormatUsageFindings() { + const formatPattern = /\b(?:new\s+)?Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\s*\(|\.\s*toLocale(?:String|DateString|TimeString)\s*\(/g; + const specs = createLocaleFormatScanSpecs(); const findings = []; for (const spec of specs) { diff --git a/scripts/i18n-contract.test.mjs b/scripts/i18n-contract.test.mjs index 1d63a3c412..13821d2493 100644 --- a/scripts/i18n-contract.test.mjs +++ b/scripts/i18n-contract.test.mjs @@ -5,13 +5,16 @@ import path from 'node:path'; import test from 'node:test'; const root = process.cwd(); +const contractTestProfile = process.env.BITFUN_I18N_CONTRACT_TEST_PROFILE ?? 'full'; +const runAuditIntegrationTests = process.env.BITFUN_I18N_CONTRACT_TEST_AUDIT_INTEGRATION === '1'; +const skipAuditIntegrationTests = contractTestProfile === 'ci' && !runAuditIntegrationTests; const contractPath = path.join(root, 'src', 'shared', 'i18n', 'contract', 'locales.json'); const sharedTermsDir = path.join(root, 'src', 'shared', 'i18n', 'resources', 'shared'); const expectedGeneratedFiles = [ 'src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts', 'src/mobile-web/src/i18n/generatedLocaleContract.ts', 'BitFun-Installer/src/i18n/generatedLocaleContract.ts', - 'src/crates/core/src/service/i18n/generated_locale_contract.rs', + 'src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs', 'BitFun-Installer/src-tauri/src/installer/generated_locale_contract.rs', ]; const expectedGeneratedJsonFiles = [ @@ -38,6 +41,17 @@ function runI18nAudit(args = []) { }); } +// CI runs one full i18n:audit immediately after this file; keep mutation-heavy +// audit integration cases in the default profile without paying that cost twice. +function auditIntegrationTest(name, optionsOrFn, maybeFn) { + const runner = skipAuditIntegrationTests ? test.skip : test; + if (typeof optionsOrFn === 'function') { + return runner(name, optionsOrFn); + } + + return runner(name, optionsOrFn, maybeFn); +} + function withTemporaryTextFile(relativePath, content, callback) { const absolutePath = path.join(root, relativePath); const existed = fs.existsSync(absolutePath); @@ -65,6 +79,12 @@ function flattenKeys(value, prefix = '') { .sort(); } +function getValueByPath(value, dottedPath) { + return dottedPath.split('.').reduce((current, segment) => ( + current && typeof current === 'object' ? current[segment] : undefined + ), value); +} + function listFiles(dir, predicate) { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries.flatMap((entry) => { @@ -134,7 +154,7 @@ test('shared i18n terms exist for every canonical locale with matching keys', () }); test('core runtime uses the generated locale contract for language identity', () => { - const typesSource = readText('src/crates/core/src/service/i18n/types.rs'); + const typesSource = readText('src/crates/assembly/core/src/service/i18n/types.rs'); assert.match( typesSource, /generated_locale_contract::\{[\s\S]*GENERATED_LOCALE_CONTRACT/, @@ -146,7 +166,7 @@ test('core runtime uses the generated locale contract for language identity', () 'types.rs must not read language identity from the backend resource registry', ); - const resourceRegistrySource = readText('src/crates/core/src/service/i18n/locale_registry.rs'); + const resourceRegistrySource = readText('src/crates/assembly/core/src/service/i18n/locale_registry.rs'); assert.doesNotMatch( resourceRegistrySource, /\b(name|english_name|native_name|rtl|model_language_name|short_model_instruction|aliases):/, @@ -164,7 +184,7 @@ test('shared i18n terms are consumed by each product surface runtime', () => { const installerLanguagesSource = readText('BitFun-Installer/src/i18n/languages.ts'); assert.match(installerLanguagesSource, /SHARED_TERMS_BY_APP_LANGUAGE/, 'installer should merge shared terms into its i18next resources'); - const coreServiceSource = readText('src/crates/core/src/service/i18n/service.rs'); + const coreServiceSource = readText('src/crates/assembly/core/src/service/i18n/service.rs'); assert.match(coreServiceSource, /generated_shared_term/, 'core i18n service should resolve generated shared terms'); }); @@ -247,6 +267,60 @@ test('web-ui synchronous i18nService.t namespaces stay in the bootstrap set', () ); }); +test('web-ui slash command picker distinguishes all commands from quick actions', () => { + const source = readText('src/web-ui/src/flow_chat/components/ChatInput.tsx'); + const allCommandsStart = source.indexOf("if (slashCommandState.kind === 'all')"); + const allCommandsEnd = source.indexOf('if (!canSwitchModes)', allCommandsStart); + assert.notEqual(allCommandsStart, -1, 'ChatInput should render an all-command slash picker state'); + assert.notEqual(allCommandsEnd, -1, 'ChatInput all-command branch should stay before the mode-only branch'); + + const allCommandsBlock = source.slice(allCommandsStart, allCommandsEnd); + assert.match( + allCommandsBlock, + /t\('chatInput\.commands'\)/, + 'all-command slash picker should use a Commands label, not the quick-action label', + ); + assert.doesNotMatch( + allCommandsBlock, + /t\('chatInput\.quickAction'\)/, + 'all-command slash picker must not reuse the quick-action label', + ); + + const contract = readJson('src/shared/i18n/contract/locales.json'); + for (const locale of contract.locales.map((entry) => entry.id)) { + const resource = readJson(`src/web-ui/src/locales/${locale}/flow-chat.json`); + const commands = getValueByPath(resource, 'chatInput.commands'); + const quickAction = getValueByPath(resource, 'chatInput.quickAction'); + assert.equal(typeof commands, 'string', `${locale} flow-chat chatInput.commands should exist`); + assert.notEqual(commands, quickAction, `${locale} chatInput.commands should not duplicate chatInput.quickAction`); + } +}); + +test('review platform relative time labels are locale resources', () => { + const source = readText('src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx'); + + assert.match(source, /common:reviewPlatform\.relativeTime\.minutesAgo/, 'minute relative time label should be localized'); + assert.match(source, /common:reviewPlatform\.relativeTime\.hoursAgo/, 'hour relative time label should be localized'); + assert.match(source, /common:reviewPlatform\.relativeTime\.daysAgo/, 'day relative time label should be localized'); + assert.doesNotMatch(source, /`[^`]*\bm ago`/, 'relative minute text must not be hard-coded in ReviewPlatformPanel'); + assert.doesNotMatch(source, /`[^`]*\bh ago`/, 'relative hour text must not be hard-coded in ReviewPlatformPanel'); + assert.doesNotMatch(source, /`[^`]*\bd ago`/, 'relative day text must not be hard-coded in ReviewPlatformPanel'); + + const contract = readJson('src/shared/i18n/contract/locales.json'); + for (const locale of contract.locales.map((entry) => entry.id)) { + const resource = readJson(`src/web-ui/src/locales/${locale}/common.json`); + for (const key of [ + 'reviewPlatform.relativeTime.minutesAgo', + 'reviewPlatform.relativeTime.hoursAgo', + 'reviewPlatform.relativeTime.daysAgo', + ]) { + const value = getValueByPath(resource, key); + assert.equal(typeof value, 'string', `${locale} common ${key} should exist`); + assert.match(value, /\{\{\s*count\s*\}\}/, `${locale} common ${key} should interpolate count`); + } + } +}); + test('i18n audit enforces the checked-in hardcoded source candidate budget', () => { const baselineSource = readText('scripts/i18n-hardcoded-baseline.json'); const auditSource = readText('scripts/i18n-audit.mjs'); @@ -268,13 +342,14 @@ test('i18n audit treats locale key parity as an error', () => { test('CI runs i18n contract and audit guards before frontend builds', () => { const ciSource = readText('.github/workflows/ci.yml'); - const contractIndex = ciSource.indexOf('pnpm run i18n:contract:test'); + const contractIndex = ciSource.indexOf('pnpm run i18n:contract:test:ci'); const auditIndex = ciSource.indexOf('pnpm run i18n:audit'); const buildIndex = ciSource.indexOf('pnpm run build:web'); - assert.notEqual(contractIndex, -1, 'CI should run pnpm run i18n:contract:test'); + assert.notEqual(contractIndex, -1, 'CI should run pnpm run i18n:contract:test:ci'); assert.notEqual(auditIndex, -1, 'CI should run pnpm run i18n:audit'); assert.ok(contractIndex < buildIndex, 'i18n contract checks should run before web build'); + assert.ok(contractIndex < auditIndex, 'CI should run the fast contract check before the full i18n audit'); assert.ok(auditIndex < buildIndex, 'i18n audit should run before web build'); }); @@ -291,6 +366,31 @@ test('i18n audit enforces interpolation parameter parity across resource formats assert.match(auditSource, /extractFluentPlaceholders/, 'Fluent placeholder extraction should be explicit'); }); +test('i18n audit report surface summaries derive from owned scan and budget sources', () => { + const auditSource = readText('scripts/i18n-audit.mjs'); + + assert.doesNotMatch( + auditSource, + /const governanceSurfaceIds\s*=\s*\[/, + 'governance report surface summaries should derive from the governance baseline dimensions', + ); + assert.doesNotMatch( + auditSource, + /const localeFormatSurfaceIds\s*=\s*\[/, + 'locale format report surface summaries should derive from the locale-format scan specs', + ); + assert.match( + auditSource, + /collectGovernanceBudgetSurfaceIds/, + 'governance report should collect zero-count surfaces from the baseline bySurface budgets', + ); + assert.match( + auditSource, + /createLocaleFormatScanSpecs/, + 'locale format report should reuse the same scan specs for scanning and zero-count summaries', + ); +}); + test('i18n audit fails literal fallbacks and unknown static keys', () => { const auditSource = readText('scripts/i18n-audit.mjs'); const sourceTextAudit = auditSource.match(/function auditSourceText\(\) \{[\s\S]*?\n\}/)?.[0] ?? ''; @@ -318,7 +418,7 @@ test('i18n audit gates object-form literal fallbacks with an explicit budget', ( assert.match(auditSource, /defaultValue/, 'audit should inspect i18next defaultValue options'); }); -test('i18n audit reports literal fallback and locale formatting candidate baselines', { concurrency: false }, () => { +auditIntegrationTest('i18n audit reports literal fallback and locale formatting candidate baselines', { concurrency: false }, () => { const auditSource = readText('scripts/i18n-audit.mjs'); const localeFormatBaselineSource = readText('scripts/i18n-locale-format-baseline.json'); const localeFormatBaseline = JSON.parse(localeFormatBaselineSource); @@ -399,6 +499,7 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: assert.ok(Array.isArray(report.confirmedUnusedKeys), 'report should include confirmed unused keys'); assert.ok(Array.isArray(report.dynamicKeyCandidates), 'report should include dynamic key candidates'); assert.ok(Array.isArray(report.sharedTermDuplicates), 'report should include shared-term duplicate candidates'); + assert.ok(Array.isArray(report.sameTextLocaleInventory), 'report should include same-text locale inventory'); assert.ok(Array.isArray(report.l10nQualityCandidates), 'report should include l10n quality candidates'); assert.ok(Array.isArray(report.literalDefaultValueFallbacks), 'report should include literal defaultValue fallback candidates'); assert.ok(Array.isArray(report.localeFormatCandidates), 'report should include direct locale formatting candidates'); @@ -408,6 +509,7 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: confirmedUnusedKeys: report.confirmedUnusedKeys.length, dynamicKeyCandidates: report.dynamicKeyCandidates.length, sharedTermDuplicates: report.sharedTermDuplicates.length, + sameTextLocaleInventory: report.sameTextLocaleInventory.length, l10nQualityCandidates: report.l10nQualityCandidates.length, literalDefaultValueFallbacks: report.literalDefaultValueFallbacks.length, localeFormatCandidates: report.localeFormatCandidates.length, @@ -425,12 +527,21 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: ); } assert.equal( - report.summary.byCategory.l10nQualityCandidates.bySurface['web-ui'], + report.summary.byCategory.l10nQualityCandidates.bySurface['web-ui'] ?? 0, report.l10nQualityCandidates.filter((entry) => entry.surface === 'web-ui').length, 'report should summarize l10n candidates by surface', ); assert.equal( - report.summary.byCategory.l10nQualityCandidates.byNamespace['flow-chat'], + report.summary.byCategory.sameTextLocaleInventory.bySurface['web-ui'] ?? 0, + report.sameTextLocaleInventory.filter((entry) => entry.surface === 'web-ui').length, + 'report should summarize same-text locale inventory by surface', + ); + assert.ok( + report.sameTextLocaleInventory.length > report.l10nQualityCandidates.length, + 'same-text locale inventory should retain broad non-blocking review data beyond actionable l10n signals', + ); + assert.equal( + report.summary.byCategory.l10nQualityCandidates.byNamespace['flow-chat'] ?? 0, report.l10nQualityCandidates.filter((entry) => entry.namespace === 'flow-chat').length, 'report should summarize l10n candidates by namespace', ); @@ -455,13 +566,10 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: report.sharedTermDuplicates.every((entry) => entry.resourceKey !== entry.sharedResourceKey), 'shared-term duplicate findings should only report surface copies, not the shared source entry itself', ); - assert.ok( - report.l10nQualityCandidates.some((entry) => ( - entry.locale === 'zh-TW' && - entry.comparisonLocale === 'zh-CN' && - entry.reason === 'matches-comparison-locale' - )), - 'unchanged zh-TW copy should be reported as a localization quality candidate', + assert.equal( + report.l10nQualityCandidates.length, + 0, + 'reviewed same-writing zh-CN/zh-TW copy without a script or terminology signal should not create l10n noise', ); assert.ok( report.literalDefaultValueFallbacks.every((entry) => entry.file && entry.key && entry.location), @@ -471,17 +579,58 @@ test('i18n audit can emit a machine-readable governance report', { concurrency: report.localeFormatCandidates.every((entry) => entry.surface && entry.file && entry.location), 'locale format candidates should include surface, file, and location for cleanup reviews', ); - assert.equal( - report.l10nQualityCandidates.some((entry) => entry.resourceKey === 'shared:modes.agentic'), - false, - 'intentional zh-CN/zh-TW identical shared terms should be excluded from l10n quality candidates', - ); } finally { fs.rmSync(absoluteReportPath, { force: true }); } }); -test('mobile-web uses shared terms for stable shared concept labels', { concurrency: false }, () => { +auditIntegrationTest('i18n audit reports same-text zh-TW copy with a l10n signal', { concurrency: false }, () => { + const localePath = 'src/web-ui/src/locales/zh-TW/settings/acp-agents.json'; + const reportPath = 'scripts/.tmp-i18n-l10n-signal-report.json'; + const absoluteReportPath = path.join(root, reportPath); + const source = readText(localePath); + const fixture = source.replace('"learnMore": "瞭解更多"', '"learnMore": "了解更多"'); + + assert.notEqual(fixture, source, 'test fixture should introduce same-text zh-TW terminology debt'); + fs.rmSync(absoluteReportPath, { force: true }); + + try { + withTemporaryTextFile(localePath, fixture, () => { + const result = runI18nAudit(['--report-json', reportPath]); + assert.notEqual(result.status, 0, 'same-text zh-TW terminology debt should exceed the zero l10n baseline'); + + const report = readJson(reportPath); + assert.ok( + report.l10nQualityCandidates.some((entry) => ( + entry.surface === 'web-ui' && + entry.namespace === 'settings/acp-agents' && + entry.key === 'actions.learnMore' && + entry.locale === 'zh-TW' && + entry.comparisonLocale === 'zh-CN' && + entry.signal?.type === 'terminology' && + entry.signal?.match === '了解' + )), + 'audit report should include the concrete same-text terminology signal', + ); + assert.ok( + report.sameTextLocaleInventory.some((entry) => ( + entry.surface === 'web-ui' && + entry.namespace === 'settings/acp-agents' && + entry.key === 'actions.learnMore' && + entry.locale === 'zh-TW' && + entry.comparisonLocale === 'zh-CN' && + entry.signalType === 'terminology' && + entry.allowlistState === 'unreviewed' + )), + 'audit inventory should retain the same-text pair even though only signal-bearing entries are governance candidates', + ); + }); + } finally { + fs.rmSync(absoluteReportPath, { force: true }); + } +}); + +auditIntegrationTest('mobile-web uses shared terms for stable shared concept labels', { concurrency: false }, () => { const reportPath = 'scripts/.tmp-i18n-mobile-shared-terms-report.json'; const absoluteReportPath = path.join(root, reportPath); fs.rmSync(absoluteReportPath, { force: true }); @@ -494,11 +643,16 @@ test('mobile-web uses shared terms for stable shared concept labels', { concurre const migratedSharedKeys = new Set([ 'product.remote', 'features.workspace', + 'modes.assistant', + 'modes.expert', 'agents.claw', + 'agents.code', 'agents.cowork', + 'agents.default', 'tools.edit', 'tools.explore', 'tools.read', + 'tools.shell', 'tools.todo', 'tools.write', ]); @@ -509,12 +663,17 @@ test('mobile-web uses shared terms for stable shared concept labels', { concurre const legacyMobileKeys = [ 'common.appName', 'sessions.workspace', + 'sessions.assistantMode', 'sessions.coworkSession', + 'sessions.codeSession', + 'sessions.defaultAssistant', 'sessions.agentClaw', + 'sessions.proMode', 'workspace.title', 'tools.edit', 'tools.explore', 'tools.read', + 'tools.shell', 'tools.todo', 'tools.write', ]; @@ -543,7 +702,7 @@ test('mobile-web uses shared terms for stable shared concept labels', { concurre } }); -test('web-ui uses shared terms for stable navigation and feature labels', { concurrency: false }, () => { +auditIntegrationTest('web-ui uses shared terms for stable navigation and feature labels', { concurrency: false }, () => { const reportPath = 'scripts/.tmp-i18n-web-ui-shared-terms-report.json'; const absoluteReportPath = path.join(root, reportPath); fs.rmSync(absoluteReportPath, { force: true }); @@ -557,10 +716,16 @@ test('web-ui uses shared terms for stable navigation and feature labels', { conc 'common:app.name', 'common:header.remoteConnect', 'common:nav.sections.workspace', + 'common:nav.sessions.newCodeSessionShort', + 'common:nav.sessions.newCoworkSessionShort', 'common:scenes.settings', 'common:tabs.settings', 'common:remoteConnect.title', + 'common:remoteConnect.tabBitfunServer', + 'common:remoteConnect.tabLan', 'flow-chat:layout.noPanels', + 'flow-chat:deepReviewActionBar.minimizedDeep', + 'flow-chat:toolCards.codeReview.reviewMode', 'flow-chat:toolbar.settings', 'flow-chat:toolCards.sessionControl.workspace', 'flow-chat:toolCards.sessionMessage.workspace', @@ -568,6 +733,7 @@ test('web-ui uses shared terms for stable navigation and feature labels', { conc 'scenes/skills:suite.modes.claw', 'settings:title', 'settings:configCenter.title', + 'settings/review:overview.badge', 'settings:workspace.title', 'settings/lsp:tabs.settings', ]); @@ -578,16 +744,23 @@ test('web-ui uses shared terms for stable navigation and feature labels', { conc const legacyWebUiKeys = [ 'header.remoteConnect', 'remoteConnect.title', + 'remoteConnect.tabBitfunServer', + 'remoteConnect.tabLan', 'nav.sections.workspace', + 'nav.sessions.newCodeSessionShort', + 'nav.sessions.newCoworkSessionShort', 'scenes.settings', 'tabs.settings', 'layout.noPanels', + 'deepReviewActionBar.minimizedDeep', 'toolbar.settings', + 'toolCards.codeReview.reviewMode', 'toolCards.sessionControl.workspace', 'toolCards.sessionMessage.workspace', 'welcome.workspace', 'suite.modes.claw', 'configCenter.title', + 'overview.badge', 'workspace.title', ]; const webUiSourceFiles = listFiles(path.join(root, 'src', 'web-ui', 'src'), (file) => ( @@ -644,7 +817,7 @@ test('installer uses the shared product name for titlebar defaults', { concurren } }); -test('core and relay static homepage reuse shared product and feature terms', { concurrency: false }, () => { +auditIntegrationTest('core and relay static homepage reuse shared product and feature terms', { concurrency: false }, () => { const reportPath = 'scripts/.tmp-i18n-core-relay-shared-terms-report.json'; const absoluteReportPath = path.join(root, reportPath); fs.rmSync(absoluteReportPath, { force: true }); @@ -668,11 +841,11 @@ test('core and relay static homepage reuse shared product and feature terms', { ); for (const locale of ['en-US', 'zh-CN', 'zh-TW']) { - const fluentSource = readText(`src/crates/core/locales/${locale}.ftl`); + const fluentSource = readText(`src/crates/assembly/core/locales/${locale}.ftl`); assert.doesNotMatch(fluentSource, /^app-name\s*=/m, `${locale}.ftl should not copy shared.product.name`); } - const coreServiceSource = readText('src/crates/core/src/service/i18n/service.rs'); + const coreServiceSource = readText('src/crates/assembly/core/src/service/i18n/service.rs'); assert.match( coreServiceSource, /legacy_shared_term_key/, @@ -697,7 +870,7 @@ test('core and relay static homepage reuse shared product and feature terms', { } }); -test('i18n audit fails stale relay static shared-term references', { concurrency: false }, () => { +auditIntegrationTest('i18n audit fails stale relay static shared-term references', { concurrency: false }, () => { const relayPath = 'src/apps/relay-server/static/homepage/i18n.json'; const relayMessages = readJson(relayPath); relayMessages['en-US'].flowMobileSub = { $shared: 'features.__missingForTest' }; @@ -713,7 +886,7 @@ test('i18n audit fails stale relay static shared-term references', { concurrency }); }); -test('i18n audit enforces governance candidate baselines', { concurrency: false }, () => { +auditIntegrationTest('i18n audit enforces governance candidate baselines', { concurrency: false }, () => { const baselinePath = 'scripts/i18n-governance-baseline.json'; const baseline = readJson(baselinePath); @@ -730,7 +903,7 @@ test('i18n audit enforces governance candidate baselines', { concurrency: false }); }); -test('i18n audit enforces governance baseline dimensions', { concurrency: false }, () => { +auditIntegrationTest('i18n audit enforces governance baseline dimensions', { concurrency: false }, () => { const baselinePath = 'scripts/i18n-governance-baseline.json'; const baseline = readJson(baselinePath); const sharedKeyUnderTest = Object.entries(baseline.budgets.sharedTermDuplicates.bySharedKey) @@ -750,7 +923,7 @@ test('i18n audit enforces governance baseline dimensions', { concurrency: false }); }); -test('i18n audit fails stale l10n identical allowlist entries', { concurrency: false }, () => { +auditIntegrationTest('i18n audit fails stale l10n identical allowlist entries', { concurrency: false }, () => { const allowlistPath = 'scripts/i18n-l10n-identical-allowlist.json'; const allowlist = readJson(allowlistPath); @@ -776,24 +949,33 @@ test('i18n audit fails stale l10n identical allowlist entries', { concurrency: f }); }); -test('i18n audit validates l10n identical allowlist array fields', { concurrency: false }, () => { +auditIntegrationTest('i18n audit validates l10n identical allowlist array fields', { concurrency: false }, () => { const allowlistPath = 'scripts/i18n-l10n-identical-allowlist.json'; const allowlist = readJson(allowlistPath); - allowlist.entries[0].keys = 'shared:modes.'; + allowlist.entries.push({ + id: 'invalid-test-l10n-identical-key-array', + surface: 'shared', + namespace: 'shared', + locale: 'zh-TW', + comparisonLocale: 'zh-CN', + owner: 'scripts/i18n-contract.test.mjs', + reason: 'Test fixture for invalid l10n identical governance.', + keys: 'shared:modes.', + }); withTemporaryTextFile(allowlistPath, `${JSON.stringify(allowlist, null, 2)}\n`, () => { const result = runI18nAudit(); assert.notEqual(result.status, 0, 'l10n identical allowlist keys must be an array'); assert.match( `${result.stdout}\n${result.stderr}`, - /L10n identical allowlist "shared-zh-tw-same-han-terms" keys must be an array/, + /L10n identical allowlist "invalid-test-l10n-identical-key-array" keys must be an array/, 'audit output should identify the invalid l10n allowlist field', ); }); }); -test('i18n audit fails stale dynamic key allowlist entries', { concurrency: false }, () => { +auditIntegrationTest('i18n audit fails stale dynamic key allowlist entries', { concurrency: false }, () => { const allowlistPath = 'scripts/i18n-dynamic-key-allowlist.json'; const reportPath = 'scripts/.tmp-i18n-stale-dynamic-key-report.json'; const absoluteReportPath = path.join(root, reportPath); @@ -823,7 +1005,7 @@ test('i18n audit fails stale dynamic key allowlist entries', { concurrency: fals }); }); -test('i18n audit fails stale dynamic key source references', { concurrency: false }, () => { +auditIntegrationTest('i18n audit fails stale dynamic key source references', { concurrency: false }, () => { const allowlistPath = 'scripts/i18n-dynamic-key-allowlist.json'; const allowlist = readJson(allowlistPath); @@ -853,7 +1035,7 @@ test('i18n audit validates static hook translation keys with namespace context', assert.match(auditSource, /relative static Web UI i18n key/, 'audit should report missing relative hook keys clearly'); }); -test('i18n audit catches array-namespace keys and stale literal fallback budgets', { concurrency: false }, () => { +auditIntegrationTest('i18n audit catches array-namespace keys and stale literal fallback budgets', { concurrency: false }, () => { const arrayNamespaceFixture = 'src/web-ui/src/__i18n_array_namespace_audit_fixture__.tsx'; const missingFullKey = 'components:__arrayNamespaceAuditMissingKey__'; const missingRelativeKey = 'components:__arrayNamespaceAuditMissingRelativeKey__'; @@ -911,6 +1093,31 @@ test('i18n audit catches array-namespace keys and stale literal fallback budgets }); }); +auditIntegrationTest('i18n audit catches composed literal defaultValue fallbacks', { concurrency: false }, () => { + const composedFallbackFixture = 'src/web-ui/src/__i18n_composed_default_value_fixture__.tsx'; + + withTemporaryTextFile( + composedFallbackFixture, + [ + "import { useI18n } from '@/infrastructure/i18n';", + 'export function I18nComposedDefaultValueFixture() {', + " const { t } = useI18n('flow-chat');", + " return
{t('flowChatHeader.turnBadge', { current: 1, defaultValue: `Turn ${1}` + ' fallback' })}
;", + '}', + '', + ].join('\n'), + () => { + const result = runI18nAudit(); + assert.notEqual(result.status, 0, 'composed static defaultValue fallback should fail audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /literal i18next defaultValue fallback/, + 'audit output should identify composed defaultValue fallback debt', + ); + }, + ); +}); + test('i18n generation checks are stable across line endings', () => { const generatorSource = readText('scripts/generate-i18n-contract.mjs'); diff --git a/scripts/i18n-governance-baseline.json b/scripts/i18n-governance-baseline.json index 1a4a6716b2..909cce0539 100644 --- a/scripts/i18n-governance-baseline.json +++ b/scripts/i18n-governance-baseline.json @@ -6,29 +6,29 @@ "maxTotal": 0 }, "sharedTermDuplicates": { - "maxTotal": 201, + "maxTotal": 185, "bySurface": { "core": 15, "installer": 0, - "mobile-web": 6, + "mobile-web": 0, "relay-static-homepage": 0, - "web-ui": 180 + "web-ui": 170 }, "bySharedKey": { "agents.claw": 3, - "agents.code": 4, - "agents.cowork": 2, - "agents.default": 3, - "connectionMethods.bitfunServer": 1, - "connectionMethods.lan": 2, + "agents.code": 0, + "agents.cowork": 0, + "agents.default": 2, + "connectionMethods.bitfunServer": 0, + "connectionMethods.lan": 0, "features.codeAgent": 1, - "features.deepReview": 4, + "features.deepReview": 2, "features.remoteControl": 0, "features.settings": 1, "features.workspace": 2, - "modes.assistant": 2, - "modes.expert": 3, - "modes.review": 1, + "modes.assistant": 1, + "modes.expert": 2, + "modes.review": 0, "product.name": 0, "statuses.cancelled": 29, "statuses.done": 26, @@ -38,49 +38,17 @@ "tools.edit": 33, "tools.explore": 2, "tools.search": 12, - "tools.shell": 8 + "tools.shell": 7 } }, "l10nQualityCandidates": { - "maxTotal": 1087, + "maxTotal": 0, "bySurface": { - "core": 36, - "installer": 24, - "mobile-web": 19, - "relay-static-homepage": 1, - "web-ui": 1007 - }, - "byNamespace": { - "": 80, - "common": 158, - "components": 87, - "errors": 7, - "flow-chat": 210, - "flow-chat/processing-hints": 1, - "notifications": 13, - "panels/files": 25, - "panels/git": 54, - "panels/terminal": 2, - "scenes/agents": 38, - "scenes/miniapp": 7, - "scenes/profile": 40, - "scenes/skills": 28, - "settings": 70, - "settings/acp-agents": 17, - "settings/agentic-tools": 6, - "settings/ai-features": 13, - "settings/ai-model": 52, - "settings/basics": 10, - "settings/debug": 7, - "settings/default-model": 11, - "settings/editor": 13, - "settings/lsp": 14, - "settings/mcp": 31, - "settings/quick-actions": 2, - "settings/review": 4, - "settings/session-config": 5, - "settings/skills": 13, - "tools": 69 + "core": 0, + "installer": 0, + "mobile-web": 0, + "relay-static-homepage": 0, + "web-ui": 0 } } } diff --git a/scripts/i18n-l10n-identical-allowlist.json b/scripts/i18n-l10n-identical-allowlist.json index e6979dc364..7f19696e7b 100644 --- a/scripts/i18n-l10n-identical-allowlist.json +++ b/scripts/i18n-l10n-identical-allowlist.json @@ -1,21 +1,4 @@ { "version": 1, - "entries": [ - { - "id": "shared-zh-tw-same-han-terms", - "surface": "shared", - "namespace": "shared", - "locale": "zh-TW", - "comparisonLocale": "zh-CN", - "owner": "src/shared/i18n/resources/shared/zh-TW/terms.json", - "reason": "These stable shared terms are intentionally identical in Simplified and Traditional Chinese.", - "keys": [ - "modes.agentic", - "modes.assistant", - "statuses.cancelled", - "statuses.done", - "tools.explore" - ] - } - ] + "entries": [] } diff --git a/scripts/i18n-literal-fallback-baseline.json b/scripts/i18n-literal-fallback-baseline.json index ddb6596d4b..beac6157dc 100644 --- a/scripts/i18n-literal-fallback-baseline.json +++ b/scripts/i18n-literal-fallback-baseline.json @@ -1,507 +1,5 @@ { "version": 1, - "description": "Temporary no-growth allowlist for existing Web UI i18next defaultValue literal fallbacks. Lower this file when moving fallback copy into locale resources.", - "budgets": [ - { - "path": "src/web-ui/src/app/scenes/agents/AgentsScene.tsx", - "literalDefaultValues": { - "scenes/agents:nav.teams": 1, - "scenes/agents:reviewTeams.default.name": 1, - "scenes/agents:reviewTeams.default.summary": 1, - "scenes/agents:reviewTeams.default.tags": 1, - "scenes/agents:reviewTeams.detail.localOnly": 1, - "scenes/agents:reviewTeams.detail.open": 1, - "scenes/agents:teamsZone.empty": 1, - "scenes/agents:teamsZone.subtitle": 1, - "scenes/agents:teamsZone.title": 1 - } - }, - { - "path": "src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx", - "literalDefaultValues": { - "scenes/agents:reviewTeams.default.members": 1, - "scenes/agents:reviewTeams.detail.back": 1, - "scenes/agents:reviewTeams.detail.extraCount": 1, - "scenes/agents:reviewTeams.detail.fileCountValue": 1, - "scenes/agents:reviewTeams.detail.fileSplitThreshold": 1, - "scenes/agents:reviewTeams.detail.instancesValue": 1, - "scenes/agents:reviewTeams.detail.judgeTimeout": 1, - "scenes/agents:reviewTeams.detail.loading": 1, - "scenes/agents:reviewTeams.detail.localOnly": 2, - "scenes/agents:reviewTeams.detail.localOnlyDescription": 1, - "scenes/agents:reviewTeams.detail.lockedCount": 1, - "scenes/agents:reviewTeams.detail.maxSameRoleInstances": 1, - "scenes/agents:reviewTeams.detail.membersDescription": 1, - "scenes/agents:reviewTeams.detail.membersTitle": 1, - "scenes/agents:reviewTeams.detail.memberTypes.core": 1, - "scenes/agents:reviewTeams.detail.memberTypes.extra": 1, - "scenes/agents:reviewTeams.detail.memberTypes.locked": 1, - "scenes/agents:reviewTeams.detail.noTimeout": 1, - "scenes/agents:reviewTeams.detail.openSettings": 2, - "scenes/agents:reviewTeams.detail.parallelDescription": 1, - "scenes/agents:reviewTeams.detail.parallelLabel": 1, - "scenes/agents:reviewTeams.detail.policyMetricsLabel": 1, - "scenes/agents:reviewTeams.detail.policyStrategyLabel": 1, - "scenes/agents:reviewTeams.detail.policySummaryAction": 1, - "scenes/agents:reviewTeams.detail.policySummaryEyebrow": 1, - "scenes/agents:reviewTeams.detail.policySummaryIntro": 1, - "scenes/agents:reviewTeams.detail.policySummaryTitle": 1, - "scenes/agents:reviewTeams.detail.qualityGate": 2, - "scenes/agents:reviewTeams.detail.responsibilities": 1, - "scenes/agents:reviewTeams.detail.reviewerTimeout": 1, - "scenes/agents:reviewTeams.detail.secondsValue": 1, - "scenes/agents:reviewTeams.detail.splitDisabled": 1, - "scenes/agents:reviewTeams.detail.subtitle": 1, - "scenes/agents:reviewTeams.detail.summaryDescription": 1, - "scenes/agents:reviewTeams.detail.summaryTitle": 1, - "scenes/agents:reviewTeams.detail.title": 1, - "scenes/agents:reviewTeams.detail.warning": 1, - "settings/default-model:selection.fast": 1, - "settings/default-model:selection.primary": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx", - "literalDefaultValues": { - "flow-chat:childSession.stoppingReview": 2, - "flow-chat:childSession.stopReview": 2, - "flow-chat:childSession.stopReviewFailed": 1, - "flow-chat:deepReviewActionBar.minimizedDeep": 1, - "flow-chat:deepReviewActionBar.minimizedFix": 1, - "flow-chat:deepReviewActionBar.minimizedFixCompleted": 1, - "flow-chat:deepReviewActionBar.minimizedFixFailed": 1, - "flow-chat:deepReviewActionBar.minimizedFixReview": 1, - "flow-chat:deepReviewActionBar.minimizedResume": 1, - "flow-chat:deepReviewActionBar.minimizedReviewInterrupted": 1, - "flow-chat:deepReviewActionBar.minimizedReviewRunningDeep": 1, - "flow-chat:deepReviewActionBar.minimizedReviewRunningStandard": 1, - "flow-chat:deepReviewActionBar.minimizedStandard": 1, - "flow-chat:deepReviewActionBar.restore": 1, - "flow-chat:flowChatHeader.btwBackTooltipWithoutTurn": 1, - "flow-chat:flowChatHeader.btwBackTooltipWithTurn": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/ChatInput.tsx", - "literalDefaultValues": { - "flow-chat:btw.empty": 1, - "flow-chat:btw.nestedDisabled": 3, - "flow-chat:btw.noSession": 3, - "flow-chat:btw.title": 1, - "flow-chat:chatInput.compactAction": 1, - "flow-chat:chatInput.compactBusy": 1, - "flow-chat:chatInput.compactFailed": 1, - "flow-chat:chatInput.compactNoSession": 1, - "flow-chat:chatInput.compactUsage": 2, - "flow-chat:chatInput.deepreviewAction": 1, - "flow-chat:chatInput.deepreviewBusy": 1, - "flow-chat:chatInput.deepreviewFailed": 1, - "flow-chat:chatInput.deepreviewNestedDisabled": 1, - "flow-chat:chatInput.deepreviewNoSession": 1, - "flow-chat:chatInput.deepreviewThreadTitle": 1, - "flow-chat:chatInput.deepreviewUsage": 1, - "flow-chat:chatInput.goalAction": 1, - "flow-chat:chatInput.goalActivated": 1, - "flow-chat:chatInput.goalAiFailed": 1, - "flow-chat:chatInput.goalFailed": 1, - "flow-chat:chatInput.goalGenerating": 1, - "flow-chat:chatInput.goalNestedDisabled": 1, - "flow-chat:chatInput.goalNoSession": 1, - "flow-chat:chatInput.goalUsage": 2, - "flow-chat:chatInput.initAction": 1, - "flow-chat:chatInput.initBusy": 1, - "flow-chat:chatInput.initFailed": 1, - "flow-chat:chatInput.initNoSession": 1, - "flow-chat:chatInput.initUsage": 2, - "flow-chat:chatInput.noMatchingCommand": 3, - "flow-chat:chatInput.promptCacheGuardBody": 1, - "flow-chat:chatInput.promptCacheGuardCancel": 1, - "flow-chat:chatInput.promptCacheGuardConfirm": 1, - "flow-chat:chatInput.promptCacheGuardTitle": 1, - "flow-chat:chatInput.quickAction": 2, - "flow-chat:chatInput.usageAction": 1, - "flow-chat:chatInput.usageBusy": 1, - "flow-chat:chatInput.usageCommandUsage": 2, - "flow-chat:chatInput.usageFailed": 1, - "flow-chat:chatInput.usageNoSession": 2, - "flow-chat:chatInput.usageNoWorkspace": 1, - "flow-chat:input.largePastePlaceholder": 1, - "flow-chat:input.messageTooLarge": 2, - "flow-chat:input.removeImage": 1, - "flow-chat:usage.loading.markdown": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewConsent.cancel": 2, - "flow-chat:deepReviewConsent.confirm": 1, - "flow-chat:deepReviewConsent.dontShowAgain": 1, - "flow-chat:deepReviewConsent.eyebrow": 1, - "flow-chat:deepReviewConsent.runStrategy": 1, - "flow-chat:deepReviewConsent.selectedStrategy": 1, - "flow-chat:deepReviewConsent.sessionConcurrencyBody": 1, - "flow-chat:deepReviewConsent.sessionConcurrencyTitle": 1, - "flow-chat:deepReviewConsent.skippedGroupTitle": 1, - "flow-chat:deepReviewConsent.skippedMore": 1, - "flow-chat:deepReviewConsent.skippedReasons.budgetLimited": 1, - "flow-chat:deepReviewConsent.skippedReasons.disabled": 1, - "flow-chat:deepReviewConsent.skippedReasons.invalidTooling": 1, - "flow-chat:deepReviewConsent.skippedReasons.notApplicable": 1, - "flow-chat:deepReviewConsent.skippedReasons.skipped": 1, - "flow-chat:deepReviewConsent.skippedReasons.unavailable": 1, - "flow-chat:deepReviewConsent.skippedReviewers": 1, - "flow-chat:deepReviewConsent.strategyOverrideTitle": 1, - "flow-chat:deepReviewConsent.strategyRuntimeImpact": 1, - "flow-chat:deepReviewConsent.strategyTokenImpact": 1, - "flow-chat:deepReviewConsent.summaryTitle": 1, - "flow-chat:deepReviewConsent.title": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx", - "literalDefaultValues": { - "flow-chat:flowChatHeader.backgroundSubagents": 4, - "flow-chat:flowChatHeader.backgroundSubagentUntitled": 1, - "flow-chat:flowChatHeader.jumpToCurrentTurn": 1, - "flow-chat:flowChatHeader.nextTurn": 2, - "flow-chat:flowChatHeader.previousTurn": 2, - "flow-chat:flowChatHeader.searchClose": 2, - "flow-chat:flowChatHeader.searchNext": 2, - "flow-chat:flowChatHeader.searchNoResults": 1, - "flow-chat:flowChatHeader.searchOpen": 2, - "flow-chat:flowChatHeader.searchPlaceholder": 2, - "flow-chat:flowChatHeader.searchPrevious": 2, - "flow-chat:flowChatHeader.searchResult": 1, - "flow-chat:flowChatHeader.subagentStatusFinishing": 1, - "flow-chat:flowChatHeader.subagentStatusProcessing": 1, - "flow-chat:flowChatHeader.turnBadge": 2, - "flow-chat:flowChatHeader.turnList": 1, - "flow-chat:flowChatHeader.untitledTurn": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/modern/ForkSessionButton.tsx", - "literalDefaultValues": { - "flow-chat:modelRound.forkDialog": 1, - "flow-chat:modelRound.forkFailed": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx", - "literalDefaultValues": { - "flow-chat:sessionFileModificationsBar.deepReviewSource": 1, - "flow-chat:sessionFileModificationsBar.reviewSource": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx", - "literalDefaultValues": { - "flow-chat:sessionFilesBadge.actionsButton": 1, - "flow-chat:sessionFilesBadge.actionsButtonRunning": 1, - "flow-chat:sessionFilesBadge.actionsMenuHint": 1, - "flow-chat:sessionFilesBadge.collapseFileDiffList": 1, - "flow-chat:sessionFilesBadge.deepReview.displayMessage": 1, - "flow-chat:sessionFilesBadge.deepReview.displayMessageFiltered": 1, - "flow-chat:sessionFilesBadge.deepReview.threadTitle": 1, - "flow-chat:sessionFilesBadge.expandChangeListAriaCue": 1, - "flow-chat:sessionFilesBadge.expandChangeListCue": 1, - "flow-chat:sessionFilesBadge.filesSummaryCount": 3, - "flow-chat:sessionFilesBadge.review.displayMessageFiltered": 1, - "flow-chat:sessionFilesBadge.review.filteredNotice": 2, - "flow-chat:sessionFilesBadge.review.noEligibleFiles": 2, - "flow-chat:sessionFilesBadge.review.promptFiltered": 1, - "flow-chat:sessionFilesBadge.review.threadTitle": 1, - "flow-chat:sessionFilesBadge.reviewRunningHint": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.tsx", - "literalDefaultValues": { - "flow-chat:scroll.toCurrentTurn": 2 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx", - "literalDefaultValues": { - "flow-chat:stickyTaskIndicator.tooltip": 1, - "flow-chat:toolCards.taskTool.defaultAgentKind": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.taskDetailPanel.stoppingSubagent": 1, - "flow-chat:toolCards.taskDetailPanel.stopSubagent": 1, - "flow-chat:toolCards.taskDetailPanel.stopSubagentFailed": 1, - "flow-chat:toolCards.taskDetailPanel.stopSubagentHint": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.capacityQueue.activeReviewerCount": 1, - "flow-chat:deepReviewActionBar.capacityQueue.activeReviewerDetail": 1, - "flow-chat:deepReviewActionBar.capacityQueue.activeReviewerTitle": 1, - "flow-chat:deepReviewActionBar.capacityQueue.cancelQueued": 1, - "flow-chat:deepReviewActionBar.capacityQueue.continueQueue": 1, - "flow-chat:deepReviewActionBar.capacityQueue.detail": 1, - "flow-chat:deepReviewActionBar.capacityQueue.elapsed": 2, - "flow-chat:deepReviewActionBar.capacityQueue.elapsedWithMax": 1, - "flow-chat:deepReviewActionBar.capacityQueue.longLaunchBatchWaitDetail": 1, - "flow-chat:deepReviewActionBar.capacityQueue.openReviewSettings": 1, - "flow-chat:deepReviewActionBar.capacityQueue.optionalReviewer": 1, - "flow-chat:deepReviewActionBar.capacityQueue.pausedTitle": 1, - "flow-chat:deepReviewActionBar.capacityQueue.pauseQueue": 1, - "flow-chat:deepReviewActionBar.capacityQueue.providerDetail": 1, - "flow-chat:deepReviewActionBar.capacityQueue.providerTitle": 1, - "flow-chat:deepReviewActionBar.capacityQueue.reason": 1, - "flow-chat:deepReviewActionBar.capacityQueue.reviewerStatusPaused": 1, - "flow-chat:deepReviewActionBar.capacityQueue.reviewerStatusQueued": 1, - "flow-chat:deepReviewActionBar.capacityQueue.sessionBusy": 1, - "flow-chat:deepReviewActionBar.capacityQueue.skipOptionalQueued": 1, - "flow-chat:deepReviewActionBar.capacityQueue.stopHint": 1, - "flow-chat:deepReviewActionBar.capacityQueue.title": 1, - "flow-chat:deepReviewActionBar.capacityQueue.waitingReviewersTitle": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/DecisionExecutionGate.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.decisionGate.cancel": 1, - "flow-chat:deepReviewActionBar.decisionGate.description": 1, - "flow-chat:deepReviewActionBar.decisionGate.missingSelection": 1, - "flow-chat:deepReviewActionBar.decisionGate.noOptionsHint": 1, - "flow-chat:deepReviewActionBar.decisionGate.supplementLabel": 1, - "flow-chat:deepReviewActionBar.decisionGate.supplementPlaceholder": 1, - "flow-chat:deepReviewActionBar.decisionGate.title": 1, - "flow-chat:toolCards.codeReview.remediationActions.recommended": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.capacityQueue.controlFailed": 1, - "flow-chat:deepReviewActionBar.capacityQueue.controlFailedWithReason": 1, - "flow-chat:deepReviewActionBar.capacityQueue.controlPartiallyFailedWithReason": 1, - "flow-chat:deepReviewActionBar.contextOverflowTitle": 1, - "flow-chat:deepReviewActionBar.customInstructionsPlaceholder": 1, - "flow-chat:deepReviewActionBar.degradation.compressContextPending": 1, - "flow-chat:deepReviewActionBar.degradation.reduceReviewersPending": 1, - "flow-chat:deepReviewActionBar.diagnosticsCopied": 1, - "flow-chat:deepReviewActionBar.diagnosticsCopyFailed": 1, - "flow-chat:deepReviewActionBar.elapsedTime": 1, - "flow-chat:deepReviewActionBar.fixAndReviewRunning": 1, - "flow-chat:deepReviewActionBar.fixCompleted": 1, - "flow-chat:deepReviewActionBar.fixCompletedMessage": 1, - "flow-chat:deepReviewActionBar.fixFailed": 1, - "flow-chat:deepReviewActionBar.fixRunning": 1, - "flow-chat:deepReviewActionBar.fixTimeout": 1, - "flow-chat:deepReviewActionBar.hideCustomInput": 1, - "flow-chat:deepReviewActionBar.longRunningHint": 1, - "flow-chat:deepReviewActionBar.minimize": 1, - "flow-chat:deepReviewActionBar.progressResumePreserved": 1, - "flow-chat:deepReviewActionBar.replaceInputConfirmAction": 1, - "flow-chat:deepReviewActionBar.replaceInputConfirmMessage": 1, - "flow-chat:deepReviewActionBar.replaceInputConfirmTitle": 1, - "flow-chat:deepReviewActionBar.resumeBlocked": 1, - "flow-chat:deepReviewActionBar.resumeBlockedConfirmAction": 1, - "flow-chat:deepReviewActionBar.resumeBlockedConfirmMessage": 1, - "flow-chat:deepReviewActionBar.resumeBlockedConfirmTitle": 1, - "flow-chat:deepReviewActionBar.resumeBlockedWithReason": 1, - "flow-chat:deepReviewActionBar.resumeFailed": 1, - "flow-chat:deepReviewActionBar.resumeFailedMessage": 1, - "flow-chat:deepReviewActionBar.resumeFailedWithReason": 1, - "flow-chat:deepReviewActionBar.resumeRequestDisplay": 1, - "flow-chat:deepReviewActionBar.resumeRunning": 1, - "flow-chat:deepReviewActionBar.retryIncompleteFailed": 1, - "flow-chat:deepReviewActionBar.retryIncompleteRequestDisplay": 1, - "flow-chat:deepReviewActionBar.reviewError": 1, - "flow-chat:deepReviewActionBar.reviewErrorWithReason": 1, - "flow-chat:deepReviewActionBar.reviewInterrupted": 1, - "flow-chat:deepReviewActionBar.reviewInterruptedWithReason": 1, - "flow-chat:deepReviewActionBar.reviewWaitingCapacity": 1, - "flow-chat:deepReviewActionBar.showCustomInput": 1, - "flow-chat:reviewActionBar.noIssuesFound": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/PartialResultsPanel.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.hidePartialResults": 1, - "flow-chat:deepReviewActionBar.partialIssues": 1, - "flow-chat:deepReviewActionBar.partialRemediationItems": 1, - "flow-chat:deepReviewActionBar.partialResultsDescription": 1, - "flow-chat:deepReviewActionBar.partialReviewerSummaries": 1, - "flow-chat:deepReviewActionBar.viewPartialResults": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/RecoveryPlanPreview.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.recoveryPreserve": 1, - "flow-chat:deepReviewActionBar.recoveryRerun": 1, - "flow-chat:deepReviewActionBar.recoverySkip": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.remediationStatus.fixed": 1, - "flow-chat:deepReviewActionBar.remediationStatus.fixing": 1, - "flow-chat:reviewActionBar.needsDecisionTag": 1, - "flow-chat:toolCards.codeReview.remediationActions.collapseOptions": 1, - "flow-chat:toolCards.codeReview.remediationActions.expandOptions": 1, - "flow-chat:toolCards.codeReview.remediationActions.noSelectionHint": 1, - "flow-chat:toolCards.codeReview.remediationActions.recommended": 1, - "flow-chat:toolCards.codeReview.remediationActions.selectionCount": 1, - "flow-chat:toolCards.codeReview.remediationActions.ungrouped": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionControls.tsx", - "literalDefaultValues": { - "flow-chat:deepReviewActionBar.continueFix": 1, - "flow-chat:deepReviewActionBar.copyDiagnostics": 1, - "flow-chat:deepReviewActionBar.fillBackInput": 1, - "flow-chat:deepReviewActionBar.fillBackInputHint": 1, - "flow-chat:deepReviewActionBar.fixInterrupted": 1, - "flow-chat:deepReviewActionBar.minimize": 1, - "flow-chat:deepReviewActionBar.openModelSettings": 1, - "flow-chat:deepReviewActionBar.resumeReview": 1, - "flow-chat:deepReviewActionBar.retryIncompleteSlices": 1, - "flow-chat:deepReviewActionBar.skipRemaining": 1, - "flow-chat:deepReviewActionBar.switchModel": 1, - "flow-chat:deepReviewActionBar.viewPartialResults": 1, - "flow-chat:toolCards.codeReview.remediationActions.fixAndReview": 1, - "flow-chat:toolCards.codeReview.remediationActions.startFix": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts", - "literalDefaultValues": { - "flow-chat:chatInput.goalAchieved": 1, - "flow-chat:chatInput.goalVerifyFailed": 1, - "flow-chat:chatInput.goalVerifying": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/services/openBtwSession.ts", - "literalDefaultValues": { - "flow-chat:btw.threadLabel": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.codeReview.deepReviewResult": 1, - "flow-chat:toolCards.codeReview.reliabilityStatus.title": 2, - "flow-chat:toolCards.codeReview.remediationActions.certainty": 1, - "flow-chat:toolCards.codeReview.remediationActions.collapsePlan": 1, - "flow-chat:toolCards.codeReview.remediationActions.expandPlan": 1, - "flow-chat:toolCards.codeReview.remediationActions.location": 1, - "flow-chat:toolCards.codeReview.remediationActions.noRelatedIssue": 1, - "flow-chat:toolCards.codeReview.remediationActions.recommended": 1, - "flow-chat:toolCards.codeReview.remediationActions.relatedIssue": 1, - "flow-chat:toolCards.codeReview.remediationActions.severity": 1, - "flow-chat:toolCards.codeReview.remediationPlan": 1, - "flow-chat:toolCards.codeReview.reviewerIssues": 1, - "flow-chat:toolCards.codeReview.reviewerIssuesUnknown": 1, - "flow-chat:toolCards.codeReview.reviewerTeam": 1, - "flow-chat:toolCards.codeReview.reviewMode": 1, - "flow-chat:toolCards.codeReview.reviewScope": 1, - "flow-chat:toolCards.codeReview.runManifest.recommendedStrategy": 1, - "flow-chat:toolCards.codeReview.runManifest.reviewDepth": 1, - "flow-chat:toolCards.codeReview.runManifest.riskRecommendationTitle": 1, - "flow-chat:toolCards.codeReview.sectionItemCount": 3, - "flow-chat:toolCards.codeReview.sections.coverage": 1, - "flow-chat:toolCards.codeReview.sections.remediation": 1, - "flow-chat:toolCards.codeReview.sections.strengths": 1, - "flow-chat:toolCards.codeReview.sections.summary": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.contextCompression.noSummaryNotice": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.getFileDiff.diffFile": 1, - "flow-chat:toolCards.getFileDiff.failed": 2, - "flow-chat:toolCards.getFileDiff.gettingDiff": 1, - "flow-chat:toolCards.getFileDiff.preparing": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.git.commandCopied": 4, - "flow-chat:toolCards.git.copyCommand": 4, - "flow-chat:toolCards.git.copyCommandFailed": 2 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.readFile.permissionRequest": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.reviewSessionSummary.changedFilesTitle": 1, - "flow-chat:toolCards.reviewSessionSummary.deepTitle": 1, - "flow-chat:toolCards.reviewSessionSummary.emptySummary": 1, - "flow-chat:toolCards.reviewSessionSummary.failed": 1, - "flow-chat:toolCards.reviewSessionSummary.filesChanged": 1, - "flow-chat:toolCards.reviewSessionSummary.issueCount": 1, - "flow-chat:toolCards.reviewSessionSummary.noIssues": 1, - "flow-chat:toolCards.reviewSessionSummary.openReview": 1, - "flow-chat:toolCards.reviewSessionSummary.running": 1, - "flow-chat:toolCards.reviewSessionSummary.standardTitle": 1, - "flow-chat:toolCards.reviewSessionSummary.waitingSummary": 1 - } - }, - { - "path": "src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx", - "literalDefaultValues": { - "flow-chat:toolCards.timeout.cancelledDurationTooltip": 1, - "flow-chat:toolCards.timeout.completedDurationTooltip": 1, - "flow-chat:toolCards.timeout.durationTooltip": 1, - "flow-chat:toolCards.timeout.failedDurationTooltip": 1, - "flow-chat:toolCards.timeout.failedDurationTooltipWithReason": 1 - } - }, - { - "path": "src/web-ui/src/infrastructure/config/components/AppearanceConfig.tsx", - "literalDefaultValues": { - "settings/basics:appearance.languageRowHint": 1, - "settings/basics:appearance.themeRowHint": 1 - } - }, - { - "path": "src/web-ui/src/infrastructure/config/components/ReviewConfig.tsx", - "literalDefaultValues": { - "settings/default-model:selection.fast": 2, - "settings/default-model:selection.primary": 2 - } - }, - { - "path": "src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx", - "literalDefaultValues": { - "settings/skills:filters.project": 1, - "settings/skills:filters.user": 1 - } - }, - { - "path": "src/web-ui/src/tools/editor/components/MarkdownEditor.tsx", - "literalDefaultValues": { - "tools:editor.markdownEditor.copiedMarkdown": 4, - "tools:editor.markdownEditor.copyMarkdown": 4 - } - } - ] + "description": "Empty no-growth guard for Web UI i18next defaultValue literal fallbacks. Add or repair locale resources instead of raising this baseline.", + "budgets": [] } diff --git a/scripts/i18n-locale-format-baseline.json b/scripts/i18n-locale-format-baseline.json index 06dadcb6e1..5da62a835a 100644 --- a/scripts/i18n-locale-format-baseline.json +++ b/scripts/i18n-locale-format-baseline.json @@ -1,66 +1,5 @@ { "version": 1, "description": "Temporary no-growth baseline for direct locale formatting calls outside the shared i18n formatting APIs. Lower this file when callers move to i18n-owned format helpers.", - "budgets": [ - { - "path": "src/crates/core/src/miniapp/builtin/assets/coding-selfie/ui.js", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/mobile-web/src/pages/SessionListPage.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx", - "maxLocaleFormatCalls": 3 - }, - { - "path": "src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/ContextCompressionCard.tsx", - "maxLocaleFormatCalls": 6 - }, - { - "path": "src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/flow_chat/components/UserMessage.tsx", - "maxLocaleFormatCalls": 1 - }, - { - "path": "src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx", - "maxLocaleFormatCalls": 3 - }, - { - "path": "src/web-ui/src/shared/utils/format.ts", - "maxLocaleFormatCalls": 1 - } - ] + "budgets": [] } diff --git a/scripts/request-trace-tools/export-request-trace-usage-csv.mjs b/scripts/request-trace-tools/export-request-trace-usage-csv.mjs new file mode 100644 index 0000000000..1c35df71eb --- /dev/null +++ b/scripts/request-trace-tools/export-request-trace-usage-csv.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const USAGE_OUTPUT_FILE = 'usage-summary.csv'; + +function printUsage() { + console.log( + [ + 'Usage:', + ' node scripts/request-trace-tools/export-request-trace-usage-csv.mjs [output.csv]', + '', + 'Examples:', + ' node scripts/request-trace-tools/export-request-trace-usage-csv.mjs ~/.bitfun/projects/demo/request-traces/abc123', + ' node scripts/request-trace-tools/export-request-trace-usage-csv.mjs ./request-traces ./trace-usage.csv', + ].join('\n'), + ); +} + +function isHelpFlag(value) { + return value === '-h' || value === '--help'; +} + +function csvEscape(value) { + if (value === null || value === undefined) { + return ''; + } + + const text = String(value); + if (!/[",\n\r]/.test(text)) { + return text; + } + + return `"${text.replaceAll('"', '""')}"`; +} + +function normalizeInteger(value) { + return Number.isInteger(value) ? value : null; +} + +function deriveCacheMissTokens(promptTokens, cacheHitTokens) { + if (!Number.isInteger(promptTokens) || !Number.isInteger(cacheHitTokens)) { + return null; + } + + return Math.max(0, promptTokens - cacheHitTokens); +} + +function buildRow(trace, fileName) { + const usage = trace?.response?.usage ?? null; + const promptTokens = normalizeInteger(usage?.promptTokenCount); + const completionTokens = normalizeInteger(usage?.candidatesTokenCount); + const reasoningTokens = normalizeInteger(usage?.reasoningTokenCount); + const totalTokens = normalizeInteger(usage?.totalTokenCount); + const cacheHitTokens = normalizeInteger(usage?.cachedContentTokenCount); + const cacheMissTokens = deriveCacheMissTokens(promptTokens, cacheHitTokens); + const cacheWriteTokens = normalizeInteger(usage?.cacheCreationTokenCount); + + return { + file_name: fileName, + sequence: trace?.sequence ?? null, + recorded_at: trace?.recorded_at ?? '', + trace_id: trace?.trace_id ?? '', + session_id: trace?.session_id ?? '', + turn_id: trace?.turn_id ?? '', + operation_kind: trace?.operation_kind ?? '', + operation_id: trace?.operation_id ?? '', + operation_trigger: trace?.operation_trigger ?? '', + capture_mode: trace?.capture_mode ?? '', + provider: trace?.request?.provider ?? '', + api_format: trace?.request?.api_format ?? '', + model_id: trace?.request?.model_id ?? '', + request_url: trace?.request?.request_url ?? '', + attempt_number: trace?.request?.attempt_number ?? null, + response_kind: trace?.response?.kind ?? '', + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + reasoning_tokens: reasoningTokens, + total_tokens: totalTokens, + cache_hit_tokens: cacheHitTokens, + cache_miss_tokens: cacheMissTokens, + cache_write_tokens: cacheWriteTokens, + error: trace?.response?.error ?? '', + partial_recovery_reason: trace?.response?.partial_recovery_reason ?? '', + }; +} + +async function readTraceRows(traceDir) { + const entries = await fs.readdir(traceDir, { withFileTypes: true }); + const traceFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); + + const rows = []; + for (const fileName of traceFiles) { + const filePath = path.join(traceDir, fileName); + const contents = await fs.readFile(filePath, 'utf8'); + const trace = JSON.parse(contents); + rows.push(buildRow(trace, fileName)); + } + + rows.sort((left, right) => { + const leftSequence = Number.isInteger(left.sequence) ? left.sequence : Number.MAX_SAFE_INTEGER; + const rightSequence = Number.isInteger(right.sequence) ? right.sequence : Number.MAX_SAFE_INTEGER; + if (leftSequence !== rightSequence) { + return leftSequence - rightSequence; + } + + return left.file_name.localeCompare(right.file_name, undefined, { numeric: true }); + }); + + return rows; +} + +function buildCsv(rows) { + const headers = [ + 'file_name', + 'sequence', + 'prompt_tokens', + 'completion_tokens', + 'reasoning_tokens', + 'total_tokens', + 'cache_hit_tokens', + 'cache_miss_tokens', + 'cache_write_tokens', + 'recorded_at', + 'trace_id', + 'session_id', + 'turn_id', + 'operation_kind', + 'operation_id', + 'operation_trigger', + 'capture_mode', + 'provider', + 'api_format', + 'model_id', + 'request_url', + 'attempt_number', + 'response_kind', + 'error', + 'partial_recovery_reason', + ]; + + const lines = [headers.join(',')]; + for (const row of rows) { + lines.push(headers.map((header) => csvEscape(row[header])).join(',')); + } + + return `${lines.join('\n')}\n`; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.some(isHelpFlag)) { + printUsage(); + process.exit(args.length === 0 ? 1 : 0); + } + + const [traceDirArg, outputPathArg] = args; + const traceDir = path.resolve(traceDirArg); + const outputPath = outputPathArg + ? path.resolve(outputPathArg) + : path.join(traceDir, USAGE_OUTPUT_FILE); + + const traceDirStats = await fs.stat(traceDir).catch(() => null); + if (!traceDirStats?.isDirectory()) { + throw new Error(`Request trace directory not found: ${traceDir}`); + } + + const rows = await readTraceRows(traceDir); + const csv = buildCsv(rows); + + await fs.writeFile(outputPath, csv, 'utf8'); + + console.log( + `Wrote ${rows.length} trace row(s) to ${outputPath}`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/request-trace-tools/usage-dashboard.html b/scripts/request-trace-tools/usage-dashboard.html new file mode 100644 index 0000000000..3d750b269a --- /dev/null +++ b/scripts/request-trace-tools/usage-dashboard.html @@ -0,0 +1,1811 @@ + + + + + + Request Trace Usage Dashboard + + + +
+
+

Request Trace Explorer

+

把每次请求的 token 走势摊开看

+

+ 这个页面会优先读取当前目录的 usage-summary.csv,也支持手动导入 CSV。 + 它围绕 request trace 的导出字段,重点观察 + prompt_tokens、completion_tokens、cache_hit_tokens、 + cache_miss_tokens 和 cache_write_tokens 的趋势、峰值和覆盖情况。 +

+
+ + +
+ 等待数据 + 优先尝试 usage-summary.csv,失败后可手动导入 +
+
+
+ +
+ + +
+
+
+ 请求数 + - +
+
+ usage 覆盖 + - +
+
+ Prompt 总量 + - +
+
+ Completion 总量 + - +
+
+ Cache Hit 总量 + - +
+
+ Cache Miss 总量 + - +
+
+ +
+
+
+

Request Token Curves

+

等待加载数据…

+
+
+ Prompt + Completion + Cache Hit + Cache Miss + Cache Write + +
+
+ +
+
+ 还没有可视化数据 +

+ 如果你是直接双击打开页面,浏览器大概率不能直接 fetch 本地文件。 + 这时点“选择 CSV 文件”导入同目录的 usage-summary.csv 就可以。 +

+
+
+ + +

+ 鼠标滚轮缩放 X 轴,按住左键拖动平移,双击图表重置当前视窗。 + cache_miss_tokens 只有在 CSV 明确给出或能由 prompt/cache_hit 推导时才会显示。 +

+
+ +
+
+
+
+

请求列表

+

+ 默认按 cache_miss_tokens 排序,方便快速定位没有吃到缓存的请求段。 +

+
+
+ +
+
+ + + + + + + + + + + + + + + +
Seq时间Cache MissPromptCompletionCache HitModel
等待数据…
+ +
+ +
+

最近 8 条记录

+

用于对照导入文件尾部请求,顺便看 usage 是否有缺口。

+ + + + + + + + + + + + + +
Seq时间KindPromptCoverage
等待数据…
+
+
+
+
+
+ + + + diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 3eeb1f6f1e..6954e8024d 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -11,9 +11,9 @@ path = "src/main.rs" [dependencies] # Internal crates -bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } -bitfun-events = { path = "../../crates/events" } -bitfun-acp = { path = "../../crates/acp" } +bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-events = { path = "../../crates/contracts/events" } +bitfun-acp = { path = "../../crates/interfaces/acp" } # CLI framework clap = { workspace = true } diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 9f571b0dda..a35e41e669 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -44,6 +44,10 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ name: "/skills", description: "List and configure skills", }, + CommandSpec { + name: "/reload-skills", + description: "Re-scan skill directories without restarting", + }, CommandSpec { name: "/subagents", description: "List and configure subagents", @@ -130,15 +134,98 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ }, ]; -pub fn match_prefix_in( - prefix: &str, +pub fn match_substring_in( + query: &str, commands: &'static [CommandSpec], ) -> Vec<&'static CommandSpec> { - if prefix.is_empty() { + if query.is_empty() { + return Vec::new(); + } + let q = query.strip_prefix('/').unwrap_or(query).to_lowercase(); + if q.is_empty() { return Vec::new(); } commands .iter() - .filter(|spec| spec.name.starts_with(prefix)) + .filter(|spec| { + spec.name + .strip_prefix('/') + .unwrap_or(spec.name) + .to_lowercase() + .contains(&q) + }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_query_returns_empty() { + let result = match_substring_in("", COMMAND_SPECS); + assert!(result.is_empty()); + } + + #[test] + fn test_exact_match() { + let result = match_substring_in("/help", COMMAND_SPECS); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "/help"); + } + + #[test] + fn test_prefix_match() { + let result = match_substring_in("/he", COMMAND_SPECS); + assert_eq!(result.len(), 2); + let names: Vec<&str> = result.iter().map(|s| s.name).collect(); + assert!(names.contains(&"/help")); + assert!(names.contains(&"/theme")); + } + + #[test] + fn test_substring_match() { + let result = match_substring_in("/age", COMMAND_SPECS); + let names: Vec<&str> = result.iter().map(|s| s.name).collect(); + assert!(names.contains(&"/usage")); + } + + #[test] + fn test_mid_string_match() { + let result = match_substring_in("/usa", COMMAND_SPECS); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "/usage"); + } + + #[test] + fn test_multiple_substring_matches() { + let result = match_substring_in("/s", COMMAND_SPECS); + let names: Vec<&str> = result.iter().map(|s| s.name).collect(); + assert!(names.contains(&"/sessions")); + assert!(names.contains(&"/skills")); + assert!(names.contains(&"/subagents")); + assert!(names.contains(&"/mcps")); + assert!(names.contains(&"/usage")); + assert!(names.contains(&"/models")); + assert!(names.contains(&"/history")); + } + + #[test] + fn test_no_match() { + let result = match_substring_in("/zzz", COMMAND_SPECS); + assert!(result.is_empty()); + } + + #[test] + fn test_slash_only_returns_empty() { + let result = match_substring_in("/", COMMAND_SPECS); + assert!(result.is_empty()); + } + + #[test] + fn test_case_insensitive() { + let result = match_substring_in("/HELP", COMMAND_SPECS); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "/help"); + } +} diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index b51fb02803..3552d96316 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -1731,6 +1731,9 @@ impl ChatMode { "/skills" => { self.show_skill_selector(chat_view, chat_state, rt_handle); } + "/reload-skills" => { + self.reload_skills_from_disk(chat_view, chat_state, rt_handle); + } "/subagents" => { self.show_subagent_selector(chat_view, chat_state, rt_handle); } @@ -2883,6 +2886,44 @@ impl ChatMode { chat_view.show_skill_menu(); } + /// Re-scan skill directories from disk and rebuild the registry cache. + /// + /// Mirrors Claude Code 2.1.152 `/reload-skills`. Safe to call at any + /// time — does not require `is_processing` to be false because the + /// registry swap is atomic and a held `SkillInfo` reference is not + /// kept across the call. + fn reload_skills_from_disk( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = SkillRegistry::global(); + let workspace = self.agent.workspace_path_buf(); + let outcome = tokio::task::block_in_place(|| { + // refresh() is the global re-scan entry point; the workspace + // arg of refresh_for_workspace is currently a no-op upstream, + // so we call refresh() directly and re-resolve the workspace + // count afterwards. + rt_handle.block_on(async { + registry.refresh().await; + registry + .get_resolved_skills_for_workspace(Some(workspace.as_path()), None) + .await + }) + }); + + let count = outcome.len(); + chat_state.add_system_message(format!( + "Reloaded {} skill(s) from disk.", + count + )); + chat_view.set_status(Some(format!( + "Skills reloaded ({} available)", + count + ))); + } + fn show_available_skill_list( &self, chat_view: &mut ChatView, diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index a237c14f81..3b7aa3892f 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -8,7 +8,7 @@ use ratatui::{ Frame, }; -use crate::commands::{match_prefix_in, CommandSpec, COMMAND_SPECS}; +use crate::commands::{match_substring_in, CommandSpec, COMMAND_SPECS}; use crate::ui::theme::{StyleKind, Theme}; pub struct CommandMenuState { @@ -61,7 +61,7 @@ impl CommandMenuState { if query == "/" { self.items = commands.iter().collect(); } else { - self.items = match_prefix_in(query, commands); + self.items = match_substring_in(query, commands); } self.items.sort_by_key(|spec| spec.name); diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 12685a96d7..b65282c159 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -16,7 +16,7 @@ - `src/lib.rs`、`src/main.rs`:应用启动与装配 - `src/computer_use/`:操作系统相关自动化支持 -如果改动影响多个运行时共享的产品行为,真正实现通常应放在 `src/crates/core`。 +如果改动影响多个运行时共享的产品行为,真正实现通常应放在 `src/crates/assembly/core`。 ## 本模块规则 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index f8c439b13d..fdd80453dd 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -16,7 +16,7 @@ Main areas: - `src/lib.rs`, `src/main.rs`: app setup and wiring - `src/computer_use/`: OS-specific automation support -If a change affects shared product behavior across runtimes, the implementation likely belongs in `src/crates/core`. +If a change affects shared product behavior across runtimes, the implementation likely belongs in `src/crates/assembly/core`. ## Local rules diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 3ab4216997..9bcb6a3d83 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -19,10 +19,10 @@ serde_json = { workspace = true } [dependencies] # Internal crates -bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } -bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] } -bitfun-webdriver = { path = "../../crates/webdriver" } -bitfun-acp = { path = "../../crates/acp" } +bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } +bitfun-webdriver = { path = "../../crates/adapters/webdriver" } +bitfun-acp = { path = "../../crates/interfaces/acp" } # Tauri tauri = { workspace = true } @@ -33,6 +33,12 @@ tauri-plugin-log = { workspace = true } napi-ohos = { workspace = true } napi-derive-ohos = { workspace = true } tauri-plugin-global-shortcut = { workspace = true } +# Keep Tauri's transitive time resolution on the known-good release in CI, +# where the root Cargo.lock is intentionally ignored. +time = { workspace = true } +brotli-decompressor = { workspace = true } +alloc-no-stdlib = { workspace = true } +alloc-stdlib = { workspace = true } # Inherited from workspace tokio = { workspace = true } diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 4681848bd3..845f02040a 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -2,12 +2,14 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::startup_trace::DesktopStartupTrace; use bitfun_acp::client::{ - AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, - AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, - SubmitAcpPermissionResponseRequest, + AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, + AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse, + SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; use serde::{Deserialize, Serialize}; +use std::time::Instant; use tauri::{AppHandle, Emitter, State}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -88,12 +90,21 @@ pub struct ProbeAcpClientRequirementsRequest { } #[tauri::command] -pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { - let service = state - .acp_client_service - .as_ref() - .ok_or_else(|| "ACP client service not initialized".to_string())?; - service.initialize_all().await.map_err(|e| e.to_string()) +pub async fn initialize_acp_clients( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result<(), String> { + let trace_started = Instant::now(); + let result = async { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.initialize_all().await.map_err(|e| e.to_string()) + } + .await; + startup_trace.record_tauri_command_elapsed("initialize_acp_clients", None, trace_started); + result } #[tauri::command] @@ -108,19 +119,30 @@ pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, + startup_trace: State<'_, DesktopStartupTrace>, request: ProbeAcpClientRequirementsRequest, ) -> Result, String> { - let service = state - .acp_client_service - .as_ref() - .ok_or_else(|| "ACP client service not initialized".to_string())?; - service - .probe_client_requirements( - request.remote_connection_id.as_deref(), - request.force_refresh, - ) - .await - .map_err(|e| e.to_string()) + let trace_started = Instant::now(); + let result = async { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .probe_client_requirements( + request.remote_connection_id.as_deref(), + request.force_refresh, + ) + .await + .map_err(|e| e.to_string()) + } + .await; + startup_trace.record_tauri_command_elapsed( + "probe_acp_client_requirements", + None, + trace_started, + ); + result } #[tauri::command] @@ -377,6 +399,48 @@ pub async fn start_acp_dialog_turn( bitfun_core::util::errors::BitFunError::service(e.to_string()) })?; } + AcpClientStreamEvent::AvailableCommandsUpdated(commands) => { + app_handle + .emit( + "agentic://acp-available-commands-updated", + serde_json::json!({ + "sessionId": request.session_id, + "clientId": request.client_id, + "commands": commands, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::PlanUpdated(entries) => { + app_handle + .emit( + "agentic://acp-plan-updated", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "clientId": request.client_id, + "entries": entries, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::ConfigOptionsUpdated(_) => { + app_handle + .emit( + "agentic://acp-session-options-changed", + serde_json::json!({ + "sessionId": request.session_id, + "clientId": request.client_id, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } AcpClientStreamEvent::Completed => { app_handle .emit( @@ -482,6 +546,39 @@ pub async fn get_acp_session_options( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn get_acp_session_commands( + state: State<'_, AppState>, + request: GetAcpSessionOptionsRequest, +) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .get_session_commands( + &request.client_id, + request.workspace_path, + request.remote_connection_id, + session_storage_path, + request.session_id, + ) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn set_acp_session_model( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 51d866ae8c..4cd48ce1c9 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -2,11 +2,14 @@ use log::{debug, warn}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; use tauri::{AppHandle, State}; use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::agentic::coordination::{ AssistantBootstrapBlockReason, AssistantBootstrapEnsureOutcome, AssistantBootstrapSkipReason, ConversationCoordinator, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, @@ -17,8 +20,18 @@ use bitfun_core::agentic::deep_review_policy::{ apply_deep_review_queue_control, default_review_team_definition, DeepReviewQueueControlAction, ReviewTeamDefinition, }; +use bitfun_core::agentic::goal_mode::{ThreadGoal, ThreadGoalStatus}; use bitfun_core::agentic::image_analysis::ImageContextData; +use bitfun_core::agentic::session::{SessionViewRestoreRequest, SessionViewRestoreTiming}; use bitfun_core::agentic::tools::image_context::get_image_context; +use bitfun_core::agentic::tools::implementations::exec_command::{ + background_command_output_capture, control_exec_command_session, send_exec_command_input, + ExecCommandControlAction, ExecCommandControlOrigin, ExecCommandControlRequest, + ExecCommandInputRequest, ListBackgroundCommandOutputRequest, + ListBackgroundCommandOutputResponse, + ReadBackgroundCommandOutputRequest as CoreReadBackgroundCommandOutputRequest, + ReadBackgroundCommandOutputResponse, +}; use bitfun_core::service::session::{DialogTurnData, SessionRelationship}; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; @@ -142,10 +155,59 @@ pub struct ActivateSessionGoalRequest { #[serde(rename_all = "camelCase")] pub struct ActivateSessionGoalResponse { pub success: bool, - pub goal_text: String, - pub success_criteria: Vec, - pub kickoff_message: String, - pub display_message: String, + pub goal: ThreadGoal, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionThreadGoalRequest { + pub session_id: String, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionThreadGoalResponse { + pub goal: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClearSessionThreadGoalRequest { + pub session_id: String, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetSessionThreadGoalStatusRequest { + pub session_id: String, + pub status: String, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionThreadGoalObjectiveRequest { + pub session_id: String, + pub objective: String, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, } #[derive(Debug, Deserialize)] @@ -227,6 +289,7 @@ pub struct RestoreSessionViewResponse { pub is_partial: bool, pub loaded_turn_count: usize, pub total_turn_count: usize, + pub timings: SessionViewRestoreTiming, } #[derive(Debug, Default)] @@ -900,13 +963,201 @@ pub async fn activate_session_goal( Ok(ActivateSessionGoalResponse { success: true, - goal_text: activation.goal_text, - success_criteria: activation.success_criteria, - kickoff_message: activation.kickoff_message, - display_message: activation.display_message, + goal: activation, }) } +async fn ensure_session_for_thread_goal( + coordinator: &Arc, + app_state: &AppState, + session_id: &str, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result { + if coordinator + .get_session_manager() + .get_session(session_id) + .is_none() + { + let workspace_path = workspace_path + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "workspace_path is required when the session is not loaded".to_string() + })?; + let effective = desktop_effective_session_storage_path( + app_state, + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + .await; + coordinator + .restore_session(&effective, session_id) + .await + .map_err(|e| format!("Failed to restore session before thread goal access: {e}"))?; + } + + coordinator + .get_session_manager() + .get_session(session_id) + .and_then(|session| session.config.workspace_path.clone()) + .map(PathBuf::from) + .ok_or_else(|| format!("Session workspace_path is missing: {session_id}")) +} + +async fn resolve_session_workspace_path_for_thread_goal_read( + coordinator: &Arc, + app_state: &AppState, + session_id: &str, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result { + if let Some(workspace_path) = coordinator + .get_session_manager() + .get_session(session_id) + .and_then(|session| session.config.workspace_path.clone()) + { + return Ok(PathBuf::from(workspace_path)); + } + + let workspace_path = workspace_path + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "workspace_path is required when the session is not loaded".to_string())?; + + Ok(desktop_effective_session_storage_path( + app_state, + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + .await) +} + +#[tauri::command] +pub async fn get_session_thread_goal( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, + request: GetSessionThreadGoalRequest, +) -> Result { + let trace_started = Instant::now(); + let result = async { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let workspace_path = resolve_session_workspace_path_for_thread_goal_read( + coordinator.inner(), + app_state.inner(), + session_id, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let goal = coordinator + .get_thread_goal(session_id, workspace_path.as_path()) + .await + .map_err(|error| error.to_string())?; + Ok(GetSessionThreadGoalResponse { goal }) + } + .await; + startup_trace.record_tauri_command_elapsed("get_session_thread_goal", None, trace_started); + result +} + +#[tauri::command] +pub async fn clear_session_thread_goal( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: ClearSessionThreadGoalRequest, +) -> Result<(), String> { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let workspace_path = ensure_session_for_thread_goal( + coordinator.inner(), + app_state.inner(), + session_id, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + coordinator + .clear_thread_goal(session_id, workspace_path.as_path()) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn set_session_thread_goal_status( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: SetSessionThreadGoalStatusRequest, +) -> Result { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let status = match request.status.trim().to_ascii_lowercase().as_str() { + "active" => ThreadGoalStatus::Active, + "paused" => ThreadGoalStatus::Paused, + "blocked" => ThreadGoalStatus::Blocked, + "usageLimited" | "usage_limited" => ThreadGoalStatus::UsageLimited, + "budgetLimited" | "budget_limited" => ThreadGoalStatus::BudgetLimited, + "complete" => ThreadGoalStatus::Complete, + other => return Err(format!("unsupported thread goal status: {other}")), + }; + let workspace_path = ensure_session_for_thread_goal( + coordinator.inner(), + app_state.inner(), + session_id, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + coordinator + .set_thread_goal_status(session_id, workspace_path.as_path(), status) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn update_session_thread_goal_objective( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: UpdateSessionThreadGoalObjectiveRequest, +) -> Result { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let objective = request.objective.trim(); + if objective.is_empty() { + return Err("objective is required".to_string()); + } + let workspace_path = ensure_session_for_thread_goal( + coordinator.inner(), + app_state.inner(), + session_id, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + coordinator + .update_thread_goal_objective(session_id, workspace_path.as_path(), objective.to_string()) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn ensure_assistant_bootstrap( coordinator: State<'_, Arc>, @@ -1226,6 +1477,161 @@ pub async fn set_subagent_timeout( }) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ControlBackgroundCommandRequest { + pub exec_session_id: i32, + pub action: BackgroundCommandControlActionDTO, + pub remote: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BackgroundCommandControlActionDTO { + Interrupt, + Kill, +} + +impl From for ExecCommandControlAction { + fn from(action: BackgroundCommandControlActionDTO) -> Self { + match action { + BackgroundCommandControlActionDTO::Interrupt => ExecCommandControlAction::Interrupt, + BackgroundCommandControlActionDTO::Kill => ExecCommandControlAction::Kill, + } + } +} + +#[tauri::command] +pub async fn control_background_command( + request: ControlBackgroundCommandRequest, +) -> Result<(), String> { + let session_id = request.exec_session_id; + let remote = request.remote; + let action: ExecCommandControlAction = request.action.into(); + + control_exec_command_session(ExecCommandControlRequest { + session_id, + action, + origin: ExecCommandControlOrigin::OutOfBand, + remote, + yield_time_ms: Some(250), + }) + .await + .map(|response| { + if response.session_id.is_none() { + let status = match response.completion.map(|completion| completion.status) { + Some(bitfun_core::agentic::tools::implementations::exec_command::ExecCommandCompletionStatus::Interrupted) => { + bitfun_core::agentic::tools::implementations::exec_command::BackgroundCommandOutputStatus::Interrupted + } + Some(bitfun_core::agentic::tools::implementations::exec_command::ExecCommandCompletionStatus::Killed) => { + bitfun_core::agentic::tools::implementations::exec_command::BackgroundCommandOutputStatus::Killed + } + Some(bitfun_core::agentic::tools::implementations::exec_command::ExecCommandCompletionStatus::Pruned) => { + bitfun_core::agentic::tools::implementations::exec_command::BackgroundCommandOutputStatus::Pruned + } + _ => bitfun_core::agentic::tools::implementations::exec_command::BackgroundCommandOutputStatus::Exited, + }; + let capture = background_command_output_capture(); + tauri::async_runtime::spawn(async move { + capture + .finish_by_session(remote, session_id, status, response.exit_code) + .await; + }); + } + }) + .map_err(|e| { + log::error!( + "Failed to control background command: exec_session_id={}, remote={}, error={}", + session_id, + remote, + e + ); + format!("Failed to control background command: {}", e) + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendBackgroundCommandInputRequest { + pub exec_session_id: i32, + pub remote: bool, + pub chars: String, + pub append_enter: bool, +} + +#[tauri::command] +pub async fn send_background_command_input( + request: SendBackgroundCommandInputRequest, +) -> Result<(), String> { + if request.chars.is_empty() && !request.append_enter { + return Err("chars or append_enter is required".to_string()); + } + + send_exec_command_input(ExecCommandInputRequest { + session_id: request.exec_session_id, + chars: request.chars, + append_enter: request.append_enter, + remote: request.remote, + }) + .await + .map_err(|e| { + log::error!( + "Failed to send input to background command: exec_session_id={}, remote={}, error={}", + request.exec_session_id, + request.remote, + e + ); + format!("Failed to send input to background command: {}", e) + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadBackgroundCommandOutputRequest { + pub exec_session_id: i32, + pub remote: bool, + pub cursor: Option, +} + +#[tauri::command] +pub async fn read_background_command_output( + request: ReadBackgroundCommandOutputRequest, +) -> Result { + background_command_output_capture() + .read(CoreReadBackgroundCommandOutputRequest { + exec_session_id: request.exec_session_id, + remote: request.remote, + cursor: request.cursor, + }) + .await + .ok_or_else(|| { + format!( + "Background command output not found: exec_session_id={}, remote={}", + request.exec_session_id, request.remote + ) + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListBackgroundCommandActivitiesRequest { + pub agent_session_id: Option, +} + +#[tauri::command] +pub async fn list_background_command_activities( + request: ListBackgroundCommandActivitiesRequest, +) -> Result { + let agent_session_id = request.agent_session_id.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }); + + Ok(background_command_output_capture() + .list(ListBackgroundCommandOutputRequest { agent_session_id }) + .await) +} + #[tauri::command] pub async fn cancel_tool( coordinator: State<'_, Arc>, @@ -1303,108 +1709,131 @@ pub async fn restore_session( pub async fn restore_session_view( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: RestoreSessionRequest, ) -> Result { - let started_at = std::time::Instant::now(); - let trace_id = request.trace_id.as_deref().unwrap_or("none"); - debug!( - "restore_session_view request received: trace_id={}, session_id={}", - trace_id, request.session_id - ); - let path_started_at = std::time::Instant::now(); - let effective_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) + let started_at = Instant::now(); + let result = async { + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_view request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) .await; - debug!( - "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", - trace_id, - request.session_id, - path_started_at.elapsed().as_millis() - ); - - let tail_turn_count = request.tail_turn_count.filter(|count| *count > 0); - let (session, mut turns, total_turn_count) = if let Some(tail_turn_count) = tail_turn_count { - let tail_turn_count = tail_turn_count.min(16); - if request.include_internal { - coordinator - .restore_internal_session_view_tail( - &effective_path, - &request.session_id, - tail_turn_count, - ) - .await - } else { - coordinator - .restore_session_view_tail(&effective_path, &request.session_id, tail_turn_count) - .await + debug!( + "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + + let view_request = SessionViewRestoreRequest { + workspace_path: effective_path, + session_id: request.session_id.clone(), + include_internal: request.include_internal, + tail_turn_count: request.tail_turn_count, + }; + let tail_turn_count = view_request + .tail_turn_count + .filter(|count| *count > 0) + .map(|count| count.min(16)); + let (session, mut turns, total_turn_count, timings) = + if let Some(tail_turn_count) = tail_turn_count { + if view_request.include_internal { + coordinator + .restore_internal_session_view_tail_timed( + &view_request.workspace_path, + &view_request.session_id, + tail_turn_count, + ) + .await + } else { + coordinator + .restore_session_view_tail_timed( + &view_request.workspace_path, + &view_request.session_id, + tail_turn_count, + ) + .await + } + } else if view_request.include_internal { + coordinator + .restore_internal_session_view_timed( + &view_request.workspace_path, + &view_request.session_id, + ) + .await + .map(|(session, turns, timings)| { + let total_turn_count = turns.len(); + (session, turns, total_turn_count, timings) + }) + } else { + coordinator + .restore_session_view_timed(&view_request.workspace_path, &view_request.session_id) + .await + .map(|(session, turns, timings)| { + let total_turn_count = turns.len(); + (session, turns, total_turn_count, timings) + }) + } + .map_err(|e| format!("Failed to restore session view: {}", e))?; + let loaded_turn_count = turns.len(); + let is_partial = loaded_turn_count < total_turn_count; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + total_turn_count, + is_partial, + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } } - } else if request.include_internal { - coordinator - .restore_internal_session_view(&effective_path, &request.session_id) - .await - .map(|(session, turns)| { - let total_turn_count = turns.len(); - (session, turns, total_turn_count) - }) - } else { - coordinator - .restore_session_view(&effective_path, &request.session_id) - .await - .map(|(session, turns)| { - let total_turn_count = turns.len(); - (session, turns, total_turn_count) - }) - } - .map_err(|e| format!("Failed to restore session view: {}", e))?; - let loaded_turn_count = turns.len(); - let is_partial = loaded_turn_count < total_turn_count; - if log::log_enabled!(log::Level::Debug) { - let payload_stats = restore_turn_payload_stats(&turns); - if payload_stats.raw_result_string_chars >= 1024 * 1024 - || payload_stats.result_for_assistant_chars >= 1024 * 1024 - { - debug!( - "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", - trace_id, - request.session_id, - turns.len(), - total_turn_count, - is_partial, - payload_stats.tool_result_count, - payload_stats.raw_result_string_chars, - payload_stats.result_for_assistant_chars, - payload_stats.largest_raw_result_chars, - payload_stats.largest_raw_result_path, - format_top_raw_results(&payload_stats.top_raw_results) - ); - } + compact_tool_results_for_session_view(&mut turns); + + debug!( + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, context_restore_state=pending, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + total_turn_count, + is_partial, + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionViewResponse { + session: session_to_response_with_turn_count(session, total_turn_count), + turns, + context_restore_state: "pending".to_string(), + is_partial, + loaded_turn_count, + total_turn_count, + timings, + }) } - - compact_tool_results_for_session_view(&mut turns); - - debug!( - "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, context_restore_state=pending, duration_ms={}", - trace_id, - request.session_id, - turns.len(), - total_turn_count, - is_partial, - started_at.elapsed().as_millis() - ); - - Ok(RestoreSessionViewResponse { - session: session_to_response(session), - turns, - context_restore_state: "pending".to_string(), - is_partial, - loaded_turn_count, - total_turn_count, - }) + .await; + startup_trace.record_tauri_command_elapsed("restore_session_view", None, started_at); + result } #[tauri::command] @@ -1555,7 +1984,11 @@ pub async fn generate_session_title( } #[tauri::command] -pub async fn get_available_modes(state: State<'_, AppState>) -> Result, String> { +pub async fn get_available_modes( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result, String> { + let trace_started = Instant::now(); let mode_infos = state.agent_registry.get_modes_info().await; let dtos: Vec = mode_infos @@ -1580,6 +2013,7 @@ pub async fn get_available_modes(state: State<'_, AppState>) -> Result SessionResponse { + let turn_count = session.dialog_turn_ids.len(); + session_to_response_with_turn_count(session, turn_count) +} + +fn session_to_response_with_turn_count(session: Session, turn_count: usize) -> SessionResponse { SessionResponse { session_id: session.session_id, session_name: session.session_name, @@ -1664,7 +2103,7 @@ fn session_to_response(session: Session) -> SessionResponse { last_user_dialog_agent_type: session.last_user_dialog_agent_type, last_submitted_agent_type: session.last_submitted_agent_type, state: format!("{:?}", session.state), - turn_count: session.dialog_turn_ids.len(), + turn_count, created_at: system_time_to_unix_secs(session.created_at), } } @@ -1718,6 +2157,8 @@ mod tests { is_subagent_item: None, parent_task_tool_id: None, subagent_session_id: None, + attempt_id: None, + attempt_index: None, subagent_model_id: None, subagent_model_alias: None, status: None, @@ -1744,6 +2185,7 @@ mod tests { id: "round-1".to_string(), turn_id: "turn-1".to_string(), round_index: 0, + round_group_id: None, timestamp: 1, text_items: vec![], tool_items: vec![ @@ -1768,6 +2210,9 @@ mod tests { start_time: 1, end_time: Some(2), duration_ms: Some(1), + token_usage: None, + finish_reason: None, + has_final_response: None, status: TurnStatus::Completed, }; @@ -1786,6 +2231,21 @@ mod tests { assert!(!stats.top_raw_results[0].path.contains(&"x".repeat(20))); } + #[test] + fn session_view_response_can_report_total_turn_count_for_tail_view() { + let mut session = Session::new_with_id( + "session-1".to_string(), + "Tail view".to_string(), + "agentic".to_string(), + SessionConfig::default(), + ); + session.dialog_turn_ids = vec!["turn-49".to_string(), "turn-50".to_string()]; + + let response = session_to_response_with_turn_count(session, 50); + + assert_eq!(response.turn_count, 50); + } + #[test] fn omit_assistant_only_tool_results_preserves_visible_results() { let mut turns = vec![DialogTurnData { @@ -1805,6 +2265,7 @@ mod tests { id: "round-1".to_string(), turn_id: "turn-1".to_string(), round_index: 0, + round_group_id: None, timestamp: 1, text_items: vec![], tool_items: vec![tool_item( @@ -1830,6 +2291,9 @@ mod tests { start_time: 1, end_time: Some(2), duration_ms: Some(1), + token_usage: None, + finish_reason: None, + has_final_response: None, status: TurnStatus::Completed, }]; @@ -1863,6 +2327,7 @@ mod tests { id: "round-1".to_string(), turn_id: "turn-1".to_string(), round_index: 0, + round_group_id: None, timestamp: 1, text_items: vec![], tool_items: vec![tool_item( @@ -1888,6 +2353,9 @@ mod tests { start_time: 1, end_time: Some(2), duration_ms: Some(1), + token_usage: None, + finish_reason: None, + has_final_response: None, status: TurnStatus::Completed, }]; diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 31c05429e1..723bbd7fd9 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -159,7 +159,7 @@ impl AppState { match miniapp_manager.mark_stale_drafts_for_cleanup().await { Ok(cleanup_targets) if !cleanup_targets.is_empty() => { let cleanup_manager = miniapp_manager.clone(); - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { if let Err(e) = cleanup_manager.cleanup_marked_drafts(cleanup_targets).await { log::warn!("Failed to clean marked miniapp drafts: {}", e); } diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index 7208d39738..7def913ef2 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -93,6 +93,7 @@ fn parse_uri_list(content: &str) -> Vec { .collect() } +#[cfg(any(target_os = "macos", test))] fn parse_clipboard_path_segments(content: &str) -> Vec { content .split(|c| c == '\n' || c == '\r') diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 6929b22d91..5fd9af33ad 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -14,6 +14,7 @@ use crate::api::search_api::{ should_use_workspace_search, SearchMetadataResponse, }; use crate::api::workspace_activation::spawn_workspace_background_warmup; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::{ BatchedFileSearchProgressSink, FileSearchOutcome, FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, SearchMatchType, @@ -31,7 +32,7 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, State}; fn remote_workspace_from_info(info: &WorkspaceInfo) -> Option { @@ -827,30 +828,67 @@ pub struct RevealInExplorerRequest { pub path: String, } -async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHandle) { +async fn clear_active_workspace_context( + state: &State<'_, AppState>, + app: &AppHandle, + startup_trace: Option<&DesktopStartupTrace>, +) { #[cfg(not(target_os = "macos"))] let _ = app; + let step_started = Instant::now(); let previous_workspace_path = state.workspace_path.read().await.clone(); *state.workspace_path.write().await = None; + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.clear_active_workspace_path", + step_started, + ); + } if let Some(previous_workspace_path) = previous_workspace_path { + let step_started = Instant::now(); let root_str = previous_workspace_path.to_string_lossy().to_string(); if !is_remote_path(root_str.trim()).await { state .workspace_search_service .schedule_repo_release(previous_workspace_path); } + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.release_previous_workspace_search", + step_started, + ); + } } if let Some(ref pool) = state.js_worker_pool { + let step_started = Instant::now(); pool.stop_all().await; + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.stop_js_worker_pool", + step_started, + ); + } } + let step_started = Instant::now(); state.agent_registry.clear_custom_subagents(); + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.clear_custom_subagents", + step_started, + ); + } #[cfg(target_os = "macos")] { + let step_started = Instant::now(); let language = state .config_service .get_config::(Some("app.language")) @@ -863,6 +901,13 @@ async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHa crate::macos_menubar::MenubarMode::Startup, edit_mode, ); + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.set_macos_startup_menubar", + step_started, + ); + } } } @@ -870,18 +915,44 @@ async fn apply_active_workspace_context( state: &State<'_, AppState>, app: &AppHandle, workspace_info: &bitfun_core::service::workspace::manager::WorkspaceInfo, + startup_trace: Option<&DesktopStartupTrace>, ) { #[cfg(not(target_os = "macos"))] let _ = app; - clear_active_workspace_context(state, app).await; + let step_started = Instant::now(); + clear_active_workspace_context(state, app, startup_trace).await; + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.clear_active_workspace_context", + step_started, + ); + } + let step_started = Instant::now(); *state.workspace_path.write().await = Some(workspace_info.root_path.clone()); + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.set_active_workspace_path", + step_started, + ); + } + let step_started = Instant::now(); spawn_workspace_background_warmup(&*state, workspace_info.clone()); + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.spawn_workspace_background_warmup", + step_started, + ); + } #[cfg(target_os = "macos")] { + let step_started = Instant::now(); let language = state .config_service .get_config::(Some("app.language")) @@ -894,10 +965,18 @@ async fn apply_active_workspace_context( crate::macos_menubar::MenubarMode::Workspace, edit_mode, ); + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.set_macos_workspace_menubar", + step_started, + ); + } } // Keep global SSH registry + active connection hint aligned with the **foreground** workspace // so two servers opened at the same remote path (e.g. `/`) stay distinct. + let step_started = Instant::now(); if workspace_info.workspace_kind == WorkspaceKind::Remote { if let Some(rw) = remote_workspace_from_info(workspace_info) { if let Err(e) = state.set_remote_workspace(rw).await { @@ -913,15 +992,39 @@ async fn apply_active_workspace_context( m.set_active_connection_hint(None).await; } } + if let Some(trace) = startup_trace { + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.sync_remote_workspace_context", + step_started, + ); + } } #[tauri::command] pub async fn initialize_global_state( state: State<'_, AppState>, app: tauri::AppHandle, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result { - if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { - apply_active_workspace_context(&state, &app, &workspace_info).await; + let command_started = Instant::now(); + let trace = startup_trace.inner(); + let step_started = Instant::now(); + let current_workspace = state.workspace_service.get_current_workspace().await; + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.get_current_workspace", + step_started, + ); + + if let Some(workspace_info) = current_workspace { + let step_started = Instant::now(); + apply_active_workspace_context(&state, &app, &workspace_info, Some(trace)).await; + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.apply_active_workspace_context", + step_started, + ); info!( "Global state initialized with active workspace: workspace_id={}, path={}", @@ -929,10 +1032,23 @@ pub async fn initialize_global_state( workspace_info.root_path.display() ); } else { - clear_active_workspace_context(&state, &app).await; + let step_started = Instant::now(); + clear_active_workspace_context(&state, &app, Some(trace)).await; + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.clear_active_workspace_context", + step_started, + ); info!("Global state initialized without active workspace"); } + trace.record_elapsed_step( + "tauri_command", + "initialize_global_state.total", + command_started, + ); + trace.record_tauri_command_elapsed("initialize_global_state", None, command_started); + Ok("Global state initialized successfully".to_string()) } @@ -974,19 +1090,18 @@ pub async fn initialize_ai(state: State<'_, AppState>) -> Result .primary .clone() .ok_or_else(|| { - "Primary model not configured, please configure it in settings".to_string() - })?; + "Primary model not configured, please configure it in settings".to_string() + })?; let model_config = global_config .ai .models .iter() .find(|m| m.id == primary_model_id) .ok_or_else(|| format!("Primary model '{}' does not exist", primary_model_id))?; - let stream_options = - bitfun_core::infrastructure::ai::build_stream_options_for_model( - &global_config.ai, - Some(model_config), - ); + let stream_options = bitfun_core::infrastructure::ai::build_stream_options_for_model( + &global_config.ai, + Some(model_config), + ); let ai_config = bitfun_core::util::types::AIConfig::try_from(model_config.clone()) .map_err(|e| format!("Failed to convert AI configuration: {}", e))?; @@ -1019,11 +1134,10 @@ async fn create_transient_ai_client_for_config( .get_config(None) .await .map_err(|e| format!("Failed to get configuration: {}", e))?; - let stream_options = - bitfun_core::infrastructure::ai::build_stream_options_for_model( - &global_config.ai, - Some(&model_config), - ); + let stream_options = bitfun_core::infrastructure::ai::build_stream_options_for_model( + &global_config.ai, + Some(&model_config), + ); let mut ai_config: bitfun_core::util::types::AIConfig = model_config .try_into() @@ -1253,7 +1367,7 @@ pub async fn open_workspace( .await { Ok(workspace_info) => { - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; if let Err(e) = state .workspace_identity_watch_service @@ -1394,7 +1508,7 @@ pub async fn open_remote_workspace( warn!("Failed to set remote workspace state: {}", e); } - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; info!( "Remote workspace opened: name={}, remote_path={}, connection_id={}", @@ -1423,7 +1537,7 @@ pub async fn create_assistant_workspace( .await { Ok(workspace_info) => { - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; if let Err(e) = state .workspace_identity_watch_service @@ -1514,9 +1628,9 @@ pub async fn delete_assistant_workspace( .map_err(|e| format!("Failed to remove assistant workspace state: {}", e))?; if let Some(current_workspace) = state.workspace_service.get_current_workspace().await { - apply_active_workspace_context(&state, &app, ¤t_workspace).await; + apply_active_workspace_context(&state, &app, ¤t_workspace, None).await; } else { - clear_active_workspace_context(&state, &app).await; + clear_active_workspace_context(&state, &app, None).await; } if let Err(e) = state @@ -1643,7 +1757,7 @@ pub async fn reset_assistant_workspace( .map(|workspace| workspace.id == request.workspace_id) .unwrap_or(false) { - apply_active_workspace_context(&state, &app, &updated_workspace).await; + apply_active_workspace_context(&state, &app, &updated_workspace, None).await; } info!( @@ -1684,9 +1798,9 @@ pub async fn close_workspace( } if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; } else { - clear_active_workspace_context(&state, &app).await; + clear_active_workspace_context(&state, &app, None).await; } info!("Workspace closed: workspace_id={}", request.workspace_id); @@ -1717,7 +1831,7 @@ pub async fn set_active_workspace( .await .ok_or_else(|| "Active workspace not found after switching".to_string())?; - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; info!( "Active workspace changed: workspace_id={}, path={}", @@ -1785,7 +1899,7 @@ pub async fn update_workspace_info( .unwrap_or(false); if is_active_workspace { - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; } info!( @@ -1806,25 +1920,33 @@ pub async fn update_workspace_info( #[tauri::command] pub async fn get_current_workspace( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { + let trace_started = Instant::now(); let workspace_service = &state.workspace_service; - Ok(workspace_service + let result = Ok(workspace_service .get_current_workspace() .await - .map(|info| WorkspaceInfoDto::from_workspace_info(&info))) + .map(|info| WorkspaceInfoDto::from_workspace_info(&info))); + startup_trace.record_tauri_command_elapsed("get_current_workspace", None, trace_started); + result } #[tauri::command] pub async fn get_recent_workspaces( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { + let trace_started = Instant::now(); let workspace_service = &state.workspace_service; - Ok(workspace_service + let result = Ok(workspace_service .get_recent_workspaces() .await .into_iter() .map(|info| WorkspaceInfoDto::from_workspace_info(&info)) - .collect()) + .collect()); + startup_trace.record_tauri_command_elapsed("get_recent_workspaces", None, trace_started); + result } #[tauri::command] @@ -1843,18 +1965,39 @@ pub async fn remove_recent_workspace( pub async fn cleanup_invalid_workspaces( state: State<'_, AppState>, app: tauri::AppHandle, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result { + let trace_started = Instant::now(); + let cleanup_started = Instant::now(); match state.workspace_service.cleanup_invalid_workspaces().await { Ok(local_removed_count) => { + startup_trace.record_elapsed_step( + "tauri_command", + "cleanup_invalid_workspaces.local_workspace_cleanup", + cleanup_started, + ); + let prune_remote_started = Instant::now(); let remote_removed_count = prune_unrecoverable_remote_workspaces(&state).await; + startup_trace.record_elapsed_step( + "tauri_command", + "cleanup_invalid_workspaces.remote_workspace_prune", + prune_remote_started, + ); let removed_count = local_removed_count + remote_removed_count; + let apply_context_started = Instant::now(); if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { - apply_active_workspace_context(&state, &app, &workspace_info).await; + apply_active_workspace_context(&state, &app, &workspace_info, None).await; } else { - clear_active_workspace_context(&state, &app).await; + clear_active_workspace_context(&state, &app, None).await; } + startup_trace.record_elapsed_step( + "tauri_command", + "cleanup_invalid_workspaces.apply_active_workspace_context", + apply_context_started, + ); + let sync_watchers_started = Instant::now(); if let Err(e) = state .workspace_identity_watch_service .sync_watched_workspaces() @@ -1865,15 +2008,30 @@ pub async fn cleanup_invalid_workspaces( e ); } + startup_trace.record_elapsed_step( + "tauri_command", + "cleanup_invalid_workspaces.sync_identity_watchers", + sync_watchers_started, + ); info!( "Invalid workspaces cleaned up: removed_count={}", removed_count ); + startup_trace.record_tauri_command_elapsed( + "cleanup_invalid_workspaces", + None, + trace_started, + ); Ok(removed_count) } Err(e) => { error!("Failed to cleanup invalid workspaces: {}", e); + startup_trace.record_tauri_command_elapsed( + "cleanup_invalid_workspaces", + None, + trace_started, + ); Err(format!("Failed to cleanup invalid workspaces: {}", e)) } } @@ -1957,14 +2115,18 @@ async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> u #[tauri::command] pub async fn get_opened_workspaces( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { + let trace_started = Instant::now(); let workspace_service = &state.workspace_service; - Ok(workspace_service + let result = Ok(workspace_service .get_opened_workspaces() .await .into_iter() .map(|info| WorkspaceInfoDto::from_workspace_info(&info)) - .collect()) + .collect()); + startup_trace.record_tauri_command_elapsed("get_opened_workspaces", None, trace_started); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 3daca8bc06..db2e817492 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -1,11 +1,13 @@ //! Configuration API use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; +use std::time::Instant; use tauri::State; #[derive(Debug, Deserialize)] @@ -57,11 +59,14 @@ fn is_expected_config_path_not_found(error: &BitFunError, path: Option<&str>) -> #[tauri::command] pub async fn get_config( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GetConfigRequest, ) -> Result { let config_service = &state.config_service; + let trace_started = Instant::now(); + let trace_target = request.path.clone(); - match config_service + let result = match config_service .get_config::(request.path.as_deref()) .await { @@ -70,21 +75,31 @@ pub async fn get_config( if request.skip_retry_on_not_found && is_expected_config_path_not_found(&e, request.path.as_deref()) { - return Err(format!("Failed to get config: {}", e)); + Err(format!("Failed to get config: {}", e)) + } else { + error!("Failed to get config: path={:?}, error={}", request.path, e); + Err(format!("Failed to get config: {}", e)) } - error!("Failed to get config: path={:?}, error={}", request.path, e); - Err(format!("Failed to get config: {}", e)) } - } + }; + startup_trace.record_tauri_command_elapsed( + "get_config", + trace_target.as_deref(), + trace_started, + ); + result } #[tauri::command] pub async fn get_configs( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GetConfigsRequest, ) -> Result, String> { let config_service = &state.config_service; let mut configs = BTreeMap::new(); + let trace_started = Instant::now(); + let trace_target = request.paths.join(","); for path in request.paths { if configs.contains_key(&path) { @@ -102,14 +117,25 @@ pub async fn get_configs( if request.skip_retry_on_not_found && is_expected_config_path_not_found(&e, Some(path.as_str())) { + startup_trace.record_tauri_command_elapsed( + "get_configs", + Some(&trace_target), + trace_started, + ); return Err(format!("Failed to get config: {}", e)); } error!("Failed to get config: path={}, error={}", path, e); + startup_trace.record_tauri_command_elapsed( + "get_configs", + Some(&trace_target), + trace_started, + ); return Err(format!("Failed to get config: {}", e)); } } } + startup_trace.record_tauri_command_elapsed("get_configs", Some(&trace_target), trace_started); Ok(configs) } @@ -142,11 +168,14 @@ mod tests { #[tauri::command] pub async fn set_config( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: SetConfigRequest, ) -> Result { let config_service = &state.config_service; + let trace_started = Instant::now(); + let trace_target = request.path.clone(); - match config_service + let result = match config_service .set_config(&request.path, request.value) .await { @@ -171,7 +200,13 @@ pub async fn set_config( error!("Failed to set config: path={}, error={}", request.path, e); Err(format!("Failed to set config: {}", e)) } - } + }; + startup_trace.record_tauri_command_elapsed( + "set_config", + Some(trace_target.as_str()), + trace_started, + ); + result } #[tauri::command] @@ -299,11 +334,15 @@ pub async fn get_global_config_health() -> Result { #[tauri::command] pub async fn get_runtime_logging_info( + startup_trace: State<'_, DesktopStartupTrace>, _state: State<'_, AppState>, _request: GetRuntimeLoggingInfoRequest, ) -> Result { + let trace_started = Instant::now(); let logging_info = crate::logging::get_runtime_logging_info(); - to_json_value(logging_info, "runtime logging info") + let result = to_json_value(logging_info, "runtime logging info"); + startup_trace.record_tauri_command_elapsed("get_runtime_logging_info", None, trace_started); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/cron_api.rs b/src/apps/desktop/src/api/cron_api.rs index 4e086ba560..a02e3d03e3 100644 --- a/src/apps/desktop/src/api/cron_api.rs +++ b/src/apps/desktop/src/api/cron_api.rs @@ -1,7 +1,7 @@ //! Scheduled jobs API. use bitfun_core::service::cron::{ - get_global_cron_service, CreateCronJobRequest, CronJob, UpdateCronJobRequest, + get_global_cron_service, CreateCronJobRequest, CronJob, CronJobTargetKind, UpdateCronJobRequest, }; use log::{debug, error}; use serde::Deserialize; @@ -10,7 +10,10 @@ use serde::Deserialize; #[serde(rename_all = "camelCase")] pub struct ListCronJobsRequest { pub workspace_path: Option, + pub workspace_id: Option, + pub remote_connection_id: Option, pub session_id: Option, + pub target_kind: Option, } #[derive(Debug, Deserialize)] @@ -34,15 +37,22 @@ fn cron_service() -> Result Result, String> { debug!( - "Listing scheduled jobs: workspace_path={:?}, session_id={:?}", - request.workspace_path, request.session_id + "Listing scheduled jobs: workspace_path={:?}, workspace_id={:?}, remote_connection_id={:?}, session_id={:?}, target_kind={:?}", + request.workspace_path, + request.workspace_id, + request.remote_connection_id, + request.session_id, + request.target_kind ); let service = cron_service()?; Ok(service .list_jobs_filtered( request.workspace_path.as_deref(), + request.workspace_id.as_deref(), + request.remote_connection_id.as_deref(), request.session_id.as_deref(), + request.target_kind, ) .await) } @@ -50,8 +60,8 @@ pub async fn list_cron_jobs(request: ListCronJobsRequest) -> Result #[tauri::command] pub async fn create_cron_job(request: CreateCronJobRequest) -> Result { debug!( - "Creating scheduled job: name={}, session_id={}, workspace_path={}", - request.name, request.session_id, request.workspace_path + "Creating scheduled job: name={}, target={:?}", + request.name, request.target ); let service = cron_service()?; diff --git a/src/apps/desktop/src/api/debug_api.rs b/src/apps/desktop/src/api/debug_api.rs index d1a1de6ce3..e090b63a7a 100644 --- a/src/apps/desktop/src/api/debug_api.rs +++ b/src/apps/desktop/src/api/debug_api.rs @@ -66,6 +66,13 @@ pub async fn debug_element_picked(request: DebugElementPickedRequest) -> Result< Ok(()) } +/// Report whether desktop debug commands are available in this build. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_devtools_available() -> Result { + Ok(true) +} + /// Open the native webview DevTools window for the main window. #[tauri::command] #[cfg(any(debug_assertions, feature = "devtools"))] @@ -92,6 +99,12 @@ pub async fn debug_close_devtools(app: tauri::AppHandle) -> Result<(), String> { // No-op stubs for release builds (so the module always compiles) // --------------------------------------------------------------------------- +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_devtools_available() -> Result { + Ok(false) +} + #[tauri::command] #[cfg(not(any(debug_assertions, feature = "devtools")))] pub async fn debug_element_picked(_request: DebugElementPickedRequest) -> Result<(), String> { diff --git a/src/apps/desktop/src/api/editor_ai_api.rs b/src/apps/desktop/src/api/editor_ai_api.rs index 60c5657e3f..dfde59be72 100644 --- a/src/apps/desktop/src/api/editor_ai_api.rs +++ b/src/apps/desktop/src/api/editor_ai_api.rs @@ -125,7 +125,7 @@ pub async fn editor_ai_stream( let mut full_text = String::new(); let mut last_finish_reason: Option = None; - let mut stream = match client.send_message_stream(messages, None).await { + let mut stream = match client.send_message_stream(messages, None, None).await { Ok(response) => response.stream, Err(error) => { runtime.remove(&request_id).await; diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index ed7c6a239b..764fc31017 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -1,6 +1,7 @@ //! Git API use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::storage::StorageOptions; use bitfun_core::service::git::{ build_git_changed_files_args, build_git_diff_args, GitAddParams, GitChangedFile, @@ -11,8 +12,9 @@ use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; -use log::{debug, error, info}; +use log::{error, info}; use serde::{Deserialize, Serialize}; +use std::time::Instant; use tauri::State; #[derive(Debug, Clone)] @@ -503,27 +505,32 @@ pub struct GitRemoveWorktreeRequest { #[tauri::command] pub async fn git_is_repository( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GitRepositoryRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await { - let output = execute_remote_git_command( + let trace_started = Instant::now(); + let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + execute_remote_git_command( &state, &target, &["rev-parse".to_string(), "--is-inside-work-tree".to_string()], ) - .await?; - return Ok(output.exit_code == 0 && output.stdout.trim() == "true"); - } - - GitService::is_repository(&request.repository_path) .await - .map_err(|e| { - error!( - "Failed to check Git repository: path={}, error={}", - request.repository_path, e - ); - format!("Failed to check Git repository: {}", e) - }) + .map(|output| output.exit_code == 0 && output.stdout.trim() == "true") + } else { + GitService::is_repository(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to check Git repository: path={}, error={}", + request.repository_path, e + ); + format!("Failed to check Git repository: {}", e) + }) + }; + + startup_trace.record_tauri_command_elapsed("git_is_repository", None, trace_started); + result } #[tauri::command] @@ -591,60 +598,57 @@ pub async fn git_get_repository( #[tauri::command] pub async fn git_get_repository_basic( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GitRepositoryRequest, ) -> Result { - let started_at = std::time::Instant::now(); - let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { - let current_branch = execute_remote_git_success( - &state, - &target, - &["branch".to_string(), "--show-current".to_string()], - ) - .await - .map(|s| { - let branch = s.trim(); - if branch.is_empty() { - "HEAD".to_string() - } else { - branch.to_string() - } - })?; + let trace_started = Instant::now(); + let result = async { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let current_branch = execute_remote_git_success( + &state, + &target, + &["branch".to_string(), "--show-current".to_string()], + ) + .await + .map(|s| { + let branch = s.trim(); + if branch.is_empty() { + "HEAD".to_string() + } else { + branch.to_string() + } + })?; - let name = target - .repository_path - .rsplit('/') - .find(|part| !part.is_empty()) - .unwrap_or("/") - .to_string(); + let name = target + .repository_path + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("/") + .to_string(); - Ok(GitRepository { - path: target.repository_path, - name, - current_branch, - is_bare: false, - has_changes: false, - remotes: Vec::new(), - }) - } else { - GitService::get_repository_basic(&request.repository_path) - .await - .map_err(|e| { - error!( - "Failed to get basic Git repository info: path={}, error={}", - request.repository_path, e - ); - format!("Failed to get basic Git repository info: {}", e) + Ok(GitRepository { + path: target.repository_path, + name, + current_branch, + is_bare: false, + has_changes: false, + remotes: Vec::new(), }) - }; - - let duration_ms = started_at.elapsed().as_millis(); - if duration_ms >= 80 { - debug!( - "Git basic repository lookup completed: path={}, duration_ms={}", - request.repository_path, duration_ms - ); + } else { + GitService::get_repository_basic(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to get basic Git repository info: path={}, error={}", + request.repository_path, e + ); + format!("Failed to get basic Git repository info: {}", e) + }) + } } + .await; + startup_trace.record_tauri_command_elapsed("git_get_repository_basic", None, trace_started); result } @@ -1149,7 +1153,7 @@ pub async fn git_get_graph( return Err("Git graph is not supported for remote SSH workspaces yet".to_string()); } - GitService::get_git_graph_for_branch(&repository_path, max_count, branch_name.as_deref()) + GitService::get_git_graph_for_branch(&repository_path, max_count, branch_name) .await .map_err(|e| e.to_string()) } diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index 8d8af547f6..b0cb9a7777 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -1,6 +1,7 @@ //! MCP API use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::service::mcp::auth::{ has_stored_oauth_credentials, MCPRemoteOAuthSessionSnapshot, }; @@ -12,6 +13,7 @@ use bitfun_core::service::mcp::MCPServerType; use bitfun_core::service::runtime::{RuntimeManager, RuntimeSource}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::time::Instant; use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -120,37 +122,57 @@ async fn load_mcp_prompts( } #[tauri::command] -pub async fn initialize_mcp_servers(state: State<'_, AppState>) -> Result<(), String> { - let mcp_service = state - .mcp_service - .as_ref() - .ok_or_else(|| "MCP service not initialized".to_string())?; - - mcp_service - .server_manager() - .initialize_all() - .await - .map_err(|e| e.to_string())?; +pub async fn initialize_mcp_servers( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result<(), String> { + let trace_started = Instant::now(); + let result = async { + let mcp_service = state + .mcp_service + .as_ref() + .ok_or_else(|| "MCP service not initialized".to_string())?; + + mcp_service + .server_manager() + .initialize_all() + .await + .map_err(|e| e.to_string())?; - Ok(()) + Ok(()) + } + .await; + startup_trace.record_tauri_command_elapsed("initialize_mcp_servers", None, trace_started); + result } #[tauri::command] pub async fn initialize_mcp_servers_non_destructive( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result<(), String> { - let mcp_service = state - .mcp_service - .as_ref() - .ok_or_else(|| "MCP service not initialized".to_string())?; - - mcp_service - .server_manager() - .initialize_non_destructive() - .await - .map_err(|e| e.to_string())?; + let trace_started = Instant::now(); + let result = async { + let mcp_service = state + .mcp_service + .as_ref() + .ok_or_else(|| "MCP service not initialized".to_string())?; + + mcp_service + .server_manager() + .initialize_non_destructive() + .await + .map_err(|e| e.to_string())?; - Ok(()) + Ok(()) + } + .await; + startup_trace.record_tauri_command_elapsed( + "initialize_mcp_servers_non_destructive", + None, + trace_started, + ); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs new file mode 100644 index 0000000000..9c969cb70e --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -0,0 +1,475 @@ +//! MiniApp agent bridge API. +//! +//! Lets a MiniApp (gated by the `agent` permission group) run full host agent +//! turns — the complete agent loop with tools (WebSearch/WebFetch/Read/...) +//! and skills — instead of the raw single-call LLM access provided by the +//! `ai` permission group. +//! +//! A run creates or reuses a hidden subagent session (invisible in the session +//! list), owned by `miniapp-agent:{app_id}:{run_id}`, and submits one dialog +//! turn through the standard `DialogScheduler`. Streaming output reaches the +//! MiniApp iframe through the normal `agentic://*` Tauri events, which the +//! web-ui MiniApp bridge filters by session id and forwards into the iframe. + +use log::warn; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::State; + +use crate::api::app_state::AppState; +use bitfun_core::agentic::coordination::{ + ConversationCoordinator, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, +}; +use bitfun_core::agentic::core::{MessageContent, MessageRole, SessionConfig}; +use bitfun_core::miniapp::agent_bridge::{ + agent_owner, agent_run_metadata, default_agent_run_id, extract_agent_turn_text, + requested_session_id, require_enabled_agent_permissions, resolve_agent_workspace_path, + session_name_or_default, validate_reused_session, MiniAppAgentRateLimiter, + MiniAppAgentRunRecord, MiniAppAgentRunRegistry, MiniAppAgentTurnMessage, + MiniAppAgentTurnMessageRole, MINIAPP_AGENT_KIND, UNKNOWN_AGENT_RUN_MESSAGE, +}; + +// ============== Run registry ============== + +/// Active/recent agent runs: run_id → record. Used for ownership validation, +/// stale-run cancellation after a webview reload, and turn-text fallback. +static AGENT_RUN_REGISTRY: OnceLock = OnceLock::new(); + +/// Per-app agent rate limiter state: app_id → (request_count, window_start_ms). +static AGENT_RATE_LIMITER: OnceLock = OnceLock::new(); + +static AGENT_RUN_COUNTER: AtomicU64 = AtomicU64::new(1); + +fn agent_run_registry() -> &'static MiniAppAgentRunRegistry { + AGENT_RUN_REGISTRY.get_or_init(MiniAppAgentRunRegistry::default) +} + +fn agent_rate_limiter() -> &'static MiniAppAgentRateLimiter { + AGENT_RATE_LIMITER.get_or_init(MiniAppAgentRateLimiter::default) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// Resolve a MiniApp-requested agent workspace inside the app's own appdata +/// directory. The subdir must be a clean relative path (no `..`, no absolute +/// or rooted components) so a MiniApp can never point the agent outside its +/// own storage. The directory is created if missing. +fn resolve_app_data_workspace( + state: &AppState, + app_id: &str, + subdir: &str, +) -> Result { + let app_data_dir = state.miniapp_manager.path_manager().miniapp_dir(app_id); + let workspace = resolve_agent_workspace_path(None, Some(subdir), &app_data_dir)?; + std::fs::create_dir_all(&workspace) + .map_err(|e| format!("Failed to create MiniApp agent workspace: {}", e))?; + Ok(workspace.to_string_lossy().to_string()) +} + +fn check_agent_rate_limit(app_id: &str, rate_limit_per_minute: u32) -> Result<(), String> { + agent_rate_limiter().check(app_id, rate_limit_per_minute, now_ms()) +} + +async fn require_agent_permission( + state: &AppState, + app_id: &str, +) -> Result { + let app = state + .miniapp_manager + .get(app_id) + .await + .map_err(|e| e.to_string())?; + require_enabled_agent_permissions(app.permissions.agent.as_ref()) +} + +// ============== Request/Response DTOs ============== + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentRunRequest { + pub app_id: String, + /// Full user prompt for the agent turn. The MiniApp owns its own task + /// protocol; the host only wraps it into a hidden agent session. + pub prompt: String, + /// Optional idempotency key reused as the turn id. + #[serde(default)] + pub run_id: Option, + /// Optional human-readable session name for diagnostics. + #[serde(default)] + pub session_name: Option, + #[serde(default)] + pub workspace_path: Option, + /// Defaults to true for backward compatibility. MiniApps may disable tools + /// for deterministic render-only turns after a tool-enabled planning turn. + /// Only applies when a new session is created. + #[serde(default)] + pub enable_tools: Option, + /// Reuse an existing hidden session created by an earlier run of the same + /// MiniApp. Later turns then share the session context (loaded skills, + /// research results, prior outputs), so multi-step tasks load each + /// resource once and "continue" turns can resume interrupted work. + #[serde(default)] + pub session_id: Option, + /// Relative subdirectory inside the MiniApp's own appdata directory to use + /// as the agent workspace (created if missing). File-protocol MiniApps use + /// this so the agent reads/writes project files in app-owned storage + /// instead of the user's workspace. Must be a clean relative path. + #[serde(default)] + pub app_data_workspace: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentRunResponse { + pub session_id: String, + pub turn_id: String, + pub action_run_id: String, + pub status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentCancelRequest { + pub app_id: String, + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentTurnTextRequest { + pub app_id: String, + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentTurnTextResponse { + pub text: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentCancelStaleRunsRequest { + pub app_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppAgentCancelStaleRunsResponse { + pub cancelled_runs: u32, +} + +// ============== Commands ============== + +/// Start a full agent turn for a MiniApp inside a hidden subagent session. +#[tauri::command] +pub async fn miniapp_agent_run( + state: State<'_, AppState>, + coordinator: State<'_, Arc>, + scheduler: State<'_, Arc>, + request: MiniAppAgentRunRequest, +) -> Result { + if request.prompt.trim().is_empty() { + return Err("prompt is required".to_string()); + } + let agent_perms = require_agent_permission(&state, &request.app_id).await?; + check_agent_rate_limit( + &request.app_id, + agent_perms.rate_limit_per_minute.unwrap_or(0), + )?; + + let workspace_path = if let Some(subdir) = request + .app_data_workspace + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + resolve_app_data_workspace(&state, &request.app_id, subdir)? + } else { + request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or("workspacePath is required for MiniApp agent runs")? + .to_string() + }; + + let run_id = request + .run_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + default_agent_run_id( + &request.app_id, + AGENT_RUN_COUNTER.fetch_add(1, Ordering::Relaxed), + ) + }); + let owner = agent_owner(&request.app_id, &run_id); + let session_name = session_name_or_default(request.session_name.as_deref()); + + let requested_session_id = requested_session_id(request.session_id.as_deref()); + + let session_id = if let Some(existing_session_id) = requested_session_id { + // Reuse a hidden session created by an earlier run of this MiniApp so + // the new turn shares its context (skills, research, prior outputs). + let session = coordinator + .get_session_manager() + .get_session(&existing_session_id) + .ok_or("Unknown MiniApp agent session")?; + validate_reused_session( + session.created_by.as_deref(), + session.config.workspace_path.as_deref(), + &request.app_id, + &workspace_path, + )?; + existing_session_id + } else { + // One hidden session per task keeps MiniApp work isolated and out of + // the visible session list. Follow-up turns may reuse it via sessionId. + let enable_tools = request.enable_tools.unwrap_or(true); + let config = SessionConfig { + enable_tools, + safe_mode: true, + auto_compact: true, + enable_context_compression: true, + compression_threshold: 0.65, + ..Default::default() + }; + // Cowork supplies the office skill group and research/file tools when + // enabled. + let session = coordinator + .create_hidden_subagent_session_with_workspace( + None, + session_name, + MINIAPP_AGENT_KIND.to_string(), + config, + workspace_path.clone(), + Some(owner), + ) + .await + .map_err(|e| format!("Failed to create MiniApp agent session: {}", e))?; + session.session_id + }; + + let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) + .with_skip_tool_confirmation(true); + let metadata = agent_run_metadata(&request.app_id, &run_id); + + let outcome = scheduler + .submit( + session_id.clone(), + request.prompt.clone(), + Some("MiniApp agent run".to_string()), + Some(run_id.clone()), + MINIAPP_AGENT_KIND.to_string(), + Some(workspace_path), + policy, + None, + Some(metadata), + None, + ) + .await + .map_err(|e| format!("Failed to start MiniApp agent turn: {}", e))?; + + let status = match outcome { + bitfun_core::agentic::coordination::DialogSubmitOutcome::Started { .. } => "started", + bitfun_core::agentic::coordination::DialogSubmitOutcome::Queued { .. } => "queued", + }; + + agent_run_registry().register(MiniAppAgentRunRecord { + app_id: request.app_id.clone(), + session_id: session_id.clone(), + turn_id: run_id.clone(), + }); + + Ok(MiniAppAgentRunResponse { + session_id, + turn_id: run_id.clone(), + action_run_id: run_id, + status: status.to_string(), + }) +} + +/// Cancel a running MiniApp agent turn. +#[tauri::command] +pub async fn miniapp_agent_cancel( + state: State<'_, AppState>, + coordinator: State<'_, Arc>, + request: MiniAppAgentCancelRequest, +) -> Result<(), String> { + require_agent_permission(&state, &request.app_id).await?; + if agent_run_registry() + .lookup(&request.app_id, &request.session_id, &request.turn_id) + .is_none() + { + return Err(UNKNOWN_AGENT_RUN_MESSAGE.to_string()); + } + coordinator + .cancel_dialog_turn(&request.session_id, &request.turn_id) + .await + .map_err(|e| e.to_string())?; + agent_run_registry().remove(&request.turn_id); + Ok(()) +} + +/// Read the assistant text of a (completed) MiniApp agent turn from the live +/// in-memory session. Used by MiniApps as a fallback when streaming was +/// interrupted (for example a webview reload during generation). +#[tauri::command] +pub async fn miniapp_agent_turn_text( + state: State<'_, AppState>, + coordinator: State<'_, Arc>, + request: MiniAppAgentTurnTextRequest, +) -> Result { + require_agent_permission(&state, &request.app_id).await?; + if agent_run_registry() + .lookup(&request.app_id, &request.session_id, &request.turn_id) + .is_none() + { + return Err(UNKNOWN_AGENT_RUN_MESSAGE.to_string()); + } + + let messages = coordinator + .get_session_manager() + .get_context_messages(&request.session_id) + .await + .map_err(|e| e.to_string())?; + let turn_messages: Vec = messages + .iter() + .map(|message| { + let role = if message.role == MessageRole::Assistant { + MiniAppAgentTurnMessageRole::Assistant + } else if message.role == MessageRole::Tool { + MiniAppAgentTurnMessageRole::Tool + } else { + MiniAppAgentTurnMessageRole::Other + }; + let text = match &message.content { + MessageContent::Text(text) => text.clone(), + MessageContent::Multimodal { text, .. } => text.clone(), + MessageContent::Mixed { text, .. } => text.clone(), + MessageContent::ToolResult { .. } => String::new(), + }; + MiniAppAgentTurnMessage { + turn_id: message.metadata.turn_id.clone(), + role, + is_tool_result: matches!(message.content, MessageContent::ToolResult { .. }), + text, + } + }) + .collect(); + let text = extract_agent_turn_text(&turn_messages, &request.turn_id); + + Ok(MiniAppAgentTurnTextResponse { text }) +} + +/// Cancel every tracked agent run for the given MiniApp. Called by the app on +/// startup/recovery so webview reloads do not leave orphaned agent turns. +#[tauri::command] +pub async fn miniapp_agent_cancel_stale_runs( + state: State<'_, AppState>, + coordinator: State<'_, Arc>, + request: MiniAppAgentCancelStaleRunsRequest, +) -> Result { + require_agent_permission(&state, &request.app_id).await?; + + let runs = agent_run_registry().take_for_app(&request.app_id); + let mut cancelled = 0u32; + for run in runs { + match coordinator + .cancel_dialog_turn(&run.session_id, &run.turn_id) + .await + { + Ok(()) => cancelled += 1, + Err(error) => { + // Completed turns fail to cancel; that is the expected steady state. + warn!( + "MiniApp agent stale-run cancel skipped: app_id={}, session_id={}, turn_id={}, error={}", + run.app_id, run.session_id, run.turn_id, error + ); + } + } + } + + Ok(MiniAppAgentCancelStaleRunsResponse { + cancelled_runs: cancelled, + }) +} + +#[cfg(test)] +mod tests { + use super::MiniAppAgentRunRequest; + use bitfun_core::miniapp::agent_bridge::is_clean_relative_subdir; + use serde_json::json; + + #[test] + fn miniapp_agent_run_request_keeps_tool_enablement_backward_compatible() { + let legacy: MiniAppAgentRunRequest = serde_json::from_value(json!({ + "appId": "builtin-ppt-live", + "prompt": "plan", + "workspacePath": "/tmp/workspace" + })) + .expect("legacy MiniApp agent request should deserialize"); + assert!(legacy.enable_tools.unwrap_or(true)); + assert!(legacy.session_id.is_none()); + + let render: MiniAppAgentRunRequest = serde_json::from_value(json!({ + "appId": "builtin-ppt-live", + "prompt": "render", + "workspacePath": "/tmp/workspace", + "enableTools": false + })) + .expect("render-only MiniApp agent request should deserialize"); + assert_eq!(render.enable_tools, Some(false)); + } + + #[test] + fn miniapp_agent_run_request_accepts_session_reuse() { + let follow_up: MiniAppAgentRunRequest = serde_json::from_value(json!({ + "appId": "builtin-ppt-live", + "prompt": "render slide 2", + "workspacePath": "/tmp/workspace", + "sessionId": "session-1" + })) + .expect("session-reuse MiniApp agent request should deserialize"); + assert_eq!(follow_up.session_id.as_deref(), Some("session-1")); + } + + #[test] + fn miniapp_agent_run_request_accepts_app_data_workspace() { + let request: MiniAppAgentRunRequest = serde_json::from_value(json!({ + "appId": "builtin-ppt-live", + "prompt": "plan a deck", + "appDataWorkspace": "decks/deck-123" + })) + .expect("appdata-workspace MiniApp agent request should deserialize"); + assert_eq!( + request.app_data_workspace.as_deref(), + Some("decks/deck-123") + ); + assert!(request.workspace_path.is_none()); + } + + #[test] + fn app_data_workspace_subdir_must_stay_inside_app_storage() { + assert!(is_clean_relative_subdir("decks/deck-123")); + assert!(is_clean_relative_subdir("decks")); + assert!(!is_clean_relative_subdir("")); + assert!(!is_clean_relative_subdir("/etc")); + assert!(!is_clean_relative_subdir("../outside")); + assert!(!is_clean_relative_subdir("decks/../../outside")); + assert!(!is_clean_relative_subdir("./decks")); + } +} diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 2a65fb2d29..cbfb9eeb21 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -1,7 +1,13 @@ //! MiniApp API — Tauri commands for MiniApp CRUD, JS Worker, and dialog. use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; +use bitfun_core::miniapp::ai_bridge::{ + available_models_for_permissions, build_ai_message_plan, require_enabled_ai_permissions, + validate_model, MiniAppAiMessageRole, MiniAppAiModelDescriptor, MiniAppAiModelInfo, +}; +use bitfun_core::miniapp::rate_limit::{MiniAppRateLimitState, MiniAppRateLimitSubject}; use bitfun_core::miniapp::{ dispatch_host, is_host_primitive, InstallResult as CoreInstallResult, MiniApp, MiniAppAiContext, MiniAppCustomizationMetadata, MiniAppDraft, MiniAppMeta, @@ -16,7 +22,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, State}; // ============== Request/Response DTOs ============== @@ -352,12 +358,18 @@ async fn ensure_worker_dependencies( // ============== App management commands ============== #[tauri::command] -pub async fn list_miniapps(state: State<'_, AppState>) -> Result, String> { - state +pub async fn list_miniapps( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result, String> { + let trace_started = Instant::now(); + let result = state .miniapp_manager .list() .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string()); + startup_trace.record_tauri_command_elapsed("list_miniapps", None, trace_started); + result } #[tauri::command] @@ -680,11 +692,16 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> #[tauri::command] pub async fn miniapp_worker_list_running( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { - let Some(ref pool) = state.js_worker_pool else { - return Ok(vec![]); + let trace_started = Instant::now(); + let result = if let Some(ref pool) = state.js_worker_pool { + Ok(pool.list_running().await) + } else { + Ok(vec![]) }; - Ok(pool.list_running().await) + startup_trace.record_tauri_command_elapsed("miniapp_worker_list_running", None, trace_started); + result } #[tauri::command] @@ -1113,14 +1130,14 @@ pub async fn miniapp_decline_builtin_update( static AI_STREAM_REGISTRY: OnceLock>>> = OnceLock::new(); /// Per-app rate limiter state: app_id → (request_count, window_start_ms). -static AI_RATE_LIMITER: OnceLock>> = OnceLock::new(); +static AI_RATE_LIMITER: OnceLock> = OnceLock::new(); fn ai_stream_registry() -> &'static Mutex>> { AI_STREAM_REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } -fn ai_rate_limiter() -> &'static Mutex> { - AI_RATE_LIMITER.get_or_init(|| Mutex::new(HashMap::new())) +fn ai_rate_limiter() -> &'static Mutex { + AI_RATE_LIMITER.get_or_init(|| Mutex::new(MiniAppRateLimitState::default())) } fn now_ms() -> u64 { @@ -1130,47 +1147,6 @@ fn now_ms() -> u64 { .as_millis() as u64 } -/// Check and increment the rate limiter for a given app. Returns Err if rate limit exceeded. -fn check_rate_limit(app_id: &str, rate_limit_per_minute: u32) -> Result<(), String> { - if rate_limit_per_minute == 0 { - return Ok(()); - } - let now = now_ms(); - let window_ms: u64 = 60_000; - let mut map = ai_rate_limiter().lock().unwrap_or_else(|p| p.into_inner()); - let entry = map.entry(app_id.to_string()).or_insert((0, now)); - if now - entry.1 >= window_ms { - *entry = (1, now); - } else { - entry.0 += 1; - if entry.0 > rate_limit_per_minute { - return Err(format!( - "AI rate limit exceeded: max {} requests/minute", - rate_limit_per_minute - )); - } - } - Ok(()) -} - -/// Validate the requested model against the app's allowed_models list. -/// Returns the resolved model id (may be "primary" / "fast") to pass to AIClientFactory. -fn validate_model( - model: Option<&str>, - ai_perms: &bitfun_core::miniapp::AiPermissions, -) -> Result { - let requested = model.unwrap_or("primary"); - if let Some(ref allowed) = ai_perms.allowed_models { - if !allowed.is_empty() && !allowed.iter().any(|m| m == requested) { - return Err(format!( - "Model '{}' is not allowed by this MiniApp's AI permissions", - requested - )); - } - } - Ok(requested.to_string()) -} - // ---- Request/Response DTOs for AI commands ---- #[derive(Debug, Deserialize)] @@ -1245,15 +1221,6 @@ pub struct MiniAppAiListModelsRequest { pub app_id: String, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MiniAppAiModelInfo { - pub id: String, - pub name: String, - pub provider: String, - pub is_default: bool, -} - // ---- Payload structs for Tauri events ---- #[derive(Debug, Serialize, Clone)] @@ -1272,22 +1239,19 @@ fn build_messages_for_ai( system_prompt: Option<&str>, chat_messages: &[MiniAppAiChatMessage], ) -> Vec { - let mut msgs = Vec::new(); - if let Some(sp) = system_prompt { - if !sp.is_empty() { - msgs.push(Message::system(sp.to_string())); - } - } - for m in chat_messages { - let role = m.role.to_lowercase(); - if role == "assistant" { - msgs.push(Message::assistant(m.content.clone())); - } else { - // Treat any unrecognized role as "user" for safety - msgs.push(Message::user(m.content.clone())); - } - } - msgs + build_ai_message_plan( + system_prompt, + chat_messages + .iter() + .map(|message| (message.role.as_str(), message.content.as_str())), + ) + .into_iter() + .map(|message| match message.role { + MiniAppAiMessageRole::System => Message::system(message.content), + MiniAppAiMessageRole::Assistant => Message::assistant(message.content), + MiniAppAiMessageRole::User => Message::user(message.content), + }) + .collect() } // ---- Commands ---- @@ -1304,18 +1268,18 @@ pub async fn miniapp_ai_complete( .await .map_err(|e| e.to_string())?; - let ai_perms = app - .permissions - .ai - .as_ref() - .ok_or("AI access is not enabled for this MiniApp")?; - - if !ai_perms.enabled { - return Err("AI access is not enabled for this MiniApp".to_string()); - } + let ai_perms = require_enabled_ai_permissions(app.permissions.ai.as_ref())?; let rate_limit = ai_perms.rate_limit_per_minute.unwrap_or(0); - check_rate_limit(&request.app_id, rate_limit)?; + ai_rate_limiter() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .check( + &request.app_id, + rate_limit, + now_ms(), + MiniAppRateLimitSubject::Ai, + )?; let model_ref = validate_model(request.model.as_deref(), ai_perms)?; @@ -1334,7 +1298,7 @@ pub async fn miniapp_ai_complete( ); let stream_response = ai_client - .send_message_stream(messages, None) + .send_message_stream(messages, None, None) .await .map_err(|e| format!("AI request failed: {}", e))?; @@ -1388,18 +1352,18 @@ pub async fn miniapp_ai_chat( .await .map_err(|e| e.to_string())?; - let ai_perms = miniapp - .permissions - .ai - .as_ref() - .ok_or("AI access is not enabled for this MiniApp")?; - - if !ai_perms.enabled { - return Err("AI access is not enabled for this MiniApp".to_string()); - } + let ai_perms = require_enabled_ai_permissions(miniapp.permissions.ai.as_ref())?; let rate_limit = ai_perms.rate_limit_per_minute.unwrap_or(0); - check_rate_limit(&request.app_id, rate_limit)?; + ai_rate_limiter() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .check( + &request.app_id, + rate_limit, + now_ms(), + MiniAppRateLimitSubject::Ai, + )?; let model_ref = validate_model(request.model.as_deref(), ai_perms)?; @@ -1412,7 +1376,7 @@ pub async fn miniapp_ai_chat( let messages = build_messages_for_ai(request.system_prompt.as_deref(), &request.messages); let stream_response = ai_client - .send_message_stream(messages, None) + .send_message_stream(messages, None, None) .await .map_err(|e| format!("AI request failed: {}", e))?; @@ -1559,15 +1523,7 @@ pub async fn miniapp_ai_list_models( .await .map_err(|e| e.to_string())?; - let ai_perms = miniapp - .permissions - .ai - .as_ref() - .ok_or("AI access is not enabled for this MiniApp")?; - - if !ai_perms.enabled { - return Err("AI access is not enabled for this MiniApp".to_string()); - } + let ai_perms = require_enabled_ai_permissions(miniapp.permissions.ai.as_ref())?; let global_config = state .config_service @@ -1584,34 +1540,21 @@ pub async fn miniapp_ai_list_models( .resolve_model_selection("fast") .unwrap_or_default(); - let allowed = ai_perms.allowed_models.as_deref().unwrap_or(&[]); - - let models: Vec = global_config - .ai - .models - .iter() - .filter(|m| m.enabled) - .filter(|m| { - if allowed.is_empty() { - // No restriction — allow all - true - } else { - // Allow if model id/name matches any entry in allowed list, - // or if "primary"/"fast" is in allowed and this model is the resolved target. - allowed.iter().any(|a| match a.as_str() { - "primary" => m.id == primary_id, - "fast" => m.id == fast_id, - other => m.id == other || m.name == other, - }) - } - }) - .map(|m| MiniAppAiModelInfo { - id: m.id.clone(), - name: m.name.clone(), - provider: m.provider.clone(), - is_default: m.id == primary_id, - }) - .collect(); + let models = available_models_for_permissions( + global_config + .ai + .models + .iter() + .map(|model| MiniAppAiModelDescriptor { + id: model.id.clone(), + name: model.name.clone(), + provider: model.provider.clone(), + enabled: model.enabled, + }), + ai_perms.allowed_models.as_deref().unwrap_or(&[]), + &primary_id, + &fast_id, + ); Ok(models) } diff --git a/src/apps/desktop/src/api/miniapp_export_api.rs b/src/apps/desktop/src/api/miniapp_export_api.rs new file mode 100644 index 0000000000..1bb421095c --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_export_api.rs @@ -0,0 +1,178 @@ +//! MiniApp slide export rasterization via the desktop system WebView (WKWebView / WebView2). +//! +//! Renders one slide HTML page in a reused hidden host webview and returns the +//! base64-encoded PNG screenshot or single-page PDF. Used by presentation +//! MiniApps (e.g. ppt-live) for page-by-page export, matching the Sparo +//! `live_app_render_slide_page` behavior. + +use std::path::PathBuf; +use std::time::Duration; + +use bitfun_webdriver::platform::{print_page, take_screenshot, PrintOptions}; +use serde::Deserialize; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +use uuid::Uuid; + +const EXPORT_VIEWPORT_WIDTH: f64 = 1280.0; +const EXPORT_VIEWPORT_HEIGHT: f64 = 720.0; +const RENDER_TIMEOUT_MS: u64 = 30_000; +const RENDER_SETTLE_MS: u64 = 900; +/// Reused hidden host — one window, navigate per slide (avoids create/close flash per page). +const EXPORT_HOST_LABEL: &str = "miniapp-slide-export-host"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppRenderSlidePageRequest { + pub html: String, + pub format: String, + pub width: Option, + pub height: Option, +} + +fn wrap_slide_html(html: &str, width: u32, height: u32) -> String { + let body = html.trim(); + if body.to_ascii_lowercase().starts_with("{body}" + ) +} + +/// Write slide HTML to app cache and return a `file://` URL for the export webview. +fn file_url_for_export_html( + app: &AppHandle, + html: &str, +) -> Result<(tauri::Url, PathBuf), String> { + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|error| format!("Failed to resolve app cache dir: {error}"))?; + let export_dir = cache_dir.join("miniapp-slide-export"); + std::fs::create_dir_all(&export_dir) + .map_err(|error| format!("Failed to create export cache dir: {error}"))?; + let file_path = export_dir.join(format!("slide-{}.html", Uuid::new_v4())); + std::fs::write(&file_path, html) + .map_err(|error| format!("Failed to write export HTML: {error}"))?; + let url = tauri::Url::from_file_path(&file_path) + .map_err(|_| "Failed to build file URL for export webview".to_string())?; + Ok((url, file_path)) +} + +fn ensure_export_host_window( + app: &AppHandle, + width: u32, + height: u32, +) -> Result, String> { + #[cfg(not(target_env = "ohos"))] + { + if let Some(window) = app.get_webview_window(EXPORT_HOST_LABEL) { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize::new( + width as f64, + height as f64, + ))); + let _ = window.hide(); + return Ok(window); + } + + let blank = "about:blank" + .parse::() + .map_err(|error| format!("Invalid blank URL: {error}"))?; + let window = WebviewWindowBuilder::new(app, EXPORT_HOST_LABEL, WebviewUrl::External(blank)) + .visible(false) + .inner_size(width as f64, height as f64) + .decorations(false) + .skip_taskbar(true) + .build() + .map_err(|error| format!("Failed to create export host webview: {error}"))?; + let _ = window.hide(); + Ok(window) + } + + #[cfg(target_env = "ohos")] + Err("Failed to create export host webview".to_string()) +} + +async fn with_export_webview( + app: &AppHandle, + html: String, + width: u32, + height: u32, + task: F, +) -> Result +where + F: FnOnce(tauri::Webview) -> Fut, + Fut: std::future::Future>, +{ + #[cfg(not(target_env = "ohos"))] + { + let window = ensure_export_host_window(app, width, height)?; + let wrapped = wrap_slide_html(&html, width, height); + let (url, temp_html) = file_url_for_export_html(app, &wrapped)?; + + window + .navigate(url) + .map_err(|error| format!("Failed to navigate export webview: {error}"))?; + + tokio::time::sleep(Duration::from_millis(RENDER_SETTLE_MS)).await; + + let webview = app + .get_webview(EXPORT_HOST_LABEL) + .ok_or_else(|| format!("Export webview is not ready: {EXPORT_HOST_LABEL}"))?; + + let result = task(webview).await; + let _ = std::fs::remove_file(&temp_html); + let _ = window.hide(); + result + } + + #[cfg(target_env = "ohos")] + Err(format!("Failed to navigate export webview")) +} + +#[tauri::command] +pub async fn miniapp_render_slide_page( + app: AppHandle, + request: MiniAppRenderSlidePageRequest, +) -> Result { + let width = request.width.unwrap_or(EXPORT_VIEWPORT_WIDTH as u32); + let height = request.height.unwrap_or(EXPORT_VIEWPORT_HEIGHT as u32); + let format = request.format.trim().to_ascii_lowercase(); + + match format.as_str() { + "png" => { + with_export_webview(&app, request.html, width, height, |webview| async move { + take_screenshot(webview, RENDER_TIMEOUT_MS) + .await + .map_err(|error| error.message) + }) + .await + } + "pdf" => { + let page_width_cm = (width as f64 / 96.0) * 2.54; + let page_height_cm = (height as f64 / 96.0) * 2.54; + let options = PrintOptions { + orientation: Some("landscape".to_string()), + scale: Some(1.0), + background: Some(true), + page_width: Some(page_width_cm), + page_height: Some(page_height_cm), + margin_top: Some(0.0), + margin_bottom: Some(0.0), + margin_left: Some(0.0), + margin_right: Some(0.0), + shrink_to_fit: Some(false), + page_ranges: None, + }; + with_export_webview(&app, request.html, width, height, |webview| async move { + print_page(webview, RENDER_TIMEOUT_MS, &options) + .await + .map_err(|error| error.message) + }) + .await + } + other => Err(format!("Unsupported slide render format: {other}")), + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 926e373760..cc8a19cd9b 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -24,9 +24,10 @@ pub mod insights_api; pub mod lsp_api; pub mod lsp_workspace_api; pub mod mcp_api; +pub mod miniapp_agent_api; pub mod miniapp_api; +pub mod miniapp_export_api; pub mod path_target; -pub mod project_context_api; pub mod remote_connect_api; pub mod review_platform_api; pub mod runtime_api; diff --git a/src/apps/desktop/src/api/project_context_api.rs b/src/apps/desktop/src/api/project_context_api.rs deleted file mode 100644 index 430cfc2b86..0000000000 --- a/src/apps/desktop/src/api/project_context_api.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Project Context API - -use bitfun_core::service::project_context::{ - CategoryInfo, ContextDocumentStatus, FileConflictAction, ImportedDocument, - ProjectContextConfig, ProjectContextService, -}; -use std::path::Path; - -#[tauri::command] -pub async fn get_document_statuses( - workspace_path: String, -) -> Result, String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .get_document_statuses(workspace) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn toggle_document_enabled( - workspace_path: String, - doc_id: String, - enabled: bool, -) -> Result<(), String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .toggle_document(workspace, &doc_id, enabled) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn create_context_document( - workspace_path: String, - doc_id: String, -) -> Result { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .create_document(workspace, &doc_id) - .await - .map(|p| p.to_string_lossy().to_string()) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn generate_context_document( - workspace_path: String, - doc_id: String, -) -> Result { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .generate_document(workspace, &doc_id) - .await - .map(|p| p.to_string_lossy().to_string()) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn cancel_context_document_generation( - workspace_path: String, - doc_id: String, -) -> Result<(), String> { - let service = ProjectContextService::new(); - let _workspace = Path::new(&workspace_path); - - service - .cancel_generate_document(&doc_id) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn get_project_context_config( - workspace_path: String, -) -> Result { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .load_config(workspace) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn save_project_context_config( - workspace_path: String, - config: ProjectContextConfig, -) -> Result<(), String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .save_config(workspace, &config) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn create_project_category( - workspace_path: String, - name: String, - description: Option, - icon: String, -) -> Result { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .create_category(workspace, name, description, icon) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn delete_project_category( - workspace_path: String, - category_id: String, -) -> Result<(), String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .delete_category(workspace, &category_id) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn get_all_categories(workspace_path: String) -> Result, String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .get_all_categories(workspace) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn import_project_document( - workspace_path: String, - source_path: String, - name: String, - category_id: String, - priority: String, - on_conflict: String, -) -> Result { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - let source = Path::new(&source_path); - - let conflict_action = match on_conflict.as_str() { - "skip" => FileConflictAction::Skip, - "overwrite" => FileConflictAction::Overwrite, - "rename" => FileConflictAction::Rename, - _ => FileConflictAction::Rename, - }; - - service - .import_document( - workspace, - source, - name, - category_id, - priority, - conflict_action, - ) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn delete_imported_document( - workspace_path: String, - doc_id: String, -) -> Result<(), String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .delete_imported_document(workspace, &doc_id) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn toggle_imported_document_enabled( - workspace_path: String, - doc_id: String, - enabled: bool, -) -> Result<(), String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .toggle_imported_document(workspace, &doc_id, enabled) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn delete_context_document(workspace_path: String, doc_id: String) -> Result<(), String> { - let service = ProjectContextService::new(); - let workspace = Path::new(&workspace_path); - - service - .delete_context_document(workspace, &doc_id) - .await - .map_err(|e| e.to_string()) -} diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 5a46ee814e..13a3d1021e 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -2,6 +2,7 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::agentic::persistence::{ PersistenceManager, SessionBranchRequest, SessionBranchResult, SessionMetadataPage, }; @@ -15,6 +16,7 @@ use bitfun_core::service::session_usage::{ }; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use std::time::Instant; use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -130,6 +132,12 @@ pub struct GetSessionUsageReportRequest { pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, + #[serde(default = "default_include_hidden_subagents")] + pub include_hidden_subagents: bool, +} + +fn default_include_hidden_subagents() -> bool { + true } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -210,21 +218,28 @@ pub async fn list_persisted_sessions_page( request: ListPersistedSessionsPageRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + let trace_started = Instant::now(); + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - manager - .list_session_metadata_page(&workspace_path, request.cursor.as_deref(), request.limit) - .await - .map_err(|e| format!("Failed to list persisted session page: {}", e)) + manager + .list_session_metadata_page(&workspace_path, request.cursor.as_deref(), request.limit) + .await + .map_err(|e| format!("Failed to list persisted session page: {}", e)) + } + .await; + startup_trace.record_tauri_command_elapsed("list_persisted_sessions_page", None, trace_started); + result } #[tauri::command] @@ -232,28 +247,44 @@ pub async fn load_session_turns( request: LoadSessionTurnsRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - - let turns = if let Some(limit) = request.limit { - manager - .load_recent_turns(&workspace_path, &request.session_id, limit) - .await + let trace_started = Instant::now(); + let trace_target = if request.limit.is_some() { + "recent" } else { - manager - .load_session_turns(&workspace_path, &request.session_id) - .await + "full" }; + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - turns.map_err(|e| format!("Failed to load session turns: {}", e)) + let turns = if let Some(limit) = request.limit { + manager + .load_recent_turns(&workspace_path, &request.session_id, limit) + .await + } else { + manager + .load_session_turns(&workspace_path, &request.session_id) + .await + }; + + turns.map_err(|e| format!("Failed to load session turns: {}", e)) + } + .await; + startup_trace.record_tauri_command_elapsed( + "load_session_turns", + Some(trace_target), + trace_started, + ); + result } #[tauri::command] @@ -280,7 +311,7 @@ pub async fn get_session_usage_report( workspace_path: Some(storage_workspace_path.to_string_lossy().to_string()), remote_connection_id: request.remote_connection_id.clone(), remote_ssh_host: request.remote_ssh_host.clone(), - include_hidden_subagents: true, + include_hidden_subagents: request.include_hidden_subagents, }, ) .await @@ -395,21 +426,28 @@ pub async fn touch_session_activity( request: TouchSessionActivityRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result<(), String> { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + let trace_started = Instant::now(); + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - manager - .touch_session(&workspace_path, &request.session_id) - .await - .map_err(|e| format!("Failed to update session activity: {}", e)) + manager + .touch_session(&workspace_path, &request.session_id) + .await + .map_err(|e| format!("Failed to update session activity: {}", e)) + } + .await; + startup_trace.record_tauri_command_elapsed("touch_session_activity", None, trace_started); + result } #[tauri::command] @@ -417,23 +455,36 @@ pub async fn load_persisted_session_metadata( request: LoadPersistedSessionMetadataRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + let trace_started = Instant::now(); + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - let metadata = manager - .load_session_metadata(&workspace_path, &request.session_id) - .await - .map_err(|e| format!("Failed to load persisted session metadata: {}", e))?; + let metadata = manager + .load_session_metadata(&workspace_path, &request.session_id) + .await + .map_err(|e| format!("Failed to load persisted session metadata: {}", e))?; - Ok(metadata.filter(|metadata| !metadata.should_hide_from_user_lists())) + // Direct metadata lookups are used by persistence flows that must be able + // to read hidden subagent sessions without list-level visibility filtering. + Ok(metadata) + } + .await; + startup_trace.record_tauri_command_elapsed( + "load_persisted_session_metadata", + None, + trace_started, + ); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 48d58cb26e..948def815c 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -908,17 +908,34 @@ pub async fn delete_skill( .await .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; - remote_fs - .remove_dir_all(&entry.connection_id, &skill_info.path) - .await - .map_err(|e| format!("Failed to delete remote skill folder: {}", e))?; + match skill_info.level { + SkillLocation::Project => { + remote_fs + .remove_dir_all(&entry.connection_id, &skill_info.path) + .await + .map_err(|e| format!("Failed to delete remote skill folder: {}", e))?; + info!( + "Remote project skill deleted: key={}, path={}", + skill_key, skill_info.path + ); + } + SkillLocation::User => { + let skill_path = std::path::PathBuf::from(&skill_info.path); + if skill_path.exists() { + tokio::fs::remove_dir_all(&skill_path) + .await + .map_err(|e| format!("Failed to delete local skill folder: {}", e))?; + } + info!( + "Local user skill deleted in remote workspace context: key={}, path={}", + skill_key, + skill_path.display() + ); + } + } registry.refresh().await; - info!( - "Remote skill deleted: key={}, path={}", - skill_key, skill_info.path - ); return Ok(format!("Skill '{}' deleted successfully", skill_info.name)); } diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index 1dbae009a3..ad105ccd34 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -2,9 +2,11 @@ //! //! Tauri commands for SSH connection management and remote file operations. +use std::time::Instant; use tauri::State; use crate::api::app_state::SSHServiceError; +use crate::startup_trace::DesktopStartupTrace; use crate::AppState; use bitfun_core::service::remote_ssh::{ RemoteTreeNode, SSHAuthMethod, SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, @@ -473,8 +475,11 @@ pub async fn remote_remove_workspace( #[tauri::command] pub async fn remote_get_workspace_info( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { + let trace_started = Instant::now(); let workspace = state.get_remote_workspace_async().await; log::info!("remote_get_workspace_info: returning {:?}", workspace); + startup_trace.record_tauri_command_elapsed("remote_get_workspace_info", None, trace_started); Ok(workspace) } diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 425b12e7d5..8691fb1690 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -2,8 +2,9 @@ use crate::api::app_state::AppState; use bitfun_core::agentic::agents::{ - AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentDetail, - CustomSubagentKind, SubAgentSource, SubagentListScope, SubagentQueryContext, + subagent_source_from_custom_kind, AgentCategory, AgentInfo, CustomSubagent, + CustomSubagentConfig, CustomSubagentDetail, CustomSubagentKind, SubAgentSource, + SubagentListScope, SubagentQueryContext, }; use log::warn; use serde::{Deserialize, Serialize}; @@ -366,7 +367,7 @@ pub async fn create_subagent( state.agent_registry.register_agent( Arc::new(subagent), AgentCategory::SubAgent, - Some(SubAgentSource::from_custom_kind(kind)), + Some(subagent_source_from_custom_kind(kind)), Some(custom_config), ); diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index a2c3d271e5..eff4ba216a 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -353,6 +353,20 @@ pub struct ToggleMainWindowFullscreenResponse { pub is_maximized: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StartupWindowControlAction { + Minimize, + ToggleMaximize, + Close, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupWindowControlRequest { + pub action: StartupWindowControlAction, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct MainWindowFullscreenTransition { next_fullscreen: bool, @@ -444,6 +458,65 @@ pub async fn minimize_to_tray(app: tauri::AppHandle) -> Result<(), String> { } } +/// Minimal startup-window controls used by the static pre-React splash. +#[tauri::command] +pub async fn startup_window_control( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: StartupWindowControlRequest, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let Some(window) = app.get_webview_window("main") else { + return Err("Main window not found".to_string()); + }; + + match request.action { + StartupWindowControlAction::Minimize => { + window.minimize().map_err(|error| { + format!("Failed to minimize main window during startup: {}", error) + })?; + } + StartupWindowControlAction::ToggleMaximize => { + let is_maximized = window.is_maximized().unwrap_or(false); + if is_maximized { + window.unmaximize().map_err(|error| { + format!("Failed to restore main window during startup: {}", error) + })?; + } else { + window.maximize().map_err(|error| { + format!("Failed to maximize main window during startup: {}", error) + })?; + } + } + StartupWindowControlAction::Close => { + let behavior = state + .config_service + .get_config::(Some("app.close_button_behavior")) + .await + .unwrap_or_else(|_| "minimize_to_tray".to_string()); + + if behavior == "quit" { + log::info!("Quit requested from startup window control"); + crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); + crate::perform_process_exit_cleanup(); + app.exit(0); + } else { + window.hide().map_err(|error| { + format!("Failed to hide main window during startup close: {}", error) + })?; + log::info!("Main window hidden from startup window control"); + } + } + } + + Ok(()) + } + + #[cfg(target_env = "ohos")] + Err("Main window not found".to_string()) +} + /// Toggle OS-level fullscreen for the Desktop main window. /// /// This is intentionally not the same as maximize: maximize fills the normal diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index d5bc2673c5..171d06bf05 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -172,19 +172,7 @@ async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { None => None, }; - ToolUseContext { - tool_call_id: None, - agent_type: None, - session_id: None, - dialog_turn_id: None, - workspace, - unlocked_collapsed_tools: Vec::new(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: Default::default(), - workspace_services, - } + ToolUseContext::for_tool_listing(workspace, workspace_services) } fn to_dynamic_mcp_tool_info( diff --git a/src/apps/desktop/src/api/workspace_activation.rs b/src/apps/desktop/src/api/workspace_activation.rs index 63b11978f8..73b77c0c94 100644 --- a/src/apps/desktop/src/api/workspace_activation.rs +++ b/src/apps/desktop/src/api/workspace_activation.rs @@ -13,7 +13,7 @@ pub fn spawn_workspace_background_warmup(state: &AppState, workspace_info: Works let agent_registry = state.agent_registry.clone(); let workspace_search_service = state.workspace_search_service.clone(); - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { warm_workspace_background_services( workspace_path, agent_registry, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7104f7aa73..ec760f6e25 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -2,9 +2,12 @@ //! BitFun Desktop - Tauri-based desktop application with TransportAdapter architecture pub mod api; +#[cfg(not(target_env = "ohos"))] +pub mod computer_use; pub mod crash_diagnostics; pub mod logging; pub mod macos_menubar; +pub mod startup_trace; pub mod theme; #[cfg(not(target_env = "ohos"))] pub mod tray; @@ -58,6 +61,7 @@ use api::storage_commands::*; use api::subagent_api::*; use api::system_api::*; use api::tool_api::*; +use startup_trace::{DesktopStartupTrace, DesktopStartupTraceSnapshot}; /// Agentic Coordinator state #[derive(Clone)] @@ -71,11 +75,10 @@ pub struct SchedulerState { pub scheduler: Arc, } -pub struct OhosPlatform { - pub version: String, - pub devic_type: String, - pub api_level: i32, - pub feature: Vec, +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebdriverBridgeResultRequest { + payload: serde_json::Value, } #[cfg(target_os = "macos")] @@ -192,6 +195,18 @@ async fn hide_main_window_after_close_request(app: tauri::AppHandle) -> Result<( Ok(()) } +#[tauri::command] +async fn webdriver_bridge_result(request: WebdriverBridgeResultRequest) -> Result<(), String> { + log::debug!("webdriver_bridge_result command invoked"); + bitfun_webdriver::handle_bridge_result(request.payload) +} + +pub struct OhosPlatform { + pub version: String, + pub devic_type: String, + pub api_level: i32, + pub feature: Vec, +} impl Default for OhosPlatform { fn default() -> Self { @@ -209,6 +224,13 @@ impl Default for OhosPlatform { } } +#[tauri::command] +fn get_startup_native_trace( + state: tauri::State<'_, DesktopStartupTrace>, +) -> DesktopStartupTraceSnapshot { + state.snapshot() +} + /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -227,6 +249,8 @@ pub async fn _run() { .duration_since(UNIX_EPOCH) .map(|duration| format!("desktop-{}", duration.as_millis())) .unwrap_or_else(|_| "desktop-unknown".to_string()); + let startup_trace = DesktopStartupTrace::new(startup_trace_id.clone(), startup_started); + startup_trace.record_phase("native_process_start", "native"); let mut startup_timings = TimingCollector::default(); let in_debug = cfg!(debug_assertions) || std::env::var("DEBUG").unwrap_or_default() == "1"; let log_config = logging::LogConfig::new(in_debug); @@ -235,6 +259,12 @@ pub async fn _run() { crash_diagnostics::initialize_run_state(session_log_dir.clone(), &startup_trace_id); setup_panic_hook(); + // Install the rustls ring CryptoProvider as the process-level default early, + // so that all subsequent TLS operations (relay_client, reqwest, tokio-tungstenite) + // reuse the same provider instead of each attempting their own install_default(). + // This is a no-op on non-Windows platforms where tokio-tungstenite handles it. + bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); + eprintln!("=== BitFun Desktop Starting ==="); let step_started = Instant::now(); @@ -243,6 +273,7 @@ pub async fn _run() { return; } startup_timings.record_elapsed("initialize_global_config", step_started); + startup_trace.record_elapsed_step("native_pre_tauri", "initialize_global_config", step_started); // Initialize global I18nService so bot/remote-connect language is always in sync. { @@ -260,9 +291,20 @@ pub async fn _run() { } } startup_timings.record_elapsed("initialize_global_i18n_service", step_started); + startup_trace.record_elapsed_step( + "native_pre_tauri", + "initialize_global_i18n_service", + step_started, + ); } + let step_started = Instant::now(); let startup_log_level = resolve_runtime_log_level(log_config.level).await; + startup_trace.record_elapsed_step( + "native_pre_tauri", + "resolve_runtime_log_level", + step_started, + ); let step_started = Instant::now(); if let Err(e) = AIClientFactory::initialize_global().await { @@ -270,6 +312,11 @@ pub async fn _run() { return; } startup_timings.record_elapsed("initialize_global_ai_client_factory", step_started); + startup_trace.record_elapsed_step( + "native_pre_tauri", + "initialize_global_ai_client_factory", + step_started, + ); let step_started = Instant::now(); let (coordinator, scheduler, event_queue, event_router, ai_client_factory, token_usage_service) = @@ -281,6 +328,7 @@ pub async fn _run() { } }; startup_timings.record_elapsed("init_agentic_system", step_started); + startup_trace.record_elapsed_step("native_pre_tauri", "init_agentic_system", step_started); let step_started = Instant::now(); if let Err(e) = init_function_agents(ai_client_factory.clone()).await { @@ -288,10 +336,23 @@ pub async fn _run() { return; } startup_timings.record_elapsed("init_function_agents", step_started); + startup_trace.record_elapsed_step("native_pre_tauri", "init_function_agents", step_started); + let step_started = Instant::now(); let workspace_search_enabled = bitfun_core::service::search::workspace_search_feature_enabled().await; + startup_trace.record_elapsed_step( + "native_pre_tauri", + "workspace_search_feature_enabled", + step_started, + ); + let step_started = Instant::now(); let startup_flashgrep_path = configure_workspace_search_daemon_env(); + startup_trace.record_elapsed_step( + "native_pre_tauri", + "configure_workspace_search_daemon_env", + step_started, + ); let step_started = Instant::now(); let app_state = match AppState::new_async(token_usage_service).await { @@ -302,6 +363,7 @@ pub async fn _run() { } }; startup_timings.record_elapsed("initialize_app_state", step_started); + startup_trace.record_elapsed_step("native_pre_tauri", "initialize_app_state", step_started); let coordinator_state = CoordinatorState { coordinator: coordinator.clone(), @@ -326,13 +388,10 @@ pub async fn _run() { .manage(coordinator) .manage(scheduler) .manage(terminal_state) + .manage(startup_trace.clone()) .setup(move |app| { let setup_started = Instant::now(); - log::debug!( - "Desktop startup trace event: trace_id={}, phase=tauri_setup_start, since_process_start_ms={}", - startup_trace_id, - elapsed_ms(startup_started) - ); + startup_trace.record_phase("tauri_setup_start", "native_setup"); #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { @@ -345,8 +404,14 @@ pub async fn _run() { }); } + let step_started = Instant::now(); logging::register_runtime_log_state(startup_log_level, session_log_dir.clone()); crash_diagnostics::log_previous_unexpected_exit_if_any(); + startup_trace.record_elapsed_step( + "native_setup", + "register_runtime_log_state_and_crash_diagnostics", + step_started, + ); // Ensure the Tauri NSIS registry install-location key points to the // actual install directory, so that auto-updates respect the custom @@ -355,6 +420,7 @@ pub async fn _run() { { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; + let step_started = Instant::now(); if let Ok(exe) = std::env::current_exe() { if let Some(install_dir) = exe.parent() { @@ -394,6 +460,11 @@ pub async fn _run() { } } } + startup_trace.record_elapsed_step( + "native_setup", + "sync_install_location_registry", + step_started, + ); } for step in startup_timings.steps() { log::debug!( @@ -404,6 +475,7 @@ pub async fn _run() { } if workspace_search_enabled { + let step_started = Instant::now(); let flashgrep_path = startup_flashgrep_path.clone().or_else(|| { let binary_names = bitfun_core::service::search::workspace_search_daemon_binary_names(); @@ -447,6 +519,11 @@ pub async fn _run() { bitfun_core::service::search::workspace_search_daemon_missing_hint() ); } + startup_trace.record_elapsed_step( + "native_setup", + "resolve_workspace_search_daemon", + step_started, + ); } // Register bundled mobile-web resource path for remote connect. @@ -454,6 +531,7 @@ pub async fn _run() { // so the primary candidate is "mobile-web/dist". Additional fallbacks // handle legacy or non-standard bundle layouts. { + let step_started = Instant::now(); let candidates = ["mobile-web/dist", "mobile-web", "dist"]; let mut found = false; let path = PathBuf::from("/data/storage/el1/bundle/entry/resources/resfile/dist"); @@ -482,37 +560,47 @@ pub async fn _run() { } } } + startup_trace.record_elapsed_step( + "native_setup", + "resolve_mobile_web_resource", + step_started, + ); } let app_handle = app.handle().clone(); let window_started = Instant::now(); - log::debug!( - "Desktop startup trace event: trace_id={}, phase=main_window_create_start", - startup_trace_id - ); - theme::create_main_window(&app_handle, &startup_trace_id); + startup_trace.record_phase("main_window_create_start", "native_window"); + theme::create_main_window(&app_handle, &startup_trace_id, &startup_trace); let window_duration_ms = elapsed_ms(window_started); - log::debug!( - "Desktop startup step completed: step=create_main_window, duration_ms={}", - window_duration_ms + startup_trace.record_step( + "native_step_end", + "native_window", + "create_main_window", + window_duration_ms, ); log::debug!( - "Desktop startup trace event: trace_id={}, phase=main_window_create_end, duration_ms={}", - startup_trace_id, + "Desktop startup step completed: step=create_main_window, duration_ms={}", window_duration_ms ); + let webdriver_started = Instant::now(); bitfun_webdriver::maybe_start(app_handle.clone()); - let setup_duration_ms = elapsed_ms(setup_started); + startup_trace.record_elapsed_step( + "native_setup", + "maybe_start_webdriver", + webdriver_started, + ); + let window_phase_duration_ms = elapsed_ms(setup_started); let since_process_start_ms = elapsed_ms(startup_started); - log::debug!( - "Desktop startup timing: phase=tauri_setup, duration_ms={}, since_process_start_ms={}", - setup_duration_ms, - since_process_start_ms + startup_trace.record_step( + "native_step_end", + "native_setup", + "tauri_setup_until_main_window_created", + window_phase_duration_ms, ); + startup_trace.record_phase("tauri_setup_window_phase_end", "native_setup"); log::debug!( - "Desktop startup trace event: trace_id={}, phase=tauri_setup_end, duration_ms={}, since_process_start_ms={}", - startup_trace_id, - setup_duration_ms, + "Desktop startup timing: phase=tauri_setup_until_main_window_created, duration_ms={}, since_process_start_ms={}", + window_phase_duration_ms, since_process_start_ms ); @@ -549,13 +637,26 @@ pub async fn _run() { let transport = Arc::new(TauriTransportAdapter::new(app_handle.clone())); + let step_started = Instant::now(); start_event_loop_with_transport(event_queue, event_router, transport); + startup_trace.record_elapsed_step( + "native_setup", + "start_event_loop_with_transport", + step_started, + ); // Eagerly initialize the remote connect service so previously // paired bots start listening immediately on app startup. + let step_started = Instant::now(); api::remote_connect_api::init_on_startup(); + startup_trace.record_elapsed_step( + "native_setup", + "remote_connect_init_on_startup", + step_started, + ); { + let step_started = Instant::now(); let _terminal_state: tauri::State<'_, api::terminal_api::TerminalState> = app.state(); let terminal_state_inner = api::terminal_api::TerminalState::new(); @@ -564,23 +665,53 @@ pub async fn _run() { terminal_state_inner, app_handle_clone, ); + startup_trace.record_elapsed_step( + "native_setup", + "spawn_terminal_event_loop", + step_started, + ); } + let step_started = Instant::now(); init_mcp_servers(app_handle.clone()); + startup_trace.record_elapsed_step("native_setup", "init_mcp_servers", step_started); + let step_started = Instant::now(); init_acp_clients(app_handle.clone()); + startup_trace.record_elapsed_step("native_setup", "init_acp_clients", step_started); + let step_started = Instant::now(); init_services(app_handle.clone(), startup_log_level); + startup_trace.record_elapsed_step("native_setup", "init_services", step_started); + let step_started = Instant::now(); logging::spawn_log_cleanup_task(); + startup_trace.record_elapsed_step("native_setup", "spawn_log_cleanup_task", step_started); // Set up system tray icon. #[cfg(not(target_env = "ohos"))] { - if let Err(error) = crate::tray::setup_tray(app) { + let step_started = Instant::now(); + if let Err(error) = crate::tray::setup_tray(app, &startup_trace) { log::warn!("Failed to set up system tray: {}", error); } + startup_trace.record_elapsed_step("native_setup", "setup_tray", step_started); } + + let setup_duration_ms = elapsed_ms(setup_started); + let since_process_start_ms = elapsed_ms(startup_started); + startup_trace.record_step( + "native_step_end", + "native_setup", + "tauri_setup", + setup_duration_ms, + ); + startup_trace.record_phase("tauri_setup_end", "native_setup"); + log::debug!( + "Desktop startup timing: phase=tauri_setup, duration_ms={}, since_process_start_ms={}", + setup_duration_ms, + since_process_start_ms + ); log::info!("BitFun Desktop started successfully"); Ok(()) }) @@ -650,6 +781,10 @@ pub async fn _run() { api::agentic_api::start_dialog_turn, api::agentic_api::compact_session, api::agentic_api::activate_session_goal, + api::agentic_api::get_session_thread_goal, + api::agentic_api::clear_session_thread_goal, + api::agentic_api::set_session_thread_goal_status, + api::agentic_api::update_session_thread_goal_objective, api::agentic_api::ensure_assistant_bootstrap, api::agentic_api::run_init_agents_md, api::agentic_api::cancel_dialog_turn, @@ -657,10 +792,15 @@ pub async fn _run() { api::agentic_api::control_deep_review_queue, api::agentic_api::cancel_session, api::agentic_api::set_subagent_timeout, + api::agentic_api::control_background_command, + api::agentic_api::send_background_command_input, + api::agentic_api::read_background_command_output, + api::agentic_api::list_background_command_activities, api::agentic_api::delete_session, api::agentic_api::restore_session, api::agentic_api::restore_session_view, api::agentic_api::restore_session_with_turns, + get_startup_native_trace, api::agentic_api::list_sessions, api::agentic_api::confirm_tool_execution, api::agentic_api::reject_tool_execution, @@ -868,20 +1008,6 @@ pub async fn _run() { archive_all_sessions, list_archived_sessions, delete_all_archived_sessions, - api::project_context_api::get_document_statuses, - api::project_context_api::toggle_document_enabled, - api::project_context_api::create_context_document, - api::project_context_api::generate_context_document, - api::project_context_api::cancel_context_document_generation, - api::project_context_api::get_project_context_config, - api::project_context_api::save_project_context_config, - api::project_context_api::create_project_category, - api::project_context_api::delete_project_category, - api::project_context_api::get_all_categories, - api::project_context_api::import_project_document, - api::project_context_api::delete_imported_document, - api::project_context_api::toggle_imported_document_enabled, - api::project_context_api::delete_context_document, initialize_mcp_servers, api::mcp_api::initialize_mcp_servers_non_destructive, get_mcp_servers, @@ -918,6 +1044,7 @@ pub async fn _run() { start_acp_dialog_turn, cancel_acp_dialog_turn, get_acp_session_options, + get_acp_session_commands, set_acp_session_model, lsp_initialize, lsp_start_server_for_file, @@ -1008,6 +1135,7 @@ pub async fn _run() { send_system_notification, api::system_api::quit_app, api::system_api::minimize_to_tray, + api::system_api::startup_window_control, api::system_api::toggle_main_window_fullscreen, check_command_exists, check_commands_exist, @@ -1026,7 +1154,6 @@ pub async fn _run() { api::remote_connect_api::remote_connect_start, api::remote_connect_api::remote_connect_stop, api::remote_connect_api::remote_connect_stop_bot, - api::remote_connect_api::send_remote_connect_dialog_status, api::remote_connect_api::remote_connect_status, api::remote_connect_api::remote_connect_get_form_state, api::remote_connect_api::remote_connect_set_form_state, @@ -1076,6 +1203,11 @@ pub async fn _run() { api::miniapp_api::miniapp_ai_chat, api::miniapp_api::miniapp_ai_cancel, api::miniapp_api::miniapp_ai_list_models, + api::miniapp_agent_api::miniapp_agent_run, + api::miniapp_agent_api::miniapp_agent_cancel, + api::miniapp_agent_api::miniapp_agent_turn_text, + api::miniapp_agent_api::miniapp_agent_cancel_stale_runs, + api::miniapp_export_api::miniapp_render_slide_page, // Browser API (embedded webview) api::browser_api::browser_webview_eval, api::browser_api::browser_get_url, @@ -1137,6 +1269,7 @@ pub async fn _run() { set_theme_mode, // Debug API (no-op stubs in release builds) + api::debug_api::debug_devtools_available, api::debug_api::debug_element_picked, api::debug_api::debug_open_devtools, api::debug_api::debug_close_devtools, @@ -1261,6 +1394,10 @@ async fn init_agentic_system() -> anyhow::Result<( bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()), ); event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); + event_router.subscribe_internal( + "thread_goal_tokens".to_string(), + Arc::new(bitfun_core::agentic::goal_mode::ThreadGoalTokenSubscriber), + ); log::info!("Token usage service initialized and subscriber registered"); @@ -1268,14 +1405,16 @@ async fn init_agentic_system() -> anyhow::Result<( let scheduler = coordination::DialogScheduler::new(coordinator.clone(), session_manager.clone()); coordinator.set_scheduler_notifier(scheduler.outcome_sender()); - coordinator.set_round_preempt_source(scheduler.preempt_monitor()); coordinator.set_round_injection_source(scheduler.round_injection_monitor()); coordination::set_global_scheduler(scheduler.clone()); - let cron_service = - bitfun_core::service::cron::CronService::new(path_manager.clone(), scheduler.clone()) - .await - .map_err(|e| anyhow::anyhow!("Failed to initialize cron service: {}", e))?; + let cron_service = bitfun_core::service::cron::CronService::new( + path_manager.clone(), + coordinator.clone(), + scheduler.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to initialize cron service: {}", e))?; bitfun_core::service::cron::set_global_cron_service(cron_service.clone()); let cron_subscriber = Arc::new(bitfun_core::service::cron::CronEventSubscriber::new( cron_service.clone(), @@ -1329,6 +1468,8 @@ fn setup_panic_hook() { let thread = std::thread::current(); let thread_name = thread.name().map(str::to_string); let thread_id = format!("{:?}", thread.id()); + let is_main_thread = thread_name.as_deref() == Some("main") || thread_name.is_none(); // unnamed threads in simple test contexts + let location = panic_info .location() .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) @@ -1346,11 +1487,18 @@ fn setup_panic_hook() { }) .unwrap_or("unknown panic message"); - log::error!("Application panic at {}: {}", location, message); + log::error!( + "Application panic at {} (thread={:?}, id={}, main={}): {}", + location, + thread_name, + thread_id, + is_main_thread, + message, + ); crate::crash_diagnostics::write_panic_report( location.clone(), message.to_string(), - thread_name, + thread_name.clone(), thread_id, ); @@ -1372,6 +1520,19 @@ fn setup_panic_hook() { log::error!(" 3) Run as administrator"); } + // ── Recovery strategy ────────────────────────────────────────── + // Main-thread panics are unrecoverable — the event loop is gone. + // Spawned-thread panics only kill that thread; the rest of the + // application can continue. We log a clear message and skip the + // hard exit so the user isn't forced to restart. + if !is_main_thread { + log::warn!( + "Non-main thread panicked — application will continue. \ + The affected feature may be degraded until the next restart." + ); + return; + } + perform_process_exit_cleanup(); std::process::exit(1); })); diff --git a/src/apps/desktop/src/startup_trace.rs b/src/apps/desktop/src/startup_trace.rs new file mode 100644 index 0000000000..ba47c32ec0 --- /dev/null +++ b/src/apps/desktop/src/startup_trace.rs @@ -0,0 +1,175 @@ +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use bitfun_core::util::elapsed_ms; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopStartupTraceEvent { + pub trace_id: String, + pub phase: String, + pub at_ms: u64, + pub since_process_start_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub step: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopStartupTraceSnapshot { + pub trace_id: String, + pub events: Vec, +} + +#[derive(Clone)] +pub struct DesktopStartupTrace { + trace_id: String, + started_at: Instant, + events: Arc>>, +} + +impl DesktopStartupTrace { + pub fn new(trace_id: String, started_at: Instant) -> Self { + Self { + trace_id, + started_at, + events: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn trace_id(&self) -> &str { + &self.trace_id + } + + pub fn record_phase(&self, phase: impl Into, category: impl Into) { + self.record_event(phase.into(), Some(category.into()), None, None, None, None); + } + + pub fn record_step( + &self, + phase: impl Into, + category: impl Into, + step: impl Into, + duration_ms: u128, + ) { + self.record_event( + phase.into(), + Some(category.into()), + Some(step.into()), + None, + None, + Some(duration_ms), + ); + } + + pub fn record_elapsed_step( + &self, + category: impl Into, + step: impl Into, + started_at: Instant, + ) -> u128 { + let duration_ms = elapsed_ms(started_at); + self.record_step("native_step_end", category, step, duration_ms); + duration_ms + } + + pub fn record_tauri_command_elapsed( + &self, + command: impl Into, + target: Option<&str>, + started_at: Instant, + ) -> u128 { + let duration_ms = elapsed_ms(started_at); + let command = command.into(); + self.record_event( + "native_step_end".to_string(), + Some("tauri_command".to_string()), + Some(command.clone()), + Some(command), + target.map(ToOwned::to_owned), + Some(duration_ms), + ); + duration_ms + } + + pub fn snapshot(&self) -> DesktopStartupTraceSnapshot { + let events = self + .events + .lock() + .map(|events| events.clone()) + .unwrap_or_default(); + DesktopStartupTraceSnapshot { + trace_id: self.trace_id.clone(), + events, + } + } + + fn record_event( + &self, + phase: String, + category: Option, + step: Option, + command: Option, + target: Option, + duration_ms: Option, + ) { + let since_process_start_ms = to_u64(elapsed_ms(self.started_at)); + let duration_ms = duration_ms.map(to_u64); + let event = DesktopStartupTraceEvent { + trace_id: self.trace_id.clone(), + phase, + at_ms: since_process_start_ms, + since_process_start_ms, + category, + step, + command, + target, + duration_ms, + }; + + if let Ok(mut events) = self.events.lock() { + events.push(event); + } + } +} + +fn to_u64(value: u128) -> u64 { + value.min(u64::MAX as u128) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn records_tauri_command_with_safe_target() { + let trace = DesktopStartupTrace::new( + "trace-test".to_string(), + Instant::now() - Duration::from_millis(20), + ); + + trace.record_tauri_command_elapsed( + "get_config", + Some("app.auto_update"), + Instant::now() - Duration::from_millis(7), + ); + + let snapshot = trace.snapshot(); + let event = snapshot.events.last().expect("event should be recorded"); + + assert_eq!(event.category.as_deref(), Some("tauri_command")); + assert_eq!(event.command.as_deref(), Some("get_config")); + assert_eq!(event.target.as_deref(), Some("app.auto_update")); + assert!(event.duration_ms.unwrap_or_default() >= 7); + } +} diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index c1d57dcf57..434af27914 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,6 +1,8 @@ //! Theme System use std::sync::{OnceLock, RwLock}; +#[cfg(target_os = "windows")] +use std::time::Duration; use std::time::Instant; use bitfun_core::infrastructure::try_get_path_manager_arc; @@ -9,17 +11,114 @@ use log::{debug, error, warn}; use tauri::webview::PageLoadEvent; use tauri::{Manager, WebviewUrl}; +use crate::startup_trace::DesktopStartupTrace; + const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; const AGENT_COMPANION_WINDOW_MIN_SIZE: f64 = 96.0; const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0; const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0; const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64; const AGENT_COMPANION_WINDOW_EDGE_MARGIN: f64 = 8.0; +#[cfg(target_os = "windows")] +const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX: Duration = Duration::from_millis(150); +#[cfg(target_os = "windows")] +const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN: Duration = Duration::from_millis(16); +#[cfg(target_os = "windows")] +const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL: Duration = Duration::from_millis(8); static AGENT_COMPANION_WINDOW_OPS: OnceLock> = OnceLock::new(); static AGENT_COMPANION_WINDOW_LAST_POSITION: OnceLock>>> = OnceLock::new(); +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowsMaximizeShowWaitAction { + Ready, + Sleep(Duration), + TimedOut, +} + +#[cfg(target_os = "windows")] +fn windows_maximize_show_wait_action( + is_maximized: Option, + elapsed: Duration, +) -> WindowsMaximizeShowWaitAction { + if is_maximized == Some(true) && elapsed >= WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN { + return WindowsMaximizeShowWaitAction::Ready; + } + + if elapsed >= WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX { + return WindowsMaximizeShowWaitAction::TimedOut; + } + + let target_wait = if is_maximized == Some(true) { + WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN + } else { + WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX + }; + WindowsMaximizeShowWaitAction::Sleep( + target_wait + .saturating_sub(elapsed) + .min(WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL), + ) +} + +#[cfg(target_os = "windows")] +fn read_windows_maximize_state( + window: &tauri::WebviewWindow, + logged_error: &mut bool, +) -> Option { + match window.is_maximized() { + Ok(is_maximized) => Some(is_maximized), + Err(error) => { + if !*logged_error { + warn!( + "Failed to read main window maximize state during startup show wait: {}", + error + ); + *logged_error = true; + } + None + } + } +} + +#[cfg(target_os = "windows")] +fn wait_for_windows_maximize_before_show(window: &tauri::WebviewWindow) -> &'static str { + let started_at = Instant::now(); + let mut logged_error = false; + + loop { + match windows_maximize_show_wait_action( + read_windows_maximize_state(window, &mut logged_error), + started_at.elapsed(), + ) { + WindowsMaximizeShowWaitAction::Ready => return "ready", + WindowsMaximizeShowWaitAction::TimedOut => return "timeout", + WindowsMaximizeShowWaitAction::Sleep(duration) => std::thread::sleep(duration), + } + } +} + +#[cfg(target_os = "windows")] +async fn wait_for_windows_maximize_before_show_async( + window: &tauri::WebviewWindow, +) -> &'static str { + let started_at = Instant::now(); + let mut logged_error = false; + + loop { + match windows_maximize_show_wait_action( + read_windows_maximize_state(window, &mut logged_error), + started_at.elapsed(), + ) { + WindowsMaximizeShowWaitAction::Ready => return "ready", + WindowsMaximizeShowWaitAction::TimedOut => return "timeout", + WindowsMaximizeShowWaitAction::Sleep(duration) => tokio::time::sleep(duration).await, + } + } +} + fn agent_companion_window_ops() -> &'static tokio::sync::Mutex<()> { AGENT_COMPANION_WINDOW_OPS.get_or_init(|| tokio::sync::Mutex::new(())) } @@ -99,6 +198,7 @@ fn clamp_agent_companion_window_position( #[derive(Debug, Clone)] pub struct ThemeConfig { pub id: String, + pub selection_id: Option, pub bg_primary: String, pub bg_secondary: String, pub bg_scene: String, @@ -110,8 +210,9 @@ pub struct ThemeConfig { impl Default for ThemeConfig { fn default() -> Self { - Self::get_builtin_theme("bitfun-light").unwrap_or_else(|| Self { + let mut theme = Self::get_builtin_theme("bitfun-light").unwrap_or_else(|| Self { id: "bitfun-light".to_string(), + selection_id: None, bg_primary: "#f3f3f5".to_string(), bg_secondary: "#ffffff".to_string(), bg_scene: "#ffffff".to_string(), @@ -119,7 +220,9 @@ impl Default for ThemeConfig { text_primary: "#1e293b".to_string(), text_muted: "#64748b".to_string(), accent_color: "#64748b".to_string(), - }) + }); + theme.selection_id = None; + theme } } @@ -128,6 +231,7 @@ impl ThemeConfig { match theme_id { "bitfun-slate" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#1a1c1e".to_string(), bg_secondary: "#1a1c1e".to_string(), bg_scene: "#1d2023".to_string(), @@ -138,6 +242,7 @@ impl ThemeConfig { }), "bitfun-dark" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#121214".to_string(), bg_secondary: "#18181a".to_string(), bg_scene: "#16161a".to_string(), @@ -148,6 +253,7 @@ impl ThemeConfig { }), "bitfun-midnight" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#2b2d30".to_string(), bg_secondary: "#1e1f22".to_string(), bg_scene: "#27292c".to_string(), @@ -158,6 +264,7 @@ impl ThemeConfig { }), "bitfun-cyber" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#101010".to_string(), bg_secondary: "#151515".to_string(), bg_scene: "#141414".to_string(), @@ -168,6 +275,7 @@ impl ThemeConfig { }), "bitfun-tokyo-night" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#1a1b26".to_string(), bg_secondary: "#16161e".to_string(), bg_scene: "#1a1b26".to_string(), @@ -178,6 +286,7 @@ impl ThemeConfig { }), "bitfun-china-night" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#1a1814".to_string(), bg_secondary: "#141210".to_string(), bg_scene: "#1e1c17".to_string(), @@ -188,6 +297,7 @@ impl ThemeConfig { }), "bitfun-light" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#f3f3f5".to_string(), bg_secondary: "#ffffff".to_string(), bg_scene: "#ffffff".to_string(), @@ -198,6 +308,7 @@ impl ThemeConfig { }), "bitfun-china-style" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#faf8f0".to_string(), bg_secondary: "#f5f3e8".to_string(), bg_scene: "#fdfcf6".to_string(), @@ -251,7 +362,10 @@ impl ThemeConfig { let resolved_id = Self::resolve_builtin_theme_id(theme_id); match Self::get_builtin_theme(resolved_id) { - Some(config) => config, + Some(mut config) => { + config.selection_id = Some(theme_id.to_string()); + config + } None => { warn!("Unknown theme ID: {}, using default theme", theme_id); default @@ -265,19 +379,92 @@ impl ThemeConfig { "system" } + fn load_startup_locale_from_config() -> String { + let path_manager = match try_get_path_manager_arc() { + Ok(pm) => pm, + Err(_) => return "zh-CN".to_string(), + }; + let config_file = path_manager.app_config_file(); + let Ok(config_content) = std::fs::read_to_string(config_file) else { + return "zh-CN".to_string(); + }; + let Ok(config_value) = serde_json::from_str::(&config_content) else { + return "zh-CN".to_string(); + }; + config_value + .pointer("/app/language") + .and_then(|value| value.as_str()) + .or_else(|| { + config_value + .pointer("/i18n/currentLanguage") + .and_then(|value| value.as_str()) + }) + .unwrap_or("zh-CN") + .to_string() + } + + fn startup_messages_json(locale: &str) -> String { + let messages = match locale { + "en-US" | "en" => serde_json::json!({ + "loadingApp": "Starting BitFun...", + "minimize": "Minimize", + "maximize": "Maximize", + "close": "Close", + "petLoading": "Loading companion..." + }), + "zh-TW" | "zh-Hant-TW" => serde_json::json!({ + "loadingApp": "正在啟動 BitFun...", + "minimize": "最小化", + "maximize": "最大化", + "close": "關閉", + "petLoading": "正在載入助手..." + }), + _ => serde_json::json!({ + "loadingApp": "正在启动 BitFun...", + "minimize": "最小化", + "maximize": "最大化", + "close": "关闭", + "petLoading": "正在加载助手..." + }), + }; + messages.to_string() + } + pub fn generate_init_script(&self, startup_trace_id: &str) -> String { let theme_type = if self.is_light { "light" } else { "dark" }; + let startup_locale = Self::load_startup_locale_from_config(); + let startup_locale_json = + serde_json::to_string(&startup_locale).unwrap_or_else(|_| "\"zh-CN\"".to_string()); + let startup_messages_json = Self::startup_messages_json(&startup_locale); + let show_startup_window_controls = !cfg!(target_os = "macos"); let startup_trace_id_json = serde_json::to_string(startup_trace_id) .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); + let bootstrap_log_level_json = serde_json::to_string(crate::logging::level_to_str( + crate::logging::current_runtime_log_level(), + )) + .unwrap_or_else(|_| "\"warn\"".to_string()); let perf_trace_enabled = cfg!(debug_assertions) || ((cfg!(feature = "devtools") || std::env::var_os("BITFUN_PERF_TRACE").is_some()) && std::env::var_os("BITFUN_WEBDRIVER_PORT").is_some()); + let bootstrap_theme_id_json = + serde_json::to_string(&self.id).unwrap_or_else(|_| "\"bitfun-light\"".to_string()); + let bootstrap_theme_selection_json = self + .selection_id + .as_ref() + .and_then(|selection| serde_json::to_string(selection).ok()) + .unwrap_or_else(|| "null".to_string()); format!( r#" (function() {{ window.__BITFUN_STARTUP_TRACE_ID__ = {startup_trace_id_json}; window.__BITFUN_PERF_TRACE_ENABLED__ = {perf_trace_enabled}; + window.__BITFUN_BOOTSTRAP_LOG_LEVEL__ = {bootstrap_log_level_json}; + window.__BITFUN_BOOTSTRAP_LOCALE__ = {startup_locale_json}; + window.__BITFUN_BOOTSTRAP_MESSAGES__ = {startup_messages_json}; + window.__BITFUN_SHOW_STARTUP_WINDOW_CONTROLS__ = {show_startup_window_controls}; + window.__BITFUN_BOOTSTRAP_THEME_ID__ = {bootstrap_theme_id_json}; + window.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = {bootstrap_theme_selection_json}; function applyTheme() {{ var root = document.documentElement; if (!root) return false; @@ -299,7 +486,7 @@ impl ThemeConfig { if (document.body) {{ document.body.style.backgroundColor = '{bg_primary}'; }} - + return true; }} @@ -322,6 +509,10 @@ impl ThemeConfig { text_primary = self.text_primary, startup_trace_id_json = startup_trace_id_json, perf_trace_enabled = perf_trace_enabled, + bootstrap_log_level_json = bootstrap_log_level_json, + startup_locale_json = startup_locale_json, + startup_messages_json = startup_messages_json, + show_startup_window_controls = show_startup_window_controls, ) } @@ -334,11 +525,21 @@ impl ThemeConfig { } } -pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) { +pub fn create_main_window( + app_handle: &tauri::AppHandle, + startup_trace_id: &str, + startup_trace: &DesktopStartupTrace, +) { let total_started_at = Instant::now(); let theme = ThemeConfig::load_from_config(); let bg_color = theme.to_tauri_color(); let init_script = theme.generate_init_script(startup_trace_id); + startup_trace.record_step( + "native_step_end", + "native_window", + "prepare_theme", + total_started_at.elapsed().as_millis(), + ); debug!( "Main window creation step completed: step=prepare_theme duration_ms={}", total_started_at.elapsed().as_millis() @@ -404,6 +605,7 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) let build_started_at = Instant::now(); match builder.build() { Ok(window) => { + startup_trace.record_elapsed_step("native_window", "webview_build", build_started_at); debug!( "Main window creation step completed: step=build url_kind={} duration_ms={} total_duration_ms={}", main_url_kind, @@ -420,7 +622,7 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) } } - show_main_window_for_startup(&window, total_started_at); + show_main_window_for_startup(&window, total_started_at, startup_trace); } Err(e) => { error!( @@ -432,20 +634,37 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) } } -fn show_main_window_for_startup(window: &tauri::WebviewWindow, total_started_at: Instant) { +fn show_main_window_for_startup( + window: &tauri::WebviewWindow, + total_started_at: Instant, + startup_trace: &DesktopStartupTrace, +) { #[cfg(target_os = "windows")] { let step_started_at = Instant::now(); if let Err(error) = window.maximize() { warn!("Failed to maximize main window during startup: {}", error); } else { + startup_trace.record_elapsed_step("native_window", "windows_maximize", step_started_at); debug!( "Main window startup show step completed: step=maximize duration_ms={} since_create_start_ms={}", step_started_at.elapsed().as_millis(), total_started_at.elapsed().as_millis() ); } - std::thread::sleep(std::time::Duration::from_millis(150)); + let show_delay_started_at = Instant::now(); + let show_wait_outcome = wait_for_windows_maximize_before_show(window); + startup_trace.record_elapsed_step( + "native_window", + "windows_show_after_maximize_wait", + show_delay_started_at, + ); + debug!( + "Main window startup show step completed: step=wait_for_maximize_state outcome={} duration_ms={} since_create_start_ms={}", + show_wait_outcome, + show_delay_started_at.elapsed().as_millis(), + total_started_at.elapsed().as_millis() + ); } #[cfg(not(target_env = "ohos"))] @@ -455,6 +674,7 @@ fn show_main_window_for_startup(window: &tauri::WebviewWindow, total_started_at: warn!("Failed to show main window during startup: {}", error); return; } + startup_trace.record_elapsed_step("native_window", "show_window", show_started_at); debug!( "Main window startup show step completed: step=show duration_ms={} since_create_start_ms={}", show_started_at.elapsed().as_millis(), @@ -466,12 +686,12 @@ fn show_main_window_for_startup(window: &tauri::WebviewWindow, total_started_at: warn!("Failed to focus main window during startup: {}", error); return; } + startup_trace.record_elapsed_step("native_window", "focus_window", focus_started_at); debug!( "Main window startup show step completed: step=focus duration_ms={} since_create_start_ms={}", focus_started_at.elapsed().as_millis(), total_started_at.elapsed().as_millis() ); - } } @@ -640,7 +860,6 @@ fn resize_agent_companion_window( remember_agent_companion_window_position(next_position); } } - warn!("Failed to position Agent companion window") } } @@ -649,9 +868,9 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( #[cfg(not(target_env = "ohos"))] { let _guard = agent_companion_window_ops().lock().await; - let started_at = Instant::now(); - let _guard = agent_companion_window_ops().lock().await; - debug!("Agent companion window show requested"); + let started_at = Instant::now(); + let _guard = agent_companion_window_ops().lock().await; + debug!("Agent companion window show requested"); // Reuse any existing window: never destroy here. A previous implementation destroyed // whenever `is_visible` was false, which raced with another `show` that had built the @@ -666,49 +885,13 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; + debug!( + "Agent companion window reused: total_duration_ms={}", + started_at.elapsed().as_millis() + ); return Ok(()); - } - // Reuse any existing window: never destroy here. A previous implementation destroyed - // whenever `is_visible` was false, which raced with another `show` that had built the - // window but not called `show()` yet (or with `hide`), producing duplicate pets or - // stuck windows. - if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { - if let Err(e) = window.unminimize() { - warn!("Failed to unminimize Agent companion window: {}", e); - } - position_agent_companion_window(&app, &window); - window.show().map_err(|e| { - error!("Failed to show Agent companion window: {}", e); - format!("Failed to show Agent companion window: {}", e) - })?; - debug!( - "Agent companion window reused: total_duration_ms={}", - started_at.elapsed().as_millis() - ); - return Ok(()); } - let url = app_url("?bitfunWindow=agent-companion"); - let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) - .title("BitFun Agent Companion") - .inner_size( - AGENT_COMPANION_WINDOW_MIN_SIZE, - AGENT_COMPANION_WINDOW_MIN_SIZE, - ) - .max_inner_size( - AGENT_COMPANION_WINDOW_MAX_WIDTH, - AGENT_COMPANION_WINDOW_MAX_HEIGHT, - ) - .min_inner_size(1.0, 1.0) - .resizable(false) - .decorations(false) - .transparent(true) - .always_on_top(true) - .skip_taskbar(true) - .shadow(false) - .visible(false) - .accept_first_mouse(true) - .background_color(tauri::window::Color(0, 0, 0, 0)); let url = app_url("?bitfunWindow=agent-companion"); let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) .title("BitFun Agent Companion") @@ -746,12 +929,8 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( } }); - builder = builder.disable_drag_drop_handler(); + builder = builder.disable_drag_drop_handler(); - let window = builder.build().map_err(|e| { - error!("Failed to create Agent companion window: {}", e); - format!("Failed to create Agent companion window: {}", e) - })?; let build_started_at = Instant::now(); let window = builder.build().map_err(|e| { error!( @@ -767,12 +946,8 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( started_at.elapsed().as_millis() ); - position_agent_companion_window(&app, &window); + position_agent_companion_window(&app, &window); - window.show().map_err(|e| { - error!("Failed to show Agent companion window: {}", e); - format!("Failed to show Agent companion window: {}", e) - })?; let show_started_at = Instant::now(); window.show().map_err(|e| { error!("Failed to show Agent companion window: {}", e); @@ -820,7 +995,6 @@ pub async fn resize_agent_companion_desktop_pet( } Ok(()) } - } #[tauri::command] @@ -852,23 +1026,40 @@ pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { #[cfg(not(target_env = "ohos"))] { + let total_started_at = Instant::now(); if let Some(main_window) = app.get_webview_window("main") { #[cfg(target_os = "windows")] { // Work around Windows startup flicker: avoid creating the native window // in maximized mode, and maximize it right before showing instead. + let step_started_at = Instant::now(); main_window.maximize().map_err(|e| { error!("Failed to maximize main window: {}", e); format!("Failed to maximize main window: {}", e) })?; + debug!( + "Main window show step completed: step=maximize duration_ms={}", + step_started_at.elapsed().as_millis() + ); - tokio::time::sleep(std::time::Duration::from_millis(150)).await; + let wait_started_at = Instant::now(); + let show_wait_outcome = wait_for_windows_maximize_before_show_async(&main_window).await; + debug!( + "Main window show step completed: step=wait_for_maximize_state outcome={} duration_ms={}", + show_wait_outcome, + wait_started_at.elapsed().as_millis() + ); } + let step_started_at = Instant::now(); main_window.show().map_err(|e| { error!("Failed to show main window: {}", e); format!("Failed to show main window: {}", e) })?; + debug!( + "Main window show step completed: step=show duration_ms={}", + step_started_at.elapsed().as_millis() + ); #[cfg(target_os = "macos")] { @@ -876,19 +1067,71 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { crate::mark_main_window_hidden_on_macos(false); } + let step_started_at = Instant::now(); main_window.set_focus().map_err(|e| { error!("Failed to focus main window: {}", e); format!("Failed to focus main window: {}", e) })?; + debug!( + "Main window show step completed: step=focus duration_ms={}", + step_started_at.elapsed().as_millis() + ); } else { error!("Main window not found"); return Err("Main window not found".to_string()); } + debug!( + "Main window shown: total_duration_ms={}", + total_started_at.elapsed().as_millis() + ); Ok(()) } + #[cfg(target_env = "ohos")] { Err("Failed to show main window".to_string()) } + + +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "windows")] + #[test] + fn windows_maximize_show_wait_releases_when_maximized() { + assert_eq!( + windows_maximize_show_wait_action(Some(true), Duration::ZERO), + WindowsMaximizeShowWaitAction::Sleep(WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL) + ); + assert_eq!( + windows_maximize_show_wait_action(Some(true), WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN), + WindowsMaximizeShowWaitAction::Ready + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_maximize_show_wait_polls_until_max_wait() { + assert_eq!( + windows_maximize_show_wait_action(Some(false), Duration::from_millis(20)), + WindowsMaximizeShowWaitAction::Sleep(WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL) + ); + assert_eq!( + windows_maximize_show_wait_action(None, Duration::from_millis(148)), + WindowsMaximizeShowWaitAction::Sleep(Duration::from_millis(2)) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_maximize_show_wait_times_out_at_original_bound() { + assert_eq!( + windows_maximize_show_wait_action(Some(false), WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX), + WindowsMaximizeShowWaitAction::TimedOut + ); + } } diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 0d76769dc3..fa9d021b45 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -14,6 +14,7 @@ //! periodically, and after locale changes. use std::sync::OnceLock; +use std::time::Instant; use tauri::menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; @@ -24,6 +25,7 @@ use bitfun_core::service::config::types::AIExperienceConfig; use bitfun_core::service::i18n::LocaleId; use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; static TRAY_ICON: OnceLock = OnceLock::new(); @@ -164,13 +166,19 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> { } /// Build and attach the system tray icon to the Tauri application. -pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { +pub fn setup_tray( + app: &tauri::App, + startup_trace: &DesktopStartupTrace, +) -> Result<(), Box> { + let step_started = Instant::now(); let pet_item = CheckMenuItemBuilder::with_id("toggle_desktop_pet", STRINGS_EN_US.desktop_pet) .checked(false) .build(app)?; let show_item = MenuItemBuilder::with_id("show_window", STRINGS_EN_US.show_app).build(app)?; let quit_item = MenuItemBuilder::with_id("quit", STRINGS_EN_US.quit_app).build(app)?; + startup_trace.record_elapsed_step("native_setup", "setup_tray.menu_items", step_started); + let step_started = Instant::now(); let initial_menu = MenuBuilder::new(app) .item(&pet_item) .separator() @@ -178,12 +186,16 @@ pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { .separator() .item(&quit_item) .build()?; + startup_trace.record_elapsed_step("native_setup", "setup_tray.menu", step_started); + let step_started = Instant::now(); let icon = app .default_window_icon() .ok_or("No default window icon")? .clone(); + startup_trace.record_elapsed_step("native_setup", "setup_tray.icon", step_started); + let step_started = Instant::now(); let tray = TrayIconBuilder::new() .icon(icon) .menu(&initial_menu) @@ -222,9 +234,13 @@ pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { _ => {} }) .build(app)?; + startup_trace.record_elapsed_step("native_setup", "setup_tray.build", step_started); + let step_started = Instant::now(); let _ = TRAY_ICON.set(tray); + startup_trace.record_elapsed_step("native_setup", "setup_tray.store", step_started); + let step_started = Instant::now(); let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; @@ -236,6 +252,7 @@ pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { rebuild_tray_menu(&app_handle).await; } }); + startup_trace.record_elapsed_step("native_setup", "setup_tray.spawn_refresh", step_started); Ok(()) } diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index bc01dca3fa..ee247c87a5 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-server" -version = "0.2.8" +version = "0.2.10" authors = ["BitFun Team"] edition = "2021" description = "BitFun Relay Server - WebSocket relay for Remote Connect" diff --git a/src/apps/relay-server/src/lib.rs b/src/apps/relay-server/src/lib.rs index b8e472faf0..d2e7f85848 100644 --- a/src/apps/relay-server/src/lib.rs +++ b/src/apps/relay-server/src/lib.rs @@ -244,24 +244,24 @@ pub fn build_relay_router( Router::new() .route("/health", get(routes::api::health_check)) .route("/api/info", get(routes::api::server_info)) - .route("/api/rooms/:room_id/pair", post(routes::api::pair)) + .route("/api/rooms/{room_id}/pair", post(routes::api::pair)) .route( - "/api/rooms/:room_id/command", + "/api/rooms/{room_id}/command", post(routes::api::command).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), ) .route( - "/api/rooms/:room_id/upload-web", + "/api/rooms/{room_id}/upload-web", post(routes::api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), ) .route( - "/api/rooms/:room_id/check-web-files", + "/api/rooms/{room_id}/check-web-files", post(routes::api::check_web_files), ) .route( - "/api/rooms/:room_id/upload-web-files", + "/api/rooms/{room_id}/upload-web-files", post(routes::api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), ) - .route("/r/*rest", get(routes::api::serve_room_web_catchall)) + .route("/r/{*rest}", get(routes::api::serve_room_web_catchall)) .route("/ws", get(routes::websocket::websocket_handler)) .layer(tower_http::cors::CorsLayer::permissive()) .with_state(state) diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index f4a912cfcb..8b8ba25c92 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -43,7 +43,10 @@ pub async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result> + Send>, >, pub raw_sse_rx: Option>, + pub trace_handle: Option, } /// Default time to wait for the first response headers / stream body to start. @@ -112,27 +116,14 @@ impl AIClient { } } - /// Provider-specific request overrides for PlaintextFollowup Write content generation. - /// - /// OpenAI-compatible APIs accept `tool_choice: "none"` to suppress tool calls when - /// history still contains prior tool turns. Anthropic/Gemini reject that field. - pub fn write_content_generation_extra_body(&self) -> Option { - match ApiFormat::parse(&self.config.format) { - Ok(ApiFormat::OpenAIChat | ApiFormat::OpenAIResponses) => { - Some(serde_json::json!({ "tool_choice": "none" })) - } - Ok(ApiFormat::Anthropic | ApiFormat::Gemini | ApiFormat::GeminiCodeAssist) => None, - Err(_) => None, - } - } - pub async fn send_message_stream( &self, messages: Vec, tools: Option>, + trace: Option, ) -> Result { let custom_body = self.config.custom_request_body.clone(); - self.send_message_stream_with_extra_body(messages, tools, custom_body) + self.send_message_stream_with_extra_body(messages, tools, custom_body, trace) .await } @@ -141,23 +132,30 @@ impl AIClient { messages: Vec, tools: Option>, extra_body: Option, + trace: Option, ) -> Result { let max_tries = SEND_MESSAGE_STREAM_ATTEMPTS; match ApiFormat::parse(&self.config.format)? { ApiFormat::OpenAIChat => { - openai::chat::send_stream(self, messages, tools, extra_body, max_tries).await + openai::chat::send_stream(self, messages, tools, extra_body, max_tries, trace).await } ApiFormat::OpenAIResponses => { - openai::responses::send_stream(self, messages, tools, extra_body, max_tries).await + openai::responses::send_stream(self, messages, tools, extra_body, max_tries, trace) + .await } ApiFormat::Anthropic => { - anthropic::request::send_stream(self, messages, tools, extra_body, max_tries).await + anthropic::request::send_stream(self, messages, tools, extra_body, max_tries, trace) + .await } ApiFormat::Gemini => { - gemini::request::send_stream(self, messages, tools, extra_body, max_tries).await + gemini::request::send_stream(self, messages, tools, extra_body, max_tries, trace) + .await } ApiFormat::GeminiCodeAssist => { - gemini::code_assist::send_stream(self, messages, tools, extra_body, max_tries).await + gemini::code_assist::send_stream( + self, messages, tools, extra_body, max_tries, trace, + ) + .await } } } @@ -177,6 +175,28 @@ impl AIClient { messages: Vec, tools: Option>, extra_body: Option, + ) -> Result { + self.send_message_with_extra_body_and_trace(messages, tools, extra_body, None) + .await + } + + pub async fn send_message_with_trace( + &self, + messages: Vec, + tools: Option>, + trace: Option, + ) -> Result { + let custom_body = self.config.custom_request_body.clone(); + self.send_message_with_extra_body_and_trace(messages, tools, custom_body, trace) + .await + } + + pub async fn send_message_with_extra_body_and_trace( + &self, + messages: Vec, + tools: Option>, + extra_body: Option, + trace: Option, ) -> Result { for attempt in 0..SEND_MESSAGE_STREAM_ATTEMPTS { let stream_response = self @@ -184,15 +204,27 @@ impl AIClient { messages.clone(), tools.clone(), extra_body.clone(), + trace.clone(), ) .await?; + let trace_handle = stream_response.trace_handle.clone(); match response_aggregator::aggregate_stream_response(stream_response).await { - Ok(response) => return Ok(response), + Ok(response) => { + complete_aggregated_trace(trace.as_ref(), trace_handle.as_ref(), &response) + .await; + return Ok(response); + } Err(error) if attempt < SEND_MESSAGE_STREAM_ATTEMPTS - 1 && is_transient_stream_error(&error.to_string()) => { + fail_aggregated_trace( + trace.as_ref(), + trace_handle.as_ref(), + &error.to_string(), + ) + .await; let delay_ms = send_message_retry_delay_ms(attempt); warn!( "Retrying aggregated AI stream after transient error: attempt={}/{}, delay_ms={}, error={}", @@ -203,7 +235,15 @@ impl AIClient { ); tokio::time::sleep(Duration::from_millis(delay_ms)).await; } - Err(error) => return Err(error), + Err(error) => { + fail_aggregated_trace( + trace.as_ref(), + trace_handle.as_ref(), + &error.to_string(), + ) + .await; + return Err(error); + } } } @@ -320,6 +360,55 @@ fn is_transient_stream_error(error_message: &str) -> bool { .any(|k| msg.contains(k)) } +async fn complete_aggregated_trace( + trace_config: Option<&ModelExchangeTraceConfig>, + trace_handle: Option<&ModelExchangeRequestTraceHandle>, + response: &GeminiResponse, +) { + let (Some(trace_config), Some(trace_handle)) = (trace_config, trace_handle) else { + return; + }; + + trace_config + .sink + .request_attempt_completed(trace_handle, &gemini_response_to_trace(response)) + .await; +} + +async fn fail_aggregated_trace( + trace_config: Option<&ModelExchangeTraceConfig>, + trace_handle: Option<&ModelExchangeRequestTraceHandle>, + error: &str, +) { + let Some(trace_config) = trace_config else { + return; + }; + + trace_config + .sink + .request_attempt_failed(trace_handle, error) + .await; +} + +fn gemini_response_to_trace(response: &GeminiResponse) -> ModelExchangeResponseTrace { + ModelExchangeResponseTrace { + kind: "completed".to_string(), + assistant_text: Some(response.text.clone()), + thinking: response.reasoning_content.clone(), + tool_calls: response + .tool_calls + .as_ref() + .and_then(|tool_calls| serde_json::to_value(tool_calls).ok()), + usage: response + .usage + .as_ref() + .and_then(|usage| serde_json::to_value(usage).ok()), + provider_metadata: response.provider_metadata.clone(), + partial_recovery_reason: None, + error: None, + } +} + #[cfg(test)] mod tests { use super::{is_transient_stream_error, AIClient}; @@ -956,7 +1045,7 @@ mod tests { ); assert_eq!(request_body["thinking"]["type"], "enabled"); - assert_eq!(request_body["thinking"]["budget_tokens"], 6144); + assert!(request_body["thinking"].get("budget_tokens").is_none()); assert_eq!(request_body["output_config"]["effort"], "max"); } @@ -997,6 +1086,118 @@ mod tests { assert_eq!(request_body["thinking"]["budget_tokens"], 3000); } + #[test] + fn build_anthropic_request_body_default_deepseek_reasoning_omits_thinking_fields() { + let client = AIClient::new(AIConfig { + name: "deepseek".to_string(), + base_url: "https://api.deepseek.com/anthropic".to_string(), + request_url: "https://api.deepseek.com/anthropic/v1/messages".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-flash".to_string(), + format: "anthropic".to_string(), + context_window: 200000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: Some("high".to_string()), + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + }); + + let request_body = anthropic::request::build_request_body( + &client, + &client.config.request_url, + None, + vec![json!({ "role": "user", "content": [{ "type": "text", "text": "hello" }] })], + None, + None, + ); + + assert!(request_body.get("thinking").is_none()); + assert!(request_body.get("output_config").is_none()); + } + + #[test] + fn build_anthropic_request_body_disabled_deepseek_reasoning_omits_effort() { + let client = AIClient::new(AIConfig { + name: "deepseek".to_string(), + base_url: "https://api.deepseek.com/anthropic".to_string(), + request_url: "https://api.deepseek.com/anthropic/v1/messages".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-flash".to_string(), + format: "anthropic".to_string(), + context_window: 200000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Disabled, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: Some("high".to_string()), + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + }); + + let request_body = anthropic::request::build_request_body( + &client, + &client.config.request_url, + None, + vec![json!({ "role": "user", "content": [{ "type": "text", "text": "hello" }] })], + None, + None, + ); + + assert_eq!(request_body["thinking"]["type"], "disabled"); + assert!(request_body.get("output_config").is_none()); + } + + #[test] + fn build_anthropic_request_body_adaptive_deepseek_reasoning_falls_back_to_enabled() { + let client = AIClient::new(AIConfig { + name: "deepseek".to_string(), + base_url: "https://api.deepseek.com/anthropic".to_string(), + request_url: "https://api.deepseek.com/anthropic/v1/messages".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-flash".to_string(), + format: "anthropic".to_string(), + context_window: 200000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Adaptive, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: Some("high".to_string()), + thinking_budget_tokens: Some(4096), + custom_request_body: None, + custom_request_body_mode: None, + }); + + let request_body = anthropic::request::build_request_body( + &client, + &client.config.request_url, + None, + vec![json!({ "role": "user", "content": [{ "type": "text", "text": "hello" }] })], + None, + None, + ); + + assert_eq!(request_body["thinking"]["type"], "enabled"); + assert!(request_body["thinking"].get("budget_tokens").is_none()); + assert_eq!(request_body["output_config"]["effort"], "high"); + } + #[test] fn build_openai_request_body_trim_mode_preserves_essential_fields() { let mut client = make_trim_test_client("openai"); @@ -1201,19 +1402,4 @@ mod tests { ); } } - - #[test] - fn write_content_generation_extra_body_is_openai_only() { - let openai = make_test_client("openai", None); - assert_eq!( - openai.write_content_generation_extra_body(), - Some(json!({ "tool_choice": "none" })) - ); - - let anthropic = make_test_client("anthropic", None); - assert_eq!(anthropic.write_content_generation_extra_body(), None); - - let gemini = make_test_client("gemini", None); - assert_eq!(gemini.write_content_generation_extra_body(), None); - } } diff --git a/src/crates/ai-adapters/src/client/format.rs b/src/crates/adapters/ai-adapters/src/client/format.rs similarity index 100% rename from src/crates/ai-adapters/src/client/format.rs rename to src/crates/adapters/ai-adapters/src/client/format.rs diff --git a/src/crates/ai-adapters/src/client/healthcheck.rs b/src/crates/adapters/ai-adapters/src/client/healthcheck.rs similarity index 100% rename from src/crates/ai-adapters/src/client/healthcheck.rs rename to src/crates/adapters/ai-adapters/src/client/healthcheck.rs diff --git a/src/crates/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs similarity index 100% rename from src/crates/ai-adapters/src/client/http.rs rename to src/crates/adapters/ai-adapters/src/client/http.rs diff --git a/src/crates/ai-adapters/src/client/quirks.rs b/src/crates/adapters/ai-adapters/src/client/quirks.rs similarity index 100% rename from src/crates/ai-adapters/src/client/quirks.rs rename to src/crates/adapters/ai-adapters/src/client/quirks.rs diff --git a/src/crates/ai-adapters/src/client/response_aggregator.rs b/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs similarity index 100% rename from src/crates/ai-adapters/src/client/response_aggregator.rs rename to src/crates/adapters/ai-adapters/src/client/response_aggregator.rs diff --git a/src/crates/ai-adapters/src/client/sse.rs b/src/crates/adapters/ai-adapters/src/client/sse.rs similarity index 75% rename from src/crates/ai-adapters/src/client/sse.rs rename to src/crates/adapters/ai-adapters/src/client/sse.rs index e920f6dba9..c10e223079 100644 --- a/src/crates/ai-adapters/src/client/sse.rs +++ b/src/crates/adapters/ai-adapters/src/client/sse.rs @@ -1,6 +1,7 @@ use crate::client::utils::elapsed_ms_u64; use crate::client::StreamResponse; use crate::stream::UnifiedResponse; +use crate::trace::{ModelExchangeRequestAttempt, ModelExchangeTraceConfig}; use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use log::{debug, error, warn}; @@ -12,7 +13,15 @@ use std::time::Duration; use tokio::sync::mpsc; const BASE_RETRY_DELAY_MS: u64 = 500; -const MAX_RETRY_AFTER_DELAY_MS: u64 = 10_000; +/// Maximum delay applied to a `Retry-After` header value. +/// +/// Some providers (especially TPM-based rate limits on aggregator platforms +/// like NVIDIA's integrate API) return large `Retry-After` values of 30-60 +/// seconds. Capping at 10s caused tight retry loops that burned through the +/// user's request budget without actually waiting for the TPM window to reset. +/// 60s is a reasonable upper bound that respects provider guidance without +/// locking the user into an interminable stall. +const MAX_RETRY_AFTER_DELAY_MS: u64 = 60_000; enum StreamSendOutcome { Response(reqwest::Response), @@ -29,13 +38,13 @@ where BuildRequest: Fn() -> reqwest::RequestBuilder, { match ttft_timeout { - Some(timeout) => match tokio::time::timeout(timeout, build_request().json(request_body).send()) - .await - { - Ok(Ok(response)) => StreamSendOutcome::Response(response), - Ok(Err(error)) => StreamSendOutcome::Transport(error), - Err(_) => StreamSendOutcome::TtftTimeout, - }, + Some(timeout) => { + match tokio::time::timeout(timeout, build_request().json(request_body).send()).await { + Ok(Ok(response)) => StreamSendOutcome::Response(response), + Ok(Err(error)) => StreamSendOutcome::Transport(error), + Err(_) => StreamSendOutcome::TtftTimeout, + } + } None => match build_request().json(request_body).send().await { Ok(response) => StreamSendOutcome::Response(response), Err(error) => StreamSendOutcome::Transport(error), @@ -93,10 +102,11 @@ fn retry_delay_ms(attempt: usize, headers: &HeaderMap) -> u64 { pub(crate) async fn execute_sse_request( label: &str, - _url: &str, + url: &str, request_body: &serde_json::Value, max_tries: usize, ttft_timeout: Option, + trace: Option, build_request: BuildRequest, spawn_handler: SpawnHandler, ) -> Result @@ -110,6 +120,18 @@ where { let mut last_error = None; for attempt in 0..max_tries { + let trace_handle = if let Some(trace) = trace.as_ref() { + trace + .sink + .request_attempt_started(&ModelExchangeRequestAttempt { + request_url: url.to_string(), + request_body: trace.capture_request_body.then(|| request_body.clone()), + attempt_number: attempt + 1, + }) + .await + } else { + None + }; let request_start_time = std::time::Instant::now(); let send_outcome = send_stream_request(&build_request, request_body, ttft_timeout).await; @@ -124,6 +146,15 @@ where .text() .await .unwrap_or_else(|e| format!("Failed to read error response: {}", e)); + if let Some(trace) = trace.as_ref() { + trace + .sink + .request_attempt_failed( + trace_handle.as_ref(), + &format!("{} client error {}: {}", label, status, error_text), + ) + .await; + } error!("{} client error {}: {}", label, status, error_text); return Err(anyhow!("{} client error {}: {}", label, status, error_text)); } @@ -153,6 +184,18 @@ where error ); last_error = Some(error); + if let Some(trace) = trace.as_ref() { + trace + .sink + .request_attempt_failed( + trace_handle.as_ref(), + &last_error + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown error".to_string()), + ) + .await; + } if attempt < max_tries - 1 { let delay_ms = retry_delay_ms(attempt, &headers); @@ -181,6 +224,12 @@ where error_msg ); last_error = Some(error); + if let Some(trace) = trace.as_ref() { + trace + .sink + .request_attempt_failed(trace_handle.as_ref(), &error_msg) + .await; + } if attempt < max_tries - 1 { let delay_ms = exponential_retry_delay_ms(attempt); @@ -207,6 +256,12 @@ where error_msg ); last_error = Some(error); + if let Some(trace) = trace.as_ref() { + trace + .sink + .request_attempt_failed(trace_handle.as_ref(), &error_msg) + .await; + } if attempt < max_tries - 1 { let delay_ms = exponential_retry_delay_ms(attempt); @@ -229,6 +284,7 @@ where return Ok(StreamResponse { stream: Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx)), raw_sse_rx: Some(rx_raw), + trace_handle, }); } @@ -280,6 +336,14 @@ mod tests { ); } + #[test] + fn retry_after_preserves_sub_cap_values() { + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_static("45")); + + assert_eq!(retry_after_delay_ms(&headers), Some(45_000)); + } + #[test] fn retry_delay_falls_back_to_exponential_backoff() { let headers = HeaderMap::new(); diff --git a/src/crates/ai-adapters/src/client/utils.rs b/src/crates/adapters/ai-adapters/src/client/utils.rs similarity index 100% rename from src/crates/ai-adapters/src/client/utils.rs rename to src/crates/adapters/ai-adapters/src/client/utils.rs diff --git a/src/crates/ai-adapters/src/diagnostics.rs b/src/crates/adapters/ai-adapters/src/diagnostics.rs similarity index 100% rename from src/crates/ai-adapters/src/diagnostics.rs rename to src/crates/adapters/ai-adapters/src/diagnostics.rs diff --git a/src/crates/ai-adapters/src/lib.rs b/src/crates/adapters/ai-adapters/src/lib.rs similarity index 77% rename from src/crates/ai-adapters/src/lib.rs rename to src/crates/adapters/ai-adapters/src/lib.rs index e9a1146d98..f86290ba75 100644 --- a/src/crates/ai-adapters/src/lib.rs +++ b/src/crates/adapters/ai-adapters/src/lib.rs @@ -5,6 +5,7 @@ pub mod diagnostics; pub mod providers; pub mod stream; pub mod tool_call_accumulator; +pub mod trace; pub mod types; pub use client::{ @@ -12,6 +13,10 @@ pub use client::{ DEFAULT_STREAM_TTFT_TIMEOUT_SECS, REASONING_STREAM_TTFT_TIMEOUT_SECS, }; pub use stream::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; +pub use trace::{ + ModelExchangeRequestAttempt, ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, + ModelExchangeTraceConfig, ModelExchangeTraceSink, +}; pub use types::{ resolve_request_url, AIConfig, ConnectionTestMessageCode, ConnectionTestResult, GeminiResponse, GeminiUsage, Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, diff --git a/src/crates/ai-adapters/src/providers/anthropic/discovery.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/discovery.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/anthropic/discovery.rs rename to src/crates/adapters/ai-adapters/src/providers/anthropic/discovery.rs diff --git a/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/message_converter.rs similarity index 98% rename from src/crates/ai-adapters/src/providers/anthropic/message_converter.rs rename to src/crates/adapters/ai-adapters/src/providers/anthropic/message_converter.rs index ea2847ef15..f242072b7a 100644 --- a/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs +++ b/src/crates/adapters/ai-adapters/src/providers/anthropic/message_converter.rs @@ -292,8 +292,8 @@ mod tests { #[test] fn trims_trailing_whitespace_from_final_assistant_prefill() { let (_, messages) = AnthropicMessageConverter::convert_messages(vec![ - Message::user("Generate the file content.".to_string()), - Message::assistant("\n".to_string()), + Message::user("Continue the assistant response.".to_string()), + Message::assistant("\n".to_string()), ]); let content = messages @@ -304,6 +304,6 @@ mod tests { .expect("assistant content"); assert_eq!(content[0]["type"], json!("text")); - assert_eq!(content[0]["text"], json!("")); + assert_eq!(content[0]["text"], json!("")); } } diff --git a/src/crates/ai-adapters/src/providers/anthropic/mod.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/anthropic/mod.rs rename to src/crates/adapters/ai-adapters/src/providers/anthropic/mod.rs diff --git a/src/crates/ai-adapters/src/providers/anthropic/request.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs similarity index 84% rename from src/crates/ai-adapters/src/providers/anthropic/request.rs rename to src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs index c0d8cb346c..baed0fad17 100644 --- a/src/crates/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs @@ -7,6 +7,7 @@ use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_anthropic_stream; +use crate::trace::ModelExchangeTraceConfig; use crate::types::ReasoningMode; use crate::types::{Message, ToolDefinition}; use anyhow::Result; @@ -107,6 +108,48 @@ fn apply_anthropic_adaptive_reasoning( }); } +fn apply_deepseek_anthropic_reasoning( + request_body: &mut serde_json::Value, + mode: ReasoningMode, + model_name: &str, + reasoning_effort: Option<&str>, +) { + match mode { + ReasoningMode::Default => {} + ReasoningMode::Disabled => { + request_body["thinking"] = serde_json::json!({ "type": "disabled" }); + if reasoning_effort.is_some_and(|value| !value.trim().is_empty()) { + warn!( + target: "ai::anthropic_stream_request", + "Omitting output_config.effort for DeepSeek Anthropic model {} because thinking is disabled", + model_name + ); + } + } + ReasoningMode::Enabled => { + request_body["thinking"] = serde_json::json!({ "type": "enabled" }); + if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort) { + request_body["output_config"] = serde_json::json!({ + "effort": effort + }); + } + } + ReasoningMode::Adaptive => { + warn!( + target: "ai::anthropic_stream_request", + "DeepSeek Anthropic model {} does not support adaptive reasoning; falling back to thinking.type=enabled", + model_name + ); + apply_deepseek_anthropic_reasoning( + request_body, + ReasoningMode::Enabled, + model_name, + reasoning_effort, + ); + } + } +} + fn apply_reasoning_fields( request_body: &mut serde_json::Value, mode: ReasoningMode, @@ -118,19 +161,16 @@ fn apply_reasoning_fields( ) { let is_deepseek_reasoning_target = is_deepseek_url(url) || is_deepseek_reasoning_effort_model(model_name); + + if is_deepseek_reasoning_target { + apply_deepseek_anthropic_reasoning(request_body, mode, model_name, reasoning_effort); + return; + } + let capability = anthropic_thinking_capability(model_name); match mode { - ReasoningMode::Default => { - if is_deepseek_reasoning_target { - if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort) - { - request_body["output_config"] = serde_json::json!({ - "effort": effort - }); - } - } - } + ReasoningMode::Default => {} ReasoningMode::Disabled => { if capability == AnthropicThinkingCapability::AdaptiveDefaultNoDisabled { warn!( @@ -155,14 +195,6 @@ fn apply_reasoning_fields( thinking["budget_tokens"] = serde_json::json!(budget_tokens); } request_body["thinking"] = thinking; - if is_deepseek_reasoning_target { - if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort) - { - request_body["output_config"] = serde_json::json!({ - "effort": effort - }); - } - } } ReasoningMode::Adaptive => { if anthropic_supports_adaptive_reasoning(capability) { @@ -287,6 +319,7 @@ pub(crate) async fn send_stream( tools: Option>, extra_body: Option, max_tries: usize, + trace: Option, ) -> Result { let url = client.config.request_url.clone(); debug!( @@ -315,6 +348,7 @@ pub(crate) async fn send_stream( &request_body, max_tries, ttft_timeout, + trace, || apply_headers(client, client.client.post(&url), &url), move |response, tx, tx_raw| { tokio::spawn(handle_anthropic_stream( diff --git a/src/crates/ai-adapters/src/providers/gemini/code_assist.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs similarity index 98% rename from src/crates/ai-adapters/src/providers/gemini/code_assist.rs rename to src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs index ae009a412d..0c00f7312a 100644 --- a/src/crates/ai-adapters/src/providers/gemini/code_assist.rs +++ b/src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs @@ -10,6 +10,7 @@ use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_gemini_stream; +use crate::trace::ModelExchangeTraceConfig; use crate::types::{Message, RemoteModelInfo, ToolDefinition}; use anyhow::{anyhow, Result}; use log::{debug, warn}; @@ -139,6 +140,7 @@ pub(crate) async fn send_stream( tools: Option>, extra_body: Option, max_tries: usize, + trace: Option, ) -> Result { let project = discover_project(client).await?; @@ -178,6 +180,7 @@ pub(crate) async fn send_stream( &request_body, max_tries, ttft_timeout, + trace, || apply_headers(client, client.client.post(&url)), move |response, tx, tx_raw| { tokio::spawn(handle_gemini_stream(response, tx, tx_raw, idle_timeout)); diff --git a/src/crates/ai-adapters/src/providers/gemini/discovery.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/discovery.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/gemini/discovery.rs rename to src/crates/adapters/ai-adapters/src/providers/gemini/discovery.rs diff --git a/src/crates/ai-adapters/src/providers/gemini/message_converter.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/message_converter.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/gemini/message_converter.rs rename to src/crates/adapters/ai-adapters/src/providers/gemini/message_converter.rs diff --git a/src/crates/ai-adapters/src/providers/gemini/mod.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/gemini/mod.rs rename to src/crates/adapters/ai-adapters/src/providers/gemini/mod.rs diff --git a/src/crates/ai-adapters/src/providers/gemini/request.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/request.rs similarity index 99% rename from src/crates/ai-adapters/src/providers/gemini/request.rs rename to src/crates/adapters/ai-adapters/src/providers/gemini/request.rs index 9113cb3bad..f865c2829b 100644 --- a/src/crates/ai-adapters/src/providers/gemini/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/gemini/request.rs @@ -3,6 +3,7 @@ use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_gemini_stream; +use crate::trace::ModelExchangeTraceConfig; use crate::types::ReasoningMode; use crate::types::{Message, ToolDefinition}; use anyhow::Result; @@ -316,6 +317,7 @@ pub(crate) async fn send_stream( tools: Option>, extra_body: Option, max_tries: usize, + trace: Option, ) -> Result { let url = resolve_request_url(&client.config.request_url, &client.config.model); debug!( @@ -342,6 +344,7 @@ pub(crate) async fn send_stream( &request_body, max_tries, ttft_timeout, + trace, || apply_headers(client, client.client.post(&url)), move |response, tx, tx_raw| { tokio::spawn(handle_gemini_stream(response, tx, tx_raw, idle_timeout)); diff --git a/src/crates/ai-adapters/src/providers/mod.rs b/src/crates/adapters/ai-adapters/src/providers/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/mod.rs rename to src/crates/adapters/ai-adapters/src/providers/mod.rs diff --git a/src/crates/ai-adapters/src/providers/openai/chat.rs b/src/crates/adapters/ai-adapters/src/providers/openai/chat.rs similarity index 97% rename from src/crates/ai-adapters/src/providers/openai/chat.rs rename to src/crates/adapters/ai-adapters/src/providers/openai/chat.rs index 70af29281e..e45704bf45 100644 --- a/src/crates/ai-adapters/src/providers/openai/chat.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/chat.rs @@ -4,6 +4,7 @@ use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_openai_stream; +use crate::trace::ModelExchangeTraceConfig; use crate::types::{Message, ToolDefinition}; use anyhow::Result; use log::{debug, warn}; @@ -76,6 +77,7 @@ pub(crate) async fn send_stream( tools: Option>, extra_body: Option, max_tries: usize, + trace: Option, ) -> Result { let url = client.config.request_url.clone(); debug!( @@ -96,6 +98,7 @@ pub(crate) async fn send_stream( &request_body, max_tries, ttft_timeout, + trace, || common::apply_headers(client, client.client.post(&url)), move |response, tx, tx_raw| { tokio::spawn(handle_openai_stream( diff --git a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs b/src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs similarity index 98% rename from src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs rename to src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs index ffadeb15e3..decd573626 100644 --- a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs @@ -23,6 +23,7 @@ use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_responses_stream; +use crate::trace::ModelExchangeTraceConfig; use crate::types::{Message, ReasoningMode, ToolDefinition}; use anyhow::Result; use log::debug; @@ -145,6 +146,7 @@ pub(crate) async fn send_stream( tools: Option>, extra_body: Option, max_tries: usize, + trace: Option, ) -> Result { let url = client.config.request_url.clone(); debug!( @@ -166,6 +168,7 @@ pub(crate) async fn send_stream( &request_body, max_tries, ttft_timeout, + trace, || common::apply_headers(client, client.client.post(&url)), move |response, tx, tx_raw| { tokio::spawn(handle_responses_stream(response, tx, tx_raw, idle_timeout)); diff --git a/src/crates/ai-adapters/src/providers/openai/common.rs b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/openai/common.rs rename to src/crates/adapters/ai-adapters/src/providers/openai/common.rs diff --git a/src/crates/ai-adapters/src/providers/openai/message_converter.rs b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs similarity index 98% rename from src/crates/ai-adapters/src/providers/openai/message_converter.rs rename to src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs index 83d50fa91a..e75df20454 100644 --- a/src/crates/ai-adapters/src/providers/openai/message_converter.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs @@ -580,20 +580,20 @@ mod tests { #[test] fn trims_trailing_whitespace_from_final_assistant_prefill_for_chat_completions() { let openai = OpenAIMessageConverter::convert_messages(vec![ - Message::user("Generate the file content.".to_string()), - Message::assistant("\n".to_string()), + Message::user("Continue the assistant response.".to_string()), + Message::assistant("\n".to_string()), ]); - assert_eq!(openai[1]["content"], json!("")); + assert_eq!(openai[1]["content"], json!("")); } #[test] fn trims_trailing_whitespace_from_final_assistant_prefill_for_responses() { let (_, input) = OpenAIMessageConverter::convert_messages_to_responses_input(vec![ - Message::user("Generate the file content.".to_string()), - Message::assistant("\n".to_string()), + Message::user("Continue the assistant response.".to_string()), + Message::assistant("\n".to_string()), ]); - assert_eq!(input[1]["content"][0]["text"], json!("")); + assert_eq!(input[1]["content"][0]["text"], json!("")); } } diff --git a/src/crates/ai-adapters/src/providers/openai/mod.rs b/src/crates/adapters/ai-adapters/src/providers/openai/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/openai/mod.rs rename to src/crates/adapters/ai-adapters/src/providers/openai/mod.rs diff --git a/src/crates/ai-adapters/src/providers/openai/responses.rs b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs similarity index 96% rename from src/crates/ai-adapters/src/providers/openai/responses.rs rename to src/crates/adapters/ai-adapters/src/providers/openai/responses.rs index 6cc48d148a..359038b7b4 100644 --- a/src/crates/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs @@ -3,6 +3,7 @@ use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_responses_stream; +use crate::trace::ModelExchangeTraceConfig; use crate::types::ReasoningMode; use crate::types::{Message, ToolDefinition}; use anyhow::Result; @@ -92,6 +93,7 @@ pub(crate) async fn send_stream( tools: Option>, extra_body: Option, max_tries: usize, + trace: Option, ) -> Result { // Codex CLI's ChatGPT-login backend (`chatgpt.com/backend-api/codex`) // speaks a constrained Responses dialect with several extra @@ -99,8 +101,10 @@ pub(crate) async fn send_stream( // `store: false`, no `max_output_tokens`, etc.). Keep that adapter // self-contained so the standard Responses path stays untouched. if super::codex_chatgpt::is_codex_chatgpt_endpoint(&client.config.request_url) { - return super::codex_chatgpt::send_stream(client, messages, tools, extra_body, max_tries) - .await; + return super::codex_chatgpt::send_stream( + client, messages, tools, extra_body, max_tries, trace, + ) + .await; } let url = client.config.request_url.clone(); @@ -128,6 +132,7 @@ pub(crate) async fn send_stream( &request_body, max_tries, ttft_timeout, + trace, || common::apply_headers(client, client.client.post(&url)), move |response, tx, tx_raw| { tokio::spawn(handle_responses_stream(response, tx, tx_raw, idle_timeout)); diff --git a/src/crates/ai-adapters/src/providers/shared.rs b/src/crates/adapters/ai-adapters/src/providers/shared.rs similarity index 100% rename from src/crates/ai-adapters/src/providers/shared.rs rename to src/crates/adapters/ai-adapters/src/providers/shared.rs diff --git a/src/crates/ai-adapters/src/stream/mod.rs b/src/crates/adapters/ai-adapters/src/stream/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/mod.rs rename to src/crates/adapters/ai-adapters/src/stream/mod.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/anthropic.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/anthropic.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/gemini.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/gemini.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/gemini.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/gemini.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/inline_think.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/inline_think.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/inline_think.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/inline_think.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/mod.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/mod.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/mod.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/openai.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/openai.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/openai.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/openai.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/responses.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/responses.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/responses.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/responses.rs diff --git a/src/crates/ai-adapters/src/stream/stream_handler/stream_stats.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/stream_stats.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/stream_handler/stream_stats.rs rename to src/crates/adapters/ai-adapters/src/stream/stream_handler/stream_stats.rs diff --git a/src/crates/ai-adapters/src/stream/types/anthropic.rs b/src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs similarity index 98% rename from src/crates/ai-adapters/src/stream/types/anthropic.rs rename to src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs index d598b7bc28..b0c053995a 100644 --- a/src/crates/ai-adapters/src/stream/types/anthropic.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs @@ -51,9 +51,8 @@ impl From for UnifiedTokenUsage { // prompt_token_count = total context tokens occupied (industry-standard // "input tokens" metric). For Anthropic this is the three disjoint // components summed; for other providers the API reports this directly. - let prompt_token_count = value.input_tokens.unwrap_or(0) - + cache_read.unwrap_or(0) - + cache_creation.unwrap_or(0); + let prompt_token_count = + value.input_tokens.unwrap_or(0) + cache_read.unwrap_or(0) + cache_creation.unwrap_or(0); let candidates_token_count = value.output_tokens.unwrap_or(0); Self { diff --git a/src/crates/ai-adapters/src/stream/types/gemini.rs b/src/crates/adapters/ai-adapters/src/stream/types/gemini.rs similarity index 99% rename from src/crates/ai-adapters/src/stream/types/gemini.rs rename to src/crates/adapters/ai-adapters/src/stream/types/gemini.rs index 6bd6a15d73..2078edc941 100644 --- a/src/crates/ai-adapters/src/stream/types/gemini.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/gemini.rs @@ -784,7 +784,11 @@ mod tests { } }); let data: GeminiSSEData = serde_json::from_value(payload).expect("gemini payload"); - let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + let usage = data.into_unified_responses()[0] + .usage + .as_ref() + .expect("usage") + .clone(); assert_eq!(usage.cached_content_token_count, Some(35)); assert_eq!(usage.cache_creation_token_count, None); } diff --git a/src/crates/ai-adapters/src/stream/types/mod.rs b/src/crates/adapters/ai-adapters/src/stream/types/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/stream/types/mod.rs rename to src/crates/adapters/ai-adapters/src/stream/types/mod.rs diff --git a/src/crates/ai-adapters/src/stream/types/openai.rs b/src/crates/adapters/ai-adapters/src/stream/types/openai.rs similarity index 97% rename from src/crates/ai-adapters/src/stream/types/openai.rs rename to src/crates/adapters/ai-adapters/src/stream/types/openai.rs index a541e77998..8146969f99 100644 --- a/src/crates/ai-adapters/src/stream/types/openai.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/openai.rs @@ -543,7 +543,9 @@ mod tests { // finish_reason should also be preserved (lives at choice top level). assert!( - responses.iter().any(|r| r.finish_reason.as_deref() == Some("stop")), + responses + .iter() + .any(|r| r.finish_reason.as_deref() == Some("stop")), "finish_reason from MiniMax final chunk must be preserved" ); } @@ -818,7 +820,11 @@ mod tests { } }"#; let data: OpenAISSEData = serde_json::from_str(raw).expect("valid openai sse data"); - let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + let usage = data.into_unified_responses()[0] + .usage + .as_ref() + .expect("usage") + .clone(); assert_eq!(usage.cached_content_token_count, Some(40)); assert_eq!(usage.cache_creation_token_count, None); } @@ -840,7 +846,11 @@ mod tests { } }"#; let data: OpenAISSEData = serde_json::from_str(raw).expect("valid deepseek sse data"); - let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + let usage = data.into_unified_responses()[0] + .usage + .as_ref() + .expect("usage") + .clone(); assert_eq!(usage.cached_content_token_count, Some(64)); } @@ -861,7 +871,11 @@ mod tests { } }"#; let data: OpenAISSEData = serde_json::from_str(raw).expect("valid proxy payload"); - let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + let usage = data.into_unified_responses()[0] + .usage + .as_ref() + .expect("usage") + .clone(); assert_eq!(usage.cached_content_token_count, Some(64)); } @@ -879,7 +893,11 @@ mod tests { } }"#; let data: OpenAISSEData = serde_json::from_str(raw).expect("valid openai sse data"); - let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + let usage = data.into_unified_responses()[0] + .usage + .as_ref() + .expect("usage") + .clone(); assert_eq!(usage.cached_content_token_count, None); assert_eq!(usage.cache_creation_token_count, None); } diff --git a/src/crates/ai-adapters/src/stream/types/responses.rs b/src/crates/adapters/ai-adapters/src/stream/types/responses.rs similarity index 98% rename from src/crates/ai-adapters/src/stream/types/responses.rs rename to src/crates/adapters/ai-adapters/src/stream/types/responses.rs index 7a093e3e59..b5000c4654 100644 --- a/src/crates/ai-adapters/src/stream/types/responses.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/responses.rs @@ -129,7 +129,9 @@ pub fn parse_responses_output_item( #[cfg(test)] mod tests { - use super::{parse_responses_output_item, ResponsesCompleted, ResponsesStreamEvent, ResponsesUsage}; + use super::{ + parse_responses_output_item, ResponsesCompleted, ResponsesStreamEvent, ResponsesUsage, + }; use crate::stream::types::unified::UnifiedTokenUsage; use serde_json::json; diff --git a/src/crates/adapters/ai-adapters/src/stream/types/unified.rs b/src/crates/adapters/ai-adapters/src/stream/types/unified.rs new file mode 100644 index 0000000000..ba7964bb99 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/stream/types/unified.rs @@ -0,0 +1 @@ +pub use bitfun_agent_stream::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; diff --git a/src/crates/adapters/ai-adapters/src/tool_call_accumulator.rs b/src/crates/adapters/ai-adapters/src/tool_call_accumulator.rs new file mode 100644 index 0000000000..761db6bf49 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/tool_call_accumulator.rs @@ -0,0 +1 @@ +pub use bitfun_agent_stream::tool_call_accumulator::*; diff --git a/src/crates/adapters/ai-adapters/src/trace.rs b/src/crates/adapters/ai-adapters/src/trace.rs new file mode 100644 index 0000000000..0f858de725 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/trace.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelExchangeRequestAttempt { + pub request_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_body: Option, + pub attempt_number: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelExchangeRequestTraceHandle { + pub trace_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelExchangeResponseTrace { + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub partial_recovery_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[async_trait] +pub trait ModelExchangeTraceSink: Send + Sync { + async fn request_attempt_started( + &self, + attempt: &ModelExchangeRequestAttempt, + ) -> Option; + + async fn request_attempt_failed( + &self, + handle: Option<&ModelExchangeRequestTraceHandle>, + error: &str, + ); + + async fn request_attempt_completed( + &self, + handle: &ModelExchangeRequestTraceHandle, + response: &ModelExchangeResponseTrace, + ); +} + +#[derive(Clone)] +pub struct ModelExchangeTraceConfig { + pub sink: Arc, + pub capture_request_body: bool, +} diff --git a/src/crates/adapters/ai-adapters/src/types/ai.rs b/src/crates/adapters/ai-adapters/src/types/ai.rs new file mode 100644 index 0000000000..e10999f032 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/types/ai.rs @@ -0,0 +1,146 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub use bitfun_core_types::{ConnectionTestMessageCode, ConnectionTestResult, RemoteModelInfo}; + +/// Gemini API response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiResponse { + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// Gemini usage stats +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiUsage { + #[serde(rename = "promptTokenCount")] + pub prompt_token_count: u32, + #[serde(rename = "candidatesTokenCount")] + pub candidates_token_count: u32, + #[serde(rename = "totalTokenCount")] + pub total_token_count: u32, + #[serde(rename = "reasoningTokenCount")] + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_token_count: Option, + #[serde(rename = "cachedContentTokenCount")] + #[serde(skip_serializing_if = "Option::is_none")] + pub cached_content_token_count: Option, + #[serde(rename = "cacheCreationTokenCount")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation_token_count: Option, +} + +impl From for GeminiUsage { + fn from(usage: bitfun_agent_stream::UnifiedTokenUsage) -> Self { + Self { + prompt_token_count: usage.prompt_token_count, + candidates_token_count: usage.candidates_token_count, + total_token_count: usage.total_token_count, + reasoning_token_count: usage.reasoning_token_count, + cached_content_token_count: usage.cached_content_token_count, + cache_creation_token_count: usage.cache_creation_token_count, + } + } +} + +impl From for bitfun_agent_stream::UnifiedTokenUsage { + fn from(usage: GeminiUsage) -> Self { + Self { + prompt_token_count: usage.prompt_token_count, + candidates_token_count: usage.candidates_token_count, + total_token_count: usage.total_token_count, + reasoning_token_count: usage.reasoning_token_count, + cached_content_token_count: usage.cached_content_token_count, + cache_creation_token_count: usage.cache_creation_token_count, + } + } +} + +#[cfg(test)] +mod tests { + use super::GeminiUsage; + + #[test] + fn gemini_usage_roundtrips_cache_creation_field() { + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: Some(20), + }; + let json = serde_json::to_string(&usage).expect("serialize"); + assert!(json.contains("\"cacheCreationTokenCount\":20")); + + let parsed: GeminiUsage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.cache_creation_token_count, Some(20)); + } + + #[test] + fn gemini_usage_legacy_payload_parses_with_new_field_absent() { + // Records persisted before this plan don't have cacheCreationTokenCount; + // they must still parse, with the new field defaulting to None. + let raw = r#"{ + "promptTokenCount": 10, + "candidatesTokenCount": 5, + "totalTokenCount": 15, + "cachedContentTokenCount": 3 + }"#; + let parsed: GeminiUsage = serde_json::from_str(raw).expect("legacy payload"); + assert_eq!(parsed.cached_content_token_count, Some(3)); + assert_eq!(parsed.cache_creation_token_count, None); + } + + #[test] + fn gemini_usage_converts_to_and_from_unified_token_usage() { + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: Some(7), + cached_content_token_count: Some(30), + cache_creation_token_count: Some(20), + }; + + let unified: bitfun_agent_stream::UnifiedTokenUsage = usage.clone().into(); + assert_eq!(unified.prompt_token_count, usage.prompt_token_count); + assert_eq!(unified.candidates_token_count, usage.candidates_token_count); + assert_eq!(unified.total_token_count, usage.total_token_count); + assert_eq!(unified.reasoning_token_count, usage.reasoning_token_count); + assert_eq!( + unified.cached_content_token_count, + usage.cached_content_token_count + ); + assert_eq!( + unified.cache_creation_token_count, + usage.cache_creation_token_count + ); + + let roundtrip: GeminiUsage = unified.into(); + assert_eq!(roundtrip.prompt_token_count, usage.prompt_token_count); + assert_eq!( + roundtrip.candidates_token_count, + usage.candidates_token_count + ); + assert_eq!(roundtrip.total_token_count, usage.total_token_count); + assert_eq!(roundtrip.reasoning_token_count, usage.reasoning_token_count); + assert_eq!( + roundtrip.cached_content_token_count, + usage.cached_content_token_count + ); + assert_eq!( + roundtrip.cache_creation_token_count, + usage.cache_creation_token_count + ); + } +} diff --git a/src/crates/ai-adapters/src/types/config.rs b/src/crates/adapters/ai-adapters/src/types/config.rs similarity index 75% rename from src/crates/ai-adapters/src/types/config.rs rename to src/crates/adapters/ai-adapters/src/types/config.rs index 739dd7b912..eca5c1c15b 100644 --- a/src/crates/ai-adapters/src/types/config.rs +++ b/src/crates/adapters/ai-adapters/src/types/config.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +pub use bitfun_core_types::{AIConfig, ProxyConfig, ReasoningMode}; fn append_endpoint(base_url: &str, endpoint: &str) -> String { let base = base_url.trim(); @@ -63,49 +63,6 @@ pub fn resolve_request_url(base_url: &str, provider: &str, model_name: &str) -> } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum ReasoningMode { - #[default] - Default, - Enabled, - Disabled, - Adaptive, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ProxyConfig { - pub enabled: bool, - pub url: String, - pub username: Option, - pub password: Option, -} - -/// AI client configuration shared across runtime contexts. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AIConfig { - pub name: String, - pub base_url: String, - pub request_url: String, - pub api_key: String, - pub model: String, - pub format: String, - pub context_window: u32, - pub max_tokens: Option, - pub temperature: Option, - pub top_p: Option, - pub reasoning_mode: ReasoningMode, - pub inline_think_in_text: bool, - pub custom_headers: Option>, - pub custom_headers_mode: Option, - pub skip_ssl_verify: bool, - pub reasoning_effort: Option, - pub thinking_budget_tokens: Option, - pub custom_request_body: Option, - pub custom_request_body_mode: Option, -} - #[cfg(test)] mod tests { use super::resolve_request_url; diff --git a/src/crates/adapters/ai-adapters/src/types/message.rs b/src/crates/adapters/ai-adapters/src/types/message.rs new file mode 100644 index 0000000000..5198f6284c --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/types/message.rs @@ -0,0 +1 @@ +pub use bitfun_core_types::Message; diff --git a/src/crates/ai-adapters/src/types/mod.rs b/src/crates/adapters/ai-adapters/src/types/mod.rs similarity index 100% rename from src/crates/ai-adapters/src/types/mod.rs rename to src/crates/adapters/ai-adapters/src/types/mod.rs diff --git a/src/crates/core/src/util/types/tool.rs b/src/crates/adapters/ai-adapters/src/types/tool.rs similarity index 74% rename from src/crates/core/src/util/types/tool.rs rename to src/crates/adapters/ai-adapters/src/types/tool.rs index 674858c76b..70e68c9498 100644 --- a/src/crates/core/src/util/types/tool.rs +++ b/src/crates/adapters/ai-adapters/src/types/tool.rs @@ -1,4 +1,4 @@ -pub use bitfun_ai_adapters::types::{ +pub use bitfun_core_types::{ ToolCall, ToolCallConfirmationDetails, ToolCallRequestInfo, ToolCallResponseInfo, ToolDefinition, }; diff --git a/src/crates/ai-adapters/src/types/tool_image_attachment.rs b/src/crates/adapters/ai-adapters/src/types/tool_image_attachment.rs similarity index 100% rename from src/crates/ai-adapters/src/types/tool_image_attachment.rs rename to src/crates/adapters/ai-adapters/src/types/tool_image_attachment.rs diff --git a/src/crates/agent-stream/tests/common/fixture_loader.rs b/src/crates/adapters/ai-adapters/tests/common/fixture_loader.rs similarity index 100% rename from src/crates/agent-stream/tests/common/fixture_loader.rs rename to src/crates/adapters/ai-adapters/tests/common/fixture_loader.rs diff --git a/src/crates/agent-stream/tests/common/mod.rs b/src/crates/adapters/ai-adapters/tests/common/mod.rs similarity index 100% rename from src/crates/agent-stream/tests/common/mod.rs rename to src/crates/adapters/ai-adapters/tests/common/mod.rs diff --git a/src/crates/agent-stream/tests/common/sse_fixture_server.rs b/src/crates/adapters/ai-adapters/tests/common/sse_fixture_server.rs similarity index 100% rename from src/crates/agent-stream/tests/common/sse_fixture_server.rs rename to src/crates/adapters/ai-adapters/tests/common/sse_fixture_server.rs diff --git a/src/crates/agent-stream/tests/common/stream_test_harness.rs b/src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs similarity index 98% rename from src/crates/agent-stream/tests/common/stream_test_harness.rs rename to src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs index b8cee4933a..8aa01bd003 100644 --- a/src/crates/agent-stream/tests/common/stream_test_harness.rs +++ b/src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs @@ -181,6 +181,8 @@ pub async fn run_stream_fixture_with_options( "session_fixture".to_string(), "turn_fixture".to_string(), "round_fixture".to_string(), + "round_fixture:attempt:1".to_string(), + 1, &cancellation_token, ), ) diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/closed_after_message_delta.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/closed_after_message_delta.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/closed_after_message_delta.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/closed_after_message_delta.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/extended_thinking.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/extended_thinking.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/extended_thinking.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/extended_thinking.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/inline_think_text.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/inline_think_text.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/inline_think_text.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/inline_think_text.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/gemini/function_call_string_args.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/gemini/function_call_string_args.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/gemini/function_call_string_args.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/gemini/function_call_string_args.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/inline_think_text.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/inline_think_text.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/inline_think_text.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/inline_think_text.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_split_with_usage.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_split_with_usage.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_split_with_usage.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_split_with_usage.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_missing_type_field.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_call_missing_type_field.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_missing_type_field.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_call_missing_type_field.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse diff --git a/src/crates/agent-stream/tests/fixtures/stream/responses/malformed_function_call_arguments.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/responses/malformed_function_call_arguments.sse similarity index 100% rename from src/crates/agent-stream/tests/fixtures/stream/responses/malformed_function_call_arguments.sse rename to src/crates/adapters/ai-adapters/tests/fixtures/stream/responses/malformed_function_call_arguments.sse diff --git a/src/crates/agent-stream/tests/stream_processor_anthropic.rs b/src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs similarity index 100% rename from src/crates/agent-stream/tests/stream_processor_anthropic.rs rename to src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs diff --git a/src/crates/agent-stream/tests/stream_processor_openai.rs b/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs similarity index 99% rename from src/crates/agent-stream/tests/stream_processor_openai.rs rename to src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs index 21ec50ad86..2c0d84ec21 100644 --- a/src/crates/agent-stream/tests/stream_processor_openai.rs +++ b/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs @@ -52,7 +52,7 @@ async fn openai_fixture_keeps_collecting_tool_args_across_usage_chunks() { } => { assert_eq!(round_id, "round_fixture"); Some(params.as_str()) - }, + } _ => None, }) .collect(); @@ -127,7 +127,7 @@ async fn openai_fixture_keeps_malformed_tool_arguments_invalid() { } => { assert_eq!(round_id, "round_fixture"); Some(tool_id.as_str()) - }, + } _ => None, }) .collect(); @@ -236,7 +236,7 @@ async fn openai_fixture_reattaches_id_only_prelude_to_following_payload_chunk() } => { assert_eq!(round_id, "round_fixture"); Some(params.as_str()) - }, + } _ => None, }) .collect(); @@ -366,7 +366,7 @@ async fn openai_fixture_filters_orphan_id_only_block_when_it_shares_chunk_with_f } => { assert_eq!(round_id, "round_fixture"); Some(tool_id.as_str()) - }, + } _ => None, }) .collect(); @@ -386,7 +386,7 @@ async fn openai_fixture_filters_orphan_id_only_block_when_it_shares_chunk_with_f } => { assert_eq!(round_id, "round_fixture"); Some((tool_id.as_str(), params.as_str())) - }, + } _ => None, }) .collect(); @@ -439,7 +439,7 @@ async fn openai_fixture_routes_interleaved_tool_args_by_index() { } => { assert_eq!(round_id, "round_fixture"); Some(tool_id.as_str()) - }, + } _ => None, }) .collect(); @@ -459,7 +459,7 @@ async fn openai_fixture_routes_interleaved_tool_args_by_index() { } => { assert_eq!(round_id, "round_fixture"); Some((tool_id.as_str(), params.as_str())) - }, + } _ => None, }) .collect(); @@ -541,7 +541,7 @@ async fn openai_fixture_ignores_trailing_empty_tool_args_finish_chunk() { } => { assert_eq!(round_id, "round_fixture"); Some(tool_id.as_str()) - }, + } _ => None, }) .collect(); @@ -558,7 +558,7 @@ async fn openai_fixture_ignores_trailing_empty_tool_args_finish_chunk() { } => { assert_eq!(round_id, "round_fixture"); Some(params.as_str()) - }, + } _ => None, }) .collect(); diff --git a/src/crates/agent-stream/tests/stream_processor_tool_arguments.rs b/src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs similarity index 100% rename from src/crates/agent-stream/tests/stream_processor_tool_arguments.rs rename to src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs diff --git a/src/crates/agent-stream/tests/stream_replay_regressions.rs b/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs similarity index 99% rename from src/crates/agent-stream/tests/stream_replay_regressions.rs rename to src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs index f2be430681..e7206c1a54 100644 --- a/src/crates/agent-stream/tests/stream_replay_regressions.rs +++ b/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs @@ -102,7 +102,7 @@ async fn replays_structurally_empty_openai_reasoning_content_with_tool_call() { } => { assert_eq!(round_id, "round_fixture"); Some((tool_id.as_str(), params.as_str())) - }, + } _ => None, }) .collect(); diff --git a/src/crates/agent-stream/tests/stream_test_harness.rs b/src/crates/adapters/ai-adapters/tests/stream_test_harness.rs similarity index 100% rename from src/crates/agent-stream/tests/stream_test_harness.rs rename to src/crates/adapters/ai-adapters/tests/stream_test_harness.rs diff --git a/src/crates/adapters/api-layer/AGENTS.md b/src/crates/adapters/api-layer/AGENTS.md new file mode 100644 index 0000000000..5b80d2a011 --- /dev/null +++ b/src/crates/adapters/api-layer/AGENTS.md @@ -0,0 +1,28 @@ +# api-layer Agent Guide + +Scope: this guide applies to `src/crates/adapters/api-layer`. + +`bitfun-api-layer` owns platform-agnostic API DTOs and handler coordination. It +is the stable boundary between app entrypoints and lower transport/runtime +layers. + +## Guardrails + +- Do not depend on `bitfun-core`, app crates, Tauri, desktop-only adapters, + concrete services, AI providers, terminal, or tool-runtime implementations. +- Keep request/response DTOs stable and explicit. Avoid catch-all payloads that + hide product or platform coupling. +- Handlers may coordinate ports and transport surfaces; they must not own + product policy, tool execution, session lifecycle, filesystem/process IO, or + platform-specific behavior. +- Preserve command/API compatibility when renaming fields, routes, or response + shapes. + +## Verification + +```bash +cargo check -p bitfun-api-layer +node scripts/check-core-boundaries.mjs +``` + +For documentation-only changes, run `git diff --check`. diff --git a/src/crates/api-layer/Cargo.toml b/src/crates/adapters/api-layer/Cargo.toml similarity index 100% rename from src/crates/api-layer/Cargo.toml rename to src/crates/adapters/api-layer/Cargo.toml diff --git a/src/crates/api-layer/src/dto.rs b/src/crates/adapters/api-layer/src/dto.rs similarity index 100% rename from src/crates/api-layer/src/dto.rs rename to src/crates/adapters/api-layer/src/dto.rs diff --git a/src/crates/api-layer/src/handlers.rs b/src/crates/adapters/api-layer/src/handlers.rs similarity index 100% rename from src/crates/api-layer/src/handlers.rs rename to src/crates/adapters/api-layer/src/handlers.rs diff --git a/src/crates/api-layer/src/lib.rs b/src/crates/adapters/api-layer/src/lib.rs similarity index 100% rename from src/crates/api-layer/src/lib.rs rename to src/crates/adapters/api-layer/src/lib.rs diff --git a/src/crates/adapters/transport/AGENTS.md b/src/crates/adapters/transport/AGENTS.md new file mode 100644 index 0000000000..da0ea74465 --- /dev/null +++ b/src/crates/adapters/transport/AGENTS.md @@ -0,0 +1,27 @@ +# transport Agent Guide + +Scope: this guide applies to `src/crates/adapters/transport`. + +`bitfun-transport` owns cross-platform communication contracts and adapters. It +bridges product/API events to concrete delivery channels without owning product +logic. + +## Guardrails + +- Do not depend on `bitfun-core`, API handlers, app crates, product domains, + concrete services, AI providers, terminal, or tool-runtime implementations. +- Keep adapter features explicit. Tauri, CLI, and websocket adapters must remain + feature-gated and must not change the default build surface. +- Transport may serialize and deliver events; it must not decide product policy, + session lifecycle, tool exposure, permissions, or remote workspace behavior. +- Preserve event names, payload compatibility, ordering assumptions, and + backpressure/error semantics when refactoring adapters. + +## Verification + +```bash +cargo check -p bitfun-transport +node scripts/check-core-boundaries.mjs +``` + +For documentation-only changes, run `git diff --check`. diff --git a/src/crates/transport/Cargo.toml b/src/crates/adapters/transport/Cargo.toml similarity index 93% rename from src/crates/transport/Cargo.toml rename to src/crates/adapters/transport/Cargo.toml index 77c158e27e..b17384b53d 100644 --- a/src/crates/transport/Cargo.toml +++ b/src/crates/adapters/transport/Cargo.toml @@ -10,7 +10,7 @@ name = "bitfun_transport" [dependencies] # Events layer -bitfun-events = { path = "../events" } +bitfun-events = { path = "../../contracts/events" } # Inherited from workspace tokio = { workspace = true } diff --git a/src/crates/transport/src/adapters/cli.rs b/src/crates/adapters/transport/src/adapters/cli.rs similarity index 97% rename from src/crates/transport/src/adapters/cli.rs rename to src/crates/adapters/transport/src/adapters/cli.rs index b9e0b71e3b..31e6113b41 100644 --- a/src/crates/transport/src/adapters/cli.rs +++ b/src/crates/adapters/transport/src/adapters/cli.rs @@ -32,6 +32,7 @@ pub enum CliEvent { turn_id: String, success: Option, finish_reason: Option, + has_final_response: Option, }, /// Generic event (for LSP, file watch, etc.) Generic { @@ -97,12 +98,14 @@ impl TransportAdapter for CliTransportAdapter { turn_id, success, finish_reason, + has_final_response, .. } => CliEvent::DialogTurnCompleted { session_id, turn_id, success, finish_reason, + has_final_response, }, _ => return Ok(()), }; diff --git a/src/crates/transport/src/adapters/mod.rs b/src/crates/adapters/transport/src/adapters/mod.rs similarity index 100% rename from src/crates/transport/src/adapters/mod.rs rename to src/crates/adapters/transport/src/adapters/mod.rs diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/adapters/transport/src/adapters/tauri.rs similarity index 95% rename from src/crates/transport/src/adapters/tauri.rs rename to src/crates/adapters/transport/src/adapters/tauri.rs index b90006fc76..f6291cad83 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/adapters/transport/src/adapters/tauri.rs @@ -46,6 +46,8 @@ impl TransportAdapter for TauriTransportAdapter { session_name, agent_type, workspace_path, + remote_connection_id, + remote_ssh_host, } => { self.app_handle.emit( "agentic://session-created", @@ -54,6 +56,8 @@ impl TransportAdapter for TauriTransportAdapter { "sessionName": session_name, "agentType": agent_type, "workspacePath": workspace_path, + "remoteConnectionId": remote_connection_id, + "remoteSshHost": remote_ssh_host, }), )?; } @@ -137,6 +141,7 @@ impl TransportAdapter for TauriTransportAdapter { session_id, turn_id, round_id, + round_group_id, round_index, model_id, } => { @@ -146,6 +151,7 @@ impl TransportAdapter for TauriTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "roundGroupId": round_group_id, "roundIndex": round_index, "modelId": model_id, }), @@ -155,6 +161,8 @@ impl TransportAdapter for TauriTransportAdapter { session_id, turn_id, round_id, + attempt_id, + attempt_index, text, } => { self.app_handle.emit( @@ -163,6 +171,8 @@ impl TransportAdapter for TauriTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, "text": text, }), )?; @@ -171,6 +181,8 @@ impl TransportAdapter for TauriTransportAdapter { session_id, turn_id, round_id, + attempt_id, + attempt_index, content, is_end, } => { @@ -180,6 +192,8 @@ impl TransportAdapter for TauriTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, "text": content, "contentType": "thinking", "isThinkingEnd": is_end, @@ -190,6 +204,8 @@ impl TransportAdapter for TauriTransportAdapter { session_id, turn_id, round_id, + attempt_id, + attempt_index, tool_event, } => { self.app_handle.emit( @@ -198,6 +214,8 @@ impl TransportAdapter for TauriTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, "toolEvent": tool_event, }), )?; @@ -208,6 +226,7 @@ impl TransportAdapter for TauriTransportAdapter { partial_recovery_reason, success, finish_reason, + has_final_response, .. } => { self.app_handle.emit( @@ -218,6 +237,7 @@ impl TransportAdapter for TauriTransportAdapter { "partialRecoveryReason": partial_recovery_reason, "success": success, "finishReason": finish_reason, + "hasFinalResponse": has_final_response, }), )?; } @@ -360,29 +380,12 @@ impl TransportAdapter for TauriTransportAdapter { }), )?; } - AgenticEvent::GoalVerificationStarted { - session_id, - source_turn_id, - } => { - self.app_handle.emit( - "agentic://goal-verification-started", - json!({ - "sessionId": session_id, - "sourceTurnId": source_turn_id, - }), - )?; - } - AgenticEvent::GoalVerificationFinished { - session_id, - source_turn_id, - outcome, - } => { + AgenticEvent::ThreadGoalUpdated { session_id, goal } => { self.app_handle.emit( - "agentic://goal-verification-finished", + "agentic://thread-goal-updated", json!({ "sessionId": session_id, - "sourceTurnId": source_turn_id, - "outcome": outcome, + "goal": goal, }), )?; } diff --git a/src/crates/transport/src/adapters/websocket.rs b/src/crates/adapters/transport/src/adapters/websocket.rs similarity index 92% rename from src/crates/transport/src/adapters/websocket.rs rename to src/crates/adapters/transport/src/adapters/websocket.rs index 985395db9f..a6697ee927 100644 --- a/src/crates/transport/src/adapters/websocket.rs +++ b/src/crates/adapters/transport/src/adapters/websocket.rs @@ -113,6 +113,7 @@ impl TransportAdapter for WebSocketTransportAdapter { session_id, turn_id, round_id, + round_group_id, round_index, model_id, } => { @@ -121,6 +122,7 @@ impl TransportAdapter for WebSocketTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "roundGroupId": round_group_id, "roundIndex": round_index, "modelId": model_id, }) @@ -129,6 +131,8 @@ impl TransportAdapter for WebSocketTransportAdapter { session_id, turn_id, round_id, + attempt_id, + attempt_index, text, } => { json!({ @@ -136,6 +140,8 @@ impl TransportAdapter for WebSocketTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, "text": text, }) } @@ -143,6 +149,8 @@ impl TransportAdapter for WebSocketTransportAdapter { session_id, turn_id, round_id, + attempt_id, + attempt_index, content, is_end, } => { @@ -151,6 +159,8 @@ impl TransportAdapter for WebSocketTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, "text": content, "contentType": "thinking", "isThinkingEnd": is_end, @@ -160,6 +170,8 @@ impl TransportAdapter for WebSocketTransportAdapter { session_id, turn_id, round_id, + attempt_id, + attempt_index, tool_event, } => { json!({ @@ -167,6 +179,8 @@ impl TransportAdapter for WebSocketTransportAdapter { "sessionId": session_id, "turnId": turn_id, "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, "toolEvent": tool_event, }) } @@ -236,6 +250,7 @@ impl TransportAdapter for WebSocketTransportAdapter { partial_recovery_reason, success, finish_reason, + has_final_response, .. } => { json!({ @@ -245,6 +260,7 @@ impl TransportAdapter for WebSocketTransportAdapter { "partialRecoveryReason": partial_recovery_reason, "success": success, "finishReason": finish_reason, + "hasFinalResponse": has_final_response, }) } AgenticEvent::DeepReviewQueueStateChanged { @@ -272,6 +288,13 @@ impl TransportAdapter for WebSocketTransportAdapter { }, }) } + AgenticEvent::ThreadGoalUpdated { session_id, goal } => { + json!({ + "type": "thread-goal-updated", + "sessionId": session_id, + "goal": goal, + }) + } _ => return Ok(()), }; diff --git a/src/crates/transport/src/emitter.rs b/src/crates/adapters/transport/src/emitter.rs similarity index 100% rename from src/crates/transport/src/emitter.rs rename to src/crates/adapters/transport/src/emitter.rs diff --git a/src/crates/transport/src/event_bus.rs b/src/crates/adapters/transport/src/event_bus.rs similarity index 100% rename from src/crates/transport/src/event_bus.rs rename to src/crates/adapters/transport/src/event_bus.rs diff --git a/src/crates/transport/src/events.rs b/src/crates/adapters/transport/src/events.rs similarity index 100% rename from src/crates/transport/src/events.rs rename to src/crates/adapters/transport/src/events.rs diff --git a/src/crates/transport/src/lib.rs b/src/crates/adapters/transport/src/lib.rs similarity index 100% rename from src/crates/transport/src/lib.rs rename to src/crates/adapters/transport/src/lib.rs diff --git a/src/crates/transport/src/traits.rs b/src/crates/adapters/transport/src/traits.rs similarity index 100% rename from src/crates/transport/src/traits.rs rename to src/crates/adapters/transport/src/traits.rs diff --git a/src/crates/adapters/webdriver/AGENTS.md b/src/crates/adapters/webdriver/AGENTS.md new file mode 100644 index 0000000000..c327936a28 --- /dev/null +++ b/src/crates/adapters/webdriver/AGENTS.md @@ -0,0 +1,24 @@ +# webdriver Agent Guide + +Scope: this guide applies to `src/crates/adapters/webdriver`. + +`bitfun-webdriver` owns the embedded desktop WebDriver bridge. It is a +platform-integration crate, not a product runtime or tool-policy owner. + +## Guardrails + +- Keep startup gated by the existing debug, feature, and environment checks. +- Platform capture, evaluation, and native WebView access may live here; product + policy, session lifecycle, tool exposure, and agent decisions must not. +- Preserve WebDriver protocol response shapes, session/window/element semantics, + and platform-specific capture/evaluation behavior. +- Do not expose this crate as a shared runtime contract; route product-facing + behavior through desktop/API/transport boundaries. + +## Verification + +```bash +cargo check -p bitfun-webdriver +``` + +For documentation-only changes, run `git diff --check`. diff --git a/src/crates/webdriver/Cargo.toml b/src/crates/adapters/webdriver/Cargo.toml similarity index 100% rename from src/crates/webdriver/Cargo.toml rename to src/crates/adapters/webdriver/Cargo.toml diff --git a/src/crates/webdriver/src/executor/element/actions.rs b/src/crates/adapters/webdriver/src/executor/element/actions.rs similarity index 100% rename from src/crates/webdriver/src/executor/element/actions.rs rename to src/crates/adapters/webdriver/src/executor/element/actions.rs diff --git a/src/crates/webdriver/src/executor/element/lookup.rs b/src/crates/adapters/webdriver/src/executor/element/lookup.rs similarity index 100% rename from src/crates/webdriver/src/executor/element/lookup.rs rename to src/crates/adapters/webdriver/src/executor/element/lookup.rs diff --git a/src/crates/webdriver/src/executor/element/mod.rs b/src/crates/adapters/webdriver/src/executor/element/mod.rs similarity index 100% rename from src/crates/webdriver/src/executor/element/mod.rs rename to src/crates/adapters/webdriver/src/executor/element/mod.rs diff --git a/src/crates/webdriver/src/executor/element/read.rs b/src/crates/adapters/webdriver/src/executor/element/read.rs similarity index 100% rename from src/crates/webdriver/src/executor/element/read.rs rename to src/crates/adapters/webdriver/src/executor/element/read.rs diff --git a/src/crates/webdriver/src/executor/element/shadow.rs b/src/crates/adapters/webdriver/src/executor/element/shadow.rs similarity index 100% rename from src/crates/webdriver/src/executor/element/shadow.rs rename to src/crates/adapters/webdriver/src/executor/element/shadow.rs diff --git a/src/crates/webdriver/src/executor/interaction.rs b/src/crates/adapters/webdriver/src/executor/interaction.rs similarity index 100% rename from src/crates/webdriver/src/executor/interaction.rs rename to src/crates/adapters/webdriver/src/executor/interaction.rs diff --git a/src/crates/webdriver/src/executor/mod.rs b/src/crates/adapters/webdriver/src/executor/mod.rs similarity index 100% rename from src/crates/webdriver/src/executor/mod.rs rename to src/crates/adapters/webdriver/src/executor/mod.rs diff --git a/src/crates/webdriver/src/executor/navigation.rs b/src/crates/adapters/webdriver/src/executor/navigation.rs similarity index 100% rename from src/crates/webdriver/src/executor/navigation.rs rename to src/crates/adapters/webdriver/src/executor/navigation.rs diff --git a/src/crates/webdriver/src/executor/session.rs b/src/crates/adapters/webdriver/src/executor/session.rs similarity index 100% rename from src/crates/webdriver/src/executor/session.rs rename to src/crates/adapters/webdriver/src/executor/session.rs diff --git a/src/crates/webdriver/src/executor/window.rs b/src/crates/adapters/webdriver/src/executor/window.rs similarity index 100% rename from src/crates/webdriver/src/executor/window.rs rename to src/crates/adapters/webdriver/src/executor/window.rs diff --git a/src/crates/webdriver/src/lib.rs b/src/crates/adapters/webdriver/src/lib.rs similarity index 98% rename from src/crates/webdriver/src/lib.rs rename to src/crates/adapters/webdriver/src/lib.rs index d86d72411f..dbfdc8664a 100644 --- a/src/crates/webdriver/src/lib.rs +++ b/src/crates/adapters/webdriver/src/lib.rs @@ -1,5 +1,5 @@ mod executor; -mod platform; +pub mod platform; mod runtime; pub mod server; pub mod webdriver; diff --git a/src/crates/webdriver/src/platform/capture.rs b/src/crates/adapters/webdriver/src/platform/capture.rs similarity index 100% rename from src/crates/webdriver/src/platform/capture.rs rename to src/crates/adapters/webdriver/src/platform/capture.rs diff --git a/src/crates/webdriver/src/platform/evaluator/macos.rs b/src/crates/adapters/webdriver/src/platform/evaluator/macos.rs similarity index 100% rename from src/crates/webdriver/src/platform/evaluator/macos.rs rename to src/crates/adapters/webdriver/src/platform/evaluator/macos.rs diff --git a/src/crates/webdriver/src/platform/evaluator/mod.rs b/src/crates/adapters/webdriver/src/platform/evaluator/mod.rs similarity index 100% rename from src/crates/webdriver/src/platform/evaluator/mod.rs rename to src/crates/adapters/webdriver/src/platform/evaluator/mod.rs diff --git a/src/crates/webdriver/src/platform/evaluator/windows.rs b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs similarity index 100% rename from src/crates/webdriver/src/platform/evaluator/windows.rs rename to src/crates/adapters/webdriver/src/platform/evaluator/windows.rs diff --git a/src/crates/webdriver/src/platform/image.rs b/src/crates/adapters/webdriver/src/platform/image.rs similarity index 100% rename from src/crates/webdriver/src/platform/image.rs rename to src/crates/adapters/webdriver/src/platform/image.rs diff --git a/src/crates/webdriver/src/platform/mod.rs b/src/crates/adapters/webdriver/src/platform/mod.rs similarity index 100% rename from src/crates/webdriver/src/platform/mod.rs rename to src/crates/adapters/webdriver/src/platform/mod.rs diff --git a/src/crates/webdriver/src/platform/types.rs b/src/crates/adapters/webdriver/src/platform/types.rs similarity index 100% rename from src/crates/webdriver/src/platform/types.rs rename to src/crates/adapters/webdriver/src/platform/types.rs diff --git a/src/crates/webdriver/src/runtime/api/element.rs b/src/crates/adapters/webdriver/src/runtime/api/element.rs similarity index 100% rename from src/crates/webdriver/src/runtime/api/element.rs rename to src/crates/adapters/webdriver/src/runtime/api/element.rs diff --git a/src/crates/webdriver/src/runtime/api/interaction.rs b/src/crates/adapters/webdriver/src/runtime/api/interaction.rs similarity index 100% rename from src/crates/webdriver/src/runtime/api/interaction.rs rename to src/crates/adapters/webdriver/src/runtime/api/interaction.rs diff --git a/src/crates/webdriver/src/runtime/api/mod.rs b/src/crates/adapters/webdriver/src/runtime/api/mod.rs similarity index 100% rename from src/crates/webdriver/src/runtime/api/mod.rs rename to src/crates/adapters/webdriver/src/runtime/api/mod.rs diff --git a/src/crates/webdriver/src/runtime/api/navigation.rs b/src/crates/adapters/webdriver/src/runtime/api/navigation.rs similarity index 100% rename from src/crates/webdriver/src/runtime/api/navigation.rs rename to src/crates/adapters/webdriver/src/runtime/api/navigation.rs diff --git a/src/crates/webdriver/src/runtime/mod.rs b/src/crates/adapters/webdriver/src/runtime/mod.rs similarity index 100% rename from src/crates/webdriver/src/runtime/mod.rs rename to src/crates/adapters/webdriver/src/runtime/mod.rs diff --git a/src/crates/webdriver/src/runtime/script.rs b/src/crates/adapters/webdriver/src/runtime/script.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script.rs rename to src/crates/adapters/webdriver/src/runtime/script.rs diff --git a/src/crates/webdriver/src/runtime/script/core/alert.rs b/src/crates/adapters/webdriver/src/runtime/script/core/alert.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/alert.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/alert.rs diff --git a/src/crates/webdriver/src/runtime/script/core/context.rs b/src/crates/adapters/webdriver/src/runtime/script/core/context.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/context.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/context.rs diff --git a/src/crates/webdriver/src/runtime/script/core/cookie.rs b/src/crates/adapters/webdriver/src/runtime/script/core/cookie.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/cookie.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/cookie.rs diff --git a/src/crates/webdriver/src/runtime/script/core/execution.rs b/src/crates/adapters/webdriver/src/runtime/script/core/execution.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/execution.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/execution.rs diff --git a/src/crates/webdriver/src/runtime/script/core/locator.rs b/src/crates/adapters/webdriver/src/runtime/script/core/locator.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/locator.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/locator.rs diff --git a/src/crates/webdriver/src/runtime/script/core/mod.rs b/src/crates/adapters/webdriver/src/runtime/script/core/mod.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/mod.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/mod.rs diff --git a/src/crates/webdriver/src/runtime/script/core/runtime.rs b/src/crates/adapters/webdriver/src/runtime/script/core/runtime.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/runtime.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/runtime.rs diff --git a/src/crates/webdriver/src/runtime/script/core/shadow.rs b/src/crates/adapters/webdriver/src/runtime/script/core/shadow.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/shadow.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/shadow.rs diff --git a/src/crates/webdriver/src/runtime/script/core/store.rs b/src/crates/adapters/webdriver/src/runtime/script/core/store.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/store.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/store.rs diff --git a/src/crates/webdriver/src/runtime/script/core/visibility.rs b/src/crates/adapters/webdriver/src/runtime/script/core/visibility.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/core/visibility.rs rename to src/crates/adapters/webdriver/src/runtime/script/core/visibility.rs diff --git a/src/crates/webdriver/src/runtime/script/input.rs b/src/crates/adapters/webdriver/src/runtime/script/input.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/input.rs rename to src/crates/adapters/webdriver/src/runtime/script/input.rs diff --git a/src/crates/webdriver/src/runtime/script/keyboard/edit.rs b/src/crates/adapters/webdriver/src/runtime/script/keyboard/edit.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/keyboard/edit.rs rename to src/crates/adapters/webdriver/src/runtime/script/keyboard/edit.rs diff --git a/src/crates/webdriver/src/runtime/script/keyboard/event.rs b/src/crates/adapters/webdriver/src/runtime/script/keyboard/event.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/keyboard/event.rs rename to src/crates/adapters/webdriver/src/runtime/script/keyboard/event.rs diff --git a/src/crates/webdriver/src/runtime/script/keyboard/focus.rs b/src/crates/adapters/webdriver/src/runtime/script/keyboard/focus.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/keyboard/focus.rs rename to src/crates/adapters/webdriver/src/runtime/script/keyboard/focus.rs diff --git a/src/crates/webdriver/src/runtime/script/keyboard/mapping.rs b/src/crates/adapters/webdriver/src/runtime/script/keyboard/mapping.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/keyboard/mapping.rs rename to src/crates/adapters/webdriver/src/runtime/script/keyboard/mapping.rs diff --git a/src/crates/webdriver/src/runtime/script/keyboard/mod.rs b/src/crates/adapters/webdriver/src/runtime/script/keyboard/mod.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/keyboard/mod.rs rename to src/crates/adapters/webdriver/src/runtime/script/keyboard/mod.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/key_source.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/key_source.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/key_source.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/key_source.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/mod.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/mod.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/mod.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/mod.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/mouse.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/mouse.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/mouse.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/mouse.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/perform.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/perform.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/perform.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/perform.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/pointer_source.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/pointer_source.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/pointer_source.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/pointer_source.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/release.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/release.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/release.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/release.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/wheel.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/wheel.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/wheel.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/wheel.rs diff --git a/src/crates/webdriver/src/runtime/script/pointer/wheel_source.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/wheel_source.rs similarity index 100% rename from src/crates/webdriver/src/runtime/script/pointer/wheel_source.rs rename to src/crates/adapters/webdriver/src/runtime/script/pointer/wheel_source.rs diff --git a/src/crates/webdriver/src/server/handlers/actions.rs b/src/crates/adapters/webdriver/src/server/handlers/actions.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/actions.rs rename to src/crates/adapters/webdriver/src/server/handlers/actions.rs diff --git a/src/crates/webdriver/src/server/handlers/alert.rs b/src/crates/adapters/webdriver/src/server/handlers/alert.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/alert.rs rename to src/crates/adapters/webdriver/src/server/handlers/alert.rs diff --git a/src/crates/webdriver/src/server/handlers/cookie.rs b/src/crates/adapters/webdriver/src/server/handlers/cookie.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/cookie.rs rename to src/crates/adapters/webdriver/src/server/handlers/cookie.rs diff --git a/src/crates/webdriver/src/server/handlers/element.rs b/src/crates/adapters/webdriver/src/server/handlers/element.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/element.rs rename to src/crates/adapters/webdriver/src/server/handlers/element.rs diff --git a/src/crates/webdriver/src/server/handlers/frame.rs b/src/crates/adapters/webdriver/src/server/handlers/frame.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/frame.rs rename to src/crates/adapters/webdriver/src/server/handlers/frame.rs diff --git a/src/crates/webdriver/src/server/handlers/logs.rs b/src/crates/adapters/webdriver/src/server/handlers/logs.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/logs.rs rename to src/crates/adapters/webdriver/src/server/handlers/logs.rs diff --git a/src/crates/webdriver/src/server/handlers/mod.rs b/src/crates/adapters/webdriver/src/server/handlers/mod.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/mod.rs rename to src/crates/adapters/webdriver/src/server/handlers/mod.rs diff --git a/src/crates/webdriver/src/server/handlers/navigation.rs b/src/crates/adapters/webdriver/src/server/handlers/navigation.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/navigation.rs rename to src/crates/adapters/webdriver/src/server/handlers/navigation.rs diff --git a/src/crates/webdriver/src/server/handlers/print.rs b/src/crates/adapters/webdriver/src/server/handlers/print.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/print.rs rename to src/crates/adapters/webdriver/src/server/handlers/print.rs diff --git a/src/crates/webdriver/src/server/handlers/screenshot.rs b/src/crates/adapters/webdriver/src/server/handlers/screenshot.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/screenshot.rs rename to src/crates/adapters/webdriver/src/server/handlers/screenshot.rs diff --git a/src/crates/webdriver/src/server/handlers/script.rs b/src/crates/adapters/webdriver/src/server/handlers/script.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/script.rs rename to src/crates/adapters/webdriver/src/server/handlers/script.rs diff --git a/src/crates/webdriver/src/server/handlers/session.rs b/src/crates/adapters/webdriver/src/server/handlers/session.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/session.rs rename to src/crates/adapters/webdriver/src/server/handlers/session.rs diff --git a/src/crates/webdriver/src/server/handlers/shadow.rs b/src/crates/adapters/webdriver/src/server/handlers/shadow.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/shadow.rs rename to src/crates/adapters/webdriver/src/server/handlers/shadow.rs diff --git a/src/crates/webdriver/src/server/handlers/timeouts.rs b/src/crates/adapters/webdriver/src/server/handlers/timeouts.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/timeouts.rs rename to src/crates/adapters/webdriver/src/server/handlers/timeouts.rs diff --git a/src/crates/webdriver/src/server/handlers/window.rs b/src/crates/adapters/webdriver/src/server/handlers/window.rs similarity index 100% rename from src/crates/webdriver/src/server/handlers/window.rs rename to src/crates/adapters/webdriver/src/server/handlers/window.rs diff --git a/src/crates/webdriver/src/server/mod.rs b/src/crates/adapters/webdriver/src/server/mod.rs similarity index 100% rename from src/crates/webdriver/src/server/mod.rs rename to src/crates/adapters/webdriver/src/server/mod.rs diff --git a/src/crates/webdriver/src/server/response.rs b/src/crates/adapters/webdriver/src/server/response.rs similarity index 100% rename from src/crates/webdriver/src/server/response.rs rename to src/crates/adapters/webdriver/src/server/response.rs diff --git a/src/crates/webdriver/src/server/router.rs b/src/crates/adapters/webdriver/src/server/router.rs similarity index 100% rename from src/crates/webdriver/src/server/router.rs rename to src/crates/adapters/webdriver/src/server/router.rs diff --git a/src/crates/webdriver/src/webdriver/element.rs b/src/crates/adapters/webdriver/src/webdriver/element.rs similarity index 100% rename from src/crates/webdriver/src/webdriver/element.rs rename to src/crates/adapters/webdriver/src/webdriver/element.rs diff --git a/src/crates/webdriver/src/webdriver/locator.rs b/src/crates/adapters/webdriver/src/webdriver/locator.rs similarity index 100% rename from src/crates/webdriver/src/webdriver/locator.rs rename to src/crates/adapters/webdriver/src/webdriver/locator.rs diff --git a/src/crates/webdriver/src/webdriver/mod.rs b/src/crates/adapters/webdriver/src/webdriver/mod.rs similarity index 100% rename from src/crates/webdriver/src/webdriver/mod.rs rename to src/crates/adapters/webdriver/src/webdriver/mod.rs diff --git a/src/crates/webdriver/src/webdriver/session.rs b/src/crates/adapters/webdriver/src/webdriver/session.rs similarity index 100% rename from src/crates/webdriver/src/webdriver/session.rs rename to src/crates/adapters/webdriver/src/webdriver/session.rs diff --git a/src/crates/agent-runtime/AGENTS.md b/src/crates/agent-runtime/AGENTS.md deleted file mode 100644 index 8b1e5fd5a1..0000000000 --- a/src/crates/agent-runtime/AGENTS.md +++ /dev/null @@ -1,24 +0,0 @@ -# agent-runtime Agent Guide - -Scope: this guide applies to `src/crates/agent-runtime`. - -`bitfun-agent-runtime` owns portable agent runtime decisions that can be built -and tested without `bitfun-core`. - -## Guardrails - -- Do not depend on `bitfun-core`, app crates, Tauri, ACP protocol, web UI, - concrete service crates, or product-domain implementations. -- Keep concrete scheduler/session lifecycle execution in `bitfun-core` until a - reviewed owner migration proves behavior equivalence. -- Prefer pure facts and decisions first: queue policy, background delivery, - cancellation routing, runtime event facts, and registry visibility. -- Add focused tests before moving any runtime decision into this crate. - -## Verification - -```bash -cargo test -p bitfun-agent-runtime -node scripts/check-core-boundaries.mjs -cargo check -p bitfun-core --features product-full -``` diff --git a/src/crates/agent-runtime/Cargo.toml b/src/crates/agent-runtime/Cargo.toml deleted file mode 100644 index 6d50a0078b..0000000000 --- a/src/crates/agent-runtime/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "bitfun-agent-runtime" -version.workspace = true -authors.workspace = true -edition.workspace = true -description = "Agent runtime contracts and owner decisions for BitFun" - -[lib] -name = "bitfun_agent_runtime" -crate-type = ["rlib"] - -[dependencies] -bitfun-runtime-ports = { path = "../runtime-ports" } diff --git a/src/crates/agent-runtime/src/lib.rs b/src/crates/agent-runtime/src/lib.rs deleted file mode 100644 index f442694556..0000000000 --- a/src/crates/agent-runtime/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Agent runtime owner contracts. -//! -//! This crate owns runtime decisions that can be built and tested without -//! depending on `bitfun-core` concrete session or scheduler lifecycle. - -pub mod scheduler; diff --git a/src/crates/agent-runtime/src/scheduler.rs b/src/crates/agent-runtime/src/scheduler.rs deleted file mode 100644 index ff7d552357..0000000000 --- a/src/crates/agent-runtime/src/scheduler.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Scheduler owner decisions. - -use bitfun_runtime_ports::{ - DialogQueuePriority, DialogSessionStateFact, DialogSubmissionPolicy, DialogTriggerSource, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BackgroundDeliveryFacts { - pub session_state: DialogSessionStateFact, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BackgroundDeliveryAction { - InjectIntoRunningTurn, - SubmitAgentSessionFollowUp { - queue_priority: DialogQueuePriority, - skip_tool_confirmation: bool, - }, -} - -impl BackgroundDeliveryAction { - pub const fn follow_up_submission_policy(self) -> Option { - match self { - Self::InjectIntoRunningTurn => None, - Self::SubmitAgentSessionFollowUp { - queue_priority, - skip_tool_confirmation, - } => Some(DialogSubmissionPolicy::new( - DialogTriggerSource::AgentSession, - queue_priority, - skip_tool_confirmation, - )), - } - } -} - -pub const fn resolve_background_delivery_action( - facts: BackgroundDeliveryFacts, -) -> BackgroundDeliveryAction { - match facts.session_state { - DialogSessionStateFact::Processing => BackgroundDeliveryAction::InjectIntoRunningTurn, - DialogSessionStateFact::Missing - | DialogSessionStateFact::Idle - | DialogSessionStateFact::Error => { - let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); - BackgroundDeliveryAction::SubmitAgentSessionFollowUp { - queue_priority: policy.queue_priority, - skip_tool_confirmation: policy.skip_tool_confirmation, - } - } - } -} diff --git a/src/crates/agent-runtime/tests/scheduler_contracts.rs b/src/crates/agent-runtime/tests/scheduler_contracts.rs deleted file mode 100644 index 6aa7f3702a..0000000000 --- a/src/crates/agent-runtime/tests/scheduler_contracts.rs +++ /dev/null @@ -1,56 +0,0 @@ -use bitfun_agent_runtime::scheduler::{ - resolve_background_delivery_action, BackgroundDeliveryAction, BackgroundDeliveryFacts, -}; -use bitfun_runtime_ports::{DialogQueuePriority, DialogSessionStateFact, DialogTriggerSource}; - -#[test] -fn background_delivery_injects_when_session_is_processing() { - let action = resolve_background_delivery_action(BackgroundDeliveryFacts { - session_state: DialogSessionStateFact::Processing, - }); - - assert_eq!(action, BackgroundDeliveryAction::InjectIntoRunningTurn); -} - -#[test] -fn background_delivery_starts_agent_session_follow_up_when_session_is_not_processing() { - for session_state in [ - DialogSessionStateFact::Missing, - DialogSessionStateFact::Idle, - DialogSessionStateFact::Error, - ] { - let action = resolve_background_delivery_action(BackgroundDeliveryFacts { session_state }); - - assert_eq!( - action, - BackgroundDeliveryAction::SubmitAgentSessionFollowUp { - queue_priority: DialogQueuePriority::Low, - skip_tool_confirmation: true, - } - ); - } -} - -#[test] -fn background_delivery_follow_up_uses_agent_session_source_semantics() { - let action = resolve_background_delivery_action(BackgroundDeliveryFacts { - session_state: DialogSessionStateFact::Missing, - }); - - let policy = action - .follow_up_submission_policy() - .expect("follow-up action should expose submission policy"); - - assert_eq!(policy.trigger_source, DialogTriggerSource::AgentSession); - assert_eq!(policy.queue_priority, DialogQueuePriority::Low); - assert!(policy.skip_tool_confirmation); -} - -#[test] -fn background_delivery_injection_does_not_expose_follow_up_policy() { - let action = resolve_background_delivery_action(BackgroundDeliveryFacts { - session_state: DialogSessionStateFact::Processing, - }); - - assert_eq!(action.follow_up_submission_policy(), None); -} diff --git a/src/crates/agent-tools/AGENTS.md b/src/crates/agent-tools/AGENTS.md deleted file mode 100644 index b2c7db270a..0000000000 --- a/src/crates/agent-tools/AGENTS.md +++ /dev/null @@ -1,40 +0,0 @@ -# agent-tools Agent Guide - -Scope: this guide applies to `src/crates/agent-tools`. - -`bitfun-agent-tools` owns portable tool contracts. It must stay independent of -the product tool runtime. - -## Guardrails - -- Do not depend on `bitfun-core`, concrete service crates, `tool-packs`, app - crates, Tauri, Git, MCP, network clients, or CLI UI dependencies. -- This crate may own `ToolResult`, validation DTOs, runtime restriction DTOs, - path-resolution DTOs, host path normalization, runtime artifact URI, - remote POSIX path pure contracts, provider-neutral path resolution / - absolute-path checks, runtime artifact reference assembly, file guidance - markers, file-read freshness comparison policy, oversized tool-result - preview/rendering policy, tool execution result/error/invalid-call presentation policy, allowed-list / - collapsed-tool execution gate policy, generic/static/dynamic provider contracts, pure - manifest/exposure helpers, generic contextual prompt-manifest resolver - contracts, generic catalog snapshot provider contracts, generic GetToolSpec - catalog provider/detail/summary helpers, provider-backed GetToolSpec runtime - facades, and `ToolContextFacts` / `PortableToolContextProvider`. -- This crate may own generic provider containers such as - `StaticToolProviderGroup`, but concrete tool construction and product runtime - registration stay outside this crate until H1 explicitly moves an owner. -- Do not move `ToolUseContext`, concrete tools, workspace services, cancellation - tokens, session file-read state storage, tool-result filesystem writes, - snapshot decoration, collapsed unlock state, product registry - snapshot access, or concrete `GetToolSpecTool` execution here without H1 - approval and equivalence tests. -- Provider-specific wire serialization belongs in AI adapters, not in these - provider-neutral contracts. - -## Verification - -```bash -cargo test -p bitfun-agent-tools -cargo test -p bitfun-agent-tools --test tool_contracts -node scripts/check-core-boundaries.mjs -``` diff --git a/src/crates/agent-tools/src/lib.rs b/src/crates/agent-tools/src/lib.rs deleted file mode 100644 index 3e88b9eb5b..0000000000 --- a/src/crates/agent-tools/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Agent tool contracts. -//! -//! Pure tool DTOs and helpers live here before the concrete tool framework and -//! tool packs are moved out of the core facade. - -pub mod file_guidance; -pub mod file_read_freshness; -pub mod framework; -pub mod input_validator; -pub mod tool_execution_presentation; -pub mod tool_result_storage; - -pub use bitfun_core_types::ToolImageAttachment; -pub use bitfun_runtime_ports::{ - DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, -}; -pub use file_guidance::{ - file_tool_guidance_message, is_file_tool_guidance_message, FILE_TOOL_GUIDANCE_PREFIX, -}; -pub use file_read_freshness::{ - file_read_facts_are_fresh, file_read_facts_content_matches, normalize_tool_file_content, - FileReadFreshnessFacts, -}; -pub use framework::{ - build_bitfun_runtime_uri, build_collapsed_tool_stub_definition, - build_get_tool_spec_assistant_detail, build_get_tool_spec_catalog_description, - build_get_tool_spec_catalog_description_from_provider, - build_get_tool_spec_collapsed_tool_entry, build_get_tool_spec_description, - build_get_tool_spec_detail_result, build_get_tool_spec_duplicate_load_hint, - build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, - build_tool_manifest_policy_tools, build_tool_path_policy_denial_message, - build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, - collect_loaded_collapsed_tool_names, get_tool_spec_input_schema, - get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, - get_tool_spec_short_description, is_bitfun_runtime_uri, is_remote_posix_path_within_root, - is_tool_path_allowed_by_resolved_roots, normalize_absolute_posix_path, normalize_host_path, - normalize_runtime_relative_path, parse_bitfun_runtime_uri, posix_resolve_path_with_workspace, - posix_style_path_is_absolute, render_get_tool_spec_tool_use_message, - resolve_contextual_tool_manifest, resolve_contextual_tool_manifest_from_provider, - resolve_contextual_visible_tools, resolve_contextual_visible_tools_from_provider, - resolve_get_tool_spec_detail, resolve_get_tool_spec_detail_from_provider, - resolve_get_tool_spec_execution_plan, resolve_get_tool_spec_execution_result_from_provider, - resolve_host_path, resolve_host_path_with_workspace, resolve_readonly_enabled_tools, - resolve_tool_manifest_policy, resolve_tool_path_with_context, resolve_workspace_tool_path, - sort_tool_manifest_definitions, summarize_get_tool_spec_collapsed_tools, - tool_manifest_sort_rank, tool_path_is_effectively_absolute, validate_collapsed_tool_usage, - validate_get_tool_spec_input, validate_tool_allowed_by_list, CollapsedToolUsageError, - ContextualToolManifest, ContextualToolManifestItem, ContextualVisibleTools, DynamicMcpToolInfo, - DynamicToolInfo, GetToolSpecCatalogProvider, GetToolSpecCollapsedToolSummary, - GetToolSpecDetail, GetToolSpecExecutionError, GetToolSpecExecutionPlan, - GetToolSpecLoadObservation, GetToolSpecRuntime, ParsedBitFunRuntimeUri, - PortableToolContextProvider, PromptVisibleToolManifestItem, SnapshotToolDecorator, - SnapshotToolWrapper, SnapshotToolWrapperRef, StaticToolProvider, StaticToolProviderGroup, - ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, - ToolExecutionAccessError, ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution, - ToolManifestPolicyTool, ToolPathBackend, ToolPathContractError, ToolPathOperation, - ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, - ToolRestrictionError, ToolResult, ToolRuntimeAssembly, ToolRuntimeRestrictions, - ToolWorkspaceKind, ValidationResult, BITFUN_RUNTIME_URI_PREFIX, GET_TOOL_SPEC_TOOL_NAME, -}; -pub use input_validator::InputValidator; -pub use tool_execution_presentation::{ - build_invalid_tool_call_error_message, build_tool_execution_error_presentation, - build_user_steering_interrupted_presentation, render_tool_result_for_assistant, - truncate_raw_tool_arguments_preview, truncate_raw_tool_arguments_preview_to, - truncate_tool_arguments_preview, ToolExecutionErrorPresentation, - TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_STEERING_INTERRUPTED_MESSAGE, -}; -pub use tool_result_storage::{ - build_persisted_tool_output_message, count_tool_result_lines, generate_tool_result_preview, - sanitize_tool_result_file_component, select_tool_result_indices_for_persistence, - tool_result_is_persisted_output, PersistedToolOutput, ToolResultPersistenceCandidate, - ToolResultStoragePolicy, DEFAULT_MAX_TOOL_RESULT_CHARS, MAX_TOOL_RESULTS_PER_ROUND_CHARS, - PERSISTED_OUTPUT_CLOSING_TAG, PERSISTED_OUTPUT_TAG, TOOL_RESULT_PREVIEW_CHARS, -}; diff --git a/src/crates/ai-adapters/src/types/ai.rs b/src/crates/ai-adapters/src/types/ai.rs deleted file mode 100644 index ee89826113..0000000000 --- a/src/crates/ai-adapters/src/types/ai.rs +++ /dev/null @@ -1,111 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// Gemini API response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiResponse { - pub text: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub usage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub finish_reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_metadata: Option, -} - -/// Gemini usage stats -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiUsage { - #[serde(rename = "promptTokenCount")] - pub prompt_token_count: u32, - #[serde(rename = "candidatesTokenCount")] - pub candidates_token_count: u32, - #[serde(rename = "totalTokenCount")] - pub total_token_count: u32, - #[serde(rename = "reasoningTokenCount")] - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_token_count: Option, - #[serde(rename = "cachedContentTokenCount")] - #[serde(skip_serializing_if = "Option::is_none")] - pub cached_content_token_count: Option, - #[serde(rename = "cacheCreationTokenCount")] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_creation_token_count: Option, -} - -/// Structured message codes for localized connection test messaging. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ConnectionTestMessageCode { - ToolCallsNotDetected, - ImageInputCheckFailed, -} - -/// AI connection test result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectionTestResult { - /// Whether the test succeeded - pub success: bool, - /// Response time (ms) - pub response_time_ms: u64, - /// Model response content (if successful) - #[serde(skip_serializing_if = "Option::is_none")] - pub model_response: Option, - /// Structured message code for localized frontend messaging - #[serde(skip_serializing_if = "Option::is_none")] - pub message_code: Option, - /// Raw error or diagnostic details - #[serde(skip_serializing_if = "Option::is_none")] - pub error_details: Option, -} - -/// Remote model info discovered from a provider API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteModelInfo { - /// Provider model identifier (used as the actual model_name). - pub id: String, - /// Optional human-readable display name returned by the provider. - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, -} - -#[cfg(test)] -mod tests { - use super::GeminiUsage; - - #[test] - fn gemini_usage_roundtrips_cache_creation_field() { - let usage = GeminiUsage { - prompt_token_count: 100, - candidates_token_count: 20, - total_token_count: 120, - reasoning_token_count: None, - cached_content_token_count: Some(30), - cache_creation_token_count: Some(20), - }; - let json = serde_json::to_string(&usage).expect("serialize"); - assert!(json.contains("\"cacheCreationTokenCount\":20")); - - let parsed: GeminiUsage = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(parsed.cache_creation_token_count, Some(20)); - } - - #[test] - fn gemini_usage_legacy_payload_parses_with_new_field_absent() { - // Records persisted before this plan don't have cacheCreationTokenCount; - // they must still parse, with the new field defaulting to None. - let raw = r#"{ - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - "cachedContentTokenCount": 3 - }"#; - let parsed: GeminiUsage = serde_json::from_str(raw).expect("legacy payload"); - assert_eq!(parsed.cached_content_token_count, Some(3)); - assert_eq!(parsed.cache_creation_token_count, None); - } -} diff --git a/src/crates/ai-adapters/src/types/message.rs b/src/crates/ai-adapters/src/types/message.rs deleted file mode 100644 index fb1fd35616..0000000000 --- a/src/crates/ai-adapters/src/types/message.rs +++ /dev/null @@ -1,86 +0,0 @@ -use super::tool::ToolCall; -use super::tool_image_attachment::ToolImageAttachment; -use serde::{Deserialize, Serialize}; - -/// Internal message representation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub role: String, // "user", "assistant", "tool", "system" - pub content: Option, - /// Reasoning content (for interleaved thinking mode) - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, - /// Signature for Anthropic extended thinking - #[serde(skip_serializing_if = "Option::is_none")] - pub thinking_signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Indicates if the tool result is an error - #[serde(skip_serializing_if = "Option::is_none")] - pub is_error: Option, - /// Images attached to a tool result (Anthropic multimodal tool_result). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_image_attachments: Option>, -} - -impl Message { - pub fn user(content: String) -> Self { - Self { - role: "user".to_string(), - content: Some(content), - reasoning_content: None, - thinking_signature: None, - tool_calls: None, - tool_call_id: None, - name: None, - is_error: None, - tool_image_attachments: None, - } - } - - pub fn assistant(content: String) -> Self { - Self { - role: "assistant".to_string(), - content: Some(content), - reasoning_content: None, - thinking_signature: None, - tool_calls: None, - tool_call_id: None, - name: None, - is_error: None, - tool_image_attachments: None, - } - } - - pub fn assistant_with_tools(tool_calls: Vec) -> Self { - Self { - role: "assistant".to_string(), - content: None, - reasoning_content: None, - thinking_signature: None, - tool_calls: Some(tool_calls), - tool_call_id: None, - name: None, - is_error: None, - tool_image_attachments: None, - } - } - - pub fn system(content: String) -> Self { - Self { - role: "system".to_string(), - content: Some(content), - reasoning_content: None, - thinking_signature: None, - tool_calls: None, - tool_call_id: None, - name: None, - is_error: None, - tool_image_attachments: None, - } - } -} diff --git a/src/crates/ai-adapters/src/types/tool.rs b/src/crates/ai-adapters/src/types/tool.rs deleted file mode 100644 index d7ecd5f1df..0000000000 --- a/src/crates/ai-adapters/src/types/tool.rs +++ /dev/null @@ -1,61 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub arguments: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub raw_arguments: Option, -} - -impl ToolCall { - pub fn serialized_arguments(&self) -> String { - self.raw_arguments - .as_deref() - .filter(|raw| serde_json::from_str::(raw).is_ok()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| { - serde_json::to_string(&self.arguments).unwrap_or_else(|_| "{}".to_string()) - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallConfirmationDetails { - pub request: ToolCallRequestInfo, - #[serde(rename = "type")] - pub confirmation_type: String, // 'edit' | 'execute' | 'confirm' - pub message: Option, - pub file_diff: Option, - pub file_name: Option, - pub original_content: Option, - pub new_content: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallRequestInfo { - pub call_id: String, - pub name: String, - pub args: HashMap, - pub is_client_initiated: bool, - pub prompt_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallResponseInfo { - pub call_id: String, - pub response_parts: serde_json::Value, - pub result_display: Option, - pub error: Option, - pub error_type: Option, -} diff --git a/src/crates/assembly/AGENTS-CN.md b/src/crates/assembly/AGENTS-CN.md new file mode 100644 index 0000000000..08cab04f8d --- /dev/null +++ b/src/crates/assembly/AGENTS-CN.md @@ -0,0 +1,26 @@ +**中文** | [English](AGENTS.md) + +# 产品组装层 + +本层负责产品组装、兼容导出、能力选择和 runtime 注册。它为不同交付形态把下层能力接线起来,但不拥有具体 adapter 行为、可复用 service 实现、OS 集成或稳定产品领域契约。 + +## 模块 + +| Crate | 职责 | 本地文档 | +|---|---|---| +| `core` | `bitfun-core` 兼容门面与 product-full 组装 | [AGENTS.md](core/AGENTS.md) | +| `product-capabilities` | 产品能力 profile、tool group facts、service requirements 与 harness selection | [AGENTS.md](product-capabilities/AGENTS.md) | + +## 放置规则 + +- product-full 接线、兼容 shim、能力 profile 选择和 adapter/service 注册放在这里。 +- 产品领域规则属于 `contracts/product-domains`;组装层可以选择这些事实,但不拥有它们。 +- 稳定 owner 逻辑下移到 `contracts`,可移植执行逻辑下移到 `execution`,协议适配下移到 `adapters`,可复用实现下移到 `services`。 +- 保持现有 public import path,除非迁移明确移除并补充兼容说明和测试。 + +## 依赖边界 + +- `assembly/core` 可以依赖下层 owner 来组装当前产品 runtime。 +- 组装层可以依赖 adapter 与 service crate,但不实现它们的协议序列化、认证、transport 或平台细节。 +- 避免在组装层直接使用宿主 API;Tauri 支持必须保持 feature-gated,并尽可能由 app 或 adapter 拥有。 +- interface crate 可以调用组装 API;adapter 和 service 不得依赖组装层。 diff --git a/src/crates/assembly/AGENTS.md b/src/crates/assembly/AGENTS.md new file mode 100644 index 0000000000..b268547194 --- /dev/null +++ b/src/crates/assembly/AGENTS.md @@ -0,0 +1,41 @@ +[中文](AGENTS-CN.md) | **English** + +# Product Assembly Layer + +This layer owns product assembly, compatibility exports, capability selection, +and runtime registration. It wires lower layers together for a delivery form; +it does not own concrete adapter behavior, reusable service implementations, OS +integration, or stable product-domain contracts. + +## Modules + +| Crate | Responsibility | Local doc | +|---|---|---| +| `core` | `bitfun-core` compatibility facade and product-full assembly | [AGENTS.md](core/AGENTS.md) | +| `product-capabilities` | Product capability profiles, tool group facts, service requirements, and harness selections | [AGENTS.md](product-capabilities/AGENTS.md) | + +## Placement Rules + +- Put product-full wiring, compatibility shims, capability profile selection, + and adapter/service registration here. +- Keep product-domain rules in `contracts/product-domains`; assembly may select + those facts but must not become their owner. +- Move stable owner logic to `contracts`, portable execution logic to + `execution`, concrete protocol adaptation to `adapters`, and reusable + implementation behavior to `services` when a lower layer can own it. +- Preserve existing public import paths unless a migration explicitly removes + them with compatibility notes and tests. +- Keep assembly additions small and traceable; broad feature growth here is a + sign that ownership has not been pushed down far enough. + +## Dependency Boundaries + +- `assembly/core` may depend on lower owner layers to assemble the current product + runtime. +- Assembly may depend on adapter and service crates for selected delivery forms, + but should not implement their protocol serialization, authentication, + transport, or platform details. +- Avoid direct host APIs in assembly code; Tauri support must remain feature-gated + and should be owned by app or adapter code when possible. +- Interface crates may call assembly APIs, but adapters and services must not + depend on assembly. diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md new file mode 100644 index 0000000000..e56ffb6c33 --- /dev/null +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -0,0 +1,82 @@ +**中文** | [English](AGENTS.md) + +# Core Agent 指南 + +## 适用范围 + +本文件适用于 `src/crates/assembly/core`。仓库级规则请看顶层 `AGENTS.md`;进入更具体目录后,优先遵循更近的局部指南。 + +## 定位 + +`bitfun-core` 是共享产品 runtime facade。它仍承载兼容路径和 `product-full` 组装边界,但新的拆解工作应优先遵循 +`docs/architecture/core-decomposition.md` 与 +`docs/architecture/agent-runtime-services-design.md` 中定义的 owner crate 边界。 + +主要区域: + +- `src/agentic/`:agents、prompts、tools、sessions、execution、persistence +- `src/service/`:config、filesystem、terminal、git、LSP、MCP、remote connect、AI memory +- `src/infrastructure/`:AI clients、app paths、event system、storage、debug log server +- `src/product_runtime/`:product-full 兼容 adapter 与 runtime service provider wiring + +Agent 运行时心智模型: + +```text +SessionManager -> Session -> DialogTurn -> ModelRound +``` + +## 边界规则 + +- 共享 core 必须保持平台无关。避免引入 `tauri::AppHandle` 等宿主 API;优先使用 + `bitfun_events::EventEmitter` 等共享抽象。 +- 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 连接回来。 +- 不要在没有窄 port/interface 边界的情况下新增 `service` 到 `agentic` 的跨层引用。 +- 不要把平台专属逻辑、构建脚本行为、产品能力选择或 provider-specific AI 序列化写进 shared core。 +- owner 从 core 外移时,在下游调用点被有意迁移前,用 facade 或 re-export 保持旧 import path。 + +## 拆解规则 + +- 将 `bitfun-core` 视为兼容 facade 与完整产品组装点,而不是新稳定契约的默认归属。 +- 稳定 DTO、facts、ports 和纯决策应放到有明确边界的 owner crate;具体 manager、IO、平台 adapter 和产品执行在没有评审过的 + port/provider 设计与行为等价测试前继续留在 core。 +- Tool 改动必须保持 expanded/collapsed exposure、prompt-visible manifest、`GetToolSpec`、权限行为、 + `ToolUseContext` 语义,以及 desktop/MCP/ACP catalog 行为等价。 +- Runtime owner 迁移在目标 owner 具备评审过的 port/provider 设计和行为等价测试前,不应移动 concrete lifecycle、IO、event delivery、permission orchestration 或 remote/platform implementation。 +- Product-domain 改动可以在有等价保护时迁移纯产品领域计划;filesystem writes、worker/host side effect、 + Git/AI concrete calls、marker IO 和 path-manager integration 仍留在 core,除非有经过评审的 owner 设计。 +- Remote/service 改动必须保持 external protocol lifecycle、workspace projection、scheduler/session restore、 + terminal pre-warm 和 product execution 边界清晰。 +- Feature 改动必须保持 `product-full` 作为兼容产品组装边界;默认能力选择只有在单独的 product matrix review 后才能变化。 + +## 归属参考 + +归属细节放在下列文件中,不要继续扩写本指南: + +- `docs/architecture/core-decomposition.md` +- `docs/architecture/agent-runtime-services-design.md` +- `src/crates/execution/agent-runtime/AGENTS.md` +- `src/crates/execution/tool-contracts/AGENTS.md` +- `src/crates/execution/harness/AGENTS.md` +- `src/crates/contracts/product-domains/AGENTS.md` +- `src/crates/contracts/runtime-ports/` 与 `src/crates/execution/runtime-services/` 源码说明 +- `src/crates/services/services-core/AGENTS.md` +- `src/crates/services/services-integrations/AGENTS.md` +- `src/crates/execution/tool-provider-groups/AGENTS.md` + +部分子目录已有更细指南: + +- `src/crates/adapters/ai-adapters/AGENTS.md` +- `src/agentic/execution/AGENTS.md` +- `src/agentic/deep_review/AGENTS.md` + +## 验证 + +按触及行为选择最小检查: + +```bash +cargo check --workspace +cargo test -p bitfun-core -- --nocapture +node scripts/check-core-boundaries.mjs +``` + +仅改文档时运行 `git diff --check`。 diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md new file mode 100644 index 0000000000..ac3ae658b2 --- /dev/null +++ b/src/crates/assembly/core/AGENTS.md @@ -0,0 +1,101 @@ +[中文](AGENTS-CN.md) | **English** + +# Core Agent Guide + +## Scope + +This file applies to `src/crates/assembly/core`. Use the top-level `AGENTS.md` for +repository-wide rules and the nearest narrower guide when one exists. + +## Role + +`bitfun-core` is the shared product runtime facade. It still owns compatibility +paths and the `product-full` assembly boundary, but new decomposition work should +prefer the owner crates described in `docs/architecture/core-decomposition.md` +and `docs/architecture/agent-runtime-services-design.md`. + +Main areas: + +- `src/agentic/`: agents, prompts, tools, sessions, execution, persistence +- `src/service/`: config, filesystem, terminal, git, LSP, MCP, remote connect, AI memory +- `src/infrastructure/`: AI clients, app paths, event system, storage, debug log server +- `src/product_runtime/`: product-full compatibility adapters and runtime service provider wiring + +Agent runtime mental model: + +```text +SessionManager -> Session -> DialogTurn -> ModelRound +``` + +## Boundary Rules + +- Keep shared core platform-agnostic. Avoid host-specific APIs such as + `tauri::AppHandle`; use shared abstractions such as + `bitfun_events::EventEmitter`. +- Desktop-only host adapters belong in `src/apps/desktop`, then flow through + transport/API layers. +- Do not add new cross-layer references from `service` to `agentic` without a + narrow port/interface boundary. +- Do not move platform-specific logic, build-script behavior, product capability + selection, or provider-specific AI serialization into shared core. +- When moving ownership out of core, preserve old import paths with facade or + re-export code until downstream call sites are intentionally migrated. + +## Decomposition Rules + +- Treat `bitfun-core` as a compatibility facade plus full product assembly point, + not as the preferred home for new stable contracts. +- Put stable DTOs, facts, ports, and pure decisions in the matching owner crate + where a clear owner exists. Keep concrete managers, IO, platform adapters, and + product execution in core until a reviewed port/adapter/service design and + behavior equivalence tests exist. +- Tool changes must preserve expanded/collapsed exposure, prompt-visible + manifests, `GetToolSpec`, permission behavior, `ToolUseContext` semantics, and + desktop/MCP/ACP catalog behavior. +- Runtime-owner migrations must keep concrete lifecycle, IO, event delivery, + permission orchestration, and remote/platform implementations in core until + the target owner has a reviewed port/adapter/service design plus + behavior-equivalence tests. +- Product-domain changes may move pure product-domain plans with equivalence + coverage, but filesystem writes, worker/host side effects, Git/AI concrete + calls, marker IO, and path-manager integration stay in core unless a reviewed + owner design says otherwise. +- Remote/service changes must keep external protocol lifecycle, workspace + projection, scheduler/session restore, terminal pre-warm, and product + execution boundaries explicit. +- Feature work must keep `product-full` as the compatibility product assembly + boundary unless a separate product matrix review changes default capability + selection. + +## Owner References + +Use these files for ownership details instead of expanding this guide: + +- `docs/architecture/core-decomposition.md` +- `docs/architecture/agent-runtime-services-design.md` +- `src/crates/execution/agent-runtime/AGENTS.md` +- `src/crates/execution/tool-contracts/AGENTS.md` +- `src/crates/execution/harness/AGENTS.md` +- `src/crates/contracts/product-domains/AGENTS.md` +- `src/crates/contracts/runtime-ports/` and `src/crates/execution/runtime-services/` source docs +- `src/crates/services/services-core/AGENTS.md` +- `src/crates/services/services-integrations/AGENTS.md` +- `src/crates/execution/tool-provider-groups/AGENTS.md` + +Narrower local guides already exist for some subtrees: + +- `src/crates/adapters/ai-adapters/AGENTS.md` +- `src/agentic/execution/AGENTS.md` +- `src/agentic/deep_review/AGENTS.md` + +## Verification + +Use the smallest check that matches the touched behavior: + +```bash +cargo check --workspace +cargo test -p bitfun-core -- --nocapture +node scripts/check-core-boundaries.mjs +``` + +For documentation-only changes, run `git diff --check`. diff --git a/src/crates/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml similarity index 67% rename from src/crates/core/Cargo.toml rename to src/crates/assembly/core/Cargo.toml index 023a4a8864..c48c7aad36 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -40,17 +40,19 @@ aes = { workspace = true, optional = true } hex = { workspace = true } dashmap = { workspace = true, optional = true } indexmap = { workspace = true, optional = true } +htmd = "0.5.4" +legible = "0.4.2" + +reqwest = { workspace = true, optional = true } lazy_static = "1.4" once_cell = "1.19" parking_lot = "0.12" -reqwest = { workspace = true } # Debug Log HTTP Server -axum = { workspace = true } -tower-http = { workspace = true } +axum = { workspace = true, optional = true } +tower-http = { workspace = true, optional = true } glob = { workspace = true, optional = true } -ignore = { workspace = true } notify = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } @@ -67,8 +69,6 @@ which = { workspace = true } similar = { workspace = true, optional = true } urlencoding = { workspace = true } -grep-searcher = { workspace = true } -grep-regex = { workspace = true } globset = { workspace = true, optional = true } eventsource-stream = { workspace = true, optional = true } @@ -80,72 +80,72 @@ rmcp = { workspace = true, features = [ sse-stream = { workspace = true, optional = true } # Shared AI protocol adapters -bitfun-ai-adapters = { path = "../ai-adapters" } +bitfun-ai-adapters = { path = "../../adapters/ai-adapters", optional = true } # Lightweight agent stream processing -bitfun-agent-stream = { path = "../agent-stream" } +bitfun-agent-stream = { path = "../../execution/agent-stream" } # Agent runtime owner contracts -bitfun-agent-runtime = { path = "../agent-runtime" } +bitfun-agent-runtime = { path = "../../execution/agent-runtime" } + +# Harness workflow contracts +bitfun-harness = { path = "../../execution/harness" } + +# Product capability pack contracts +bitfun-product-capabilities = { path = "../product-capabilities", default-features = false, optional = true } # Agent tool contracts -bitfun-agent-tools = { path = "../agent-tools" } +bitfun-agent-tools = { path = "../../execution/tool-contracts" } # Tool pack provider plan -bitfun-tool-packs = { path = "../tool-packs", default-features = false, optional = true } +bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } # Core service owner crate -bitfun-services-core = { path = "../services-core" } +bitfun-services-core = { path = "../../services/services-core" } # Integration service owner crate -bitfun-services-integrations = { path = "../services-integrations", default-features = false, features = ["remote-ssh"] } +bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["remote-ssh"] } # Product domain owner crate -bitfun-product-domains = { path = "../product-domains", default-features = false, optional = true } +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, optional = true } # Tool runtime -tool-runtime = { path = "../tool-runtime", optional = true } +tool-runtime = { path = "../../execution/tool-execution", optional = true } # terminal -terminal-core = { path = "../terminal" } +terminal-core = { path = "../../services/terminal" } # I18n internationalization fluent-bundle = { workspace = true } unic-langid = { workspace = true } # Encryption (Remote Connect E2E) -x25519-dalek = { workspace = true, optional = true } aes-gcm = { workspace = true, optional = true } sha2 = { workspace = true } rand = { workspace = true, optional = true } # Device/Network info (Remote Connect) local-ip-address = { workspace = true, optional = true } -hostname = { workspace = true, optional = true } # QR code generation -qrcode = { workspace = true, optional = true } # WebSocket client tokio-tungstenite = { workspace = true, optional = true } # SSH - Remote SSH support (optional feature) russh = { workspace = true, optional = true } -russh-sftp = { workspace = true, optional = true } -russh-keys = { workspace = true, optional = true } -shellexpand = { workspace = true, optional = true } -ssh_config = { workspace = true, optional = true } # Relay server shared library (embedded relay reuses standalone relay logic) -bitfun-relay-server = { path = "../../apps/relay-server", optional = true } +bitfun-relay-server = { path = "../../../apps/relay-server", optional = true } # Event layer dependency (lowest layer) -bitfun-core-types = { path = "../core-types" } -bitfun-events = { path = "../events" } -bitfun-runtime-ports = { path = "../runtime-ports" } +bitfun-core-types = { path = "../../contracts/core-types" } +bitfun-events = { path = "../../contracts/events" } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } +bitfun-runtime-services = { path = "../../execution/runtime-services" } # Transport layer dependency -bitfun-transport = { path = "../transport" } +bitfun-transport = { path = "../../adapters/transport" } # Tauri dependency (optional, enabled only when needed) tauri = { workspace = true } @@ -154,6 +154,9 @@ tauri = { workspace = true } [target.'cfg(not(windows))'.dependencies] git2 = { workspace = true, features = ["vendored-openssl"], optional = true } +[target.'cfg(not(target_env = "ohos"))'.dependencies] +readability-js = "0.1.5" + [target.'cfg(windows)'.dependencies] win32job = { workspace = true } rustls = { workspace = true } @@ -165,6 +168,7 @@ schannel = "0.1" # explicitly before `bitfun-core` default features are made lighter. default = ["product-full"] product-full = [ + "ai-adapter-runtime", "dep:chrono-tz", "dep:cron", "dep:dashmap", @@ -180,39 +184,45 @@ product-full = [ "dep:similar", "dep:tool-runtime", "ssh-remote", + "product-capabilities", "product-domains", + "runtime-services", "service-integrations", "tool-packs", ] -product-domains = ["dep:bitfun-product-domains", "bitfun-product-domains/product-full"] +ai-adapter-runtime = ["dep:bitfun-ai-adapters", "dep:reqwest"] +product-capabilities = ["dep:bitfun-product-capabilities"] +product-domains = [ + "ai-adapter-runtime", + "dep:bitfun-product-domains", + "bitfun-services-integrations/function-agents", + "bitfun-services-integrations/miniapp-runtime", + "bitfun-product-domains/product-full", +] +runtime-services = [] service-integrations = [ "dep:aes", "dep:aes-gcm", + "dep:axum", "dep:bitfun-relay-server", "dep:git2", - "dep:hostname", "dep:image", "dep:local-ip-address", "dep:md5", - "dep:qrcode", "dep:rand", + "dep:reqwest", "dep:rmcp", "dep:sse-stream", "dep:tokio-tungstenite", - "dep:x25519-dalek", + "dep:tower-http", "bitfun-services-integrations/product-full", ] tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full"] tauri-support = [] # Optional tauri support ssh-remote = [ - "dep:aes-gcm", - "dep:rand", + "bitfun-services-integrations/remote-ssh-concrete", "russh", - "russh-sftp", - "russh-keys", - "shellexpand", - "ssh_config", -] # russh-keys pure-Rust crypto backend (no openssl) +] [build-dependencies] sha2 = { workspace = true } diff --git a/src/crates/core/build.rs b/src/crates/assembly/core/build.rs similarity index 99% rename from src/crates/core/build.rs rename to src/crates/assembly/core/build.rs index 041895cae2..d018b1906f 100644 --- a/src/crates/core/build.rs +++ b/src/crates/assembly/core/build.rs @@ -114,7 +114,7 @@ fn embed_agents_prompt_data() -> Result<(), Box> { println!("cargo:rerun-if-changed=src/agentic/agents/prompts"); println!("cargo:rerun-if-changed=src/agentic/prompts"); - // Get CARGO_MANIFEST_DIR (i.e. crates/core directory) + // Get CARGO_MANIFEST_DIR (i.e. src/crates/assembly/core directory) let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?; let mut prompts = HashMap::new(); diff --git a/src/crates/core/builtin_playbooks/browser_data_extraction.yaml b/src/crates/assembly/core/builtin_playbooks/browser_data_extraction.yaml similarity index 100% rename from src/crates/core/builtin_playbooks/browser_data_extraction.yaml rename to src/crates/assembly/core/builtin_playbooks/browser_data_extraction.yaml diff --git a/src/crates/core/builtin_playbooks/browser_form_fill.yaml b/src/crates/assembly/core/builtin_playbooks/browser_form_fill.yaml similarity index 100% rename from src/crates/core/builtin_playbooks/browser_form_fill.yaml rename to src/crates/assembly/core/builtin_playbooks/browser_form_fill.yaml diff --git a/src/crates/core/builtin_playbooks/browser_screenshot.yaml b/src/crates/assembly/core/builtin_playbooks/browser_screenshot.yaml similarity index 100% rename from src/crates/core/builtin_playbooks/browser_screenshot.yaml rename to src/crates/assembly/core/builtin_playbooks/browser_screenshot.yaml diff --git a/src/crates/core/builtin_playbooks/desktop_app_automation.yaml b/src/crates/assembly/core/builtin_playbooks/desktop_app_automation.yaml similarity index 100% rename from src/crates/core/builtin_playbooks/desktop_app_automation.yaml rename to src/crates/assembly/core/builtin_playbooks/desktop_app_automation.yaml diff --git a/src/crates/core/builtin_playbooks/im_send_message.yaml b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml similarity index 100% rename from src/crates/core/builtin_playbooks/im_send_message.yaml rename to src/crates/assembly/core/builtin_playbooks/im_send_message.yaml diff --git a/src/crates/core/builtin_skills/agent-browser/SKILL.md b/src/crates/assembly/core/builtin_skills/agent-browser/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/SKILL.md rename to src/crates/assembly/core/builtin_skills/agent-browser/SKILL.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/authentication.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/authentication.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/authentication.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/authentication.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/commands.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/commands.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/commands.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/commands.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/profiling.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/profiling.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/profiling.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/profiling.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/proxy-support.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/proxy-support.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/proxy-support.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/proxy-support.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/session-management.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/session-management.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/session-management.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/session-management.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/snapshot-refs.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/snapshot-refs.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/snapshot-refs.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/snapshot-refs.md diff --git a/src/crates/core/builtin_skills/agent-browser/references/video-recording.md b/src/crates/assembly/core/builtin_skills/agent-browser/references/video-recording.md similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/references/video-recording.md rename to src/crates/assembly/core/builtin_skills/agent-browser/references/video-recording.md diff --git a/src/crates/core/builtin_skills/agent-browser/templates/authenticated-session.sh b/src/crates/assembly/core/builtin_skills/agent-browser/templates/authenticated-session.sh similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/templates/authenticated-session.sh rename to src/crates/assembly/core/builtin_skills/agent-browser/templates/authenticated-session.sh diff --git a/src/crates/core/builtin_skills/agent-browser/templates/capture-workflow.sh b/src/crates/assembly/core/builtin_skills/agent-browser/templates/capture-workflow.sh similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/templates/capture-workflow.sh rename to src/crates/assembly/core/builtin_skills/agent-browser/templates/capture-workflow.sh diff --git a/src/crates/core/builtin_skills/agent-browser/templates/form-automation.sh b/src/crates/assembly/core/builtin_skills/agent-browser/templates/form-automation.sh similarity index 100% rename from src/crates/core/builtin_skills/agent-browser/templates/form-automation.sh rename to src/crates/assembly/core/builtin_skills/agent-browser/templates/form-automation.sh diff --git a/src/crates/core/builtin_skills/docx/LICENSE.txt b/src/crates/assembly/core/builtin_skills/docx/LICENSE.txt similarity index 100% rename from src/crates/core/builtin_skills/docx/LICENSE.txt rename to src/crates/assembly/core/builtin_skills/docx/LICENSE.txt diff --git a/src/crates/core/builtin_skills/docx/SKILL.md b/src/crates/assembly/core/builtin_skills/docx/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/docx/SKILL.md rename to src/crates/assembly/core/builtin_skills/docx/SKILL.md diff --git a/src/crates/core/builtin_skills/docx/scripts/__init__.py b/src/crates/assembly/core/builtin_skills/docx/scripts/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/__init__.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/__init__.py diff --git a/src/crates/core/builtin_skills/docx/scripts/accept_changes.py b/src/crates/assembly/core/builtin_skills/docx/scripts/accept_changes.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/accept_changes.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/accept_changes.py diff --git a/src/crates/core/builtin_skills/docx/scripts/comment.py b/src/crates/assembly/core/builtin_skills/docx/scripts/comment.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/comment.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/comment.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/helpers/__init__.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/helpers/__init__.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/__init__.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/pack.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/pack.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/pack.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/pack.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd diff --git a/src/crates/core/builtin_skills/docx/scripts/office/soffice.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/soffice.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/soffice.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/soffice.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/unpack.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/unpack.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/unpack.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/unpack.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validate.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validate.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/validate.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/validate.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/__init__.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/validators/__init__.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/__init__.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/base.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/base.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/validators/base.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/base.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/docx.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/docx.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/validators/docx.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/docx.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/pptx.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/pptx.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/validators/pptx.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/pptx.py diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/redlining.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/redlining.py similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/office/validators/redlining.py rename to src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/redlining.py diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/comments.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/comments.xml similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/templates/comments.xml rename to src/crates/assembly/core/builtin_skills/docx/scripts/templates/comments.xml diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtended.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtended.xml similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/templates/commentsExtended.xml rename to src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtended.xml diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml rename to src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/commentsIds.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsIds.xml similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/templates/commentsIds.xml rename to src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsIds.xml diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/people.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/people.xml similarity index 100% rename from src/crates/core/builtin_skills/docx/scripts/templates/people.xml rename to src/crates/assembly/core/builtin_skills/docx/scripts/templates/people.xml diff --git a/src/crates/core/builtin_skills/find-skills/SKILL.md b/src/crates/assembly/core/builtin_skills/find-skills/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/find-skills/SKILL.md rename to src/crates/assembly/core/builtin_skills/find-skills/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-autoplan/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-autoplan/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-autoplan/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-autoplan/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-cso/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-cso/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-cso/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-cso/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-design-consultation/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-design-consultation/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-design-consultation/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-design-consultation/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-design-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-design-review/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-design-review/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-design-review/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-document-release/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-document-release/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-document-release/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-document-release/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-investigate/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-investigate/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-investigate/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-investigate/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-office-hours/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-office-hours/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-office-hours/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-office-hours/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-plan-ceo-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-plan-ceo-review/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-plan-ceo-review/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-plan-ceo-review/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-plan-design-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-plan-design-review/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-plan-design-review/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-plan-design-review/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-plan-eng-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-plan-eng-review/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-plan-eng-review/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-plan-eng-review/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-qa-only/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-qa-only/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-qa-only/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-qa-only/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-qa/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-qa/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-qa/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-qa/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-retro/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-retro/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-retro/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-retro/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-review/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-review/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-review/SKILL.md diff --git a/src/crates/core/builtin_skills/gstack-ship/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-ship/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/gstack-ship/SKILL.md rename to src/crates/assembly/core/builtin_skills/gstack-ship/SKILL.md diff --git a/src/crates/core/builtin_skills/pdf/LICENSE.txt b/src/crates/assembly/core/builtin_skills/pdf/LICENSE.txt similarity index 100% rename from src/crates/core/builtin_skills/pdf/LICENSE.txt rename to src/crates/assembly/core/builtin_skills/pdf/LICENSE.txt diff --git a/src/crates/core/builtin_skills/pdf/SKILL.md b/src/crates/assembly/core/builtin_skills/pdf/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/pdf/SKILL.md rename to src/crates/assembly/core/builtin_skills/pdf/SKILL.md diff --git a/src/crates/core/builtin_skills/pdf/forms.md b/src/crates/assembly/core/builtin_skills/pdf/forms.md similarity index 100% rename from src/crates/core/builtin_skills/pdf/forms.md rename to src/crates/assembly/core/builtin_skills/pdf/forms.md diff --git a/src/crates/core/builtin_skills/pdf/reference.md b/src/crates/assembly/core/builtin_skills/pdf/reference.md similarity index 100% rename from src/crates/core/builtin_skills/pdf/reference.md rename to src/crates/assembly/core/builtin_skills/pdf/reference.md diff --git a/src/crates/core/builtin_skills/pdf/scripts/check_bounding_boxes.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/check_bounding_boxes.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/check_bounding_boxes.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/check_bounding_boxes.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/check_fillable_fields.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/check_fillable_fields.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/check_fillable_fields.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/check_fillable_fields.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/create_validation_image.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/create_validation_image.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/create_validation_image.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/create_validation_image.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/extract_form_field_info.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_field_info.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/extract_form_field_info.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_field_info.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/extract_form_structure.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_structure.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/extract_form_structure.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_structure.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/fill_fillable_fields.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_fillable_fields.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/fill_fillable_fields.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/fill_fillable_fields.py diff --git a/src/crates/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py similarity index 100% rename from src/crates/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py rename to src/crates/assembly/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/LICENSE.txt b/src/crates/assembly/core/builtin_skills/ppt-design/LICENSE.txt new file mode 100644 index 0000000000..f6472a1140 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/LICENSE.txt @@ -0,0 +1,16 @@ +MIT License + +Copyright (c) 2026 Sparo OS contributors (huashu-design fork) +Upstream: https://github.com/alchaincyf/huashu-design (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md b/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md new file mode 100644 index 0000000000..e9d1692803 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md @@ -0,0 +1,224 @@ +--- +name: ppt-design +description: 使用 HTML 设计与生成高质量演讲幻灯片(PPT/Deck)。当用户希望生成、设计、修改幻灯片、deck、slides、presentation、汇报、提案、pitch、课件时触发。主干:960×540pt 可编辑 HTML 幻灯片(4 条 OOXML 硬约束)+ 可选 1920×1080 高保真演讲版 + 数据/信息可视化意识 + 5 种设计哲学 + 反 AI slop + 单页自包含 `slide-XX.html`。不生图;缺图用版式/SVG/占位并标注建议补图。 +license: MIT +--- + +# PPT Design + +你是 **幻灯片设计师**,产出 **HTML 幻灯片**(deck):可全屏演讲,也可在遵守约束时转为可编辑 PPTX。 + +> 视频 / 配音 / 多模态生图不在本 skill。按需 `Read` 同目录 `references/*.md`。 + +## 项目约定 + +`{{ppt_project_dir}}` 为当前 deck 根目录,**只在此目录读写**。 + +- **页文件**:`slides/slide-01.html` …(两位数编号,与 `outline[].slide_id` 对应) +- **大纲**:`project.json` → `outline[]`:`{ id, title, bullets[], slide_id }` +- **品牌图**:`brand/` + +## 核心原则(严格遵守) + +### 1. One-Shot First + +首轮 **禁止反问**,按默认值开工: + +| 维度 | 默认推断 | +|------|----------| +| 受众 | 「客户/投资人/pitch」→ 商务;「同事/汇报」→ 内部;否则通用专业 | +| 张数 | 从 prompt 取数;无则简介 8–10、pitch 10–15、汇报 10–15 | +| 风格 | 默认 **Pentagram 信息建筑**;「高端/极简」→ Build;「东方/留白」→ Kenya Hara | +| 主题 | 跟随用户/系统;明示则覆盖 | + +写一行假设:`面向 X · N 页 · 风格 Y · 主题 Z`。 + +### 2. 极简文风 + +克制、无 emoji 装饰、不重复。少解释,**直接改文件**;用户追问再简短总结。 + +### 3. 反 AI Slop + +- ❌ 紫/蓝紫渐变背景、emoji 当图标、圆角盒+左色条 SaaS 风 +- ❌ 剪影/抽象球/玻璃拟态滥用在信息页 +- ❌ 硬编码「微软雅黑」「Arial」——用 `system-ui, -apple-system, "PingFang SC", "Source Han Sans SC", sans-serif` + +### 4. 信息密度 + +- 每页 **一个核心结论**;标题用 **断言句**(✗「Q3 营收」 ✓「Q3 营收增长 23%」) +- 正文 ≤3 层;字号:标题 36–48pt、副 18–24pt、正文 14–18pt、注解 10–12pt +- **留白优先**,宁可拆页 + +### 5. 缺图(不生图) + +1. 文字+留白完成信息 → 2. SVG/Unicode 几何 → 3. outline 标注 `[建议补图:…]`,图进 `brand/` + +## 画布与交付目标 + +**默认(可编辑 PPTX + 本仓库 PPT 生成)**:`body { width: 960pt; height: 540pt; }`,全程遵守 `references/editable-pptx.md` 四条硬约束。 + +| 用户目标 | HTML 怎么写 | +|----------|----------------| +| 要在 PowerPoint 里改字 | 960×540pt + 四条硬约束;写完对照 editable-pptx 自检 | +| 只要演讲/视觉自由、不改 pptx | 可用 1920×1080px;复杂 CSS/渐变可保留 | +| 既要复杂视觉又要可改字 pptx | **不可兼得**——说明限制;保留 1920 演讲版或另做简化 960 版 | + +``` +{{ppt_project_dir}}/ +├── project.json # outline[], slide_order[], style, assumptions +├── brand/ +├── slides/slide-XX.html +├── thumbnails/ # 系统生成 +└── versions/ # 系统快照 +``` + +架构选型(多文件 vs 单文件 deck-stage)、聚合 `index.html`、grammar showcase → `references/slide-decks.md`。 + +## 防溢出预算(每页必检,违规即重写该页) + +溢出 = 渲染后内容超出 960×540pt 画布,是最常见的硬伤。写每页 HTML 前先做**垂直预算心算**,写完后逐页核对: + +- 可用高度恒等式:`540pt = 标题区 + 正文区 + 底部脚注 + 安全边距`。标题区按 70–95pt、脚注行按 20–25pt、**底部安全边距 ≥ 36pt(0.5in,PPTX 导出校验线)** 预留,正文区实际只有约 **390–420pt**。 +- 预算估算法:正文每行 ≈ `font-size × line-height`(如 12px × 1.5 ≈ 13.5pt/行);表格每行 ≈ 字号 + 上下 padding;卡片 = 内容行数 × 行高 + padding × 2 + 间距。**所有块的预算之和必须 ≤ 正文区高度**,估不下就删行、合栏或拆页,禁止靠缩字号硬塞。 +- 兜底结构:`body { overflow: hidden; }`,根容器 `display: flex; flex-direction: column; height: 540pt;`,可伸缩区给 `flex: 1; min-height: 0; overflow: hidden;`——即使估错也只在容器内裁切,不撑破画布。 +- 高风险元素单独检查:满版表格(行数 × 行高先算再写)、多行卡片网格、长 bullet 列表、流程图标注。一个元素预算超了就整体减行,而不是指望浏览器挤一挤。 +- 任何文本框(>12px 字号)的底边必须离画布底部 ≥ 0.5in,否则 PPTX 导出会判为越界。 + +## 数据与信息可视化意识(启发式) + +遇到数字、比较、时间、流程、层级、因果或空间关系时,**主动想一次可视化是否比当前文字更有效**。这不是逐页门禁,也没有图表配额或固定映射;最终形式由 Agent 结合主题、受众、叙事角色、数据质量、视觉风格、页面节奏和导出目标自行决定。结构化文字、摄影、留白或纯排版都可能是更好的答案。 + +可用下面的问题帮助判断,不要求机械逐项执行: + +1. 这页在整套叙事中承担什么角色:建立情绪、解释概念、提供证据、推动比较,还是促成行动? +2. 观众要看见的是数值大小、变化、构成、关系、步骤,还是一句观点本身? +3. 图表/图示是否真的比文字更快、更清楚,并与当前风格相符? +4. 如果用了可视化,它是否直接服务于标题结论,而非只是让页面“看起来有数据”? + +以下只是常见候选,不是模板规则: + +| 信息意图 | 可考虑的形式 | +|----------|--------------| +| 指标与基准 | 大数字、进度/目标对比、短注释 | +| 排名与差异 | 条形图、点图、矩阵、表格 | +| 趋势与阶段 | 折线/柱状、时间线、阶段带、small multiples | +| 构成与变化来源 | 堆叠条、环形图、瀑布图、前后对照 | +| 分布与关联 | 点图、直方图、散点图、2×2 | +| 流程与系统 | 流程图、泳道、架构图、关系图 | +| 层级与论证 | 树状图、问题树、证据链、结构化文字 | + +原则: + +- 同一份数据可以有多种正确表达;选择最符合该页叙事意图和视觉语言的一种,必要时创造混合形式。 +- 一个主视觉通常更利于聚焦,但对仪表盘、对照分析、教学拆解等页面可使用多个协调视图。 +- 数据图应说明单位、时间、口径、来源或「估算」;没有可靠数字时不得编造。 +- 用强调色引导结论,但保持全 deck 的对象颜色与视觉语义一致。 +- 3D 图、表盘、雷达图、图标阵列等并非绝对禁用;只有在它们确实改善理解且不扭曲数据时使用。 +- 可编辑 PPTX 的 CSS gradient 等限制是技术硬约束;复杂图形无法稳定转译时,可简化、换表达,或仅用于 1920 HTML 演讲版。 +- 更多选择思路、信息图模式和实现提示 → `references/data-information-visualization.md`;把它当作设计词汇库,不是逐条执行清单。 + +## 5 种风格 + +| 风格 | 何时选 | +|------|--------| +| **Pentagram 信息建筑**(默认) | 商务、汇报、数据 | +| **Müller-Brockmann 网格** | 学术、技术 | +| **Build 极简** | 高端品牌、宣言 pitch | +| **Kenya Hara 留白** | 文化、艺术 | +| **Takram 柔和科技** | 设计、科技人文 | + +DNA 与样例 → `references/design-styles.md`。 + +## 风格预设(style presets) + +当输入里出现 `style.stylePreset`(或用户点名某个预设名)时,按以下流程套用预设: + +1. `Read references/style-presets/.md`(路径相对本 skill 目录)。文件定义该预设的视觉系统、配色、排版、推荐版式、CSS 实现要点与禁忌,**必须逐条落实到每页 HTML**。 +2. 预设只接管「视觉身份」:配色、字体气质、装饰语言、版式偏好。本 skill 的核心原则全部继续生效——断言式标题、单页一结论、信息密度、反 AI slop、960×540pt 画布、可编辑 PPTX 四条硬约束、不许溢出。 +3. 从上面 5 种设计哲学中选最接近的一种作为版式骨架(structural grammar),预设负责皮肤;两者冲突时以信息传达优先、弱化装饰。 +4. 预设文件缺失或 key 未知时,回退到 5 种哲学中最接近的一种,并沿用输入提供的 palette。 + +| styleKey | 预设 | 一句话 DNA | +|----------|------|------------| +| `clean-business` | 简洁商务 | 纯白背景、平静蓝强调、产品文档式极简 | +| `insight-report` | 洞察汇报 | 分析文档式高密度页:完整句子论证、固定分析框架、满版矩阵与过程叙事 | +| `minimal-gallery` | 黑白极简 | 严格网格、黑白灰、画册式留白 | +| `bold-editorial` | 黑白红大字 | 白底黑色大字、红色点缀、非对称编辑排版 | +| `yellow-magazine` | 黄底黑字杂志 | 高识别度黄底黑字、手写点缀、杂志感 | +| `pink-pop` | 粉色波普 | 哑光粉底、精致编辑或街头波普两种力度 | +| `creative-studio` | 黑橙创意 | 白底黑字血橙强调、干练机构感 | +| `retro-pop` | 复古海报波普 | 复古色调、粗体海报排版、可混搭古典雕塑 | +| `dark-neon` | 暗黑霓虹 | 深色底、故障艺术或霓虹制图两种方言 | +| `pop-infographic` | 波普信息图 | 鲜艳粉青配色、有机形态或复古像素 | + +## 工作流 + +1. **假设 + 纲**:更新 `project.json` 的 `outline[]` / `slide_order`;顺带识别可能受益于图表、图示或更强视觉表达的页面,先不批量写 HTML。 +2. **≥5 页先打样**:做 2 页视觉差异最大的 showcase,定 grammar 再批量(见 slide-decks.md)。 +3. **逐页 HTML**:封面 `slide-01`(标题/副标题/作者或日期)→ 按 outline 生成其余页;根据内容与风格自由选择图表、图示、文字、图片或混合构图,每页完整内联 CSS。 +4. **整体视觉 + PPTX 自检**:检查是否遗漏明显的可视化机会,也检查是否为了变化而滥用图表;再逐页核对四条硬约束,违规即改 HTML。 +5. **改稿范围**(输入里若有 `scope`): + - `deck`:可改 outline 与任意 `slides/*.html` + - `current_slide` / `slide_index`:**只改指定页**,不动其他 slide 文件 + +## 单页模板(960×540pt · Pentagram) + +```html + + + + + +
+

断言句标题

+

本页核心结论一行说清

+
    +
  • 要点一(≤20 字)
  • +
  • 要点二
  • +
+
+ + +``` + +可编辑 PPTX 四条、合并文本框 `data-pptx-merge`、常见错误 → `references/editable-pptx.md`。 + +## 多轮编辑 + +- 改 outline 某项 → 同步重写出对应 `slide-XX.html` +- 加页 → outline + 新 `slide-NN.html` + 更新 `slide_order` +- 删页 → 从 `slide_order` 移除 id(文件可保留便于回滚) +- 单页指令 → 只动该页 HTML + +## 参考路由 + +| 主题 | 文件 | +|------|------| +| 多文件/单文件架构、交付格式决策、showcase | `references/slide-decks.md` | +| 可编辑 PPTX 约束 | `references/editable-pptx.md` | +| 风格 DNA | `references/design-styles.md` | +| 风格预设(stylePreset)视觉规范 | `references/style-presets/.md` | +| 文案与排版 | `references/content-guidelines.md` | +| 场景版式 | `references/scene-templates.md` | +| 数据可视化、信息可视化与图表实现 | `references/data-information-visualization.md` | + +## 不在范围 + +- 视频/动画/配音/TTS、多模态生图、通用网站 SEO + +--- + +**节奏**:先纲 →(大 deck 先 2 页定 grammar)→ 逐页 HTML → 快、稳、像设计过的幻灯片,不要长开场白。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/assets/deck_index.html b/src/crates/assembly/core/builtin_skills/ppt-design/assets/deck_index.html new file mode 100644 index 0000000000..fdbc4a4afb --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/assets/deck_index.html @@ -0,0 +1,237 @@ + + + + +Deck · Multi-file Slide Index + + + + + + + + + + + +
+ +
+ + + +
1 / 1
+ + + + + + + + diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/content-guidelines.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/content-guidelines.md new file mode 100644 index 0000000000..140cf818c8 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/content-guidelines.md @@ -0,0 +1,260 @@ +# Content Guidelines:反AI slop、内容准则、Scale规范 + +AI设计里最容易掉进去的陷阱。这是一份「不做什么」的清单,比「做什么」更重要——因为AI slop是默认值,你不主动避免就会发生。 + +## AI Slop 完整黑名单 + +### 视觉陷阱 + +**❌ 激进渐变背景** +- 紫色 → 粉色 → 蓝色 全屏渐变(AI生成网页的典型味道) +- 任何方向的rainbow gradient +- Mesh gradient铺满背景 +- ✅ 如果要用渐变:subtle、单色系、有意图地点缀(比如button hover) + +**❌ 圆角卡片 + 左border accent色** +```css +/* 这是AI味卡片的典型签名 */ +.card { + border-radius: 12px; + border-left: 4px solid #3b82f6; + padding: 16px; +} +``` +这种卡片在AI生成的Dashboard里泛滥。想做强调?用更有设计感的方式:背景色对比、字重/字号对比、plain分隔线、或者干脆不分卡片。 + +**❌ Emoji 装饰** +除非品牌本身使用emoji(比如Notion、Slack),否则不要在UI上放emoji。**尤其不要**: +- 标题前的 🚀 ⚡️ ✨ 🎯 💡 +- Feature列表的 ✅ +- CTA按钮里的 →(箭头单独出现OK,emoji箭头不行) + +没图标用真icon库(Lucide/Heroicons/Phosphor),或者用placeholder。 + +**❌ SVG 画 imagery** +不要试图用SVG画:人物、场景、设备、物品、抽象艺术。AI画的SVG imagery一眼就是AI味,幼稚且廉价。**一个灰色矩形+"插画位 1200×800"的文字标签,比一个拙劣的SVG hero illustration强100倍**。 + +唯一可以用SVG的场景: +- 真正的icon(16×16到32×32级别) +- 几何图形做装饰元素 +- Data viz的chart + +**❌ 过多iconography** +不是每个标题/feature/section都需要icon。滥用icon会让界面像toy。Less is more。 + +**❌ "Data slop"** +编造的stats装饰: +- "10,000+ happy customers" (你都不知道有没有) +- "99.9% uptime" (没有真数据就别写) +- 用图标+数字+词组成的装饰"metric cards" +- Mock table里的假数据装点得花里胡哨 + +如果没真数据,留placeholder或问用户要。 + +**❌ "Quote slop"** +编造的用户评价、名人名言装饰页面。留placeholder问用户要真quote。 + +### 字体陷阱 + +**❌ 避免这些烂大街字体**: +- Inter(AI生成的网页默认) +- Roboto +- Arial / Helvetica +- 纯system font stack +- Fraunces(AI发现了这个就用滥了) +- Space Grotesk(最近AI的最爱) + +**✅ 用有特点的display+body配对**。灵感方向: +- 衬线display + 无衬线body(editorial feel) +- Mono display + sans body(technical feel) +- Heavy display + light body(contrast) +- Variable font做hero的粗细动画 + +字体资源: +- Google Fonts的冷门好选项(Instrument Serif、Cormorant、Bricolage Grotesque、JetBrains Mono) +- 开源字体站(Fraunces的兄弟字体、Adobe Fonts) +- 不要凭空发明字体名 + +### 色彩陷阱 + +**❌ 凭空发明颜色** +不要从头设计一整套不熟悉的色彩。这通常不和谐。 + +**✅ 策略**: +1. 有品牌色 → 用品牌色,缺的color token用oklch插值 +2. 没有品牌色但有参考 → 从参考产品截图吸色 +3. 完全从零 → 选一个known的配色系统(Radix Colors / Tailwind默认palette / Anthropic brand),不要自己调 + +**oklch定义色彩**是最现代的做法: +```css +:root { + --primary: oklch(0.65 0.18 25); /* 温暖的terracotta */ + --primary-light: oklch(0.85 0.08 25); /* 同色系浅色 */ + --primary-dark: oklch(0.45 0.20 25); /* 同色系深色 */ +} +``` +oklch能保证调整亮度时色相不漂移,比hsl好用。 + +**❌ 夜间模式随手加反色** +不是简单invert颜色。好的dark mode需要重新调整饱和度、对比度、accent色。不想做dark mode就别做。 + +### Layout陷阱 + +**❌ Bento grid 过度泛滥** +每个AI生成的landing page都想搞bento。除非你的信息structure确实适合bento,否则用其他layout。 + +**❌ 大hero + 3-column features + testimonials + CTA** +这个landing page模板被用烂了。想创新就真创新。 + +**❌ Card grid里每个card长一样** +Asymmetric、不同大小的cards、有的带image有的只有文字、有的跨列——这才像真设计师做的。 + +## 内容准则 + +### 1. Don't add filler content + +每个元素都必须earn its place。空白是设计问题,用**构图**解决(对比、节奏、留白),**不是**靠内容填满。 + +**判断filler的问题**: +- 如果去掉这段内容,设计会变差吗?答案若是"不会",就去掉。 +- 这个元素解决了什么真问题?如果是"让页面不那么空",删掉。 +- 这个stats/quote/feature有真数据支持吗?没有就不要凭空写。 + +「One thousand no's for every yes」。 + +### 2. Ask before adding material + +你觉得多加一段/一页/一个section会更好?先问用户,不要单方面加。 + +原因: +- 用户知道他的受众比你清楚 +- 加内容有成本,用户可能不想要 +- 单方面加内容违反了"junior designer汇报工作"的关系 + +### 3. Create a system up front + +探索完design context后,**先口头说出你要用的系统**,让用户确认: + +```markdown +我的设计系统: +- 色彩:#1A1A1A主体 + #F0EEE6背景 + #D97757 accent(来自你的品牌) +- 字型:Instrument Serif做display + Geist Sans做body +- 节奏:section title用full-bleed彩色背景 + 白字;普通section用白背景 +- 图像:hero用full-bleed照片,feature section用placeholder等你提供 +- 最多用2种背景色,避免杂乱 + +确认这个方向我就开始做。 +``` + +用户确认后再动手。这个check-in能避免"做完一半发现方向错"。 + +## Scale 规范 + +### 幻灯片(1920×1080) + +- 正文最小 **24px**,理想 28-36px +- 标题 60-120px +- Section title 80-160px +- Hero headline 可以用 180-240px 的大字 +- 永远不要用 <24px 的字放幻灯片 + +### 印刷文档 + +- 正文最小 **10pt**(≈13.3px),理想 11-12pt +- 标题 18-36pt +- Caption 8-9pt + +### Web和移动端 + +- 正文最小 **14px**(老年人友好用16px) +- 移动端正文 **16px**(避免iOS自动缩放) +- Hit target(可点击元素)最小 **44×44px** +- 行高 1.5-1.7(中文1.7-1.8) + +### 对比度 + +- 正文 vs 背景 **至少 4.5:1**(WCAG AA) +- 大字 vs 背景 **至少 3:1** +- 用Chrome DevTools的accessibility工具检查 + +## CSS 神器 + +**高级CSS特性**是设计师的好朋友,大胆用: + +### 排版 + +```css +/* 让标题换行更自然,不会最后一行孤单单一个词 */ +h1, h2, h3 { text-wrap: balance; } + +/* 正文换行,避免寡孀和孤儿 */ +p { text-wrap: pretty; } + +/* 中文排版神器:标点挤压、行首行尾控制 */ +p { + text-spacing-trim: space-all; + hanging-punctuation: first; +} +``` + +### Layout + +```css +/* CSS Grid + named areas = 可读性爆表 */ +.layout { + display: grid; + grid-template-areas: + "header header" + "sidebar main" + "footer footer"; + grid-template-columns: 240px 1fr; + grid-template-rows: auto 1fr auto; +} + +/* Subgrid对齐卡片内容 */ +.card { display: grid; grid-template-rows: subgrid; } +``` + +### 视觉效果 + +```css +/* 有设计感的滚动条 */ +* { scrollbar-width: thin; scrollbar-color: #666 transparent; } + +/* 玻璃拟态(克制使用) */ +.glass { + backdrop-filter: blur(20px) saturate(150%); + background: color-mix(in oklch, white 70%, transparent); +} + +/* View transitions API让页面切换丝滑 */ +@view-transition { navigation: auto; } +``` + +### 交互 + +```css +/* :has()选择器让条件样式变容易 */ +.card:has(img) { padding-top: 0; } /* 有图片的卡片无顶padding */ + +/* container queries让组件真的响应式 */ +@container (min-width: 500px) { ... } + +/* 新的color-mix函数 */ +.button:hover { + background: color-mix(in oklch, var(--primary) 85%, black); +} +``` + +## 决策速查:当你犹豫时 + +- 想加个渐变?→ 大概率不加 +- 想加个emoji?→ 不加 +- 想给卡片加圆角+border-left accent?→ 不加,换其他方式 +- 想用SVG画个hero插画?→ 不画,用placeholder +- 想加一段quote装饰?→ 先问用户有没有真quote +- 想加一排icon features?→ 先问要不要icon,可能不需要 +- 用Inter?→ 换一个更有特点的 +- 用紫色渐变?→ 换一个有根据的配色 + +**当你觉得"加一下会更好看"的时候——那通常是AI slop的征兆**。先做最简的版本,只在用户要求时加。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/data-information-visualization.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/data-information-visualization.md new file mode 100644 index 0000000000..7542831365 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/data-information-visualization.md @@ -0,0 +1,205 @@ +# PPT 数据可视化与信息可视化 + +本参考是数据与信息可视化的**设计词汇库**,用于在合适时拓宽 Agent 的表达选择,不是图表配方、逐页流程或合规清单。Agent 可以根据主题、受众、叙事、风格、数据质量与技术条件采用、组合、改造或放弃这里的建议。 + +不要把「可视化」狭义理解为标准商业图表。带标注的照片、按比例排布的文字、空间构图、逐步揭示的概念图、地图式叙事、编辑化数字排版和主题化隐喻都可能有效。图表名称只是可复用词汇,不是设计边界;只要不虚构或扭曲信息,可以创造更贴合主题的新形式。 + +## 目录 + +1. 灵活判断框架 +2. 数据可视化选择 +3. 信息可视化选择 +4. 页面构图与标注 +5. 数据诚信 +6. 可编辑 PPTX 实现 +7. 常见失败模式 +8. 回看提示 + +## 1. 灵活判断框架 + +当页面包含数据或结构关系、或当前构图显得只是文字陈列时,可以从这些角度思考;不要求按顺序或逐页执行: + +1. **提取证据**:列出真实数字、类别、时间点、目标值、步骤、角色、层级、因果或依赖关系。 +2. **定义任务**:观众最需要完成的是比较、识别趋势、理解构成、定位异常、理解关系,还是查精确值? +3. **形成一句结论**:先写出该页希望观众复述的判断,再设计图。 +4. **选择视觉编码**:位置和长度通常更容易准确比较;角度、面积、颜色、形状和图像也可以在适合主题时使用。 +5. **选择页面形式**:一个主图、图 + 注释、表格、流程/架构图,或结构化文字。 +6. **验证数据与导出**:检查单位、口径、来源、可编辑 PPTX 兼容性和画布溢出。 + +### 值得探索可视化的信号 + +- 有两个以上可比较数值。 +- 有三个以上时间点,或明确的前后变化。 +- 有整体与部分、目标与实际、基准与结果。 +- 有步骤顺序、责任交接、层级、依赖或因果关系。 +- 文字中反复出现「高于、低于、增长、下降、占比、集中、分散、先后、驱动、阻碍」。 + +### 文字、图片或表格可能更好的情况 + +- 观众需要查找精确值,而不是识别模式。 +- 数据点很少且结论一句话即可讲清。 +- 类别名称很长、数值只是辅助。 +- 证据主要是定性判断、案例细节或引用。 +- 数据不完整,无法支持诚实的尺度比较。 + +## 2. 数据可视化候选 + +下面描述常见适配关系,而非唯一答案。同一数据可以因叙事目的不同使用完全不同的表现形式。 + +### 比较与排名 + +- **水平条形图**:适合类别名较长、排名、正负值比较。 +- **点图**:类别很多但希望更轻、更节省墨水时使用。 +- **分组条形图**:同一类别只比较 2–3 个系列;系列更多时改小倍图或热力矩阵。 +- 排名叙事通常适合排序;有固定顺序、分组逻辑或需要制造揭示节奏时可保留其他排列。 + +### 时间趋势 + +- **折线图**:时间连续、关注走势和拐点;系列尽量不超过 3 条。 +- **柱状图**:时间离散、强调每期规模,或可编辑路径不适合复杂折线时使用。 +- **斜率图**:只比较起点与终点,突出谁上升、谁下降。 +- 定量趋势通常保持时间间隔真实;若采用非等距叙事时间线,应明确它表达的是事件而非连续尺度。不要选择性删除会改变结论的时点。 + +### 实际、目标与变化 + +- **子弹图**:实际值 + 目标线 + 可选参考区间,通常比表盘更紧凑;强调状态或品牌隐喻时也可使用其他表达。 +- **进度条**:单一完成率;标清分母和截止日期。 +- **瀑布图**:解释起点如何经正负因素形成终点;每个增减项直接标值。 +- **指数化折线**:不同量级系列比较增长速度时,把起点统一为 100,并明确注明。 + +### 构成占比 + +- **100% 堆叠条**:适合比较一个或多个整体的构成。 +- **环形/饼图**:少量类别、整体关系明确时可以很直观;类别多或需要精细比较时通常换条形图更清楚。 +- **普通堆叠条**:既要看总量又要看构成,但中间分段较难比较;必要时拆成总量图 + 100% 构成图。 +- 类别较多时可合并长尾、用条形图,或采用与主题相符的分组/分面表达。 + +### 分布、波动与离群值 + +- **直方图**:连续数值的分布形状;分箱必须一致且有解释。 +- **点图/蜂群图**:样本不多,希望保留每个观测值。 +- **箱线图**:比较多组中位数、四分位和离群值;受众不熟悉时加简短读图说明。 +- 只有平均值没有样本或范围时,不要假装展示分布。 + +### 关系与相关 + +- **散点图**:两个连续变量的关系;需要时加趋势线,但不要把相关写成因果。 +- **气泡图**:只有第三变量确实重要时才用;面积编码难比较,气泡数量要少。 +- **2×2 矩阵**:变量是定性高/低定位,而非精确连续数据。 +- 标注关键点、异常点和目标对象,其余点弱化。 + +### 多维比较 + +- **热力矩阵**:多个对象 × 多个维度,颜色表达等级或区间;格内仍可保留关键数值。 +- **对比表**:精确查阅、文字证据较多时使用;突出关键行列,不把整表都染色。 +- **小倍图**:相同尺度下重复同一种小图,适合比较多个地区、产品或人群的趋势。 +- **雷达图**:适合表达少量对象的整体轮廓或品牌化能力画像,但不适合精确比较;需要精度时考虑点图、热力矩阵或分组条形图。 + +### 空间数据 + +- 地理位置本身影响结论时,地图通常有价值;若地图只是氛围元素,则应确认它与整体视觉叙事一致。 +- 地区值比较优先分级设色地图,地点事件优先点位图。 +- 没有可靠边界、地理数据或可编辑实现时,改用地区排序条形图。 +- 避免让地图只承担填满页面的作用。 + +## 3. 信息可视化选择 + +没有可量化数字,不等于只能写 bullet。信息存在结构时,可以考虑下列图示,也可以继续使用更有节奏的文字、图像或混合构图。 + +| 信息结构 | 推荐形式 | 关键要求 | +|----------|----------|----------| +| 线性步骤 | 流程图 | 动词开头,3–7 步,标明输入/输出 | +| 多角色交接 | 泳道图 | 泳道按责任方,节点按时间推进 | +| 时间演进 | 时间线/阶段带 | 日期与事件绑定,突出转折点 | +| 因果关系 | 因果链/问题树 | 区分原因、机制、结果,箭头有方向 | +| 层级与拆解 | 树状图/金字塔 | 上下位关系明确,同层粒度一致 | +| 系统组成 | 分层架构图 | 层名、模块、连接关系均有标签 | +| 多方关系 | 利益相关者图/关系网络 | 中心对象明确,线型或方向有含义 | +| 前后变化 | Before / After | 使用同一组维度对齐比较 | +| 方案选择 | 2×2 / 决策矩阵 | 轴和评分依据可解释 | +| 用户体验 | Journey map | 阶段、行为、痛点、机会按列对齐 | +| 论点结构 | 论点树/证据链 | 结论、分论点、证据层级清楚 | + +信息图中的框、线、箭头和图标最好具有可解释的语义;纯装饰元素可以存在,但不应冒充信息关系或干扰阅读。 + +## 4. 页面构图与标注 + +### 推荐构图 + +- **结论 + 主图**:标题给结论,主图承担主要证据,旁边放少量解释;具体占比由风格和内容决定。 +- **主图 + 证据注释**:在关键峰值、拐点、异常点旁直接注释原因。 +- **图 + 明细表**:图显示模式,小表提供精确值;两者应使用同一口径。 +- **小倍图**:每个小图使用相同尺度、相同时间范围和相同视觉编码。 +- **过程图 + 结果栏**:左侧解释机制,右侧放结果指标或结论。 + +### 常见标注层级 + +1. 断言式标题或有意保留悬念的叙事标题。 +2. 图内直接标签和关键数值。 +3. 解释差异、原因或下一步的注释。 +4. 必要的单位、时间、来源、样本与估算说明。 + +图例只在直接标注会造成拥挤时使用。系列名称放在线末端或条形旁通常比单独图例更快。 + +### 颜色与尺度 + +- 1 个强调色 + 中性色是稳妥方案;品牌化、文化或教育主题可以使用更丰富但有秩序的色彩系统。 +- 同一 deck 中同一对象保持同色;不要在不同页面随意换色。 +- 条形图数值轴通常从 0 开始;折线图等场景可使用截断轴,但应明确标出并避免夸大。 +- 使用面积表达数量时按面积映射;若图形只是定性象征,应避免让观众误读为精确比例。 +- 3D、透视、阴影体积和纹理容易扭曲数量判断;只有当它们主要承担风格表达且不会误导时再使用。 + +## 5. 数据诚信(硬约束) + +每张数据图至少核对: + +- 数值与来源一致,没有把估算写成事实。 +- 单位、币种、时间范围、地域、样本量和分母明确。 +- 百分比能解释分母;构成图总和应为 100% 或说明为何不是。 +- 同一图中的系列口径一致;名义值与实际值、累计值与单期值不混用。 +- 缺失值显示为空缺或 `N/A`,不自动当作 0。 +- 预测、目标、实际用不同线型/填充并清楚标注。 +- 没有数据时使用定性矩阵、流程或结构化文字,不生成伪图表。 + +## 6. 可编辑 PPTX 实现(技术约束) + +默认 960×540pt 可编辑路径遵守 `editable-pptx.md`: + +- 条形、柱形、进度、堆叠条:用纯色 `div` 表达长度;标签放在独立 `

` 中。 +- 点图、散点:用小尺寸圆形 `div` 定位;关键点旁直接放标签。 +- 折线/斜率:用 1–2pt 高的纯色 `div` 配 `transform: rotate(...)` 连接点;线段太多时改柱状图或表格。 +- 目标线、坐标轴、连接线:用细 `div` 或 border;不要用 CSS gradient。 +- 100% 构成:相邻纯色 `div` 的分段条导出更稳;`conic-gradient` 环形图不适用于默认可编辑路径。 +- 热力矩阵:每个单元格使用离散的 3–5 档纯色,不依赖连续渐变。 +- 复杂地图、桑基图、密集网络、复杂 SVG:不适合默认可编辑路径。改为简化结构、表格/条形图,或明确只用于 1920 HTML 演讲版。 +- 所有图表文字仍遵守「div 不直接写文字」;背景、边框放在 `div`,文本放在 `

/`。 + +纯 HTML 演讲版可以使用 SVG path、clip-path 和渐变,但仍需保证图形真实表达数据,不把视觉效果当作证据。 + +## 7. 常见风险信号 + +以下现象值得重新思考,但不自动代表设计错误;若它们服务于明确的叙事或风格意图,可以保留。 + +- 有 5 个季度数据,却做成 5 张指标卡。 +- 有 8 个类别排名,却用饼图。 +- 用一个巨大数字,却没有单位、基准、时间或目标。 +- 图标题只写主题,不告诉观众该看什么。 +- 所有系列同等鲜艳,关键结论没有视觉焦点。 +- 图例与图形距离太远,观众需要反复匹配颜色。 +- 表格整页都是数字,没有高亮行列和结论。 +- 流程图只有名词框,没有动作、方向、输入输出。 +- 复杂图塞在半页,字号和标签小到无法演示。 +- 为了「更像数据页」编造数字、补齐缺失值或伪造精度。 + +## 8. 回看提示 + +完成 deck 后可以整体回看,不要求每页都满足同一种标准: + +1. 有数据或结构的页面里,是否错过了能显著提升理解的可视化机会? +2. 是否也存在为了“看起来丰富”而硬加的图表或图示? +3. deck 的视觉表达是否随主题和叙事变化,而不是重复同一种卡片或同一种图表? +4. 图形、文字、图片和留白之间是否形成了合适的节奏? +5. 数据页的数值、单位、时间、口径和来源是否齐全? +6. 所有页面是否满足画布、底部安全边距和目标导出路径的技术约束? + +目标不是增加图表数量,而是扩大 Agent 的表达选择:该图则图,该文则文,也允许创造无法被上述分类表完整命名的新形式。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/design-styles.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/design-styles.md new file mode 100644 index 0000000000..702d53282a --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/design-styles.md @@ -0,0 +1,591 @@ +# 设计哲学风格库:20种体系 + +> 用于视觉设计(网页/PPT/PDF/信息图/配图/App等)的设计风格库。 +> 每种风格提供:哲学内核 + 核心特征 + 提示词DNA(与场景模板组合使用)。 + +## 风格×场景×执行路径 速查表 + +| 风格 | 网页 | PPT | PDF | 信息图 | 封面 | AI生成 | 最佳路径 | +|------|:---:|:---:|:---:|:-----:|:---:|:-----:|---------| +| 01 Pentagram | ★★★ | ★★★ | ★★☆ | ★★☆ | ★★★ | ★☆☆ | HTML | +| 02 Stamen Design | ★★☆ | ★★☆ | ★★☆ | ★★★ | ★★☆ | ★★☆ | 混合 | +| 03 Information Architects | ★★★ | ★☆☆ | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | HTML | +| 04 Fathom | ★★☆ | ★★★ | ★★★ | ★★★ | ★★☆ | ★☆☆ | HTML | +| 05 Locomotive | ★★★ | ★★☆ | ★☆☆ | ★☆☆ | ★★☆ | ★★☆ | 混合 | +| 06 Active Theory | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | ★★☆ | ★★★ | AI生成 | +| 07 Field.io | ★★☆ | ★★☆ | ★☆☆ | ★★☆ | ★★★ | ★★★ | AI生成 | +| 08 Resn | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | ★★☆ | ★★☆ | AI生成 | +| 09 Experimental Jetset | ★★☆ | ★★☆ | ★★☆ | ★★☆ | ★★★ | ★★☆ | 混合 | +| 10 Müller-Brockmann | ★★☆ | ★★★ | ★★★ | ★★★ | ★★☆ | ★☆☆ | HTML | +| 11 Build | ★★★ | ★★★ | ★★☆ | ★☆☆ | ★★★ | ★☆☆ | HTML | +| 12 Sagmeister & Walsh | ★★☆ | ★★★ | ★☆☆ | ★★☆ | ★★★ | ★★★ | AI生成 | +| 13 Zach Lieberman | ★☆☆ | ★☆☆ | ★☆☆ | ★★☆ | ★★★ | ★★★ | AI生成 | +| 14 Raven Kwok | ★☆☆ | ★★☆ | ★☆☆ | ★★☆ | ★★★ | ★★★ | AI生成 | +| 15 Ash Thorp | ★★☆ | ★★☆ | ★☆☆ | ★☆☆ | ★★★ | ★★★ | AI生成 | +| 16 Territory Studio | ★★☆ | ★★☆ | ★☆☆ | ★★☆ | ★★★ | ★★★ | AI生成 | +| 17 Takram | ★★★ | ★★★ | ★★★ | ★★☆ | ★★☆ | ★☆☆ | HTML | +| 18 Kenya Hara | ★★☆ | ★★★ | ★★★ | ★☆☆ | ★★★ | ★☆☆ | HTML | +| 19 Irma Boom | ★☆☆ | ★★☆ | ★★★ | ★★☆ | ★★★ | ★★☆ | 混合 | +| 20 Neo Shen | ★★☆ | ★★☆ | ★★☆ | ★★☆ | ★★★ | ★★★ | AI生成 | + +> 场景适配:★★★ = 强烈推荐 / ★★☆ = 适合 / ★☆☆ = 需改造 +> AI生成:★★★ = 直出效果好 / ★★☆ = 需调整 / ★☆☆ = 建议HTML执行 +> 最佳路径:AI生成(图片直出)/ HTML(代码渲染,数据精确)/ 混合(HTML布局+AI配图) + +**核心规律**:有明确视觉元素的风格(插画/粒子/生成艺术)AI直出效果好;依赖精确排版和数据的风格(网格/信息架构/留白)HTML渲染更可控。 + +--- + +## 一、信息建筑派(01-04) +> 哲学:「数据不是装饰,是建筑材料」 + +### 01. Pentagram - Michael Bierut风格 +**哲学**:字体即语言,网格即思想 +**核心特征**: +- 极度克制的颜色(黑白+1个品牌色) +- 瑞士网格系统的现代演绎 +- 字体排印作为主要视觉语言 +- 负空间的战略性使用(60%+留白) + +**提示词DNA**: +``` +Pentagram/Michael Bierut style: +- Extreme typographic hierarchy, Helvetica/Univers family +- Swiss grid with precise mathematical spacing +- Black/white + one accent color (#HEX) +- Information architecture as visual structure +- 60%+ whitespace ratio +- Data visualization as primary decoration +``` + +**代表作**:Hillary Clinton 2016 campaign identity +**搜索关键词**:pentagram hillary logo system + +--- + +### 02. Stamen Design - 数据诗学 +**哲学**:让数据成为可触摸的风景 +**核心特征**: +- 地图学思维应用于信息设计 +- 算法生成的有机图形 +- 温暖的数据可视化色调(赭石、鼠尾草绿、深蓝) +- 可交互的层级系统 + +**提示词DNA**: +``` +Stamen Design aesthetic: +- Cartographic approach to data visualization +- Organic, algorithm-generated patterns +- Warm palette (terracotta, sage green, deep blues) +- Layered information like topographic maps +- Hand-crafted feel despite digital precision +- Soft shadows and depth +``` + +**代表作**:COVID-19 surge map +**搜索关键词**:stamen covid map visualization + +--- + +### 03. Information Architects - 内容优先原则 +**哲学**:设计不是装饰,是内容的建筑 +**核心特征**: +- 极端的内容层级清晰度 +- 只使用系统字体(优化阅读) +- 蓝色超链接传统的坚守 +- 性能即美学 + +**提示词DNA**: +``` +Information Architects philosophy: +- Content-first hierarchy, zero decorative elements +- System fonts only (SF Pro/Roboto/Inter) +- Classic blue hyperlinks (#0000EE) +- Reading-optimized line length (66 characters) +- Progressive disclosure of depth +- Text-heavy, fast-loading design +``` + +**代表作**:iA Writer app +**搜索关键词**:information architects ia writer + +--- + +### 04. Fathom Information Design - 科学叙事 +**哲学**:每一个像素都必须承载信息 +**核心特征**: +- 科学期刊的严谨+设计的优雅 +- 定量数据的精确可视化 +- 冷静的专业色调(灰、海军蓝) +- 注释与引用系统的设计化 + +**提示词DNA**: +``` +Fathom Information Design style: +- Scientific journal aesthetic meets modern design +- Precise data visualization (charts, timelines, scatter plots) +- Neutral scheme (grays, navy, one highlight color) +- Footnote/citation design integrated into layout +- Clean sans-serif (GT America/Graphik) +- Information density without clutter +``` + +**代表作**:Bill & Melinda Gates Foundation年度报告 +**搜索关键词**:fathom information design gates foundation + +--- + +## 二、运动诗学派(05-08) +> 哲学:「技术本身就是一种流动的诗」 + +### 05. Locomotive - 滚动叙事大师 +**哲学**:滚动不是浏览,是旅程 +**核心特征**: +- 丝滑的视差滚动 +- 电影化的分镜叙事 +- 大胆的空间留白 +- 动态元素的精确编排 + +**提示词DNA**: +``` +Locomotive scroll narrative style: +- Film-like scene composition with parallax depth +- Generous vertical spacing between sections +- Bold typography emerging from darkness +- Smooth motion blur effects +- Dark mode (near-black backgrounds) +- Strategic glowing accents +- Hero sections 100vh tall +``` + +**代表作**:Lusion.co website +**搜索关键词**:locomotive scroll lusion + +--- + +### 06. Active Theory - WebGL诗人 +**哲学**:让技术可见化即让技术可理解 +**核心特征**: +- 3D粒子系统作为核心元素 +- 实时渲染的数据可视化 +- 鼠标交互驱动的世界构建 +- 霓虹与深空的配色 + +**提示词DNA**: +``` +Active Theory WebGL aesthetic: +- Particle systems representing data flow +- 3D visualization in depth space +- Neon gradients (cyan/magenta/electric blue) on dark +- Mouse-reactive environment +- Depth of field and bokeh effects +- Floating UI with glassmorphism +``` + +**代表作**:NASA Prospect +**搜索关键词**:active theory nasa webgl + +--- + +### 07. Field.io - 算法美学 +**哲学**:代码即设计师 +**核心特征**: +- 生成艺术系统 +- 每次访问都不同的动态图形 +- 抽象几何的智能编排 +- 技术感与艺术性的平衡 + +**提示词DNA**: +``` +Field.io generative design style: +- Abstract geometric patterns, algorithmically generated +- Dynamic composition that feels computational +- Monochromatic base with vibrant accent +- Mathematical precision in spacing +- Voronoi diagrams or Delaunay triangulation +- Clean code aesthetic +``` + +**代表作**:British Council digital installations +**搜索关键词**:field.io generative design + +--- + +### 08. Resn - 叙事驱动的交互 +**哲学**:每个点击都推进故事 +**核心特征**: +- 游戏化的用户旅程 +- 强烈的情感化设计 +- 插画与代码的深度结合 +- 非线性的探索体验 + +**提示词DNA**: +``` +Resn interactive storytelling approach: +- Illustrative style mixed with UI elements +- Gamified exploration (progress indicators) +- Warm color palette despite tech subject +- Character-driven design +- Scroll-triggered animations +- Editorial illustration meets product design +``` + +**代表作**:Resn.co.nz portfolio +**搜索关键词**:resn interactive storytelling + +--- + +## 三、极简主义派(09-12) +> 哲学:「删减到无法再删」 + +### 09. Experimental Jetset - 概念极简 +**哲学**:一个想法=一个形式 +**核心特征**: +- 单一视觉隐喻贯穿整个设计 +- 蓝/红/黄+黑白的蒙德里安色系 +- 字体即图形 +- 反商业的诚实设计 + +**提示词DNA**: +``` +Experimental Jetset conceptual minimalism: +- Single visual metaphor for entire design +- Primary colors only (red/blue/yellow) + black/white +- Typography as main graphic element +- Grid-based with deliberate rule-breaking +- No photography, only type and geometry +- Anti-commercial, honest aesthetic +``` + +**代表作**:Whitney Museum identity +**搜索关键词**:experimental jetset whitney responsive w + +--- + +### 10. Müller-Brockmann传承 - 瑞士网格纯粹主义 +**哲学**:客观性即美 +**核心特征**: +- 数学精确的网格系统(8pt基线) +- 绝对的左对齐或居中 +- 单色或双色方案 +- 功能主义至上 + +**提示词DNA**: +``` +Josef Müller-Brockmann Swiss modernism: +- Mathematical grid system (8pt baseline) +- Strict alignment (flush left or centered) +- Two-color maximum (black + one accent) +- Akzidenz-Grotesk or similar rationalist typeface +- No decorative elements +- Timeless, objective aesthetic +``` + +**代表作**:《Grid Systems in Graphic Design》 +**搜索关键词**:muller brockmann grid systems poster + +--- + +### 11. Build - 当代极简品牌 +**哲学**:精致的简单比复杂更难 +**核心特征**: +- 奢侈品级的留白(70%+) +- 微妙的字重对比(200-600) +- 单一强调色的战略使用 +- 呼吸感的节奏 + +**提示词DNA**: +``` +Build studio luxury minimalism: +- Generous whitespace (70%+ of area) +- Subtle typography weight shifts (200 to 600) +- Single accent color used sparingly +- High-end product photography aesthetic +- Soft shadows and subtle gradients +- Golden ratio proportions +``` + +**代表作**:Build studio portfolio +**搜索关键词**:build studio london branding + +--- + +### 12. Sagmeister & Walsh - 快乐极简 +**哲学**:美即功能的情感维度 +**核心特征**: +- 意外的色彩爆发 +- 手工感与数字的融合 +- 正能量的视觉语言 +- 实验性但可读 + +**提示词DNA**: +``` +Sagmeister & Walsh joyful philosophy: +- Unexpected color bursts on minimal base +- Handmade elements (physical objects in digital) +- Optimistic visual language +- Experimental typography that remains legible +- Human warmth through imperfection +- Mix of analog and digital aesthetics +``` + +**代表作**:The Happy Show +**搜索关键词**:sagmeister walsh happy show + +--- + +## 四、实验先锋派(13-16) +> 哲学:「打破规则即创造规则」 + +### 13. Zach Lieberman - 代码诗学 +**哲学**:编程即绘画 +**核心特征**: +- 手绘感的算法图形 +- 实时生成艺术 +- 黑白的纯粹表达 +- 工具本身的可见性 + +**提示词DNA**: +``` +Zach Lieberman code-as-art style: +- Hand-drawn aesthetic generated by code +- Black and white only, no color +- Real-time generative patterns +- Sketch-like line quality +- Visible process/grid/construction lines +- Poetic interpretation of algorithms +``` + +**代表作**:openFrameworks creative coding +**搜索关键词**:zach lieberman openframeworks generative + +--- + +### 14. Raven Kwok - 参数化美学 +**哲学**:系统的美胜过个体的美 +**核心特征**: +- 分形与递归图形 +- 黑白高对比 +- 建筑化的信息结构 +- 东方园林的算法演绎 + +**提示词DNA**: +``` +Raven Kwok parametric aesthetic: +- Fractal patterns and recursive structures +- High-contrast black and white +- Architectural visualization of data +- Chinese garden principles in algorithm form +- Intricate detail that rewards zooming +- Processing/Creative coding aesthetic +``` + +**代表作**:Raven Kwok generative art exhibitions +**搜索关键词**:raven kwok processing generative art + +--- + +### 15. Ash Thorp - 赛博诗意 +**哲学**:未来不是冰冷的,是孤独的诗 +**核心特征**: +- 电影级的光影 +- 赛博朋克的温暖版本(橙/青,非冷蓝) +- 故事性的概念设计 +- 工业美学的精致化 + +**提示词DNA**: +``` +Ash Thorp cinematic concept art: +- Film-grade lighting and atmospheric effects +- Warm cyberpunk (orange/teal, NOT cold blue) +- Industrial design meets luxury +- Narrative concept art feel +- Volumetric lighting and god rays +- Blade Runner warmth over Tron coldness +``` + +**代表作**:Ghost in the Shell concept art +**搜索关键词**:ash thorp ghost shell concept art + +--- + +### 16. Territory Studio - 屏幕界面虚构 +**哲学**:未来UI的今日想象 +**核心特征**: +- 科幻电影中的屏幕设计(FUI) +- 全息投影感 +- 多层叠加的数据可视化 +- 可信的未来感 + +**提示词DNA**: +``` +Territory Studio FUI (Fantasy User Interface): +- Fantasy User Interface design +- Holographic projection aesthetics +- Orange/amber monochrome or cyan accents +- Multiple overlapping data layers +- Believable future technology +- Technical readouts and data streams +``` + +**代表作**:Blade Runner 2049 screen graphics +**搜索关键词**:territory studio blade runner interface + +--- + +## 五、东方哲学派(17-20) +> 哲学:「留白即内容」 + +### 17. Takram - 日式思辨设计 +**哲学**:技术是思考的媒介 +**核心特征**: +- 概念原型的优雅 +- 柔和的科技感(圆角、柔和阴影) +- 图表即艺术 +- 谦逊的精致 + +**提示词DNA**: +``` +Takram Japanese speculative design: +- Elegant concept prototypes and diagrams +- Soft tech aesthetic (rounded corners, gentle shadows) +- Charts and diagrams as art pieces +- Modest sophistication +- Neutral natural colors (beige, soft gray, muted green) +- Design as philosophical inquiry +``` + +**代表作**:NHK Fabricated City +**搜索关键词**:takram nhk data visualization + +--- + +### 18. Kenya Hara - 空的设计 +**哲学**:设计不是填充,是清空 +**核心特征**: +- 极致的留白(80%+) +- 纸张质感的数字化 +- 白色的层次(暖白、冷白、米白) +- 触觉的视觉化 + +**提示词DNA**: +``` +Kenya Hara "emptiness" design: +- Extreme whitespace (80%+) +- Paper texture and tactility in digital form +- Layers of white (warm white, cool white, off-white) +- Minimal color (if any, very desaturated) +- Design by subtraction not addition +- Zen simplicity +``` + +**代表作**:Muji art direction, 《Designing Design》 +**搜索关键词**:kenya hara designing design muji + +--- + +### 19. Irma Boom - 书籍建筑师 +**哲学**:信息的物理诗学 +**核心特征**: +- 非线性的信息架构 +- 边缘与边界的游戏 +- 意外的颜色组合(粉+红、橙+棕) +- 手工艺的数字转译 + +**提示词DNA**: +``` +Irma Boom book architecture style: +- Non-linear information structure +- Play with edges, margins, boundaries +- Unexpected color combos (pink+red, orange+brown) +- Handcraft translated to digital +- Dense information inviting exploration +- Editorial design, unconventional grid +``` + +**代表作**:SHV Think Book (2136 pages) +**搜索关键词**:irma boom shv think book + +--- + +### 20. Neo Shen - 东方光影诗 +**哲学**:技术需要人的温度 +**核心特征**: +- 水墨晕染的数字化 +- 柔和的光晕效果 +- 诗意的留白 +- 情感化的色彩(深蓝、暖灰、柔金) + +**提示词DNA**: +``` +Neo Shen poetic Chinese aesthetic: +- Digital interpretation of ink wash painting +- Soft glow and light diffusion effects +- Poetic negative space +- Emotional palette (deep blues, warm grays, soft gold) +- Calligraphic influences in typography +- Atmospheric depth +``` + +**代表作**:Neo Shen digital art series +**搜索关键词**:neo shen digital ink wash art + +--- + +## 提示词使用说明 + +**组合公式**:`[风格提示词DNA] + [场景模板(见scene-templates.md)] + [具体内容]` + +### 核心原则:描述情绪而非布局(Mood, Not Layout) + +AI图像生成的关键:短提示词 > 长提示词。描述3句情绪和内容,比30行布局细节效果更好。 + +| 杀死多样性的写法 | 激发创造力的写法 | +|----------------|----------------| +| 指定颜色比例(60%/25%/15%) | 描述情绪("warm like Sunday morning") | +| 规定布局位置("标题居中,图片右侧") | 引用具体美学("Pentagram editorial feel") | +| 限制角色姿势和表情 | 让AI自然诠释风格 | +| 列出所有要包含的视觉元素 | 描述观众应该感受到什么 | + +### Good / Bad 示例 + +**Bad — 过度约束(AI生成出来空且平):** +``` +Professional presentation slide. Dark background, light text. +Title centered at top. Two columns below. Left column: bullet points. +Right column: bar chart. Colors: navy 60%, white 30%, gold 10%. +Font size: title 36pt, body 18pt. Margins: 40px all sides. +``` + +**Good — 情绪驱动(生成多样且有质感):** +``` +A data visualization that feels like a Bloomberg Businessweek +editorial spread. The key number "28.5%" should dominate the +composition like a headline. Warm cream tones with sharp black +typography. The data tells a story of dramatic channel shift. +``` + +### 执行路径选择 + +根据速查表的「最佳路径」列选择: +- **AI生成**:有明确视觉元素的风格(06/07/12/13/14/15/16/20),用 Gemini/Midjourney 直出 +- **HTML渲染**:依赖精确排版的风格(01/03/04/10/11/17/18),代码控制数据和布局 +- **混合**:HTML做骨架布局 + AI生成配图/背景(02/05/08/09/19) + +### 质量控制 + +1. ❌ 不要直接写 "in the style of Pentagram" → ✅ 用具体设计特征描述 +2. 文字在AI生成中常出错 → 生成后替换文字 +3. 比例易失真 → 明确指定 aspect ratio +4. 先生成3-5个变体,选择最佳后细化 + +**默认审美禁区**(用户可按自己品牌 override): +- ❌ 赛博霓虹/深蓝色底(#0D1117) +- ❌ 封面图加个人署名/水印 + +--- + +**版本**:v2.1 +**更新日期**:2026-02-13 +**适用场景**:网页/PPT/PDF/信息图/封面/配图/App等所有视觉设计 +**与 image-to-slides 联动**:PPT场景可直接引用本文件风格,通过 image-to-slides skill 执行生成 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/editable-pptx.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/editable-pptx.md new file mode 100644 index 0000000000..760541e7c5 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/editable-pptx.md @@ -0,0 +1,314 @@ +# 可编辑 PPTX:HTML 硬约束 + 尺寸 + 常见错误 + +把 HTML **逐元素** 翻译成 PowerPoint 文本框/形状时,必须满足下列规则(与 `html2pptx` 管线一致)。 + +> **核心前提**:要走这条路,HTML 必须从第一行就按下面 4 条约束写。**不是写完再转**——事后补救会触发 2-3 小时返工(2026-04-20 期权私董会项目实测踩坑)。 +> +> 视觉自由度优先(动画 / web component / CSS 渐变 / 复杂 SVG)**不要**走可编辑 PPTX 管线——这是格式物理约束(见文末「为什么 4 条约束不是 Bug 而是物理约束」)。演讲用保留 1920 HTML 即可。 + +--- + +## 画布尺寸:用 960×540pt(LAYOUT_WIDE) + +PPTX 单位是 **inch**(物理尺寸),不是 px。决策原则:body 的 computedStyle 尺寸要**匹配 presentation layout 的 inch 尺寸**(±0.1",由 `html2pptx.cjs` 的 `validateDimensions` 强制检查)。 + +### 3 个候选尺寸对比 + +| HTML body | 物理尺寸 | 对应 PPT layout | 何时选 | +|---|---|---|---| +| **`960pt × 540pt`** | **13.333″ × 7.5″** | **pptxgenjs `LAYOUT_WIDE`** | ✅ **默认推荐**(现代 PowerPoint 16:9 标配) | +| `720pt × 405pt` | 10″ × 5.625″ | 自定义 | 仅当用户指定「老版 PowerPoint Widescreen」模板时 | +| `1920px × 1080px` | 20″ × 11.25″ | 自定义 | ❌ 非标尺寸,投影后字体显得异常小 | + +**别把 HTML 尺寸当分辨率想。** PPTX 是矢量文档,body 尺寸决定的是**物理尺寸**不是清晰度。超大 body(20″×11.25″)不会让文字更清晰——只会让字号 pt 相对画布变小,投影/打印时反而更难看。 + +### body 写法三选一(等价) + +```css +body { width: 960pt; height: 540pt; } /* 最清晰,推荐 */ +body { width: 1280px; height: 720px; } /* 等价,px 习惯 */ +body { width: 13.333in; height: 7.5in; } /* 等价,英寸直觉 */ +``` + +配套的 pptxgenjs 代码: + +```js +const pptx = new pptxgen(); +pptx.layout = 'LAYOUT_WIDE'; // 13.333 × 7.5 inch, 无需自定义 +``` + +--- + +## 4 条硬约束(违反会直接报错) + +`html2pptx.cjs` 把 HTML 的 DOM 逐元素翻译成 PowerPoint 对象。PowerPoint 的格式约束投射到 HTML 上 = 下面 4 条规则。 + +### 规则 1:DIV 里不能直接写文字 — 必须用 `

` 或 `

`-`

` 包裹 + +```html + +
Q3营收增长23%
+ + +

Q3营收增长23%

+

新用户是主要驱动力

+``` + +**为什么**:PowerPoint 文本必须存在 text frame 里,text frame 对应 HTML 的段落级元素(p/h*/li)。裸 `
` 在 PPTX 里没有对应的文本容器。 + +**也不能用 `` 承载主文字**——span 是行内元素,没法独立对齐成文本框。span 只能**夹在 p/h\* 里**做局部样式(加粗、换色)。 + +### 规则 2:不支持 CSS 渐变 — 只能用纯色 + +```css +/* ❌ 错误 */ +background: linear-gradient(to right, #FF6B6B, #4ECDC4); + +/* ✅ 正确:纯色 */ +background: #FF6B6B; + +/* ✅ 如果必须多色条纹,用 flex 子元素各自纯色 */ +.stripe-bar { display: flex; } +.stripe-bar div { flex: 1; } +.red { background: #FF6B6B; } +.teal { background: #4ECDC4; } +``` + +**为什么**:PowerPoint 的 shape fill 只支持 solid/gradient-fill 两种,但 pptxgenjs 的 `fill: { color: ... }` 只映射 solid。渐变走 PowerPoint 原生 gradient 需要另写结构,目前工具链不支持。 + +### 规则 3:背景/边框/阴影只能在 DIV 上,不能在文字标签上 + +```html + +

重点内容

+ + +
+

重点内容

+
+``` + +**为什么**:PowerPoint 里 shape(方块/圆角矩形)和 text frame 是两个对象。HTML 的 `

` 只翻译成 text frame,背景/边框/阴影属于 shape——必须在**包裹 text 的 div** 上写。 + +### 规则 4:DIV 不能用 `background-image` — 用 `` 标签 + +```html + +

+ + + +``` + +**为什么**:`html2pptx.cjs` 只从 `` 元素提取图片路径,不解析 CSS 的 `background-image` URL。 + +--- + +## 合并文本框(`data-pptx-merge`) + +**默认行为**:HTML 里每个 `

`/`

`-`

` 在 PPTX 里都是**独立文本框**。卡片里写 3 个 `

` → PPT 里 3 个文本框摞着,编辑时不能整段回车换行加段,得逐个改字号/对齐。 + +**解决方法**:给外层 div 加 `data-pptx-merge="true"`,容器内的所有 `

/` 会合并为**一个可编辑文本框**,每段之间用段落分隔符隔开,PPT 里就是一段一段连续编辑。 + +```html + +

+

标题

+

第一段正文。

+

第二段:换颜色作为强调。

+

第三段:同一个文本框里继续写。

+
+``` + +**保留的样式**(per-paragraph 作为 run options 写入):`font-size`、`color`、`font-family`、`font-weight`(bold)、`font-style`(italic)、`text-decoration: underline`、`/////` 内联样式。 + +**取自第一段、整框统一**:`text-align`、`line-height`。因为 PowerPoint 的对齐和行距是 paragraph/textbox 级别——一框里只能有一种对齐。如果几段对齐不同,请别用 merge,让它们各自独立。 + +**容器自身的 `background`/`border`/`box-shadow`/`border-radius`** 照常作为 shape 渲染,行为和普通 div 完全一样——也就是说蓝色卡片底 + 文本仍然是「shape + text frame」两层,只是文本层从 3-4 个文本框塌缩成 1 个。 + +**限制**: +- 不能嵌套 `data-pptx-merge`(会报错)。 +- 容器不能用 `background-image`(同 4 条硬约束规则 4)。 +- 容器内不要再放有 `background`/`border` 的子 div——它们仍会被当作独立 shape 渲染,但里面的文字已被合并走了,可能产生视觉错位。 + +**什么时候用**:内容会反复改、要在 PPT 里继续编辑的场景。一次性导出归档的不用加,行为一致。 + +--- + +## Path A HTML 模板骨架 + +每张 slide 一个独立 HTML 文件,彼此作用域隔离(避开单文件 deck 的 CSS 污染)。 + +```html + + + + + + + + + +
+

标题用断言句,不是主题词

+

副标题补充说明

+
+ + +
+

要点一

+

简短说明文字

+
+ + +
+
    +
  • 第一条要点
  • +
  • 第二条要点
  • +
  • 第三条要点
  • +
+
+ + + + + + +``` + +--- + +## 常见错误速查 + +| 错误信息 | 原因 | 修复方法 | +|---------|------|---------| +| `DIV element contains unwrapped text "XXX"` | div 里有裸文字 | 把文字包进 `

` 或 `

`-`

` | +| `CSS gradients are not supported` | 用了 linear/radial-gradient | 改为纯色,或用 flex 子元素分段 | +| `Text element

has background` | `

` 标签加了背景色 | 外套 `

` 承载背景,`

` 只写文字 | +| `Background images on DIV elements are not supported` | div 用了 background-image | 改为 `` 标签 | +| `HTML content overflows body by Xpt vertically` | 内容超出 540pt | 减少内容或缩小字号,或 `overflow: hidden` 截断 | +| `HTML dimensions don't match presentation layout` | body 尺寸和 pres layout 对不上 | body 用 `960pt × 540pt` 配 `LAYOUT_WIDE`;或 defineLayout 自定义尺寸 | +| `Text box "XXX" ends too close to bottom edge` | 大字号 `

` 距离 body 底边 < 0.5 inch | 往上挪,留足下边距;PPT 底部本身就会被遮住一部分 | + +--- + +## 基本工作流(3 步出 PPTX) + +### Step 1:按约束写每页独立 HTML + +``` +我的Deck/ +├── slides/ +│ ├── 01-cover.html # 每个文件都是完整 960×540pt HTML +│ ├── 02-agenda.html +│ └── ... +└── illustration/ # 所有 引用的图片 + ├── chart1.png + └── ... +``` + +### Step 2:在 PowerPoint 里抽查 + +- 双击文字应能直接编辑(若变成整页图片 → 检查规则 1) +- 无文字溢出画布(`validateDimensions` / 肉眼) + +--- + +## 这条路径 vs 其他选项(什么时候选什么) + +| 需求 | 选什么 | +|------|------| +| 同事会改 PPTX 里的文字 / 发给非技术人员继续编辑 | **本文路径**(editable,需从头按 4 条约束写 HTML) | +| 只是演讲用 / 发存档,不再改 | 保持 1920 演讲 HTML,不必迁就 PPTX 四条约束 | +| 视觉自由度优先(动画、web component、CSS 渐变、复杂 SVG),接受不可编辑 | **1920 演讲 HTML**(或单独存档版),不要硬转 editable pptx | + +**绝不要在视觉自由写好的 HTML 上硬跑 html2pptx**——实测 pass 率 < 30%,应保留演讲版或重写 960pt 简化版,不要硬挤 PPTX。 + +--- + +## Fallback:已有视觉稿但用户坚持要 editable PPTX + +偶尔会遇到:已有视觉驱动的 1920 HTML(渐变、web component、复杂 SVG),用户仍坚持要可编辑 PPTX。 + +**不要硬跑 `html2pptx` 期待它 pass**——实测视觉驱动 HTML 在 html2pptx 上 pass 率 <30%,剩下 70% 会报错或走样。正确的 fallback 是: + +### Step 1 · 先告知局限性(透明沟通) + +一句话跟用户说清三件事: + +> 「你现在的 HTML 用了 [具体列出:渐变 / web component / 复杂 SVG / ...],直接转 editable PPTX 会 fail。我有两个方案: +> - A. **保留 1920 演讲 HTML**(推荐)——视觉完整,不在 PowerPoint 里改字 +> - B. **以视觉稿为蓝本,重写一版 960pt editable HTML**(保留色彩/布局/文案,按四条硬约束重组结构,**牺牲**渐变、web component、复杂 SVG) +> +> 你选哪个?」 + +不要把 B 方案说得云淡风轻——明确告知**会丢失什么**。让用户做取舍。 + +### Step 2 · 如果用户选 B:AI 主动改写,不要求用户自己写 + +这里的 doctrine 是:**用户给的是设计意图,你负责翻译成合规实现**。不是让用户去学 4 条硬约束然后自己重写。 + +改写时的遵循原则: +- **保留**:色彩系统(主色/辅色/中性色)、信息层级(标题/副标题/正文/注解)、核心文案、layout 骨架(上中下 / 左右分栏 / 网格)、页面节奏 +- **降级**:CSS 渐变 → 纯色或 flex 分段、web component → 段落级 HTML、复杂 SVG → 简化的 `` 或纯色几何、阴影 → 删除或降为极弱、自定义字体 → 向系统字体靠齐 +- **重写**:裸文字 → 包进 `

` / ``、`background-image` → `` 标签、`

` 上的背景边框 → 外层 div 承载 + +### Step 3 · 产出对照清单(透明交付) + +改写完成后给用户一份 before/after 对照,让他知道哪些视觉细节被简化了: + +``` +原设计 → editable 版调整 +- 标题区紫色渐变 → 主色 #5B3DE8 纯色背景 +- 数据卡片阴影 → 删除(改为 2pt 描边区分) +- 复杂 SVG 折线图 → 简化为 PNG(从 HTML 截图生成) +- Hero 区 web component 动效 → 静态首帧(web component 无法翻译) +``` + +### Step 4 · 双版本交付(可选) + +- **演讲版**:保留原 1920×1080 视觉 HTML +- **可编辑版**:单独目录或文件集的 960pt 简化 HTML +- 向用户说明两版分工,避免混在同一文件里互相破坏约束 + +### 什么情况下直接拒绝 B 方案 + +个别场景下改写代价过高,应该劝用户放弃 editable PPTX: +- HTML 核心价值是动画或交互(改写后只剩静态首帧,信息量损失 50%+) +- 页数 > 30,改写成本超过 2 小时 +- 视觉设计深度依赖精确 SVG / 自定义 filter(改写后和原图几乎无关) + +此时告诉用户:「改写代价过高,建议保留 1920 演讲版;若坚持可编辑 pptx,需接受视觉大幅简化。」 + +--- + +## 为什么 4 条约束不是 Bug 而是物理约束 + +这 4 条不是 `html2pptx.cjs` 作者偷懒——它们是 **PowerPoint 文件格式(OOXML)本身的约束**投射到 HTML 上的结果: + +- PPTX 里文字必须在 text frame(``),对应段落级 HTML 元素 +- PPTX 的 shape 和 text frame 是两个对象,无法在同一 element 上同时画背景和写文字 +- PPTX 的 shape fill 对 gradient 支持有限(仅某些 preset gradients,不支持 CSS 任意角度渐变) +- PPTX 的 picture 对象必须引用真实图片文件,不是 CSS 属性 + +理解这点后,**不要期待工具变聪明** —— 是 HTML 写法要适配 PPTX 格式,不是反过来。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/scene-templates.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/scene-templates.md new file mode 100644 index 0000000000..d6fe18d609 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/scene-templates.md @@ -0,0 +1,262 @@ +# 场景模板库:按输出类型组织 + +> 与 design-styles.md 的「提示词DNA」组合使用。 +> 公式:`[风格提示词DNA] + [场景模板] + [具体内容描述]` + +--- + +## 1. 公众号封面 / 文章题图 + +**规格**: +- 封面图:2.35:1(900×383px 或 1200×510px) +- 正文插图:16:9(1200×675px)或 4:3(1200×900px) + +**关键设计要素**: +- 视觉冲击力优先(用户在信息流中快速扫过) +- 文字极少或无文字(公众号标题会覆盖在上面) +- 色彩饱和度适中(微信阅读环境偏白) +- 避免过度细节(缩略图也要可辨识) + +**推荐风格**:01 Pentagram / 11 Build / 12 Sagmeister / 18 Kenya Hara / 07 Field.io + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Article cover image for WeChat subscription +- Landscape format, 2.35:1 aspect ratio +- Bold visual impact, minimal or no text +- Moderate color saturation for white reading environment +- Must remain recognizable as thumbnail +- Clean composition with clear focal point +``` + +--- + +## 2. 正文配图 / 概念插画 + +**规格**: +- 16:9(1200×675px)最通用 +- 1:1(800×800px)适合强调 +- 4:3(1200×900px)适合信息密集 + +**关键设计要素**: +- 服务于文章论点,不是装饰 +- 与上下文形成视觉节奏 +- 简洁表达一个核心概念 +- AI生成优先,HTML截图仅在精确数据表格时用 + +**推荐风格**:根据文章调性选择,常用 01/04/10/17/18 + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Article illustration, concept visualization +- [16:9 / 1:1 / 4:3] aspect ratio +- Single clear concept: [描述核心概念] +- Serve the argument, not decoration +- [Light/Dark] background to match article tone +``` + +--- + +## 3. 信息图 / 数据可视化 + +**规格**: +- 竖版长图:1080×1920px(手机阅读) +- 横版:1920×1080px(文章内嵌) +- 方形:1080×1080px(社交媒体) + +**关键设计要素**: +- 信息层级清晰(标题 → 核心数据 → 细节) +- 数据准确,不编造 +- 视觉引导线(用户阅读路径) +- 适当使用图标/图表辅助理解 + +**推荐风格**:04 Fathom / 10 Müller-Brockmann / 02 Stamen / 17 Takram + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Infographic / data visualization +- [Vertical 1080x1920 / Horizontal 1920x1080 / Square 1080x1080] +- Clear information hierarchy: title → key data → details +- Visual flow guiding reader's eye path +- Icons and charts for comprehension +- Data-accurate, no decorative distortion +``` + +--- + +## 4. PPT / Keynote 演示 + +**规格**: +- 标准:16:9(1920×1080px) +- 宽屏:16:10(1920×1200px) + +**关键设计要素**: +- 每页一个核心信息(不堆砌) +- 字号层级明确(标题40pt+ / 正文24pt+ / 注释16pt+) +- 大量留白,投影时更清晰 +- 图文比例至少 60:40 +- 一致的视觉系统(颜色、字体、间距) + +**推荐风格**:01 Pentagram / 10 Müller-Brockmann / 11 Build / 18 Kenya Hara / 04 Fathom + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Presentation slide design, 16:9 +- One core message per slide +- Clear type hierarchy (title 40pt+, body 24pt+) +- Generous whitespace for projection clarity +- Consistent visual system throughout +- [Light/Dark] theme +``` + +--- + +## 5. PDF 白皮书 / 技术报告 + +**规格**: +- A4 纵向(210×297mm / 595×842pt) +- Letter 纵向(216×279mm / 612×792pt) + +**关键设计要素**: +- 长文阅读优化(行宽66字符、行高1.5-1.8) +- 清晰的章节导航系统 +- 页眉/页脚/页码的统一设计 +- 图表与正文的优雅共存 +- 引用/注释系统 +- 封面页设计精致 + +**推荐风格**:10 Müller-Brockmann / 04 Fathom / 03 Information Architects / 17 Takram / 19 Irma Boom + +**场景提示词模板**: +``` +[风格DNA插入此处] +- PDF document / white paper design +- A4 portrait format (210×297mm) +- Long-form reading optimized (66 char line width, 1.5 line height) +- Clear chapter navigation system +- Elegant header/footer/page number design +- Charts integrated with body text +- Professional cover page +``` + +--- + +## 6. 落地页 / 产品官网 + +**规格**: +- Desktop: 1440px 宽度设计(响应至320px) +- 首屏高度:100vh + +**关键设计要素**: +- 首屏5秒内传达核心价值 +- 清晰的CTA(行动按钮) +- 滚动叙事结构(问题→方案→证明→行动) +- 移动端适配 +- 加载速度 + +**推荐风格**:05 Locomotive / 01 Pentagram / 11 Build / 08 Resn / 06 Active Theory + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Landing page / product website +- Desktop 1440px width, responsive +- Hero section 100vh, core value in 5 seconds +- Clear CTA button design +- Scroll narrative: problem → solution → proof → action +- Modern web aesthetic +``` + +--- + +## 7. App UI / 原型界面 + +**规格**: +- iOS: 390×844pt(iPhone 15) +- Android: 360×800dp +- 平板: 1024×1366pt(iPad Pro) + +**关键设计要素**: +- 触摸友好(最小点击区44×44pt) +- 系统设计语言一致性 +- 状态栏/导航栏/Tab栏的标准处理 +- 信息密度适中(移动端不宜过密) + +**推荐风格**:17 Takram / 11 Build / 03 Information Architects / 01 Pentagram + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Mobile app UI design +- iOS [390×844pt] / Android [360×800dp] +- Touch-friendly (44pt minimum tap targets) +- Consistent design system +- Standard status bar / navigation / tab bar +- Moderate information density +``` + +--- + +## 8. 小红书配图 + +**规格**: +- 竖版:3:4(1080×1440px)最佳 +- 方形:1:1(1080×1080px) +- 首图决定点击率 + +**关键设计要素**: +- 视觉吸引力第一(在瀑布流中竞争) +- 可以有少量文字(但不超过画面20%) +- 色彩鲜明但不俗 +- 生活感/质感/氛围感 + +**推荐风格**:12 Sagmeister / 11 Build / 20 Neo Shen / 09 Experimental Jetset + +**场景提示词模板**: +``` +[风格DNA插入此处] +- Social media image for Xiaohongshu (RED) +- Vertical 3:4 (1080×1440px) +- Eye-catching in waterfall feed +- Minimal text overlay (under 20% of area) +- Vivid but tasteful colors +- Lifestyle/texture/atmosphere feel +``` + +--- + +## 组合示例 + +**场景**:公众号封面,介绍一款AI编程工具,想要专业但有温度 + +**Step 1**:选风格 → 17 Takram(专业+温度) +**Step 2**:取Takram提示词DNA + 公众号封面模板 + +``` +Takram Japanese speculative design: +- Elegant concept prototypes and diagrams +- Soft tech aesthetic (rounded corners, gentle shadows) +- Charts and diagrams as art pieces +- Modest sophistication +- Neutral natural colors (beige, soft gray, muted green) +- Design as philosophical inquiry + +Article cover image for WeChat subscription +- Landscape format, 2.35:1 aspect ratio (1200×510px) +- Bold visual impact, minimal text +- Moderate color saturation for white reading environment +- Must remain recognizable as thumbnail +- Clean composition with clear focal point + +Content: An AI coding assistant tool, showing the concept of human-AI collaboration +in software development, warm and professional atmosphere +``` + +--- + +**版本**:v1.0 +**更新日期**:2026-02-13 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md new file mode 100644 index 0000000000..9342cb150b --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md @@ -0,0 +1,622 @@ +# Slide Decks:HTML幻灯片制作规范 + +做幻灯片是设计工作的高频场景。这份文档说明怎么做好 HTML 幻灯片——架构选型、单页设计、交付目标对 HTML 的约束。 + +**能力覆盖**: +- **HTML 演示版(默认必做)** → 每页独立 HTML + `deck_index.html` 聚合,浏览器全屏翻页 +- **可编辑 PPTX** → HTML 从第一行遵守 `editable-pptx.md` 四条 + 960×540pt +- **高保真演讲版** → 1920×1080px,视觉自由;与可编辑 pptx 不可混为同一套 HTML + +> **HTML 是源。** 先做好 `slides/*.html`(+ 可选聚合 `index.html`),再谈文件格式。每页可单独打开验证;改内容只改 HTML。 + +--- + +## 🛑 开工前先确认交付格式(最硬的 checkpoint) + +**这个决策比「单文件还是多文件」更先。** 2026-04-20 期权私董会项目实测:**不在动手前确认交付格式 = 2-3 小时返工。** + +### 决策树(HTML-first 架构) + +所有交付都从同一套 HTML(`slides/*.html`,可选 `index.html` 聚合)开始。先确认交付目标,再决定 **HTML 写法**: + +``` +【永远默认 · 必做】 HTML 聚合演示版(index.html + slides/*.html) + │ + ├── 只要浏览器演讲 / 本地 HTML 存档 → 到这里已经完成,HTML 视觉自由度最大 + │ + ├── 还要 PDF(打印 / 发群 / 存档) → HTML 写法自由(1920 演讲版即可) + │ + └── 还要可编辑 PPTX(同事要改文字) → 从第一行就按 4 条硬约束 + 960×540pt + 牺牲渐变 / web component / 复杂 SVG +``` + +### 需求确认(动手前一句) + +> 我会先做可在浏览器里翻页的 HTML 幻灯片。请确认:**同事是否要在 PowerPoint 里改文字?** +> - **否**(演讲/存档为主)→ 可用 1920 版,视觉自由 +> - **是** → 全程 960×540pt + 四条硬约束,牺牲渐变 / web component / 复杂 SVG + +### 为什么「要 PPTX 就得从头走 4 条硬约束」 + +PPTX 可编辑的前提是 `html2pptx.cjs` 能把 DOM 逐元素翻译为 PowerPoint 对象。它需要 **4 条硬约束**: + +1. body 固定 960pt × 540pt(匹配 `LAYOUT_WIDE`,13.333″ × 7.5″,不是 1920×1080px) +2. 所有文字包在 `

`/`

`-`

` 里(禁止 div 直接放文字,禁止用 `` 承载主文字) +3. `

`/`` 自身不能有 background/border/shadow(放外层 div) +4. `

` 不能用 `background-image`(用 `` 标签) +5. 不用 CSS gradient、不用 web component、不用复杂 SVG 装饰 + +**本 skill 默认的 HTML 视觉自由度高**——大量 span、嵌套 flex、复杂 SVG、web component(如 ``)、CSS 渐变——**几乎没有一条能天然过 html2pptx 的约束**(实测视觉驱动的 HTML 直接上 html2pptx,pass 率 < 30%)。 + +### 两条真实路径的代价对比(2026-04-20 真实踩坑) + +| 路径 | 做法 | 结果 | 代价 | +|------|------|------|------| +| ❌ **先自由写 HTML,事后补救 PPTX** | 单文件 deck-stage + 大量 SVG/span 装饰 | 要可编辑 PPTX 只剩两条路:
A. 手写 pptxgenjs 几百行 hardcode 坐标
B. 重写 17 页 HTML 成 Path A 格式 | 2-3 小时返工,且手写版**维护成本永续**(HTML 改一个字,PPTX 要再人肉同步) | +| ✅ **从第一步按 Path A 约束写** | 每页独立 HTML + 4 条硬约束 + 960×540pt | 可转可编辑 PPTX,也能浏览器演讲 | 多花几分钟把文字包进 `

`,零返工 | + +### 混合交付怎么办 + +用户说「我要 HTML 演讲 **和** 可编辑 PPTX」——**这不是混合**,是 PPTX 需求覆盖 HTML 需求。按 Path A 写出来的 HTML 本身就能浏览器全屏演讲(加个 `deck_index.html` 拼接器就行)。**没有额外代价。** + +用户说「我要 PPTX **和** 动画 / web component」——**这是真矛盾**。告诉用户:要可编辑 PPTX 就得牺牲这些视觉能力。让他做取舍,不要偷偷做手写 pptxgenjs 方案(会变成永续维护债)。 + +### 事后才知道要 PPTX 怎么办(紧急补救) + +极个别情况:HTML 已经写好了才发现要 PPTX。推荐走 **fallback 流程**(完整说明见 `references/editable-pptx.md` 末尾「Fallback:已有视觉稿但用户坚持要 editable PPTX」): + +1. **首选:保留 1920 演讲 HTML**(视觉完整)—— 若对方只需看/讲、不改字,不必重做 960pt 版 +2. **次选:以视觉稿为蓝本,重写一版 960pt editable HTML** —— 保留色彩/布局/文案,牺牲渐变、web component、复杂 SVG +3. **不推荐:手写 pptxgenjs 重建**——位置、字体、对齐都要手调,维护成本高,且后续 HTML 改一个字都得再人肉同步一次 + +永远把选择告诉用户,让他决定。**永远不要第一反应就开始手写 pptxgenjs**——那是最后的兜底手段。 + +--- + +## 🛑 批量制作前:先做 2 页 showcase 定 grammar + +**只要 deck ≥ 5 页,绝对不能从第 1 页直接写到最后一页。** 2026-04-22 moxt brochure 实战验证的正确顺序: + +1. 选 **2 个视觉差异最大的页面类型**先做 showcase(如「封面」+「情绪/引用页」,或「封面」+「产品展示页」) +2. 截图让用户确认 grammar(masthead / 字体 / 色 / 间距 / 结构 / 中英双语比例) +3. 方向通过了再批量推剩下 N-2 页,每页复用已建立的 grammar +4. 全部完成后合成 `index.html` 聚合(可选) + +**为什么**:直接写 13 页到底 → 用户说「方向不对」= 返工 13 次。先做 2 页 showcase → 方向错 = 返工 2 次。视觉 grammar 一旦确立,后续 N 页的决策空间大幅收窄,只剩「内容怎么放进去」。 + +**showcase 页选择原则**:选视觉结构最不一样的两页。这两页过了 = 其他中间态都能过。 + +| Deck 类型 | 推荐 showcase 页组合 | +|-----------|---------------------| +| B2B brochure / 产品宣发 | 封面 + 内容页(理念/情感页) | +| 品牌发布 | 封面 + 产品特色页 | +| 数据报告 | 数据大图页 + 分析结论页 | +| 教程课件 | 章节封页 + 具体知识点页 | + +--- + +## 📐 出版物 grammar 模板(moxt 实测可复用) + +适合 B2B brochure / 产品宣发 / 长报告类 deck。每页复用这套结构 = 13 页视觉完全一致、0 返工。 + +### 每页骨架 + +``` +┌─ masthead(顶部 strip + 横线)────────────┐ +│ [logo 22-28px] · A Product Brochure Issue · Date · URL │ +├──────────────────────────────────────────┤ +│ │ +│ ── kicker(绿色短横 + uppercase 标签) │ +│ CHAPTER XX · SECTION NAME │ +│ │ +│ H1(中文 Noto Serif SC 900) │ +│ 重点词单独上品牌主色 │ +│ │ +│ English subtitle (Lora italic,副标题) │ +│ ─────────── 分隔线 ────────── │ +│ │ +│ [具体内容:双栏 60/40 / 2x2 grid / 列表] │ +│ │ +├──────────────────────────────────────────┤ +│ section name XX / total │ +└──────────────────────────────────────────┘ +``` + +### 样式约定(直接抄走) + +- **H1**:中文 Noto Serif SC 900,字号 80-140px 看信息量,重点词单独上品牌主色(不要全文堆色) +- **英文副**:Lora italic 26-46px,品牌签名词(如 "AI team")粗体 + 主色斜体 +- **正文**:Noto Serif SC 17-21px,line-height 1.75-1.85 +- **accent 高亮**:正文里用主色加粗标注关键词,每页不超过 3 处(过多就失去锚点作用) +- **背景**:暖米底 #FAFAFA + 极淡 radial-gradient noise(`rgba(33,33,33,0.015)`)增加纸感 + +### 视觉主角必须差异化 + +13 页如果全是「文字 + 一张截图」就太单调。**每页的视觉主角类型轮换**: + +| 视觉类型 | 适合的 section | +|---------|---------------| +| 封面排版(大字 + masthead + pillar) | 首页 / 篇章封 | +| 单角色 portrait(超大单只 momo 等) | 介绍单个概念/角色 | +| 多角色合影 / 头像卡并排 | 团队 / 用户案例 | +| 时间轴卡片递进 | 展示「长期关系」「演进」 | +| 知识图谱 / 连接节点图 | 展示「协作」「流动」 | +| Before/After 对比卡 + 中间箭头 | 展示「改变」「差异」 | +| 产品 UI 截图 + 描边设备框 | 具体功能展示 | +| 大引号 big-quote(半页大字) | 情绪页 / 问题页 / 引文页 | +| 真人头像 + 引言卡(2×2 或 1×4) | 用户见证 / 使用场景 | +| 大字封底 + URL 椭圆按钮 | CTA / 结尾 | + +--- + +## ⚠️ 常见踩坑(moxt 实战总结) + +### 1. Emoji 在 PDF/PNG 导出时不渲染 + +导出为 PDF/PNG 时,彩色 emoji 常显示为空方框。 + +**对策**:用 Unicode 文字符号(`✦` `✓` `✕` `→` `·` `—`)替代,或直接改纯文字(「Email · 23」而不是「📧 23 emails」)。 + +### 2. Google Fonts 未加载完 → 中文落回系统黑体 + +建议 `@font-face` 本地路径或 `shared/fonts/` self-host,减少网络依赖。 + +### 4. 信息密度失衡:内容页塞太多 + +moxt philosophy 页第一版用 2×2 = 4 段 + 底部 3 信条 = 7 块内容,挤压且重复。改成 1×3 = 3 段后呼吸感立刻回来。 + +**对策**:每页控制在「1 个核心信息 + 3-4 个辅助点 + 1 个视觉主角」,超过就拆到新页。**少即是多**——观众一页看 10 秒,给他 1 个记忆点比 4 个记忆点更容易记住。 + +--- + +## 🛑 先定架构:单文件 还是 多文件? + +**这个选择是做幻灯片的第一步,错了会反复踩坑。先读完这一节再动手。** + +### 两种架构对比 + +| 维度 | 单文件 + `deck_stage.js` | **多文件 + `deck_index.html` 拼接器** | +|------|--------------------------|--------------------------------------| +| 代码结构 | 一个 HTML,所有 slide 是 `

` | 每页独立 HTML,`index.html` 用 iframe 拼接 | +| CSS 作用域 | ❌ 全局,一页的样式可能影响所有页 | ✅ 天然隔离,iframe 各自一片天 | +| 验证粒度 | ❌ 要 JS goTo 才能切到某页 | ✅ 单页文件双击就能在浏览器看 | +| 并行开发 | ❌ 一个文件,多 agent 改会冲突 | ✅ 多 agent 可并行做不同页,零冲突 merge | +| 调试难度 | ❌ 一处 CSS 出错,全 deck 翻车 | ✅ 一页出错只影响自己 | +| 内嵌交互 | ✅ 跨页共享状态很简单 | 🟡 iframe 间需 postMessage | +| 打印 PDF | ✅ 内置 | ✅ 拼接器 beforeprint 遍历 iframe | +| 键盘导航 | ✅ 内置 | ✅ 拼接器内置 | + +### 选哪个?(决策树) + +``` +│ 问:deck 预计有多少页? +├── ≤10 页、需要 in-deck 动画或跨页交互、pitch deck → 单文件 +└── ≥10 页、学术讲座、课件、长 deck、多 agent 并行 → 多文件(推荐) +``` + +**默认走多文件路径**。它不是「备选」,是**长 deck 和团队协作的主路径**。原因:单文件架构的每一个优势(键盘导航、打印、scale)多文件都有,而多文件的作用域隔离和可验证性是单文件补不回来的。 + +### 为什么这条规则这么硬?(真实事故记录) + +单文件架构曾经在 AI心理学讲座 deck 制作中连踩四坑: + +1. **CSS 特异性覆盖**:`.emotion-slide { display: grid }` (特异性 10) 干翻 `deck-stage > section { display: none }` (特异性 2),导致所有页同时渲染叠加。 +2. **Shadow DOM slot 规则被外层 CSS 压制**:`::slotted(section) { display: none }` 挡不住 outer rule 的覆盖,sections 不肯隐藏。 +3. **localStorage + hash 导航竞态**:刷新后不是跳到 hash 位置,而是停在 localStorage 记录的旧位置。 +4. **验证成本高**:必须 `page.evaluate(d => d.goTo(n))` 才能截某页,比直接 `goto(file://.../slides/05-X.html)` 慢一倍,还常报错。 + +全部根因是**单一全局命名空间**——多文件架构从物理层面把这些问题消除了。 + +--- + +## 路径 A(默认):多文件架构 + +### 目录结构 + +``` +我的Deck/ +├── index.html # 从 assets/deck_index.html 复制来,改 MANIFEST +├── shared/ +│ ├── tokens.css # 共享设计 token(色板/字号/常用 chrome) +│ └── fonts.html # 引入 Google Fonts(每页 include) +└── slides/ + ├── 01-cover.html # 每个文件都是完整 1920×1080 HTML + ├── 02-agenda.html + ├── 03-problem.html + └── ... +``` + +### 每张 slide 的模板骨架 + +```html + + + + +P05 · Chapter Title + + + + + + + +
...
+ + + +``` + +**关键约束**: +- `` 就是画布,直接在上面布局。不要包 `
` 或其他 wrapper。 +- `width: 1920px; height: 1080px` 由 `shared/tokens.css` 里的 `body` 规则锁定。 +- 引 `shared/tokens.css` 共享设计 token(色板、字号、page-header/footer 等)。 +- 字体 `` 每页自己写(fonts 单独 import 不贵,且保证每页独立可打开)。 + +### 拼接器:`deck_index.html` + +**直接从 `assets/deck_index.html` 复制**。你只需要改一处——`window.DECK_MANIFEST` 数组,按顺序列出所有 slide 文件名和人类可读标签: + +```js +window.DECK_MANIFEST = [ + { file: "slides/01-cover.html", label: "封面" }, + { file: "slides/02-agenda.html", label: "目录" }, + { file: "slides/03-problem.html", label: "问题陈述" }, + // ... +]; +``` + +拼接器已内置:键盘导航(←/→/Home/End/数字键/P 打印)、scale + letterbox、右下计数器、localStorage 记忆、hash 跳页、打印模式(遍历 iframe 按页输出 PDF)。 + +### 单页验证(这是多文件架构的杀手级优势) + +每张 slide 都是独立 HTML。**做完一张就在浏览器双击打开看**: + +```bash +open slides/05-personas.html +``` + +单页路径独立,不会被其他页的 CSS 污染——「改一点、打开这一页看」成本接近零。 + +### 并行开发 + +把每张 slide 的任务拆给不同 agent,同时跑——HTML 文件彼此独立,merge 时没有冲突。长 deck 用这种并行方式能把制作时间压到 1/N。 + +### `shared/tokens.css` 该放什么 + +只放**真正跨页共用**的东西: + +- CSS 变量(色板、字号阶、间距阶) +- `body { width: 1920px; height: 1080px; }` 这样的 canvas 锁定 +- `.page-header` / `.page-footer` 这种每页都用一模一样的 chrome + +**不要**把单页的布局 class 塞进来——那会退化回单文件架构的全局污染问题。 + +--- + +## 路径 B(小 deck):单文件 + `deck_stage.js` + +适用于 ≤10 页、需要跨页共享状态(比如一个 React tweaks 面板要操控所有页)、或者做 pitch deck demo 这种要求极度紧凑的场景。 + +### 基本用法 + +1. 从 `assets/deck_stage.js` 读取内容,嵌入 HTML 的 ` + + +``` + +### 🛑 Script 位置硬约束(2026-04-20 真实踩坑) + +**不能把 ` + + + + + + +``` + +`deck_stage.js` 本身已内置 `DOMContentLoaded` 延迟收集防御,即使 script 放 head 也不会彻底炸掉——但 `defer` 或放 body 底部仍然是更干净的做法,避免依赖防御分支。 + +### ⚠️ 单文件架构的 CSS 陷阱(务必阅读) + +单文件架构最常见的坑——**`display` 属性被单页样式偷走**。 + +常见错误姿势 1(直接写 display: flex 到 section): + +```css +/* ❌ 外部 CSS 特异性 2,覆盖了 shadow DOM 的 ::slotted(section){display:none}(也是 2)*/ +deck-stage > section { + display: flex; /* 所有页会同时叠加渲染! */ + flex-direction: column; + padding: 80px; + ... +} +``` + +常见错误姿势 2(section 有特异性更高的 class): + +```css +.emotion-slide { display: grid; } /* 特异性: 10,更糟 */ +``` + +两种都会让 **所有 slide 同时叠加渲染**——counter 可能显示 `1 / 10` 假装正常,但视觉上第一页盖着第二页盖着第三页。 + +### ✅ Starter CSS(开工直接 copy,不踩坑) + +**section 自身**只管「可见/不可见」;**layout(flex/grid 等)写到 `.active` 上**: + +```css +/* section 只定义非 display 的通用样式 */ +deck-stage > section { + background: var(--paper); + padding: 80px 120px; + overflow: hidden; + position: relative; + /* ⚠️ 不要在这里写 display! */ +} + +/* 锁死「非激活即隐藏」——特异性+权重双保险 */ +deck-stage > section:not(.active) { + display: none !important; +} + +/* 激活页才写需要的 display + layout */ +deck-stage > section.active { + display: flex; + flex-direction: column; + justify-content: center; +} + +/* 打印模式:所有页都要显示,覆盖 :not(.active) */ +@media print { + deck-stage > section { display: flex !important; } + deck-stage > section:not(.active) { display: flex !important; } +} +``` + +替代方案:**把单页的 flex/grid 写到内部 wrapper `
` 上**,section 本身永远只是 `display: block/none` 的切换器。这是最干净的做法: + +```html + +
+
...
+
+
+``` + +### 自定义尺寸 + +```html + + + +``` + +--- + +## Slide Labels + +Deck_stage 和 deck_index 都会给每页打标签(计数器显示)。给它们**更有意义**的 label: + +**多文件**:在 `MANIFEST` 里写 `{ file, label: "04 问题陈述" }` +**单文件**:在 section 上加 `
` + +**关键:Slide 编号从 1 开始,不要从 0**。 + +用户说"slide 5"时,他指的是第 5 张,永远不是数组位置 `[4]`。人类不说 0-indexed。 + +--- + +## Speaker Notes + +**默认不加**,只在用户明确要求时才加。 + +加了 speaker notes 你就可以把 slide 上的文字减少到最小,focus on impactful visuals——notes 承载完整 script。 + +### 格式 + +**多文件**:在 `index.html` 的 `` 里写: + +```html + +``` + +**单文件**:同上位置。 + +### Notes 写作要点 + +- **完整**:不是提纲,是真要讲的话 +- **对话式**:像平时说话,不是书面语 +- **对应**:数组第 N 个对应第 N 张 slide +- **长度**:200-400 字最佳 +- **情绪线**:标注重音、停顿、强调点 + +--- + +## Slide 设计模式 + +### 1. 建立一个系统(必做) + +探索完 design context 后,**先口头说你要用的系统**: + +```markdown +Deck系统: +- 背景色:最多2种(90% 白 + 10% 深色 section divider) +- 字型:display 用 Instrument Serif,body 用 Geist Sans +- 节奏:section divider 用 full-bleed 彩色 + 白字,普通 slide 白底 +- 图像:hero slide 用 full-bleed 照片,data slide 用 chart + +我按这个系统做,有问题告诉我。 +``` + +用户确认后再往下做。 + +### 2. 常用 slide layouts + +- **Title slide**:纯色背景 + 巨大标题 + 副标题 + 作者/日期 +- **Section divider**:彩色背景 + 章节号 + 章节标题 +- **Content slide**:白底 + 标题 + 1-3 bullet points +- **Data slide**:标题 + 大图表/数字 + 简短说明 +- **Image slide**:full-bleed 照片 + 底部小 caption +- **Quote slide**:留白 + 巨大 quote + attribution +- **Two-column**:左右对比(vs / before-after / problem-solution) + +一个 deck 里最多用 4-5 种 layout。 + +### 3. Scale(再次强调) + +- 正文最小 **24px**,理想 28-36px +- 标题 **60-120px** +- Hero 字 **180-240px** +- 幻灯片是给 10 米外看的,字要够大 + +### 4. 视觉节奏 + +Deck 需要 **intentional variety**: + +- 颜色节奏:大部分白底 + 偶尔彩色 section divider + 偶尔 dark 片段 +- 密度节奏:几张 text-heavy 的 + 几张 image-heavy 的 + 几张 quote 留白 +- 字号节奏:正常标题 + 偶尔巨型 hero 文字 + +**不要每张 slide 长一样**——那是 PPT 模板,不是设计。 + +### 5. 空间呼吸(数据密集页必读) + +**新手最容易踩的坑**:把所有能放的信息都塞进一页。 + +信息密度 ≠ 有效信息传达。学术/演讲类 deck 尤其要克制: + +- 列表/矩阵页:不要把 N 个元素都画成同一大小。用 **主次分层**——今天要聊的 5 个放大做主角,剩下 16 个缩小做背景 hint。 +- 大数字页:数字本身是视觉主角。周围的 caption 不要超过 3 行,否则观众眼球来回跳。 +- 引用页:引语和 attribution 之间要有留白隔开,不要贴在一起。 + +对照「数据是不是主角」「文字有没有挤在一起」两条自我审查,改到留白让你有点不安为止。 + +--- + +## 打印为 PDF + +**多文件**:`deck_index.html` 已处理 `beforeprint` 事件,按页输出 PDF。 + +**单文件**:`deck_stage.js` 同样处理。 + +打印样式已写好,不需要额外写 `@media print` CSS。 + +--- + +## 可编辑 PPTX:HTML 硬性约束 + +要在 PowerPoint 里改字时,HTML 须满足下列约束(详见 `references/editable-pptx.md`): +- 所有文字必须在 `

`/`

`-`

`/`
    `/`
      ` 里(禁止裸文本 div) +- `

      `/`` 标签自身不能有 background/border/shadow(放外层 div) +- 不用 `::before`/`::after` 插入装饰文字(伪元素提不出来) +- inline 元素(span/em/strong)不能有 margin +- 不用 CSS gradient(不可渲染) +- div 不用 `background-image`(用 ``) + +脚本已内置**自动预处理器**——把 "叶子 div 里的裸文本" 自动包成 `

      `(保留 class)。这解决了最常见的违规(裸文本)。但其他违规(p 上有 border、span 上有 margin 等)仍需 HTML 源头合规。 + +**字体回落 caveat**: +- 测量环境与 PowerPoint/Keynote 本机字体可能不一致 → **溢出或错位**,导出后要肉眼过 +- 建议目标机器装好 HTML 里用的字体,或 fallback 到 `system-ui` + +**视觉优先 + 要可改字 pptx** → 不可兼得;保留 1920 演讲 HTML,另做简化版或接受 PPTX 会丢视觉效果。 + +### 从一开始就让 HTML 对导出友好 + +长期可维护 deck:**从第一行就按 editable 四条硬约束写**。额外成本不大: + +```html + +

      关键发现
      + + +

      关键发现

      + + +

      41%

      + + +
      +

      41%

      +
      +``` + +### 何时选哪个 + +| 场景 | 推荐 | +|------|------| +| 给主办方/档案存档 | **1920 演讲 HTML** 或浏览器打印稿 | +| 发给协作者改字 | **960pt + 四条约束**(可编辑 pptx 管线) | +| 现场演讲、不改内容 | **1920 演讲 HTML** + 聚合翻页 | +| HTML 是首选呈现媒介 | 直接浏览器播放 | + +长期协作、反复改字 → 一开始就按 `editable-pptx.md` 四条写 HTML。 + +--- + +## 常见问题 + +**多文件:iframe 里的页打不开 / 白屏** +→ 检查 `MANIFEST` 的 `file` 路径是否相对 `index.html` 正确。用浏览器 DevTools 看 iframe 的 src 能否直接访问。 + +**多文件:某页样式和别页冲突** +→ 不可能(iframe 隔离)。如果感觉冲突,那是缓存——Cmd+Shift+R 强刷。 + +**单文件:多 slide 同时渲染叠加** +→ CSS 特异性问题。看上面「单文件架构的 CSS 陷阱」一节。 + +**单文件:缩放看起来不对** +→ 检查是否所有 slide 直接挂在 `` 下作为 `
      `。中间不能包 `
      `。 + +**单文件:想跳到特定 slide** +→ URL 加 hash:`index.html#slide-5` 跳到第 5 张。 + +**两种架构都适用:字在不同屏幕下位置不一致** +→ 用固定尺寸(1920×1080)和 `px` 单位,不要用 `vw`/`vh` 或 `%`。缩放统一处理。 + +--- + +## 验证检查清单(做完 deck 必过) + +1. [ ] 浏览器直接打开 `index.html`(或主 HTML),检查首页无破图、字体已加载 +2. [ ] 按 → 键翻到每一页,没有空白页、没有布局错位 +3. [ ] 按 P 键打印预览,每页恰好一张 A4(或 1920×1080)且无裁切 +4. [ ] 随机选 3 页 Cmd+Shift+R 强刷,localStorage 记忆正常工作 +5. [ ] 抽查 3 张单页 HTML(`open slides/xx.html`),布局与字体正常 +6. [ ] 搜 `TODO` / `placeholder` 残留并清理 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/bold-editorial.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/bold-editorial.md new file mode 100644 index 0000000000..c462ddcd86 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/bold-editorial.md @@ -0,0 +1,49 @@ +# 黑白红大字(Black-White-Red) + +## 风格标识 +`bold-editorial` + +## 适用场景 +观点表达、行业分析、趋势报告、品牌封面、人物访谈、结论页等需要强文字冲击力与编辑设计感的演示。 + +## 风格定位 +Bold Editorial。融合黑白红时尚编辑、现代报纸头版与杂志排版:白底黑色大字、红色点缀、非对称网格,用排版本身制造智性冲击。 + +## 视觉系统 +- 背景以白色为主(#ffffff,可用冷灰白 #f5f5f0 变体),保留充足留白。 +- 高品质排版和文字层级是视觉主体,而非图片;每页只表达一个核心信息。 +- 版式严禁简单居中:优先左上、左下或边缘锚定的非对称构图;文字可大裁切、局部溢出(负边距或超大字号)。 +- 红色只用于关键点缀:短线(width: 40px; height: 3px)、圆点、编号、关键词或强调框。 +- 可用荧光笔式黄色高亮(background: linear-gradient(transparent 60%, #facc15 60%))点缀关键数字或结论。 + +## 配色(CSS 可用) +- 背景:#ffffff(白色) +- 文字:#000000(黑色) +- 强调:#dc2626(红色);高亮 #facc15(荧光黄,少量) +- 辅助:#525252(中灰) +- 面板:#fafafa(极浅灰) + +## 排版(HTML/CSS) +- 标题用超粗无衬线(font-weight: 800-900; font-size: 36-56px; letter-spacing: -0.02em),占页面 30%-50%。 +- 标题与正文字号比例极端(如 48px vs 14px),形成报纸头版式跳跃;副标题极小(12-14px)。 +- 允许动态排版:竖排(writing-mode: vertical-rl)、跨层叠加、局部出血;红色文字只用于最关键的 1-2 个词。 + +## 推荐版式(960×540pt) +- 封面:2-5 个字或一个短词作视觉锚点 + 极小副标题,非对称布局。 +- 结论页:一句金句占满画面,局部溢出边缘,红色短线作锚点。 +- 数据页:巨大黑色数字(font-size: 80-120px)+ 红色单位/注释 + 高亮线。 +- 观点页:左侧密集文字块与右侧大留白形成对比。 + +## CSS 实现要点 +- 响应式大字:`font-size: clamp(24px, 5vw, 56px)`;溢出效果:`font-size: 120px; line-height: 0.9; overflow: hidden;` +- 红色点缀线:`width: 40px; height: 3px; background: #dc2626;` +- 非对称布局:`display: grid; grid-template-columns: 2fr 1fr;` 或绝对定位。 +- 细线分隔:`border-top: 1px solid #1c1917; width: 60px;` + +## 避免 +- 不使用普通居中标题页、半吊子字号差异或平均分布布局。 +- 不使用多色装饰、复杂渐变、重阴影或廉价素材;红色不大面积铺满。 +- 不用密集文字削弱大字冲击力;不让视觉元素抢走标题主导权。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/clean-business.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/clean-business.md new file mode 100644 index 0000000000..5c85108469 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/clean-business.md @@ -0,0 +1,46 @@ +# 简洁商务(Clean Business) + +## 风格标识 +`clean-business` + +## 适用场景 +商务汇报、产品路线、企业方案、技术发布、Agent/系统能力介绍等通用专业演示。默认预设。 + +## 风格定位 +Calm Intelligence。页面像一个「思想的界面」:平静、现代、自信、精准,把思考过程可视化为清晰结构,而不是装饰性海报。 + +## 视觉系统 +- 纯白背景(#ffffff),极简、通透、轻盈,大量留白,无视觉噪音。 +- 可在角落或边缘加入极低不透明度的蓝/青柔和渐变(透明到 5% 不透明度),只作氛围,不放在文字正后方、不承担信息结构。 +- 视觉元素应像产品文档被转化成演示界面:细线卡片、界面框架、清晰流程。 + +## 配色(CSS 可用) +- 背景:#ffffff(纯白) +- 文字:#1a1a1a(近黑) +- 强调:#2563eb(平静蓝) +- 辅助:#6b7280(中灰) +- 面板:#f8fafc(极浅灰蓝) + +## 排版(HTML/CSS) +- 标题用干净现代无衬线(font-family: system-ui, "PingFang SC", sans-serif; font-weight: 600-700; font-size: 28-40px)。 +- 层级:大标题 → 一句解释 → 短要点或短段落;避免长段落。 +- 用户指定语言为主,英文可作更小更轻的辅助标签。 + +## 推荐版式(960×540pt) +- 左侧文字 + 右侧视觉(flex 左右分栏)。 +- 三栏能力卡片(grid: 1fr 1fr 1fr),每张卡片含细线图标、标题和 1-2 行说明。 +- 界面即证明:CSS 绘制的界面框架(圆角矩形 + 内部线条模拟浏览器/应用)。 +- 序列流程:用箭头(CSS 三角形或 →)和间距暗示系统正在运行。 + +## CSS 实现要点 +- 细线卡片:`border: 1px solid #e5e7eb`,圆角 `border-radius: 8-12px`。 +- 阴影极淡:`box-shadow: 0 1px 3px rgba(0,0,0,0.05)`。 +- 图标用 Unicode 符号或 CSS 几何形状。 + +## 避免 +- 不使用像素艺术、粗黑边框、贴纸、拼贴、俏皮图标或强对比色块。 +- 不使用硬边纹理、网格背景和过度装饰。 +- 页面应显得「必然且清晰」,而不是「热闹有趣」。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/creative-studio.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/creative-studio.md new file mode 100644 index 0000000000..0894f50ff9 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/creative-studio.md @@ -0,0 +1,48 @@ +# 黑橙创意(Black-Orange Studio) + +## 风格标识 +`creative-studio` + +## 适用场景 +创意机构提案、品牌策略、产品发布、视觉案例、营销方案等需要干练时尚感的商业演示。 + +## 风格定位 +Creative Studio。白底、黑字、血橙强调色构成清爽锐利的创意机构风格,搭配动态排版与少量英文版式标签。 + +## 视觉系统 +- 背景以白色为主(#ffffff),保持干净和高对比。 +- 文字以黑色为主,血橙色用于重点、线条、编号和交互感元素。 +- 可用 CSS 抽象动态形状(倾斜矩形、斜切块)模拟运动感;照片区域用渐变或纯色块替代,配合硬裁切、斜切。 +- 少量英文标签可作版式元素,但正文遵循用户指定语言。 + +## 配色(CSS 可用) +- 背景:#ffffff(白色) +- 文字:#171717(近黑) +- 强调:#ea580c(血橙) +- 辅助:#525252(中灰) +- 面板:#fafafa(极浅灰) + +## 排版(HTML/CSS) +- 标题用大胆无衬线(font-weight: 800; font-size: 32-44px)。 +- 正文用短段落、编号列表或清晰卡片。 +- 大编号(font-size: 64-80px; color: #ea580c; opacity: 0.3)可作背景装饰;侧边竖排小字(writing-mode: vertical-rl)增强机构感。 + +## 推荐版式(960×540pt) +- 白底大标题 + 血橙编号 + 斜切形状(clip-path: polygon(...))。 +- 左侧策略文字,右侧动态色块裁切。 +- 案例页:色块网格 + 橙色标签。 +- 结论页:黑色大字 + 橙色下划线(border-bottom: 3px solid #ea580c)强调一句核心判断。 + +## CSS 实现要点 +- 斜切:`clip-path: polygon(0 0, 100% 10%, 100% 100%, 0 90%)`。 +- 背景大编号:`position: absolute; z-index: 0; opacity: 0.15;` +- 下划线用 `border-bottom: 4px solid #ea580c` 而非 text-decoration。 +- 卡片左侧强调:`border-left: 4px solid #ea580c`。 + +## 避免 +- 不使用黑橙满屏高压配色;橙色只作强调。 +- 不使用花哨渐变、卡通图标或复杂纹理。 +- 不让英文装饰替代用户指定语言的核心内容。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/dark-neon.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/dark-neon.md new file mode 100644 index 0000000000..7c47552127 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/dark-neon.md @@ -0,0 +1,48 @@ +# 暗黑霓虹(Dark Neon) + +## 风格标识 +`dark-neon` + +## 适用场景 +科技发布、AI/系统架构、前沿研究、数据分析、赛博朋克/前卫艺术主题等需要未来感与视觉冲击的演示。 + +## 风格定位 +Dark Neon。深色背景加霓虹强调,提供两种方言:故障艺术(叛逆、数字破坏感)与霓虹制图(理性、建筑制图感)。根据主题选定一种方言后全 deck 保持一致。 + +## 视觉系统 +- 背景使用纯黑(#0a0a0a)或深暖灰(#1c1917)。 +- 故障方言:CSS 模拟故障效果——文字错位、青/品红颜色分离、扫描线、噪点;霓虹青与品红作强调。 +- 制图方言:叠加极细网格线背景,CSS 线框图、技术示意图、几何拼贴;霓虹黄几何元素作核心强调,配 Fig. 01 式等宽字体标签与细指引线。 +- 图层顺序:网格/暗背景 → 霓虹几何 → 主体内容 → 前景文字标注。 + +## 配色(CSS 可用) +- 背景:#0a0a0a(纯黑)或 #1c1917(深暖灰) +- 文字:#e5e5e5 / #fafaf9(浅灰白) +- 强调(故障):#22d3ee(青色)和 #f472b6(品红) +- 强调(制图):#facc15(霓虹黄) +- 辅助:#737373 / #a8a29e(中灰) +- 面板:#171717 / #292524(深灰) + +## 排版(HTML/CSS) +- 标题用粗体无衬线(font-weight: 900; font-size: 32-48px; letter-spacing: 0.05em),可带霓虹光晕。 +- 正文小字号(14-16px)严格对齐;编号、图注、坐标标签用等宽字体(monospace)增加科技感。 + +## 推荐版式(960×540pt) +- 故障标题:大标题带颜色分离(多层 text-shadow)。 +- 霓虹数据:巨大数字 + 霓虹色 + 扫描线背景。 +- 技术制图:线框图居中(多层 div 边框嵌套),周围细线标注模块。 +- 几何流程:点线/虚线(border-style: dashed)连接霓虹几何表达逻辑流。 + +## CSS 实现要点 +- 故障:`text-shadow: 2px 0 #22d3ee, -2px 0 #f472b6;`;光晕:`text-shadow: 0 0 10px #22d3ee, 0 0 20px #22d3ee;` +- 扫描线:`background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.3) 2px, rgba(0,0,0,0.3) 4px);` +- 网格背景:`background-image: linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px); background-size: 40px 40px;` +- 霓虹线条:`border: 1px solid #facc15` + `text-shadow: 0 0 10px rgba(250,204,21,0.3)`。 + +## 避免 +- 不使用明亮背景或传统商务配色,破坏暗黑氛围。 +- 不让故障效果过度导致文字不可读;不让制图线和标注压过核心内容。 +- 不混用两种方言的强调色系;一套 deck 内保持统一。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/insight-report.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/insight-report.md new file mode 100644 index 0000000000..05c9a14a95 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/insight-report.md @@ -0,0 +1,100 @@ +# 洞察汇报(Insight Report) + +## 风格标识 +`insight-report` + +## 适用场景 +学术汇报、行业研究、咨询分析、内部洞察/复盘汇报、评测与尽调报告、技术方案深拆等需要详尽论证与高信息密度的工作/研究场景演示。 + +## 风格定位 +这是「分析文档上墙」,不是「演讲幻灯」。读者是会前后会逐行细读的领导、评审和同行,每一页都必须经得起脱离演讲者的独立阅读。每页聚焦一个主题点,用完整句子、固定分析框架、满版数据表和过程叙事把这个点**讲透**。生成时优先级排序:信息量与论证完整 > 逻辑显性 > 版面美观。**在不溢出画布的前提下,内容越多越密越好;留白在本风格中是浪费,不是美德。** + +## 画布填充硬指标(最高优先级,逐页强制自检) +- **垂直填充率 ≥ 90%**:正文内容必须从标题分隔线下方一直排到页底脚注线上方。若内容结束位置距脚注线还有超过 40pt 的空白,该页不合格——必须继续增加模块、行、列或展开分析,直到填满。 +- **内容页正文 450–900 个汉字**(或等量英文)为目标区间;低于 300 字的内容页不合格。封面页也不许空:用 120–250 字摘要段 + 指标条 + 范围/方法/来源说明填满,禁止「大标题 + 半页空白」。 +- 一个主题的素材不足以填满一页时,**不许留白**,按以下顺序补密度:(1) 增加分析维度(风险、反例、边界条件、竞品对照);(2) 给每条结论补具体证据(数字、日期、文件、原话);(3) 增加「So what / 本页结论」横条;(4) 与相邻主题合并成一页。 +- 每页底部、脚注线之上,放一条**「本页结论」横条**(浅灰底或左侧主色粗边线,1–2 个完整句子),既补密度又强制每页有判断。封面与章节页除外。 + +## 信息密度与语言 +- 用**完整句子**写分析,禁止电报式名词短语。每条要点都是「主语 + 判断 + 限定/证据」、可独立阅读的句子。例如写「它只看到了"插桩"这一个动作,但没有预见到这个动作在 MUSL 环境下、在初始化前执行的时序问题」,而不是只写「时序问题」。 +- **每个卡片/单元格/小节都必须是多行实体**:卡片至少 4–7 行(一个加粗小标题 + 3 组以上「标签:完整句子」字段);只有 1–2 行的卡片不合格,要么补字段要么合并。 +- 鼓励 2–3 级嵌套列表:第一级是阶段或分论点,第二级是固定分析维度,第三级是具体证据或例子。层级靠缩进和列表符号区分。 +- 行内强调系统承担「快速扫读」功能,这是高密度可读的关键: + - 关键概念、机制、判断词**加粗**(`font-weight: 700`); + - 失败、风险、转折点、用户介入等警示信息用强调色(可配斜体); + - 代码标识符、文件名、函数名用下划线或等宽字体。 + - 读者只扫加粗与红字就能复述页面骨架;逐行细读则获得完整论证。两层都必须成立。 +- 引用原话、指令、报错时直接嵌入正文并加引号或等宽字体,不要省略成「用户提出修改意见」这类空话。 +- 数字必须带口径与时间(「2024 年流水约 7 亿美元,国内移动端占 55% 以上」),孤零零的数字不算证据。 + +## 逻辑骨架(每页的分析结构必须显性、可复述) +- **固定分析框架复用**:从内容中提炼一组分析维度,做成带冒号的加粗标签前缀,在页内每个小节按同一顺序重复出现。例如「**AI 的行为:**… / **能力边界:**… / **对人的依赖:**…」或「**现象:**… / **根因:**… / **对策:**…」。同一页内框架必须一致,让读者按同一把尺子对比各小节。 +- **过程叙事页**:按时间线交替展开「动作 → 反应」(如 Prompt1 → 行为1 → Prompt2 → 行为2 …);节点之间插入强调色批注句标记转折点(如「用户介入,发现实现不完备,遗漏大量场景」);页首放元信息块(工具/对象/任务/轮次等键值对),页侧放结果指标面板。 +- **评测/对比矩阵页**:满版大表格是页面主体而不是配图。单元格内放多行内容:评分加粗 + 编号小结论列表(1. … 2. …);失败、异常单元格整体用强调色标出;首列加粗,表头深色底白字。 +- 标题写法:「主题 + 判断」(如「编译器场景:开发者必须补全 AI 缺失的全局视野」)或「主题 – 切面」(如「支持 MUSL 使能 HWASAN – 需求」)。正文必须逐条支撑标题。 +- 全篇组织:总-分结构。先一页结论总览(可用满版矩阵承载),后续每页深拆一个案例/维度/论点,最后一页给出可执行的启示或建议。 +- **主动考虑数据与信息可视化**(参考 SKILL.md 的「数据与信息可视化意识」及 `references/data-information-visualization.md`):比较、趋势、构成、目标差距、多维关系、流程和里程碑都可能受益于图表或图示,但不做固定映射。Agent 应结合本页论证强度、精确查数需求和整套 deck 节奏自行选择矩阵、图表、流程、结构化文字或混合形式。数据图需标数值、时间、口径与来源,并配「So what」判断;默认可编辑路径不用 `conic-gradient`。 + +## 推荐版式与量化最低规格(960×540pt,每种版式必须达标) +- **满版评测矩阵**:表格 **7–11 列 × 7–12 行**,行高按内容自适应;信息量大的单元格放「加粗短判断 + 编号小结论(1–3 条)」;失败格标红。表格必须撑满正文区宽度且行数足以排到页底;行数不够就增加对比维度行,或在表格下方补一栏「关键发现」编号列表直至填满。 +- **卡片网格深拆**:2×3 或 3×3 网格撑满整个正文区高度,**每张卡片 4–7 行**:加粗小标题 + 3 组以上「**标签:**完整句子」字段(如对比双方各 1–2 句 + 一句差异判断)。六张两行卡片只占半页属于典型不合格输出。 +- **三级嵌套深拆页**:3–4 个阶段/分论点小节,每节内按同一组加粗标签维度展开完整句子分析,每节 4–8 行,整页几乎全文字,从分隔线排到脚注线。 +- **过程叙事 + 侧栏**:左侧细边框容器装时间线叙事(顶部元信息键值块 → Prompt/行为交替段落 → 红色批注句),节点 4–6 个;右侧窄栏「效果/结论」面板 5 行以上(标签 + 数值)。 +- **流程/架构图深拆**:CSS 绘制的多级流程图或架构图占约 55–60%,节点 6 个以上且带标注;旁边一栏逐节点完整句子解释,与节点编号一一对应。 +- **左右对照深拆**:左右两栏各装一个多字段实体卡(各 6–10 行),下方横排 1–2 条红色「关键风险/核心疑问」批注条,再加「本页结论」横条收底。两栏只占上半页、下半页留白属于不合格输出。 + +## 视觉系统 +- 纯白背景(#ffffff),零装饰:没有大色块、渐变、图标堆砌和情绪化视觉;**信息本身就是视觉**。 +- 顶部标题区一行大字(允许两行),与正文区用细分隔线分开;左上角放章节 kicker(如 03 | 案例深拆),右上角页码。标题区总高度不超过画布的 18%,把空间留给正文。 +- 正文承载区占画布 80% 以上。常用分区:单栏全宽(满版表格/嵌套列表)、主区 + 侧栏(约 70% + 25%)、2–3 栏对称拆解。 +- 内容容器只用细边框(1–1.5px 实线)和浅灰底面板,不用投影、大圆角、玻璃拟态。 +- 页面底部保留来源/口径/假设脚注行(font-size: 9–10px)。 + +## 配色(CSS 可用) +- 背景:#ffffff(纯白) +- 文字:#1f2937(深灰黑) +- 主色:#1e3a8a(深海军蓝:标题、表头、结构线、编号、标签前缀) +- 强调:#dc2626(红色:失败/风险/转折批注、关键数字,克制使用但该用就用) +- 辅助:#64748b(中灰蓝:副标题、说明文字、脚注) +- 面板:#f1f5f9(浅灰蓝:表头底色、元信息块、侧栏面板、斑马纹) + +## 排版(HTML/CSS) +- 正文 11–13px,line-height 1.45–1.6;嵌套列表逐级缩进 14–18px;段间距保持密排(不超过 1.5 倍行距),整页呈现「满而有序」。 +- 表格字号 10–12px,单元格 padding 4–8px;单元格内的编号列表用 `margin: 0; padding-left: 14px;` 紧凑排布。 +- 结论标题 20–24px 粗体;小节标题与侧栏面板标题 13–14px 加粗。 +- 数字、评分、代码用等宽字体(如 "SF Mono", Consolas, monospace)增强报告感。 +- 密度的边界:正文不小于 10px、行高不小于 1.35、任何元素不得溢出 960×540pt 画布。**内容装不下时拆成两页;内容装得下但排不满时,按「画布填充硬指标」补内容。** +- 垂直预算心算(写前必做):标题区 70–95pt + 脚注 20–25pt + 底部安全边距 ≥ 36pt(0.5in),正文区实际约 390–420pt。每个块按「行数 × 字号 × 行高 + padding」估算,所有块合计 ≤ 正文区高度;表格先算「行数 × 行高」再定行数。估超就减行/合栏/拆页,禁止缩字号硬塞。 + +## CSS 实现要点 +- 表格:`border-collapse: collapse;`,表头 `background: #1e3a8a; color: #ffffff;`,斑马纹 `tr:nth-child(even){background: #f1f5f9;}`,单元格 `border: 1px solid #e2e8f0; padding: 4px 8px; vertical-align: top;`。 +- 多行单元格:单元格内用 `
        `/`
          `(`margin: 0; padding-left: 14px; font-size: 10px;`)放编号小结论;失败格 `color: #dc2626; font-weight: 700;` 或整格 `background: #fef2f2;`。 +- 标签前缀:`AI 的行为:` 后接常规正文,同一页所有小节复用同一组标签。 +- 批注条(风险/疑问/转折):`background: #fef2f2; border-left: 3px solid #dc2626; padding: 6px 10px;`,内文 `color: #dc2626;` 可配斜体。 +- 「本页结论」横条:`background: #f1f5f9; border-left: 4px solid #1e3a8a; padding: 8px 12px;`,1–2 个完整句子。 +- 元信息块/侧栏面板:`background: #f1f5f9; border: 1px solid #e2e8f0; padding: 10px 12px;`,内部用「加粗标签: 值」逐行排布。 +- 流程框:`border: 1.5px solid #1e3a8a; border-radius: 4px; padding: 6px 10px;`,连接线用细 div 或 border 绘制,箭头用 CSS 三角形。 +- 布局填满且不破:`body { overflow: hidden; }`;根容器用 `display: flex; flex-direction: column; height: 540pt;`,正文区 `flex: 1; min-height: 0; overflow: hidden;` 并让网格/表格 `height: 100%;` 拉伸——既从结构上杜绝下半页留白,又保证估错时只在容器内裁切、不撑破画布。 + +## 逐页自检清单(生成每页 HTML 后必须逐条核对,任一不过即重写本页) +1. 正文是否从标题分隔线排到了脚注线,底部空白 ≤ 40pt? +2. 正文字数是否 ≥ 300(目标 450–900)? +3. 每张卡片/每个小节是否 ≥ 4 行、每个表格是否 ≥ 7 行? +4. 每条要点是否都是完整句子并带具体证据(数字/日期/名称/原话)? +5. 页内各小节是否复用了同一组加粗标签维度? +6. 是否有「本页结论」横条或等价的判断性收底? +7. 是否无任何元素溢出 960×540pt、字号 ≥ 10px?垂直预算合计是否 ≤ 正文区高度、文本框底边距画布底部是否 ≥ 0.5in? +8. 本页的文字、表格、图表或图示是否确实最有利于当前论证,而不是沿用固定模板或为了可视化而可视化? + +## 避免 +- **不留下半页空白**——这是本风格最严重的失败模式;宁可多写一组维度、一行结论,也不留白。 +- **不写关键词式短 bullet 页**:只有名词短语的 3–5 条大字要点页是演讲风格,视为不合格输出。 +- 不写两行小卡片拼贴页:卡片必须是多字段实体,不是标签贴纸。 +- 不做大留白页、巨型 hero 数字页、情绪化金句页;不为「呼吸感」牺牲信息量。 +- 不使用装饰性图形、图标墙、大面积色块和渐变;所有视觉元素必须承载信息。 +- 不写没有结论的罗列:标题必须给出判断,正文必须支撑标题。 +- 不把长内容压成不可读:宁可拆页,不许字号低于 10px、不许行距挤压、不许溢出画布。 +- 不省略证据:分析必须落到具体的数字、文件、函数、原话或行为,不许停留在「效果不错」「存在问题」这类空泛判断。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面;输入内容单薄时必须基于输入展开成完整句子的详尽分析并补齐分析维度,直到满足画布填充硬指标,而不是缩减成短语或留白。所有视觉元素(含表格、流程图、架构图、指标面板)必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/minimal-gallery.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/minimal-gallery.md new file mode 100644 index 0000000000..38acbcba68 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/minimal-gallery.md @@ -0,0 +1,50 @@ +# 黑白极简(Monochrome Minimal) + +## 风格标识 +`minimal-gallery` + +## 适用场景 +作品集、策略提案、咨询报告、摄影/设计展示、艺术项目、数据结论页等需要克制高级感的演示。 + +## 风格定位 +Minimal Gallery。融合严格网格作品集与高端画册跨页:网格、留白、精确对齐、黑白灰层级,每一页都像画廊展墙或画册展开。 + +## 视觉系统 +- 背景纯白或浅灰(#fafafa / #ffffff),必要时可用黑底强调页(#000000,黑底白字,用于强结论或章节切换)。 +- 严格网格对齐所有元素;大面积负空间,主体通常占 50%-80%。 +- 每页左上/右上角放小节编号与标题(如 01. INTRODUCTION)。 +- 视觉主体可用低饱和色块、抽象几何或 CSS 形状;细线边框(1px solid #e5e7eb)作精致装帧。 +- 可选「纸感方言」:暖灰白底(#f5f5f4)+ 折纸多边形(clip-path)+ 微妙折叠阴影,用于艺术/文化主题;选定后全 deck 保持一致。 + +## 配色(CSS 可用) +- 背景:#fafafa(浅灰)或 #ffffff(纯白) +- 文字:#171717(近黑) +- 强调:#171717(黑色粗线、深色巨大数字) +- 辅助:#737373 / #a3a3a3(灰阶) +- 面板:#ffffff(白色);特殊页 #000000(黑底白字) + +## 排版(HTML/CSS) +- 标题用现代无衬线(font-weight: 500-700; font-size: 24-40px; letter-spacing: 0.02-0.05em)。 +- 正文小字号(12-16px),宽松字距与行高(line-height: 1.6);页码极小(10px)。 +- 层级靠字号、位置和留白制造,而不是多色装饰。 + +## 推荐版式(960×540pt) +- 标题页:分散式布局,中心小标题搭配角落编号和关键词。 +- 数据页:左侧叙述,右侧超大黑色数字 + 细分隔线。 +- 画册跨页:左右非对称,一侧大色块/图形,一侧留白。 +- 网格展示:2×2 或 3×3 等距网格(gap 统一),文字极少。 + +## CSS 实现要点 +- 网格:`display: grid; grid-template-columns: repeat(12, 1fr); gap: 20px;` +- 分隔线:`width: 40px; height: 1px; background: #171717;` +- 编号:`font-size: 12px; letter-spacing: 0.1em; color: #525252;` +- 装帧:`border: 1px solid #e5e7eb; padding: 8px;`;留白:`padding: 60px; gap: 40px;` +- 精致感:`letter-spacing: 0.05em; text-transform: uppercase;` + +## 避免 +- 不使用圆润可爱元素、渐变背景、复杂纹理或高饱和彩色块。 +- 不使用随意对齐或装饰性过强的图标;不让文字过多破坏画册感。 +- 极简风需要清晰层级和留白纪律。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/pink-pop.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/pink-pop.md new file mode 100644 index 0000000000..55b2770eb1 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/pink-pop.md @@ -0,0 +1,48 @@ +# 粉色波普(Pink Pop) + +## 风格标识 +`pink-pop` + +## 适用场景 +人物介绍、产品亮点、生活方式、年轻化品牌、社群内容、创意提案等需要亲和力与潮流感的演示。 + +## 风格定位 +Pink Pop。以哑光粉底为识别色,提供两种力度:精致编辑杂志(成熟可爱)与街头波普(活泼能量)。根据主题选定一种力度后全 deck 保持一致。 + +## 视觉系统 +- 背景使用哑光柔和粉色(#fce7f3)。 +- 精致编辑力度:中央大幅主体区域(大圆角矩形/有机形状),边缘非对称放置对话气泡、竖排文字框和手写批注;四角可加细线裁切标记,营造杂志打样感。 +- 街头波普力度:粗线条(border: 3px solid)、轻微夸张变形的几何形状、Q 弹有机色块、涂鸦线条、手绘箭头和贴纸标签。 +- 两种力度都要求信息层级清楚,装饰不得淹没正文。 + +## 配色(CSS 可用) +- 背景:#fce7f3(柔和粉) +- 文字:#831843(深粉紫红)或 #374151(炭灰,精致力度) +- 强调:#db2777 / #be185d(亮粉/深粉) +- 点缀:#f472b6(浅亮粉) +- 面板:#fdf2f8(浅粉) + +## 排版(HTML/CSS) +- 精致力度:标题用高对比衬线体(Georgia, "Songti SC", serif; font-weight: 700);批注可用手写体。 +- 街头力度:标题用粗体无衬线(font-weight: 900; font-size: 28-40px);文字可略微倾斜错位(rotate(-2deg))但保证可读。 +- 内容拆成 NO.1/NO.2 编号栏目、短句、标签或贴纸卡片;页面文字使用用户指定语言,少量英文作栏目标签。 + +## 推荐版式(960×540pt) +- 主体区域居中,编号信息分布两侧(flex 布局)。 +- 一侧竖排标题(writing-mode: vertical-rl),另一侧气泡要点。 +- 有机形状拼贴 + 手绘批注;贴纸式卡片沿对角线排列(rotate(-5deg))。 +- 观点页用巨大关键词和粗线箭头表达方向。 + +## CSS 实现要点 +- 有机形状:`border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%;` +- 气泡:`border-radius: 20px 20px 20px 0` + 柔和阴影 `box-shadow: 2px 2px 8px rgba(0,0,0,0.05)`。 +- 贴纸:`background: #fdf2f8; border: 2px solid #db2777; transform: rotate(-3deg); box-shadow: 3px 3px 0 #db2777;` +- 裁切标记:`position: absolute` 的细线 div(宽 20px,高 1px)。 + +## 避免 +- 不使用过于幼稚的鲜艳撞色、夸张渐变或重度阴影。 +- 不使用写实商务照片、玻璃拟态或低对比粉白文字。 +- 不把所有内容居中堆叠;保留编辑版式的非对称张力。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/pop-infographic.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/pop-infographic.md new file mode 100644 index 0000000000..e1a86f5805 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/pop-infographic.md @@ -0,0 +1,48 @@ +# 波普信息图(Pop Infographic) + +## 风格标识 +`pop-infographic` + +## 适用场景 +增长数据、产品教育、技术方案、系统架构、社群运营、SNS 内容等信息密集型页面。 + +## 风格定位 +Pop Infographic。鲜艳波普配色加信息图版式,提供两种方言:有机波普(流动、亲切)与复古像素(8-bit 工程感,适合开发者/技术主题)。根据主题选定一种方言后全 deck 保持一致。 + +## 视觉系统 +- 有机方言:白底(#ffffff / 极浅青 #f0fdfa),不规则变形虫状、云朵状或波浪形色块(不规则 border-radius),可在边缘局部裁切;点缀圆点图案、手绘高亮笔触、星星和聊天气泡。 +- 像素方言:浅奶油底(#fefce8)+ 极淡 CSS 网格线;模块用堆叠彩色方块、粗黑边框(border: 3px solid #171717)、硬阴影和轻微错位;图标用 Unicode 或 CSS 模拟 8-bit(圆角 ≤ 4px)。 +- 每个信息模块只用一个主导色块,避免同一区域颜色过杂。 + +## 配色(CSS 可用) +- 背景:#ffffff / #f0fdfa(有机)或 #fefce8(像素) +- 文字:#171717(近黑) +- 强调:#ec4899(鲜粉)、#06b6d4 / #22d3ee(青色)、#facc15(亮黄,像素方言) +- 辅助:#525252(中灰) +- 面板:#f0fdfa(浅青)或 #fef9c3(浅奶油) + +## 排版(HTML/CSS) +- 标题用粗无衬线(font-weight: 800; font-size: 28-44px)。 +- 数字和百分比用超大字号强调(font-size: 64-96px)。 +- 信息拆成短句、标签或卡片;文案短、直接、有主见,避免营销腔;少量英文标签不抢占主体信息。 + +## 推荐版式(960×540pt) +- 数据波普:可组合结论文字、排序条形图、分段构成条、大数字、环形图或更自由的信息图构图;根据数据意图和页面节奏选择,不固定左右模板。 +- 系统架构:堆叠彩色图层(div 堆叠 + 粗边框),每层含图标、标题和一句说明。 +- 时间线:横向方框或波浪线串联节点,最后一步放大高亮。 +- 功能清单:卡片矩阵(grid),每张卡片单一强调色背景。 + +## CSS 实现要点 +- 有机形状:`border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%;` +- 圆点背景:`background-image: radial-gradient(#ec4899 2px, transparent 2px); background-size: 20px 20px;` +- 高亮笔触:`background: linear-gradient(120deg, transparent 10%, #facc15 10%, #facc15 90%, transparent 90%);` +- 像素硬阴影:`box-shadow: 4px 4px 0 #171717;` + `border: 3px solid #171717;` +- 默认可编辑 PPTX 路径把上述 gradient 装饰降级为纯色 `div`/离散色块;图表形式仍由内容决定,并优先采用能稳定导出的实现。 + +## 避免 +- 不使用僵硬商务图表、暗沉配色、真实照片或玻璃拟态。 +- 不让有机形态或背景网格干扰正文可读性。 +- 不把页面做成儿童插画;保持现代和信息清晰。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/retro-pop.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/retro-pop.md new file mode 100644 index 0000000000..75f2839aec --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/retro-pop.md @@ -0,0 +1,48 @@ +# 复古海报波普(Retro Poster Pop) + +## 风格标识 +`retro-pop` + +## 适用场景 +复古主题活动、音乐文化、品牌宣传、艺术展览、趋势话题等需要怀旧感和强记忆点的演示。 + +## 风格定位 +Retro Pop。以复古色调、粗体排版和海报级冲击力为底盘,可选「古典雕塑混搭」方言制造超现实波普记忆点。选定方言后全 deck 保持一致。 + +## 视觉系统 +- 背景使用复古色调(#fef3c7 奶油黄为主);章节页/封面可用高饱和纯色(如 #06b6d4 青色)制造波普反差页。 +- 粗体排版和大字号是视觉主体;文字可倾斜、错位或重叠。 +- 可用 CSS 绘制放射线、星星、圆点、半调网点等复古印刷图案。 +- 雕塑方言:白色圆形/椭圆 CSS 形状模拟古典雕塑轮廓,叠加现代物件符号(🕶 🎧 📱),颜色与背景形成强对比;不重复用同一形状作所有页面主角。 + +## 配色(CSS 可用) +- 背景:#fef3c7(奶油黄);反差页 #06b6d4(青色) +- 文字:#451a03(深棕);反差页 #ffffff(白色) +- 强调:#dc2626(红色)和 #f59e0b(橙色) +- 辅助:#92400e(中棕) +- 面板:#fffbeb(浅黄) + +## 排版(HTML/CSS) +- 标题用超粗无衬线(font-weight: 900; font-size: 36-52px),可倾斜(skewX(-5deg) 或 rotate(-2deg))。 +- 正文用粗体无衬线(font-weight: 700; font-size: 14-16px),短句化。 +- 标题可与主体局部重叠,制造杂志封面冲击力。 + +## 推荐版式(960×540pt) +- 海报封面:超大标题 + 放射线背景 + 底部信息条。 +- 对比页:左右分栏,不同复古色块表达 Old/New 或 A/B。 +- 数据页:巨大数字 + 复古色条。 +- 信息页:主体形状 + 旁边气泡(不对称 border-radius)标注要点。 + +## CSS 实现要点 +- 放射线:`background: repeating-conic-gradient(#f59e0b 0deg 10deg, transparent 10deg 20deg);` +- 圆点:`background-image: radial-gradient(#dc2626 2px, transparent 2px); background-size: 16px 16px;` +- 复古边框:`border: 4px solid #451a03;`;倾斜文字:`transform: skewX(-5deg);` +- 雕塑质感:`border-radius: 50%` + `box-shadow: inset -10px -10px 20px rgba(0,0,0,0.1)`。 + +## 避免 +- 不使用现代极简风格或低对比配色。 +- 不使用过多颜色,保持复古色调统一;不让装饰物覆盖核心文字。 +- 不让文字过小,破坏海报感。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/yellow-magazine.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/yellow-magazine.md new file mode 100644 index 0000000000..01c6126e45 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/style-presets/yellow-magazine.md @@ -0,0 +1,47 @@ +# 黄底黑字杂志(Yellow Magazine) + +## 风格标识 +`yellow-magazine` + +## 适用场景 +封面、品牌宣言、活动海报、时尚消费品、生活方式、个人 IP、趋势观点等需要高识别度的演示。 + +## 风格定位 +Yellow Magazine。黄底黑字大排版加少量手写点缀,制造强烈杂志感与高级流行气质。 + +## 视觉系统 +- 背景使用高识别度黄色(#facc15,或明亮不刺眼的 #fef08a 变体),作为整套页面的识别底色。 +- 主视觉用大字排版、色块裁切或局部放大;标题、色块与贴纸元素可局部重叠,形成大胆构图。 +- 加入少量手写字、箭头、圈注或散落贴纸,制造编辑现场感;只用于强调词或批注,不用于正文。 + +## 配色(CSS 可用) +- 背景:#facc15(明黄) +- 文字:#171717(黑色) +- 辅助:#ffffff(白色)和 #57534e(暖灰) +- 强调:#171717(黑色反白块)和 #dc2626(红色批注,少量) +- 面板:#fef08a(浅黄) + +## 排版(HTML/CSS) +- 标题用大号高对比字体(font-weight: 800; font-size: 36-52px),可跨色块或溢出画面边缘。 +- 正文用短句、编号或小标签承载,不使用长段落。 +- 手写字体(font-family: cursive)仅用于批注和强调短语;字体总数控制在三类以内(标题/正文/手写)。 + +## 推荐版式(960×540pt) +- 巨大标题占半屏,色块从另一侧切入(grid: 1fr 1fr)。 +- 中央大色块,四周分布短标签或手写批注。 +- 单句宣言页:一个大词 + 一个小副标题。 +- 列表页:贴纸编号分隔要点,像杂志栏目。 + +## CSS 实现要点 +- 响应式大字:`font-size: clamp(32px, 6vw, 64px)`。 +- 反白色块:`background: #171717; color: #facc15; padding: 20px;` +- 贴纸:`background: #ffffff; border: 2px solid #171717; transform: rotate(-5deg); box-shadow: 3px 3px 0 #171717;` +- 圈注:`border: 2px solid #dc2626; border-radius: 50%;`;箭头用 CSS 三角形或 → ↗ ↓。 + +## 避免 +- 不使用复杂渐变、重阴影或廉价贴纸堆叠。 +- 不让手写元素干扰核心信息阅读。 +- 不让排版质量或裁切破坏高级感。 + +## 输出要求 +保留输入内容的信息结构和文字含义,按此风格重绘为统一连贯的 16:9 演示页面。所有视觉元素必须用 HTML/CSS 实现,不依赖外部图片资源。 diff --git a/src/crates/core/builtin_skills/pptx/LICENSE.txt b/src/crates/assembly/core/builtin_skills/pptx/LICENSE.txt similarity index 100% rename from src/crates/core/builtin_skills/pptx/LICENSE.txt rename to src/crates/assembly/core/builtin_skills/pptx/LICENSE.txt diff --git a/src/crates/core/builtin_skills/pptx/SKILL.md b/src/crates/assembly/core/builtin_skills/pptx/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/pptx/SKILL.md rename to src/crates/assembly/core/builtin_skills/pptx/SKILL.md diff --git a/src/crates/core/builtin_skills/pptx/editing.md b/src/crates/assembly/core/builtin_skills/pptx/editing.md similarity index 100% rename from src/crates/core/builtin_skills/pptx/editing.md rename to src/crates/assembly/core/builtin_skills/pptx/editing.md diff --git a/src/crates/core/builtin_skills/pptx/pptxgenjs.md b/src/crates/assembly/core/builtin_skills/pptx/pptxgenjs.md similarity index 100% rename from src/crates/core/builtin_skills/pptx/pptxgenjs.md rename to src/crates/assembly/core/builtin_skills/pptx/pptxgenjs.md diff --git a/src/crates/core/builtin_skills/pptx/scripts/__init__.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/__init__.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/__init__.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/add_slide.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/add_slide.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/add_slide.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/add_slide.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/clean.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/clean.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/clean.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/clean.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/helpers/__init__.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/helpers/__init__.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/__init__.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/helpers/merge_runs.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/merge_runs.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/helpers/merge_runs.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/merge_runs.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/helpers/simplify_redlines.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/simplify_redlines.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/helpers/simplify_redlines.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/simplify_redlines.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/pack.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/pack.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/pack.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/pack.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/soffice.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/soffice.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/soffice.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/soffice.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/unpack.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/unpack.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/unpack.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/unpack.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/validate.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validate.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/validate.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/validate.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/validators/__init__.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/validators/__init__.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/__init__.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/validators/base.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/base.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/validators/base.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/base.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/validators/docx.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/docx.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/validators/docx.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/docx.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/validators/pptx.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/pptx.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/validators/pptx.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/pptx.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/office/validators/redlining.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/redlining.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/office/validators/redlining.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/redlining.py diff --git a/src/crates/core/builtin_skills/pptx/scripts/thumbnail.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/thumbnail.py similarity index 100% rename from src/crates/core/builtin_skills/pptx/scripts/thumbnail.py rename to src/crates/assembly/core/builtin_skills/pptx/scripts/thumbnail.py diff --git a/src/crates/core/builtin_skills/writing-skills/SKILL.md b/src/crates/assembly/core/builtin_skills/writing-skills/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/SKILL.md rename to src/crates/assembly/core/builtin_skills/writing-skills/SKILL.md diff --git a/src/crates/core/builtin_skills/writing-skills/anthropic-best-practices.md b/src/crates/assembly/core/builtin_skills/writing-skills/anthropic-best-practices.md similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/anthropic-best-practices.md rename to src/crates/assembly/core/builtin_skills/writing-skills/anthropic-best-practices.md diff --git a/src/crates/core/builtin_skills/writing-skills/examples/CLAUDE_MD_TESTING.md b/src/crates/assembly/core/builtin_skills/writing-skills/examples/CLAUDE_MD_TESTING.md similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/examples/CLAUDE_MD_TESTING.md rename to src/crates/assembly/core/builtin_skills/writing-skills/examples/CLAUDE_MD_TESTING.md diff --git a/src/crates/core/builtin_skills/writing-skills/graphviz-conventions.dot b/src/crates/assembly/core/builtin_skills/writing-skills/graphviz-conventions.dot similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/graphviz-conventions.dot rename to src/crates/assembly/core/builtin_skills/writing-skills/graphviz-conventions.dot diff --git a/src/crates/core/builtin_skills/writing-skills/persuasion-principles.md b/src/crates/assembly/core/builtin_skills/writing-skills/persuasion-principles.md similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/persuasion-principles.md rename to src/crates/assembly/core/builtin_skills/writing-skills/persuasion-principles.md diff --git a/src/crates/core/builtin_skills/writing-skills/render-graphs.js b/src/crates/assembly/core/builtin_skills/writing-skills/render-graphs.js similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/render-graphs.js rename to src/crates/assembly/core/builtin_skills/writing-skills/render-graphs.js diff --git a/src/crates/core/builtin_skills/writing-skills/testing-skills-with-subagents.md b/src/crates/assembly/core/builtin_skills/writing-skills/testing-skills-with-subagents.md similarity index 100% rename from src/crates/core/builtin_skills/writing-skills/testing-skills-with-subagents.md rename to src/crates/assembly/core/builtin_skills/writing-skills/testing-skills-with-subagents.md diff --git a/src/crates/core/builtin_skills/xlsx/LICENSE.txt b/src/crates/assembly/core/builtin_skills/xlsx/LICENSE.txt similarity index 100% rename from src/crates/core/builtin_skills/xlsx/LICENSE.txt rename to src/crates/assembly/core/builtin_skills/xlsx/LICENSE.txt diff --git a/src/crates/core/builtin_skills/xlsx/SKILL.md b/src/crates/assembly/core/builtin_skills/xlsx/SKILL.md similarity index 100% rename from src/crates/core/builtin_skills/xlsx/SKILL.md rename to src/crates/assembly/core/builtin_skills/xlsx/SKILL.md diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/helpers/merge_runs.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/merge_runs.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/helpers/merge_runs.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/merge_runs.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/helpers/simplify_redlines.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/simplify_redlines.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/helpers/simplify_redlines.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/simplify_redlines.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/pack.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/pack.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/pack.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/pack.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/soffice.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/soffice.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/soffice.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/soffice.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/unpack.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/unpack.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/unpack.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/unpack.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/validate.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validate.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/validate.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validate.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/validators/__init__.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/__init__.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/validators/__init__.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/__init__.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/validators/base.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/base.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/validators/base.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/base.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/validators/docx.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/docx.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/validators/docx.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/docx.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/validators/pptx.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/pptx.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/validators/pptx.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/pptx.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/office/validators/redlining.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/redlining.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/office/validators/redlining.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/redlining.py diff --git a/src/crates/core/builtin_skills/xlsx/scripts/recalc.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/recalc.py similarity index 100% rename from src/crates/core/builtin_skills/xlsx/scripts/recalc.py rename to src/crates/assembly/core/builtin_skills/xlsx/scripts/recalc.py diff --git a/src/crates/core/locales/en-US.ftl b/src/crates/assembly/core/locales/en-US.ftl similarity index 100% rename from src/crates/core/locales/en-US.ftl rename to src/crates/assembly/core/locales/en-US.ftl diff --git a/src/crates/core/locales/zh-CN.ftl b/src/crates/assembly/core/locales/zh-CN.ftl similarity index 100% rename from src/crates/core/locales/zh-CN.ftl rename to src/crates/assembly/core/locales/zh-CN.ftl diff --git a/src/crates/core/locales/zh-TW.ftl b/src/crates/assembly/core/locales/zh-TW.ftl similarity index 69% rename from src/crates/core/locales/zh-TW.ftl rename to src/crates/assembly/core/locales/zh-TW.ftl index bfcc2286da..8f8211ca05 100644 --- a/src/crates/core/locales/zh-TW.ftl +++ b/src/crates/assembly/core/locales/zh-TW.ftl @@ -3,38 +3,38 @@ # ==================== 通用 ==================== app-version = 版本 { $version } -loading = 加載中... +loading = 載入中... welcome = 歡迎使用 BitFun # ==================== 操作 ==================== action-confirm = 確認 action-cancel = 取消 -action-save = 保存 +action-save = 儲存 action-delete = 刪除 action-edit = 編輯 -action-create = 創建 -action-add = 添加 +action-create = 建立 +action-add = 新增 action-remove = 移除 action-close = 關閉 -action-open = 打開 +action-open = 開啟 action-copy = 複製 action-paste = 粘貼 action-undo = 撤銷 action-redo = 重做 -action-refresh = 刷新 -action-search = 搜索 +action-refresh = 重新整理 +action-search = 進行搜尋 action-retry = 重試 action-stop = 停止 action-start = 開始 # ==================== 狀態 ==================== -status-loading = 加載中 -status-saving = 保存中 -status-saved = 已保存 +status-loading = 正在載入 +status-saving = 儲存中 +status-saved = 已儲存 status-success = 成功 status-error = 錯誤 status-warning = 警告 -status-info = 信息 +status-info = 資訊 status-pending = 等待中 status-processing = 處理中 status-completed = 已完成 @@ -44,25 +44,25 @@ status-ready = 就緒 status-connected = 已連接 status-disconnected = 已斷開 -# ==================== 文件 ==================== -file-not-found = 文件未找到:{ $path } -file-read-error = 讀取文件失敗:{ $path } -file-write-error = 寫入文件失敗:{ $path } -file-delete-error = 刪除文件失敗:{ $path } +# ==================== 檔案 ==================== +file-not-found = 檔案未找到:{ $path } +file-read-error = 讀取檔案失敗:{ $path } +file-write-error = 寫入檔案失敗:{ $path } +file-delete-error = 刪除檔案失敗:{ $path } file-permission-denied = 權限不足:{ $path } -file-already-exists = 文件已存在:{ $path } -file-saved = 文件已保存:{ $path } -file-created = 文件已創建:{ $path } -file-deleted = 文件已刪除:{ $path } +file-already-exists = 檔案已存在:{ $path } +file-saved = 檔案已儲存:{ $path } +file-created = 檔案已建立:{ $path } +file-deleted = 檔案已刪除:{ $path } # ==================== 工作區 ==================== -workspace-opened = 工作區已打開:{ $path } +workspace-opened = 工作區已開啟:{ $path } workspace-closed = 工作區已關閉 workspace-not-found = 工作區未找到 -workspace-open-error = 打開工作區失敗 +workspace-open-error = 開啟工作區失敗 # ==================== Git ==================== -git-not-repository = 當前目錄不是 Git 倉庫 +git-not-repository = 目前目錄不是 Git 倉庫 git-commit-success = 提交成功 git-push-success = 推送成功 git-pull-success = 拉取成功 @@ -71,7 +71,7 @@ git-commit-error = 提交失敗 git-push-error = 推送失敗 git-pull-error = 拉取失敗 git-merge-conflict = 存在合併衝突 -git-branch-created = 分支已創建:{ $name } +git-branch-created = 分支已建立:{ $name } git-branch-deleted = 分支已刪除:{ $name } git-checkout-success = 已切換到分支:{ $name } @@ -86,25 +86,25 @@ ai-thinking = 思考中... ai-generating = 生成中... # ==================== 終端 ==================== -terminal-created = 終端已創建 +terminal-created = 終端已建立 terminal-closed = 終端已關閉 -terminal-create-error = 創建終端失敗 +terminal-create-error = 建立終端失敗 terminal-command-error = 執行命令失敗 terminal-shell-not-found = Shell 未找到 -# ==================== 配置 ==================== -config-loaded = 配置已加載 -config-saved = 配置已保存 -config-load-error = 加載配置失敗 -config-save-error = 保存配置失敗 -config-invalid = 配置格式無效 -config-reset = 配置已重置 +# ==================== 設定 ==================== +config-loaded = 設定已載入 +config-saved = 設定已儲存 +config-load-error = 載入設定失敗 +config-save-error = 儲存設定失敗 +config-invalid = 設定格式無效 +config-reset = 設定已重設 # ==================== 快照 ==================== -snapshot-created = 快照已創建:{ $name } +snapshot-created = 快照已建立:{ $name } snapshot-restored = 快照已恢復:{ $name } snapshot-deleted = 快照已刪除 -snapshot-create-error = 創建快照失敗 +snapshot-create-error = 建立快照失敗 snapshot-restore-error = 恢復快照失敗 snapshot-not-found = 快照未找到 @@ -114,7 +114,7 @@ language-not-supported = 不支持的語言:{ $language } # ==================== 通知 ==================== notification-copied = 已複製到剪貼板 -notification-settings-saved = 設置已保存 +notification-settings-saved = 設置已儲存 notification-connection-established = 連接已建立 notification-connection-lost = 連接已斷開 @@ -122,7 +122,7 @@ notification-connection-lost = 連接已斷開 error-unknown = 發生未知錯誤 error-network = 網絡錯誤 error-timeout = 請求超時 -error-server = 服務器錯誤 +error-server = 伺服器錯誤 error-unauthorized = 未授權 error-forbidden = 禁止訪問 diff --git a/src/crates/core/src/agentic/agents/definitions/custom/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/custom/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/custom/mod.rs diff --git a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs similarity index 55% rename from src/crates/core/src/agentic/agents/definitions/custom/subagent.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs index 6d32411791..56fce8bb12 100644 --- a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs @@ -2,20 +2,14 @@ use crate::agentic::agents::Agent; use crate::agentic::agents::{PromptBuilder, PromptBuilderContext, UserContextPolicy}; use crate::agentic::session::SystemPromptCacheIdentity; use crate::util::errors::{BitFunError, BitFunResult}; -use crate::util::FrontMatterMarkdown; use async_trait::async_trait; -use serde_yaml::Value; +pub use bitfun_agent_runtime::custom_subagent::CustomSubagentKind; +use bitfun_agent_runtime::custom_subagent::{ + custom_subagent_read_markdown_file, custom_subagent_save_markdown_parts, + CustomSubagentDefinition, +}; use sha2::{Digest, Sha256}; -/// Subagent type: project-level or user-level -#[derive(Debug, Clone, Copy)] -pub enum CustomSubagentKind { - /// Project subagent - Project, - /// User subagent - User, -} - pub struct CustomSubagent { pub name: String, pub description: String, @@ -29,6 +23,22 @@ pub struct CustomSubagent { pub model: String, } +impl CustomSubagent { + pub(crate) fn from_definition(path: String, definition: CustomSubagentDefinition) -> Self { + Self { + name: definition.name, + description: definition.description, + tools: definition.tools, + prompt: definition.prompt, + readonly: definition.readonly, + review: definition.review, + path, + kind: definition.kind, + model: definition.model, + } + } +} + #[async_trait] impl Agent for CustomSubagent { fn as_any(&self) -> &dyn std::any::Any { @@ -92,80 +102,17 @@ impl CustomSubagent { path: String, kind: CustomSubagentKind, ) -> Self { - Self { - name, - description, - tools, - prompt, - readonly, - review: false, - path, - kind, - model: "fast".to_string(), - } - } - - pub fn from_file(path: &str, kind: CustomSubagentKind) -> BitFunResult { - let (metadata, content) = FrontMatterMarkdown::load(path)?; - let name = metadata - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::Agent("Missing name field".to_string()))? - .to_string(); - let description = metadata - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::Agent("Missing description field".to_string()))? - .to_string(); - let tools: Vec = metadata - .get("tools") - .and_then(|v| v.as_str()) - .map(|s| s.split(',').map(|x| x.trim().to_string()).collect()) - .unwrap_or_else(|| Self::DEFAULT_TOOLS.iter().map(|s| s.to_string()).collect()); + let definition = + CustomSubagentDefinition::new(name, description, tools, prompt, readonly, kind); - let readonly = metadata - .get("readonly") - .and_then(|v| v.as_bool()) - .unwrap_or(Self::DEFAULT_READONLY); - - let review = metadata - .get("review") - .and_then(|v| v.as_bool()) - .unwrap_or(Self::DEFAULT_REVIEW); - - let model = metadata - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or(Self::DEFAULT_MODEL) - .to_string(); - - Ok(Self { - name, - description, - tools, - prompt: content, - readonly, - review, - path: path.to_string(), - kind, - model, - }) + Self::from_definition(path, definition) } - const DEFAULT_TOOLS: &'static [&'static str] = &["LS", "Read", "Glob", "Grep"]; - const DEFAULT_READONLY: bool = true; - const DEFAULT_REVIEW: bool = false; - const DEFAULT_MODEL: &'static str = "fast"; + pub fn from_file(path: &str, kind: CustomSubagentKind) -> BitFunResult { + let definition = + custom_subagent_read_markdown_file(path, kind).map_err(BitFunError::Agent)?; - /// Check if tools match default values - fn is_default_tools(tools: &[String]) -> bool { - if tools.len() != Self::DEFAULT_TOOLS.len() { - return false; - } - tools - .iter() - .zip(Self::DEFAULT_TOOLS.iter()) - .all(|(a, b)| a == *b) + Ok(Self::from_definition(path.to_string(), definition)) } /// Save current subagent as markdown file with YAML front matter @@ -176,36 +123,17 @@ impl CustomSubagent { /// Fields equal to default values are not saved pub fn save_to_file(&self, model: Option<&str>) -> BitFunResult<()> { let model = model.unwrap_or(&self.model); - - let mut metadata = serde_yaml::Mapping::new(); - metadata.insert( - Value::String("name".into()), - Value::String(self.name.clone()), - ); - metadata.insert( - Value::String("description".into()), - Value::String(self.description.clone()), - ); - if !Self::is_default_tools(&self.tools) { - metadata.insert( - Value::String("tools".into()), - Value::String(self.tools.join(", ")), - ); - } - if self.readonly != Self::DEFAULT_READONLY { - metadata.insert(Value::String("readonly".into()), Value::Bool(self.readonly)); - } - if self.review != Self::DEFAULT_REVIEW { - metadata.insert(Value::String("review".into()), Value::Bool(self.review)); - } - if model != Self::DEFAULT_MODEL { - metadata.insert( - Value::String("model".into()), - Value::String(model.to_string()), - ); - } - let metadata = Value::Mapping(metadata); - FrontMatterMarkdown::save(&self.path, &metadata, &self.prompt).map_err(BitFunError::Agent) + custom_subagent_save_markdown_parts( + &self.path, + &self.name, + &self.description, + &self.tools, + &self.prompt, + self.readonly, + self.review, + model, + ) + .map_err(BitFunError::Agent) } } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs similarity index 91% rename from src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs index 5dbaba6865..73fb2f05fc 100644 --- a/src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs @@ -27,7 +27,9 @@ impl CodeReviewAgent { // Remediation tools, only after explicit user approval "Edit".to_string(), "Write".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "TodoWrite".to_string(), // Git operations tool "Git".to_string(), @@ -96,7 +98,9 @@ mod tests { assert!(tools.contains(&"AskUserQuestion".to_string())); assert!(tools.contains(&"Edit".to_string())); assert!(tools.contains(&"Write".to_string())); - assert!(tools.contains(&"Bash".to_string())); + assert!(tools.contains(&"ExecCommand".to_string())); + assert!(tools.contains(&"WriteStdin".to_string())); + assert!(tools.contains(&"ExecControl".to_string())); assert!(tools.contains(&"TodoWrite".to_string())); assert!(!agent.is_readonly()); } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs similarity index 88% rename from src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs index c6176732cb..de724dcba5 100644 --- a/src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs @@ -26,7 +26,9 @@ impl DeepReviewAgent { "AskUserQuestion".to_string(), "Edit".to_string(), "Write".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "TodoWrite".to_string(), ], } @@ -85,7 +87,9 @@ mod tests { assert!(tools.contains(&"AskUserQuestion".to_string())); assert!(tools.contains(&"Edit".to_string())); assert!(tools.contains(&"Write".to_string())); - assert!(tools.contains(&"Bash".to_string())); + assert!(tools.contains(&"ExecCommand".to_string())); + assert!(tools.contains(&"WriteStdin".to_string())); + assert!(tools.contains(&"ExecControl".to_string())); assert!(!agent.is_readonly()); } } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/generate_doc.rs b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/generate_doc.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/hidden/generate_doc.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/hidden/generate_doc.rs diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/hidden/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/hidden/mod.rs diff --git a/src/crates/core/src/agentic/agents/definitions/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/mod.rs diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs new file mode 100644 index 0000000000..cde2c4c8ff --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs @@ -0,0 +1,134 @@ +//! Agentic Mode +//! +//! Shared coding modes inherit the agentic baseline for +//! `SHARED_CODING_MODE_PROMPT_TEMPLATE`, `shared_coding_mode_tools()`, +//! `shared_coding_mode_tool_exposure_overrides()`, and +//! `shared_coding_mode_user_context_policy()`. Across the four coding modes, +//! only the reminder content differs. + +use crate::agentic::agents::{ + get_embedded_prompt, shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgentToolPolicyOverrides, UserContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; +use async_trait::async_trait; + +const AGENTIC_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "agentic_mode_first_entry_reminder"; + +pub struct AgenticMode { + default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, +} + +impl Default for AgenticMode { + fn default() -> Self { + Self::new() + } +} + +impl AgenticMode { + pub fn new() -> Self { + Self { + default_tools: shared_coding_mode_tools(), + tool_exposure_overrides: shared_coding_mode_tool_exposure_overrides(), + } + } + + fn load_reminder_template( + &self, + template_name: &str, + ) -> crate::util::errors::BitFunResult { + get_embedded_prompt(template_name) + .map(str::to_string) + .ok_or_else(|| { + crate::util::errors::BitFunError::Agent(format!( + "{} not found in embedded files", + template_name + )) + }) + } +} + +#[async_trait] +impl Agent for AgenticMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "agentic" + } + + fn name(&self) -> &str { + "Agentic" + } + + fn description(&self) -> &str { + "Full-featured AI assistant with access to all tools for comprehensive software development tasks" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + SHARED_CODING_MODE_PROMPT_TEMPLATE + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + + fn user_context_policy(&self) -> UserContextPolicy { + shared_coding_mode_user_context_policy() + } + + async fn get_system_reminder( + &self, + previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> crate::util::errors::BitFunResult { + if previous_agent_type == Some(self.id()) { + Ok(String::new()) + } else { + self.load_reminder_template(AGENTIC_MODE_FIRST_ENTRY_REMINDER_TEMPLATE) + } + } + + fn is_readonly(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::AgenticMode; + use crate::agentic::agents::Agent; + use crate::agentic::tools::framework::ToolExposure; + + #[test] + fn agentic_mode_expands_web_research_tools() { + let mode = AgenticMode::new(); + let overrides = mode.tool_exposure_overrides(); + + assert_eq!(overrides.get("WebSearch"), Some(&ToolExposure::Expanded)); + assert_eq!(overrides.get("WebFetch"), Some(&ToolExposure::Expanded)); + assert!(mode.default_tools().contains(&"WebSearch".to_string())); + assert!(mode.default_tools().contains(&"WebFetch".to_string())); + } + + #[tokio::test] + async fn returns_first_entry_reminder_only_when_entering_agentic_mode() { + let mode = AgenticMode::new(); + + assert_eq!( + mode.get_system_reminder(None, None).await.unwrap(), + "You have entered agentic mode." + ); + assert!(mode + .get_system_reminder(Some("agentic"), None) + .await + .unwrap() + .is_empty()); + } +} diff --git a/src/crates/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs similarity index 93% rename from src/crates/core/src/agentic/agents/definitions/modes/claw.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 77d1bab262..fc7e399e47 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -21,13 +21,14 @@ impl ClawMode { "Write".to_string(), "Edit".to_string(), "Delete".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "Grep".to_string(), "Glob".to_string(), "WebSearch".to_string(), "Skill".to_string(), "Git".to_string(), - "TerminalControl".to_string(), "SessionControl".to_string(), "SessionMessage".to_string(), "SessionHistory".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs similarity index 67% rename from src/crates/core/src/agentic/agents/definitions/modes/cowork.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index b1c77a1e6f..9cbc912b2d 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -2,11 +2,13 @@ //! //! A collaborative mode that prioritizes early clarification and lightweight progress tracking. -use crate::agentic::agents::{Agent, UserContextPolicy}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides, UserContextPolicy}; +use crate::agentic::tools::framework::ToolExposure; use async_trait::async_trait; pub struct CoworkMode { default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, } impl Default for CoworkMode { @@ -17,7 +19,13 @@ impl Default for CoworkMode { impl CoworkMode { pub fn new() -> Self { + // Cowork is the office/research mode; web research is baseline there, + // so keep WebSearch/WebFetch expanded (same as DeepResearch). + let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); + tool_exposure_overrides.insert("WebSearch".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); Self { + tool_exposure_overrides, default_tools: vec![ // Clarification + planning helpers "AskUserQuestion".to_string(), @@ -35,9 +43,11 @@ impl CoworkMode { // Utilities "GetFileDiff".to_string(), "Git".to_string(), - "Bash".to_string(), - "TerminalControl".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "WebSearch".to_string(), + "WebFetch".to_string(), "ControlHub".to_string(), ], } @@ -70,6 +80,10 @@ impl Agent for CoworkMode { self.default_tools.clone() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + fn user_context_policy(&self) -> UserContextPolicy { UserContextPolicy::empty() .with_workspace_context() diff --git a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs similarity index 92% rename from src/crates/core/src/agentic/agents/definitions/modes/debug.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs index 9b8fbe144a..1afc480e9e 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs @@ -1,8 +1,15 @@ -//! Debug Mode - Evidence-driven debugging mode +//! Debug Mode +//! +//! Shared coding modes inherit the agentic baseline for +//! `SHARED_CODING_MODE_PROMPT_TEMPLATE`, `shared_coding_mode_tools()`, +//! `shared_coding_mode_tool_exposure_overrides()`, and +//! `shared_coding_mode_user_context_policy()`. Across the four coding modes, +//! only the reminder content differs. use crate::agentic::agents::{ - get_embedded_prompt, shared_coding_mode_tools, shared_coding_mode_user_context_policy, Agent, - UserContextPolicy, SHARED_CODING_MODE_PROMPT_TEMPLATE, + get_embedded_prompt, shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgentToolPolicyOverrides, UserContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, }; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; @@ -12,7 +19,10 @@ use async_trait::async_trait; use log::debug; use std::path::Path; -pub struct DebugMode; +pub struct DebugMode { + default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, +} const DEBUG_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "debug_mode_first_entry_reminder"; const DEBUG_MODE_ONGOING_REMINDER_TEMPLATE: &str = "debug_mode_ongoing_reminder"; @@ -25,7 +35,10 @@ impl Default for DebugMode { impl DebugMode { pub fn new() -> Self { - Self + Self { + default_tools: shared_coding_mode_tools(), + tool_exposure_overrides: shared_coding_mode_tool_exposure_overrides(), + } } async fn get_debug_config(&self) -> DebugModeConfig { @@ -328,6 +341,10 @@ impl Agent for DebugMode { shared_coding_mode_user_context_policy() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + async fn get_system_reminder( &self, previous_agent_type: Option<&str>, @@ -352,7 +369,7 @@ impl Agent for DebugMode { } fn default_tools(&self) -> Vec { - shared_coding_mode_tools() + self.default_tools.clone() } fn is_readonly(&self) -> bool { diff --git a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs similarity index 93% rename from src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index abfcc1dbb6..7fe3bae5e5 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -26,11 +26,11 @@ impl DeepResearchMode { "Read".to_string(), "Grep".to_string(), "Glob".to_string(), - "LS".to_string(), "Write".to_string(), "Edit".to_string(), - "Bash".to_string(), - "TerminalControl".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "ControlHub".to_string(), "TodoWrite".to_string(), "AskUserQuestion".to_string(), @@ -101,8 +101,9 @@ mod tests { tools.contains(&"Edit".to_string()), "Edit required so the agent can continue a Write that was truncated by max_tokens" ); - assert!(tools.contains(&"Bash".to_string())); - assert!(tools.contains(&"TerminalControl".to_string())); + assert!(tools.contains(&"ExecCommand".to_string())); + assert!(tools.contains(&"WriteStdin".to_string())); + assert!(tools.contains(&"ExecControl".to_string())); assert!(tools.contains(&"ControlHub".to_string())); assert!( tools.contains(&"AskUserQuestion".to_string()), diff --git a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/modes/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs diff --git a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/multitask.rs similarity index 73% rename from src/crates/core/src/agentic/agents/definitions/modes/multitask.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/multitask.rs index cd91b9be36..093c36e384 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/multitask.rs @@ -1,13 +1,21 @@ //! Multitask Mode +//! +//! Shared coding modes inherit the agentic baseline for +//! `SHARED_CODING_MODE_PROMPT_TEMPLATE`, `shared_coding_mode_tools()`, +//! `shared_coding_mode_tool_exposure_overrides()`, and +//! `shared_coding_mode_user_context_policy()`. Across the four coding modes, +//! only the reminder content differs. use crate::agentic::agents::{ - get_embedded_prompt, shared_coding_mode_tools, shared_coding_mode_user_context_policy, Agent, - UserContextPolicy, SHARED_CODING_MODE_PROMPT_TEMPLATE, + get_embedded_prompt, shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgentToolPolicyOverrides, UserContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, }; use async_trait::async_trait; pub struct MultitaskMode { default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, } const MULTITASK_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "multitask_mode_first_entry_reminder"; @@ -23,6 +31,7 @@ impl MultitaskMode { pub fn new() -> Self { Self { default_tools: shared_coding_mode_tools(), + tool_exposure_overrides: shared_coding_mode_tool_exposure_overrides(), } } @@ -67,6 +76,10 @@ impl Agent for MultitaskMode { shared_coding_mode_user_context_policy() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + async fn get_system_reminder( &self, previous_agent_type: Option<&str>, diff --git a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/plan.rs similarity index 72% rename from src/crates/core/src/agentic/agents/definitions/modes/plan.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/plan.rs index 02e972147f..12b540115f 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/plan.rs @@ -1,13 +1,21 @@ //! Plan Mode +//! +//! Shared coding modes inherit the agentic baseline for +//! `SHARED_CODING_MODE_PROMPT_TEMPLATE`, `shared_coding_mode_tools()`, +//! `shared_coding_mode_tool_exposure_overrides()`, and +//! `shared_coding_mode_user_context_policy()`. Across the four coding modes, +//! only the reminder content differs. use crate::agentic::agents::{ - get_embedded_prompt, shared_coding_mode_tools, shared_coding_mode_user_context_policy, Agent, - UserContextPolicy, SHARED_CODING_MODE_PROMPT_TEMPLATE, + get_embedded_prompt, shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgentToolPolicyOverrides, UserContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, }; use async_trait::async_trait; pub struct PlanMode { default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, } const PLAN_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "plan_mode_first_entry_reminder"; @@ -23,6 +31,7 @@ impl PlanMode { pub fn new() -> Self { Self { default_tools: shared_coding_mode_tools(), + tool_exposure_overrides: shared_coding_mode_tool_exposure_overrides(), } } @@ -71,6 +80,10 @@ impl Agent for PlanMode { shared_coding_mode_user_context_policy() } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + async fn get_system_reminder( &self, previous_agent_type: Option<&str>, @@ -84,7 +97,6 @@ impl Agent for PlanMode { } fn is_readonly(&self) -> bool { - // only modify plan file, not modify project code - true + false } } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs similarity index 94% rename from src/crates/core/src/agentic/agents/definitions/modes/team.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 033438f8f9..34518543d5 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -26,7 +26,9 @@ impl TeamMode { "Write".to_string(), "Edit".to_string(), "Delete".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "Grep".to_string(), "Glob".to_string(), "WebSearch".to_string(), @@ -34,7 +36,6 @@ impl TeamMode { "TodoWrite".to_string(), "AskUserQuestion".to_string(), "Git".to_string(), - "TerminalControl".to_string(), "ControlHub".to_string(), "GetFileDiff".to_string(), ], diff --git a/src/crates/core/src/agentic/agents/definitions/review/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/review/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/review/mod.rs diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs similarity index 89% rename from src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs index 2142281cbb..3a247dd1cc 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs @@ -27,7 +27,9 @@ impl ReviewFixerAgent { "GetFileDiff".to_string(), "Edit".to_string(), "Write".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "TodoWrite".to_string(), "Git".to_string(), ], @@ -91,7 +93,9 @@ mod tests { ); assert!(tools.contains(&"Edit".to_string())); assert!(tools.contains(&"Write".to_string())); - assert!(tools.contains(&"Bash".to_string())); + assert!(tools.contains(&"ExecCommand".to_string())); + assert!(tools.contains(&"WriteStdin".to_string())); + assert!(tools.contains(&"ExecControl".to_string())); assert!(!agent.is_readonly()); } } diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs diff --git a/src/crates/core/src/agentic/agents/definitions/shared/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/shared/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/shared/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/shared/mod.rs diff --git a/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs b/src/crates/assembly/core/src/agentic/agents/definitions/shared/readonly.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/shared/readonly.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/shared/readonly.rs diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs similarity index 95% rename from src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs index 04fb10afcd..f7495a0be0 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs @@ -27,8 +27,9 @@ impl ComputerUseMode { "AskUserQuestion".to_string(), "TodoWrite".to_string(), "Skill".to_string(), - "Bash".to_string(), - "TerminalControl".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "ControlHub".to_string(), "ComputerUse".to_string(), ], diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/explore.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/explore.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/subagents/explore.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/subagents/explore.rs diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/file_finder.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/file_finder.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/subagents/file_finder.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/subagents/file_finder.rs diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs similarity index 92% rename from src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs index f278e0b0d4..82a00d23e7 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -15,14 +15,15 @@ impl GeneralPurposeAgent { pub fn new() -> Self { Self { default_tools: vec![ - "Bash".to_string(), "Read".to_string(), "Glob".to_string(), "Grep".to_string(), "Write".to_string(), "Edit".to_string(), "Delete".to_string(), - "TerminalControl".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), ], diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/subagents/mod.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/research_specialist.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/research_specialist.rs similarity index 100% rename from src/crates/core/src/agentic/agents/definitions/subagents/research_specialist.rs rename to src/crates/assembly/core/src/agentic/agents/definitions/subagents/research_specialist.rs diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs similarity index 75% rename from src/crates/core/src/agentic/agents/mod.rs rename to src/crates/assembly/core/src/agentic/agents/mod.rs index 7dc4beb294..5455dc16a4 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -5,16 +5,18 @@ mod definitions; mod prompt_builder; mod registry; -// Utility hooks used by specific agents (not themselves an agent definition): -// citation_renumber finalizes a DeepResearch report's cit_XXX references into -// consecutive `[N]` display IDs after the dialog turn completes. -pub(crate) mod citation_renumber; use crate::agentic::session::{SystemPromptCacheIdentity, UserContextCacheIdentity}; use crate::agentic::tools::framework::ToolExposure; use crate::agentic::WorkspaceBinding; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +pub use bitfun_agent_runtime::agents::{ + mode_config_profile_label, mode_config_profile_member_mode_ids, mode_presentation_rank, + resolve_mode_config_profile_id, shared_coding_mode_user_context_policy, + SHARED_CODING_MODE_CONFIG_PROFILE_ID, SHARED_CODING_MODE_CONFIG_PROFILE_LABEL, + SHARED_CODING_MODE_IDS, SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ @@ -31,20 +33,21 @@ pub use definitions::subagents::{ }; use indexmap::IndexMap; pub use prompt_builder::{ - PrependedPromptReminders, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, - ToolListingSections, UserContextPolicy, UserContextSection, + build_prompt_context_for_workspace, PrependedPromptReminders, PromptBuilder, + PromptBuilderContext, RemoteExecutionHints, RuntimeContextNeeds, ToolListingSections, + UserContextPolicy, UserContextSection, }; pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; pub use registry::types::{ - AgentCategory, AgentInfo, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, - SubagentListScope, SubagentQueryContext, SubagentStateReason, + subagent_source_from_custom_kind, AgentCategory, AgentInfo, AgentToolPolicy, + CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, + SubagentStateReason, }; pub use registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, }; pub use registry::{get_agent_registry, AgentRegistry, CustomSubagentDetail}; use std::any::Any; -use std::borrow::Cow; // Include embedded prompts generated at compile time include!(concat!(env!("OUT_DIR"), "/embedded_agents_prompt.rs")); @@ -54,32 +57,14 @@ pub type AgentToolPolicyOverrides = IndexMap; static EMPTY_AGENT_TOOL_POLICY_OVERRIDES: std::sync::LazyLock = std::sync::LazyLock::new(AgentToolPolicyOverrides::default); -pub const SHARED_CODING_MODE_PROMPT_TEMPLATE: &str = "agentic_mode"; -pub const SHARED_CODING_MODE_CONFIG_PROFILE_ID: &str = "coding_shared"; -pub const SHARED_CODING_MODE_CONFIG_PROFILE_LABEL: &str = "Coding Shared"; -pub const SHARED_CODING_MODE_IDS: &[&str] = &["agentic", "Plan", "debug", "Multitask"]; - -pub fn resolve_mode_config_profile_id<'a>(mode_id: &'a str) -> Cow<'a, str> { - match mode_id.trim() { - "agentic" | "Plan" | "debug" | "Multitask" => { - Cow::Borrowed(SHARED_CODING_MODE_CONFIG_PROFILE_ID) - } - _ => Cow::Borrowed(mode_id), - } -} - -pub fn mode_config_profile_member_mode_ids(profile_id: &str) -> &'static [&'static str] { - match profile_id.trim() { - SHARED_CODING_MODE_CONFIG_PROFILE_ID => SHARED_CODING_MODE_IDS, - _ => &[], - } -} - -pub fn mode_config_profile_label(profile_id: &str) -> Option<&'static str> { - match profile_id.trim() { - SHARED_CODING_MODE_CONFIG_PROFILE_ID => Some(SHARED_CODING_MODE_CONFIG_PROFILE_LABEL), - _ => None, - } +pub fn shared_coding_mode_tool_exposure_overrides() -> AgentToolPolicyOverrides { + // Web research is a baseline capability of the shared coding modes; keep + // WebSearch/WebFetch expanded so models do not need a GetToolSpec + // unlock round-trip when switching between those modes. + let mut overrides = AgentToolPolicyOverrides::default(); + overrides.insert("WebSearch".to_string(), ToolExposure::Expanded); + overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); + overrides } pub fn shared_coding_mode_tools() -> Vec { @@ -89,32 +74,28 @@ pub fn shared_coding_mode_tools() -> Vec { "Write".to_string(), "Edit".to_string(), "Delete".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), + "WriteStdin".to_string(), + "ExecControl".to_string(), "Grep".to_string(), "Glob".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), "TodoWrite".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "GenerativeUI".to_string(), "Skill".to_string(), "AskUserQuestion".to_string(), "CreatePlan".to_string(), "Git".to_string(), "Log".to_string(), - "TerminalControl".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), ] } -pub fn shared_coding_mode_user_context_policy() -> UserContextPolicy { - UserContextPolicy::empty() - .with_workspace_context() - .with_workspace_instructions() - .with_workspace_memory_files() - .with_project_layout() -} - /// Agent trait defining the interface for all agents #[async_trait] pub trait Agent: Send + Sync + 'static { @@ -183,8 +164,9 @@ pub trait Agent: Send + Sync + 'static { } } - /// Get the system reminder for this agent, only used for modes - /// system_reminder will be appended to the user_query + /// Get the system reminder for this agent, only used for modes. + /// The returned reminder may be prepended immediately before the user's + /// actual message in runtime context. /// `previous_agent_type` can be used to distinguish first entry vs staying /// in the same mode across turns. async fn get_system_reminder( @@ -225,8 +207,9 @@ pub trait Agent: Send + Sync + 'static { #[cfg(test)] mod tests { use super::{ - shared_coding_mode_tools, shared_coding_mode_user_context_policy, Agent, AgenticMode, - DebugMode, MultitaskMode, PlanMode, + shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgenticMode, DebugMode, MultitaskMode, + PlanMode, }; #[test] @@ -268,6 +251,8 @@ mod tests { assert!(tools.contains(&"CreatePlan".to_string())); assert!(tools.contains(&"Log".to_string())); + assert!(tools.contains(&"get_goal".to_string())); + assert!(tools.contains(&"update_goal".to_string())); } #[test] @@ -279,4 +264,18 @@ mod tests { assert_eq!(PlanMode::new().user_context_policy(), shared_policy); assert_eq!(DebugMode::new().user_context_policy(), shared_policy); } + + #[test] + fn shared_coding_mode_tool_exposure_overrides_match_all_shared_modes() { + let shared_overrides = shared_coding_mode_tool_exposure_overrides(); + let agentic = AgenticMode::new(); + let multitask = MultitaskMode::new(); + let plan = PlanMode::new(); + let debug = DebugMode::new(); + + assert_eq!(agentic.tool_exposure_overrides(), &shared_overrides); + assert_eq!(multitask.tool_exposure_overrides(), &shared_overrides); + assert_eq!(plan.tool_exposure_overrides(), &shared_overrides); + assert_eq!(debug.tool_exposure_overrides(), &shared_overrides); + } } diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/mod.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/mod.rs new file mode 100644 index 0000000000..ac04a1575a --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/mod.rs @@ -0,0 +1,9 @@ +mod prompt_builder_impl; +mod user_context; + +pub use bitfun_agent_runtime::prompt::{PrependedPromptReminders, ToolListingSections}; +pub use prompt_builder_impl::{ + build_prompt_context_for_workspace, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, + RuntimeContextNeeds, +}; +pub use user_context::{UserContextPolicy, UserContextSection}; diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs new file mode 100644 index 0000000000..2c3b17dba4 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -0,0 +1,1050 @@ +//! System prompts module providing main dialogue and agent dialogue prompts +use crate::agentic::remote_file_delivery::user_workspace_relative_file_link; +use crate::agentic::tools::implementations::ExecCommandTool; +use crate::agentic::util::remote_workspace_layout::build_remote_workspace_layout_preview; +use crate::agentic::workspace::WorkspaceBackend; +use crate::agentic::WorkspaceBinding; +use crate::service::agent_memory::{ + build_workspace_agent_memory_prompt, build_workspace_instruction_files_context, + build_workspace_memory_files_context, +}; +use crate::service::bootstrap::build_workspace_persona_prompt; +use crate::service::config::get_app_language_code; +use crate::service::config::global::GlobalConfigManager; +use crate::service::filesystem::get_formatted_directory_listing; +use crate::service::i18n::LocaleId; +use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; +use crate::service::workspace::get_global_workspace_service; +use crate::service::workspace::RelatedPath; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_agent_runtime::prompt::{ + PrependedPromptReminders, ToolListingSections, UserContextPolicy, UserContextSection, +}; +use log::{debug, warn}; +use std::path::Path; + +/// Placeholder constants +const PLACEHOLDER_PERSONA: &str = "{PERSONA}"; +const PLACEHOLDER_LANGUAGE_PREFERENCE: &str = "{LANGUAGE_PREFERENCE}"; +const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; +const PLACEHOLDER_CLAW_WORKSPACE: &str = "{CLAW_WORKSPACE}"; +const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; +const PLACEHOLDER_SESSION_ID: &str = "{SESSION_ID}"; +const PLACEHOLDER_DEEP_RESEARCH_REPORT_LINK: &str = "{DEEP_RESEARCH_REPORT_LINK}"; +const USER_CONTEXT_PROMPT: &str = + "As you answer the user's questions, you can use the following context.\nNote: this is a snapshot captured at the start of the conversation and may not reflect real-time changes made afterward."; +/// SSH remote host facts for system prompt (workspace tools run here, not on the local client). +#[derive(Debug, Clone)] +pub struct RemoteExecutionHints { + pub connection_display_name: String, + pub kernel_name: String, + pub hostname: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RuntimeContextNeeds { + pub workspace_tools: bool, + pub exec_command: bool, + pub exec_control: bool, + pub computer_use: bool, +} + +impl RuntimeContextNeeds { + pub fn from_tool_names(tool_names: I) -> Self + where + T: AsRef, + I: IntoIterator, + { + let mut needs = Self::default(); + for tool_name in tool_names { + let tool_name = tool_name.as_ref(); + match tool_name { + "Read" | "Write" | "Edit" | "Delete" | "LS" | "Grep" | "Glob" | "ExecCommand" + | "WriteStdin" | "ExecControl" => { + needs.workspace_tools = true; + if tool_name == "ExecCommand" { + needs.exec_command = true; + } + if tool_name == "ExecControl" { + needs.exec_control = true; + } + } + "ComputerUse" | "ControlHub" => { + needs.computer_use = true; + } + _ => {} + } + } + needs + } + + fn is_empty(self) -> bool { + !self.workspace_tools && !self.exec_command && !self.exec_control && !self.computer_use + } +} + +#[derive(Debug, Clone)] +pub struct PromptBuilderContext { + pub workspace_path: String, + pub related_paths: Vec, + pub session_id: Option, + pub model_name: Option, + /// When set, file/shell tools target this remote environment; OS and path instructions follow it. + pub remote_execution: Option, + /// Pre-built tree text for `{PROJECT_LAYOUT}` when the workspace is not on the local disk. + pub remote_project_layout: Option, + /// When `Some(false)`, system prompt append Computer use text-only guidance (no screenshot tool output). + pub supports_image_understanding: Option, + /// Dynamic tool listings injected outside tool descriptions for cache stability. + pub tool_listing_sections: ToolListingSections, + /// Runtime facts needed by the current model-visible tool set. + pub runtime_context_needs: RuntimeContextNeeds, + /// Remote mobile/bot turns need `computer://` links for file delivery. + pub remote_file_delivery_channel: bool, +} + +impl PromptBuilderContext { + pub fn new( + workspace_path: impl Into, + session_id: Option, + model_name: Option, + ) -> Self { + Self { + workspace_path: workspace_path.into().replace("\\", "/"), + related_paths: Vec::new(), + session_id, + model_name, + remote_execution: None, + remote_project_layout: None, + supports_image_understanding: None, + tool_listing_sections: ToolListingSections::default(), + runtime_context_needs: RuntimeContextNeeds::default(), + remote_file_delivery_channel: false, + } + } + + pub fn with_supports_image_understanding(mut self, supports: bool) -> Self { + self.supports_image_understanding = Some(supports); + self + } + + pub fn with_tool_listing_sections(mut self, sections: ToolListingSections) -> Self { + self.tool_listing_sections = sections; + self + } + + pub fn with_runtime_context_needs(mut self, needs: RuntimeContextNeeds) -> Self { + self.runtime_context_needs = needs; + self + } + + pub fn with_related_paths(mut self, related_paths: Vec) -> Self { + self.related_paths = related_paths; + self + } + + pub fn with_remote_prompt_overlay( + mut self, + execution: RemoteExecutionHints, + project_layout: Option, + ) -> Self { + self.remote_execution = Some(execution); + self.remote_project_layout = project_layout; + self + } + + pub fn with_remote_file_delivery_channel(mut self, enabled: bool) -> Self { + self.remote_file_delivery_channel = enabled; + self + } +} + +pub async fn build_prompt_context_for_workspace( + workspace: &WorkspaceBinding, + workspace_id: Option<&str>, + session_id: &str, + model_name: Option, + supports_image_understanding: Option, + tool_listing_sections: ToolListingSections, + runtime_context_needs: RuntimeContextNeeds, +) -> Option { + let workspace_path = workspace.root_path_string(); + + let related_paths = if let Some(workspace_id) = workspace_id { + if let Some(workspace_service) = get_global_workspace_service() { + workspace_service + .get_workspace(workspace_id) + .await + .map(|workspace| workspace.related_paths) + .unwrap_or_default() + } else { + Vec::new() + } + } else { + Vec::new() + }; + + let mut base = PromptBuilderContext::new( + workspace_path.clone(), + Some(session_id.to_string()), + model_name, + ) + .with_related_paths(related_paths) + .with_tool_listing_sections(tool_listing_sections) + .with_runtime_context_needs(runtime_context_needs); + if let Some(supports_image_understanding) = supports_image_understanding { + base = base.with_supports_image_understanding(supports_image_understanding); + } + + if !workspace.is_remote() { + return Some(base); + } + + let Some(connection_id) = workspace.connection_id() else { + return Some(base); + }; + let Some(manager) = get_remote_workspace_manager() else { + warn!( + "Remote workspace active but RemoteWorkspaceStateManager is missing; using client OS hints only" + ); + return Some(base); + }; + + let ssh_manager = manager.get_ssh_manager().await; + let file_service = manager.get_file_service().await; + let (kernel_name, hostname) = if let Some(ref ssh) = ssh_manager { + if let Some(info) = ssh.get_server_info(connection_id).await { + (info.os_type, info.hostname) + } else { + ("Linux".to_string(), "remote".to_string()) + } + } else { + ("Linux".to_string(), "remote".to_string()) + }; + let connection_display_name = match &workspace.backend { + WorkspaceBackend::Remote { + connection_name, .. + } => connection_name.clone(), + _ => connection_id.to_string(), + }; + let remote_layout = if let Some(ref fs) = file_service { + match build_remote_workspace_layout_preview(fs, connection_id, &workspace_path, 200).await { + Ok((_, preview)) => Some(preview), + Err(e) => { + warn!("Remote workspace layout for prompt failed: {}", e); + None + } + } + } else { + None + }; + + Some(base.with_remote_prompt_overlay( + RemoteExecutionHints { + connection_display_name, + kernel_name, + hostname, + }, + remote_layout, + )) +} + +pub struct PromptBuilder { + pub context: PromptBuilderContext, + pub file_tree_max_entries: usize, +} + +impl PromptBuilder { + pub fn new(context: PromptBuilderContext) -> Self { + Self { + context, + file_tree_max_entries: 200, + } + } + + fn local_exec_shell_runtime_guidance(shell_type: &str) -> &'static [&'static str] { + match shell_type { + "powershell" | "pwsh" => &[ + "- For inline Python or other embedded scripts, prefer PowerShell-friendly forms such as `@'\\nprint(\"Hello\")\\n'@ | python -` instead of heavily nested quoting.", + "- In PowerShell, the escape character is the backtick (`), not backslash. `\\\"` is not a reliable way to escape a double quote for the shell.", + "- For environment variables, process filtering, and file traversal, prefer native PowerShell cmdlets and syntax over shell-specific Unix patterns.", + "- Avoid mixing PowerShell with `cmd.exe` or bash in the same command unless cross-shell behavior is explicitly required.", + ], + _ => &[], + } + } + + fn push_local_exec_shell_runtime_context( + lines: &mut Vec, + shell_display_name: &str, + shell_type: &str, + shell_invocation: &str, + ) { + lines.push(format!( + "- ExecCommand shell: {shell_display_name} ({shell_type}), invoked as {shell_invocation}." + )); + lines.extend( + Self::local_exec_shell_runtime_guidance(shell_type) + .iter() + .map(|line| (*line).to_string()), + ); + } + + fn push_runtime_context_section( + lines: &mut Vec, + title: &str, + section_lines: Vec, + ) { + if section_lines.is_empty() { + return; + } + + if !lines.is_empty() { + lines.push(String::new()); + } + lines.push(format!("## {title}")); + lines.extend(section_lines); + } + + fn exec_control_runtime_guidance( + host_os: &str, + remote_execution: bool, + exec_control_available: bool, + ) -> Vec { + if !exec_control_available || remote_execution || host_os != "windows" { + return Vec::new(); + } + + vec![ + "- On local Windows ExecCommand sessions, `ExecControl` `interrupt` is effectively the same as `kill` for non-TTY processes.".to_string(), + ] + } + + /// Build runtime facts that may change independently from the agent's system prompt. + pub async fn build_runtime_context_reminder(&self) -> Option { + let needs = self.context.runtime_context_needs; + if needs.is_empty() { + return None; + } + + let host_os = std::env::consts::OS; + let host_family = std::env::consts::FAMILY; + let host_arch = std::env::consts::ARCH; + + let computer_use_keys = match host_os { + "macos" => "- Computer use / `key_chord`: the local BitFun desktop is macOS. Use `command`, `option`, `control`, and `shift` modifier names.", + "windows" => "- Computer use / `key_chord`: the local BitFun desktop is Windows. Use `meta`/`super` for the Windows key, plus `alt`, `control`, and `shift`.", + "linux" => "- Computer use / `key_chord`: the local BitFun desktop is Linux. Use `control`, `alt`, `shift`, and `meta`/`super` as appropriate for the desktop environment.", + _ => "- Computer use / `key_chord`: match modifier names to the local BitFun desktop OS.", + }; + + let mut lines = vec!["# Runtime Context".to_string()]; + + if needs.workspace_tools { + let mut workspace_lines = Vec::new(); + if let Some(remote) = &self.context.remote_execution { + workspace_lines.push(format!( + "- Workspace file and shell tools operate on remote SSH connection \"{}\".", + remote.connection_display_name.replace('"', "'") + )); + workspace_lines.push(format!( + "- Remote host: {} (uname/kernel: {})", + remote.hostname.replace('"', "'"), + remote.kernel_name.replace('"', "'") + )); + workspace_lines.push("- Path conventions for workspace operations: POSIX paths with forward slashes and Unix shell syntax. Do not use PowerShell, `cmd.exe`, or Windows-style paths for remote workspace operations.".to_string()); + } else { + workspace_lines.push( + "- Workspace file and shell tools operate on the local filesystem.".to_string(), + ); + } + Self::push_runtime_context_section(&mut lines, "Workspace Execution", workspace_lines); + } + + if needs.exec_command { + let mut exec_command_lines = Vec::new(); + if self.context.remote_execution.is_some() { + exec_command_lines.push( + "- ExecCommand uses the remote user's default POSIX shell, invoked as ` -lc `." + .to_string(), + ); + } else { + let shell = ExecCommandTool::local_shell_prompt_info().await; + Self::push_local_exec_shell_runtime_context( + &mut exec_command_lines, + &shell.display_name, + &shell.shell_type, + &shell.invocation, + ); + } + Self::push_runtime_context_section(&mut lines, "ExecCommand Shell", exec_command_lines); + } + + let exec_control_lines = Self::exec_control_runtime_guidance( + host_os, + self.context.remote_execution.is_some(), + needs.exec_control, + ); + Self::push_runtime_context_section(&mut lines, "ExecControl", exec_control_lines); + + if needs.computer_use { + let mut local_client_lines = Vec::new(); + if self.context.remote_execution.is_some() && needs.workspace_tools { + local_client_lines.push( + "- Computer use and UI automation operate on the local BitFun desktop, even when workspace file and shell tools target a remote host." + .to_string(), + ); + } + local_client_lines.push(format!( + "- Local BitFun client OS: {host_os} ({host_family})" + )); + local_client_lines.push(format!("- Local client architecture: {host_arch}")); + local_client_lines.push(computer_use_keys.to_string()); + Self::push_runtime_context_section(&mut lines, "Local Client", local_client_lines); + } + + Some(lines.join("\n")) + } + + /// Get workspace context that is intentionally injected outside the system prompt cache. + pub fn get_workspace_context(&self) -> String { + let related_paths_section = if self.context.related_paths.is_empty() { + String::new() + } else { + let items = self + .context + .related_paths + .iter() + .map(|related_path| { + let path = related_path.path.replace("\\", "/"); + match related_path.description.as_deref().map(str::trim) { + Some(description) if !description.is_empty() => { + format!(" - {} — {}", path, description) + } + _ => format!(" - {}", path), + } + }) + .collect::>() + .join("\n"); + format!( + "- Related directories (user-specified directories related to this workspace):\n{}", + items + ) + }; + + if let Some(remote) = &self.context.remote_execution { + format!( + r#"## Workspace Context + +- Workspace root (file tools, Glob, LS, ExecCommand on workspace): {} +{} +- Execution environment: **Remote SSH** — connection "{}". +- Remote host: {} (uname/kernel: {}) + +"#, + self.context.workspace_path, + if related_paths_section.is_empty() { + String::new() + } else { + format!("{}\n", related_paths_section) + }, + remote.connection_display_name.replace('"', "'"), + remote.hostname.replace('"', "'"), + remote.kernel_name.replace('"', "'"), + ) + } else { + format!( + r#"## Workspace Context + +- Current Working Directory: {} +{} + +"#, + self.context.workspace_path, + if related_paths_section.is_empty() { + String::new() + } else { + format!("\n{}", related_paths_section) + } + ) + } + } + + /// Get workspace file list + pub fn get_project_layout(&self) -> String { + if let Some(remote_layout) = &self.context.remote_project_layout { + let mut project_layout = "## Workspace Layout\n\n".to_string(); + project_layout.push_str( + "Below is a snapshot of the current workspace's file structure on the **remote** host.\n\n", + ); + project_layout.push_str(remote_layout); + project_layout.push_str("\n\n\n"); + return project_layout; + } + + let formatted_listing = get_formatted_directory_listing( + &self.context.workspace_path, + self.file_tree_max_entries, + ) + .unwrap_or_else(|e| crate::service::filesystem::FormattedDirectoryListing { + reached_limit: false, + text: format!("Error listing directory: {}", e), + }); + let mut project_layout = "## Workspace Layout\n\n".to_string(); + if formatted_listing.reached_limit { + project_layout.push_str(&format!("Below is a snapshot of the current workspace's file structure (showing up to {} entries).\n\n", self.file_tree_max_entries)); + } else { + project_layout + .push_str("Below is a snapshot of the current workspace's file structure.\n\n"); + } + project_layout.push_str(&formatted_listing.text); + project_layout.push_str("\n\n\n"); + project_layout + } + + pub fn build_skill_listing_reminder(&self) -> Option { + self.context + .tool_listing_sections + .render_skill_listing_reminder() + } + + pub fn build_agent_listing_reminder(&self) -> Option { + self.context + .tool_listing_sections + .render_agent_listing_reminder() + } + + pub fn build_collapsed_tool_listing_reminder(&self) -> Option { + self.context + .tool_listing_sections + .render_collapsed_tool_listing_reminder() + } + + pub async fn build_user_context_reminder(&self, policy: &UserContextPolicy) -> Option { + let mut additional_sections = Vec::new(); + + if policy.includes(UserContextSection::WorkspaceContext) { + additional_sections.push(self.get_workspace_context()); + } + + if self.context.remote_execution.is_none() { + let workspace = Path::new(&self.context.workspace_path); + if policy.includes(UserContextSection::WorkspaceInstructions) { + match build_workspace_instruction_files_context(workspace).await { + Ok(Some(prompt)) => additional_sections.push(prompt), + Ok(None) => {} + Err(e) => warn!( + "Failed to build workspace instruction context: path={} error={}", + workspace.display(), + e + ), + } + } + if policy.includes(UserContextSection::WorkspaceMemoryFiles) { + match build_workspace_memory_files_context(workspace).await { + Ok(Some(prompt)) => additional_sections.push(prompt), + Ok(None) => {} + Err(e) => warn!( + "Failed to build workspace memory context: path={} error={}", + workspace.display(), + e + ), + } + } + } + + if policy.includes(UserContextSection::ProjectLayout) { + additional_sections.push(self.get_project_layout()); + } + + if additional_sections.is_empty() { + None + } else { + Some(format!( + "# User Context\n{}\n\n{}", + USER_CONTEXT_PROMPT, + additional_sections + .into_iter() + .map(|section| section.trim().to_string()) + .collect::>() + .join("\n\n") + )) + } + } + + pub async fn build_prepended_reminders( + &self, + user_context_policy: &UserContextPolicy, + ) -> PrependedPromptReminders { + PrependedPromptReminders { + collapsed_tool_listing: self.build_collapsed_tool_listing_reminder(), + skill_listing: self.build_skill_listing_reminder(), + agent_listing: self.build_agent_listing_reminder(), + runtime_context: self.build_runtime_context_reminder().await, + user_context: self.build_user_context_reminder(user_context_policy).await, + } + } + + /// Get visual mode instruction from user config + /// + /// Reads `app.ai_experience.enable_visual_mode` from global config. + /// Returns a prompt snippet when enabled, or empty string when disabled. + async fn get_visual_mode_instruction(&self) -> String { + let enabled = match GlobalConfigManager::get_service().await { + Ok(service) => service + .get_config::(Some("app.ai_experience.enable_visual_mode")) + .await + .unwrap_or(false), + Err(e) => { + debug!("Failed to read visual mode config: {}", e); + false + } + }; + + if enabled { + r"# Visualizing complex logic as you explain +Use Mermaid diagrams to visualize complex logic, workflows, architectures, and data flows whenever it helps clarify the explanation. +Output Mermaid in fenced code blocks (```mermaid) so the UI can render them. +".to_string() + } else { + String::new() + } + } + + /// Get user language preference instruction + /// + /// Read app.language from global config, generate simple language instruction + /// Returns empty string if config cannot be read + /// Returns error if language code is unsupported + async fn get_language_preference(&self) -> BitFunResult { + let language_code = get_app_language_code().await; + Self::format_language_instruction(&language_code) + } + + /// Format language instruction based on language code + fn format_language_instruction(lang_code: &str) -> BitFunResult { + let Some(locale) = LocaleId::from_str(lang_code) else { + return Err(BitFunError::config(format!( + "Unknown language code: {}", + lang_code + ))); + }; + let language = format!("**{}**", locale.model_language_name()); + Ok(format!("# Language Preference\nYou MUST respond in {} regardless of the user's input language. This is the system language setting and should be followed unless the user explicitly specifies a different language. This is crucial for smooth communication and user experience\n", language)) + } + + /// Get Claw-specific workspace boundary instruction + fn get_claw_workspace_instruction(&self) -> String { + "# Workspace +Your dedicated operating space is the workspace root shown in the current user context. +Prefer doing work inside this workspace and keep it well organized with clear structure, sensible filenames, and minimal clutter. +Do not read from, modify, create, move, or delete files outside this workspace unless the user has explicitly granted permission for that external action. +" + .to_string() + } + + /// Build prompt from template, automatically fill content based on placeholders + /// + /// Supported placeholders: + /// - `{PERSONA}` - Workspace persona files (BOOTSTRAP.md, SOUL.md, USER.md, IDENTITY.md) + /// - `{LANGUAGE_PREFERENCE}` - User language preference (read from global config) + /// - `{AGENT_MEMORY}` - Agent memory instructions + auto-loaded memory index + /// - `{CLAW_WORKSPACE}` - Claw-specific workspace ownership and boundary rules + /// - `{VISUAL_MODE}` - Visual mode instruction (Mermaid diagrams, read from global config) + /// + /// If a placeholder is not in the template, corresponding content will not be added + pub async fn build_prompt_from_template(&self, template: &str) -> BitFunResult { + let mut result = template.to_string(); + + // Replace {PERSONA} + if result.contains(PLACEHOLDER_PERSONA) { + let persona = if self.context.remote_execution.is_some() { + "# Workspace persona\nMarkdown persona files (e.g. BOOTSTRAP.md, SOUL.md) live on the **remote** workspace. Use Read or Glob under the workspace root above to load them.\n\n" + .to_string() + } else { + let workspace = Path::new(&self.context.workspace_path); + match build_workspace_persona_prompt(workspace).await { + Ok(prompt) => prompt.unwrap_or_default(), + Err(e) => { + warn!( + "Failed to build workspace persona prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } + } + }; + result = result.replace(PLACEHOLDER_PERSONA, &persona); + } + + // Replace {LANGUAGE_PREFERENCE} + if result.contains(PLACEHOLDER_LANGUAGE_PREFERENCE) { + let language_preference = self.get_language_preference().await?; + result = result.replace(PLACEHOLDER_LANGUAGE_PREFERENCE, &language_preference); + } + + // Replace {CLAW_WORKSPACE} + if result.contains(PLACEHOLDER_CLAW_WORKSPACE) { + let claw_workspace = self.get_claw_workspace_instruction(); + result = result.replace(PLACEHOLDER_CLAW_WORKSPACE, &claw_workspace); + } + + // Replace {AGENT_MEMORY} + if result.contains(PLACEHOLDER_AGENT_MEMORY) { + let agent_memory = if self.context.remote_execution.is_some() { + "# Agent memory\nSession memory under `.bitfun/` is stored on the **remote** host for this workspace. Use file tools with POSIX paths under the workspace root if you need to read it.\n\n" + .to_string() + } else { + let workspace = Path::new(&self.context.workspace_path); + match build_workspace_agent_memory_prompt(workspace).await { + Ok(prompt) => prompt, + Err(e) => { + warn!( + "Failed to build workspace agent memory prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } + } + }; + result = result.replace(PLACEHOLDER_AGENT_MEMORY, &agent_memory); + } + + // Replace {VISUAL_MODE} + if result.contains(PLACEHOLDER_VISUAL_MODE) { + let visual_mode = self.get_visual_mode_instruction().await; + result = result.replace(PLACEHOLDER_VISUAL_MODE, &visual_mode); + } + + // Replace {SESSION_ID} — used by deep-research Pro mode to anchor a per-session + // work_dir under .bitfun/sessions/{SESSION_ID}/research/. Falls back to a + // timestamp slug when no session is bound (e.g. one-shot prompt builds in tests). + let mut resolved_session_id: Option = None; + if result.contains(PLACEHOLDER_SESSION_ID) + || result.contains(PLACEHOLDER_DEEP_RESEARCH_REPORT_LINK) + { + let session_id = self.context.session_id.clone().unwrap_or_else(|| { + format!("unbound-{}", chrono::Local::now().format("%Y%m%d-%H%M%S")) + }); + resolved_session_id = Some(session_id.clone()); + result = result.replace(PLACEHOLDER_SESSION_ID, &session_id); + } + + if result.contains(PLACEHOLDER_DEEP_RESEARCH_REPORT_LINK) { + let session_id = resolved_session_id.unwrap_or_else(|| { + self.context.session_id.clone().unwrap_or_else(|| { + format!("unbound-{}", chrono::Local::now().format("%Y%m%d-%H%M%S")) + }) + }); + let report_link = user_workspace_relative_file_link( + &format!(".bitfun/sessions/{session_id}/research/report.md"), + self.context.remote_file_delivery_channel, + ); + result = result.replace(PLACEHOLDER_DEEP_RESEARCH_REPORT_LINK, &report_link); + } + + if self.context.supports_image_understanding == Some(false) { + result.push_str( + "\n\n# Computer use (text-only primary model)\n\n\ +The configured **primary model does not accept image inputs**. When using **`ComputerUse`** (or **`ControlHub`** with **`domain: \"browser\"`**):\n\ +- **Do not** use **`screenshot`** (desktop) and **avoid** `domain:\"browser\" action:\"screenshot\"` — the JPEG bytes will be unreadable.\n\ +- **ACTION PRIORITY:** 1) Terminal/CLI/system commands (`ExecCommand`, or `ComputerUse` `run_script`; use `WriteStdin`/`ExecControl` for running ExecCommand sessions) 2) Keyboard shortcuts (**`key_chord`**, **`type_text`**) 3) UI control: **`click_element`** (AX) → **`locate`** → **`move_to_text`** (use **`move_to_text_match_index`** when multiple OCR hits listed) → **`mouse_move`** (**`use_screen_coordinates`: true** with coordinates from tool JSON) → **`click`**. For browser work prefer `snapshot` → click by `@e*` ref over screenshots.\n\ +- **Never guess coordinates** — always use precise methods (AX, OCR, system coordinates from tool results, or browser snapshot refs).\n", + ); + } + + Ok(result.trim().to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::PromptBuilder; + use super::PromptBuilderContext; + use super::RemoteExecutionHints; + use super::RuntimeContextNeeds; + use super::ToolListingSections; + use super::USER_CONTEXT_PROMPT; + use crate::agentic::agents::UserContextPolicy; + use crate::service::workspace::RelatedPath; + + #[tokio::test] + async fn builds_ordered_prepended_reminders_from_tool_listings_and_user_context() { + let tool_sections = ToolListingSections { + skill_listing: Some("\n- pdf\n".to_string()), + agent_listing: Some("\n- Explore\n".to_string()), + collapsed_tool_listing: Some( + "\n- WebFetch\n".to_string(), + ), + }; + let context = PromptBuilderContext::new(r"workspace\root", None, None) + .with_tool_listing_sections(tool_sections) + .with_runtime_context_needs(RuntimeContextNeeds::from_tool_names(["Read"])); + let reminders = PromptBuilder::new(context) + .build_prepended_reminders( + &UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions(), + ) + .await; + let reminders_for_order = reminders.clone(); + let ordered_reminders = reminders_for_order.ordered_reminders(); + + let skill_listing = reminders + .skill_listing + .expect("skill listing reminder should build"); + let agent_listing = reminders + .agent_listing + .expect("agent listing reminder should build"); + let collapsed_tool_listing = reminders + .collapsed_tool_listing + .expect("collapsed tool listing reminder should build"); + let user_context = reminders.user_context.expect("user context should build"); + let runtime_context = reminders + .runtime_context + .expect("runtime context should build"); + + assert!(skill_listing.contains("# Skill Listing")); + assert!(skill_listing.contains("")); + assert!(!skill_listing.contains("# Agent Listing")); + assert!(agent_listing.contains("# Agent Listing")); + assert!(agent_listing.contains("")); + assert!(!agent_listing.contains("# Collapsed Tool Listing")); + assert!(collapsed_tool_listing.contains("# Collapsed Tool Listing")); + assert!(collapsed_tool_listing.contains("")); + assert!(user_context.contains("# User Context")); + assert!(user_context.contains(USER_CONTEXT_PROMPT)); + assert!(user_context.contains("Current Working Directory: workspace/root")); + assert!(runtime_context.contains("# Runtime Context")); + assert!(runtime_context.contains("## Workspace Execution")); + assert!(runtime_context + .contains("Workspace file and shell tools operate on the local filesystem")); + assert!(!runtime_context.contains("## ExecCommand Shell")); + assert!(!runtime_context.contains("## Local Client")); + assert!(!runtime_context.contains("ExecCommand shell:")); + assert_eq!( + ordered_reminders, + vec![ + collapsed_tool_listing.as_str(), + skill_listing.as_str(), + agent_listing.as_str(), + runtime_context.as_str(), + user_context.as_str(), + ] + ); + } + + #[tokio::test] + async fn prepended_reminders_omit_runtime_context_without_runtime_tool_needs() { + let context = PromptBuilderContext::new(r"workspace\root", None, None); + let reminders = PromptBuilder::new(context) + .build_prepended_reminders(&UserContextPolicy::empty()) + .await; + + assert_eq!(reminders.skill_listing, None); + assert_eq!(reminders.agent_listing, None); + assert_eq!(reminders.collapsed_tool_listing, None); + assert_eq!(reminders.user_context, None); + assert_eq!(reminders.runtime_context, None); + } + + #[tokio::test] + async fn runtime_context_includes_workspace_info_for_workspace_tools() { + let context = PromptBuilderContext::new(r"workspace\root", None, None) + .with_runtime_context_needs(RuntimeContextNeeds::from_tool_names(["Read"])); + let runtime_context = PromptBuilder::new(context) + .build_runtime_context_reminder() + .await + .expect("runtime context should build"); + + assert!(runtime_context.contains("# Runtime Context")); + assert!(runtime_context.contains("## Workspace Execution")); + assert!(runtime_context + .contains("Workspace file and shell tools operate on the local filesystem")); + assert!(!runtime_context.contains("## ExecCommand Shell")); + assert!(!runtime_context.contains("## Local Client")); + assert!(!runtime_context.contains("ExecCommand shell:")); + } + + #[tokio::test] + async fn runtime_context_includes_shell_info_when_exec_command_is_available() { + let context = PromptBuilderContext::new(r"workspace\root", None, None) + .with_runtime_context_needs(RuntimeContextNeeds::from_tool_names(["ExecCommand"])); + let runtime_context = PromptBuilder::new(context) + .build_runtime_context_reminder() + .await + .expect("runtime context should build"); + + assert!(runtime_context.contains("# Runtime Context")); + assert!(runtime_context.contains("## Workspace Execution")); + assert!(runtime_context.contains("## ExecCommand Shell")); + assert!(runtime_context.contains("ExecCommand shell:")); + assert!(runtime_context.contains("invoked as `")); + assert!(!runtime_context.contains("## Local Client")); + } + + #[test] + fn local_exec_shell_runtime_guidance_is_added_for_powershell_shells() { + let guidance = PromptBuilder::local_exec_shell_runtime_guidance("powershell"); + + assert_eq!( + guidance, + &[ + "- For inline Python or other embedded scripts, prefer PowerShell-friendly forms such as `@'\\nprint(\"Hello\")\\n'@ | python -` instead of heavily nested quoting.", + "- In PowerShell, the escape character is the backtick (`), not backslash. `\\\"` is not a reliable way to escape a double quote for the shell.", + "- For environment variables, process filtering, and file traversal, prefer native PowerShell cmdlets and syntax over shell-specific Unix patterns.", + "- Avoid mixing PowerShell with `cmd.exe` or bash in the same command unless cross-shell behavior is explicitly required.", + ] + ); + } + + #[test] + fn local_exec_shell_runtime_guidance_is_empty_for_non_powershell_shells() { + assert!(PromptBuilder::local_exec_shell_runtime_guidance("bash").is_empty()); + } + + #[test] + fn exec_control_runtime_guidance_is_added_for_local_windows() { + let guidance = PromptBuilder::exec_control_runtime_guidance("windows", false, true); + + assert_eq!( + guidance, + vec![ + "- On local Windows ExecCommand sessions, `ExecControl` `interrupt` is effectively the same as `kill` for non-TTY processes.".to_string() + ] + ); + } + + #[test] + fn exec_control_runtime_guidance_is_empty_when_exec_control_is_unavailable() { + assert!( + PromptBuilder::exec_control_runtime_guidance("windows", false, false).is_empty() + ); + } + + #[test] + fn exec_control_runtime_guidance_is_empty_for_remote_or_non_windows_hosts() { + assert!(PromptBuilder::exec_control_runtime_guidance("linux", false, true).is_empty()); + assert!(PromptBuilder::exec_control_runtime_guidance("windows", true, true).is_empty()); + } + + #[tokio::test] + async fn runtime_context_includes_computer_use_info_only_when_needed() { + let context = PromptBuilderContext::new(r"workspace\root", None, None) + .with_runtime_context_needs(RuntimeContextNeeds::from_tool_names(["ComputerUse"])); + let runtime_context = PromptBuilder::new(context) + .build_runtime_context_reminder() + .await + .expect("runtime context should build"); + + assert!(runtime_context.contains("## Local Client")); + assert!(runtime_context.contains("Local BitFun client OS:")); + assert!(runtime_context.contains("Computer use / `key_chord`")); + assert!(!runtime_context.contains("## Workspace Execution")); + assert!(!runtime_context.contains("## ExecCommand Shell")); + assert!(!runtime_context.contains("ExecCommand shell:")); + } + + #[tokio::test] + async fn runtime_context_omits_workspace_root_for_remote_execution() { + let context = PromptBuilderContext::new("/workspace/project", None, None) + .with_runtime_context_needs(RuntimeContextNeeds::from_tool_names([ + "Read", + "ExecCommand", + "ComputerUse", + ])) + .with_remote_prompt_overlay( + RemoteExecutionHints { + connection_display_name: "dev-server".to_string(), + kernel_name: "Linux".to_string(), + hostname: "devbox".to_string(), + }, + None, + ); + let runtime_context = PromptBuilder::new(context) + .build_runtime_context_reminder() + .await + .expect("runtime context should build"); + + assert!(runtime_context + .contains("Workspace file and shell tools operate on remote SSH connection")); + assert!(runtime_context.contains("## Workspace Execution")); + assert!(runtime_context.contains("## ExecCommand Shell")); + assert!(runtime_context.contains("## Local Client")); + assert!(runtime_context.contains("Local BitFun client OS:")); + assert!(runtime_context.contains("Computer use and UI automation operate on the local BitFun desktop, even when workspace file and shell tools target a remote host.")); + assert!(runtime_context.contains("ExecCommand uses the remote user's default POSIX shell")); + } + + #[tokio::test] + async fn deep_research_report_link_defaults_to_workspace_relative_path() { + let context = + PromptBuilderContext::new("workspace/root", Some("session-1".to_string()), None); + let prompt = PromptBuilder::new(context) + .build_prompt_from_template("[View full report]({DEEP_RESEARCH_REPORT_LINK})") + .await + .expect("prompt should build"); + + assert_eq!( + prompt, + "[View full report](.bitfun/sessions/session-1/research/report.md)" + ); + } + + #[tokio::test] + async fn deep_research_report_link_uses_computer_scheme_for_remote_delivery() { + let context = + PromptBuilderContext::new("workspace/root", Some("session-1".to_string()), None) + .with_remote_file_delivery_channel(true); + let prompt = PromptBuilder::new(context) + .build_prompt_from_template("[View full report]({DEEP_RESEARCH_REPORT_LINK})") + .await + .expect("prompt should build"); + + assert_eq!( + prompt, + "[View full report](computer://.bitfun/sessions/session-1/research/report.md)" + ); + } + + #[test] + fn workspace_context_renders_related_directories() { + let context = + PromptBuilderContext::new(r"workspace\root", None, None).with_related_paths(vec![ + RelatedPath { + path: r"legacy-ts\client".to_string(), + description: Some("Legacy TypeScript implementation".to_string()), + }, + RelatedPath { + path: r"monorepo\billing".to_string(), + description: Some("Billing package".to_string()), + }, + ]); + + let workspace_context = PromptBuilder::new(context).get_workspace_context(); + + assert!(workspace_context.contains("Related directories")); + assert!(workspace_context.contains("legacy-ts/client")); + assert!(workspace_context.contains("Legacy TypeScript implementation")); + assert!(workspace_context.contains("monorepo/billing")); + } + + #[test] + fn workspace_context_renders_related_directories_without_description() { + let context = + PromptBuilderContext::new(r"workspace\root", None, None).with_related_paths(vec![ + RelatedPath { + path: r"monorepo\packages\payments".to_string(), + description: None, + }, + ]); + + let workspace_context = PromptBuilder::new(context).get_workspace_context(); + + assert!(workspace_context.contains("Related directories")); + assert!(workspace_context.contains(" - monorepo/packages/payments")); + assert!(!workspace_context.contains("payments —")); + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs new file mode 100644 index 0000000000..ba5960a437 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs @@ -0,0 +1 @@ +pub use bitfun_agent_runtime::prompt::{UserContextPolicy, UserContextSection}; diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/agentic_mode.md similarity index 84% rename from src/crates/core/src/agentic/agents/prompts/agentic_mode.md rename to src/crates/assembly/core/src/agentic/agents/prompts/agentic_mode.md index ad82884539..4ac71ac127 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/agentic_mode.md @@ -2,7 +2,7 @@ You are BitFun, an ADE (AI IDE) that helps users with software engineering tasks You are pair programming with a USER to solve their coding task. Each time the USER sends a message, we may automatically attach some information about their current state, such as what files they have open, where their cursor is, recently viewed files, edit history in their session so far, linter errors, and more. This information may or may not be relevant to the coding task, it is up for you to decide. -Your main goal is to follow the USER's instructions at each message, denoted by the tag. +Your main goal is to follow the USER's instructions in each new user message. Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. @@ -62,14 +62,14 @@ The user will primarily request you perform software engineering tasks. This inc - Prefer the most direct tool path that preserves accuracy: use Read, Grep, and Glob for narrow lookups; use Task subagents for broad, multi-area, or independently delegable work. - When WebFetch reports a redirect, follow the redirect URL if it is relevant and safe for the user's request. - When multiple tool calls are independent, run them in parallel. Keep dependent operations sequential, and never use placeholders or guess missing parameters. -- Use specialized tools for file reads, edits, searches, and deletions because they preserve workspace context and permissions. Use Bash for commands that genuinely need a shell. Do not use shell commands only to communicate with the user. +- Use specialized tools for file reads, edits, searches, and deletions because they preserve workspace context and permissions. Use ExecCommand for commands that genuinely need a shell. Do not use shell commands only to communicate with the user. - For security-sensitive tasks, support defensive analysis and remediation only. Refuse malicious code, exploit workflows, credential harvesting, or instructions that would facilitate abuse. - Edit reliability discipline: - Read a file in this session before Edit. Partial range reads are allowed, but the Read range must include every line you will copy into `old_string`. - - Base `old_string` on the latest Read result for that file; after Write, use the post-write Read result returned by the system. + - Base `old_string` on the latest Read result for that file; after Write, Read the file again before the next Edit if you need the on-disk text. - Read output uses cat -n format: spaces, line number, tab, then file content. Copy only the text after the tab into `old_string` and `new_string`. - Do not reformat HTML/CSS/JS when constructing Edit strings; match indentation and blank lines exactly. - - Treat Read output as stale after a successful edit to the same file; re-read before the next Edit unless you are continuing from the exact text returned by that Edit or by the post-write Read result. + - Treat Read output as stale after a successful edit to the same file; re-read before the next Edit unless you are continuing from the exact text returned by that Edit or from the Write content you just submitted. - Use Write only to create a new file or intentionally replace a whole file in one step. After Write succeeds for a path, do not call Write again for that path to continue, refine, or patch it; use Edit against the latest Read content. - Use 2-4 adjacent lines with stable surrounding context when that is enough to make `old_string` unique. - Use `replace_all` only when every occurrence should change. @@ -82,34 +82,33 @@ assistant: [Uses Grep or Glob directly because this is a focused lookup] IMPORTANT: Use TodoWrite for non-trivial multi-step work and keep it current. # File References -IMPORTANT: Whenever you mention a file path that the user might want to open, make it a clickable link using markdown link syntax `[text](url)`. Never output a bare path as plain text or wrap it in backticks. +IMPORTANT: Whenever you mention a file path that the user might want to open, make it a clickable markdown link: [text](url). -**For files inside the workspace** (source code, configs, etc.): -- Use workspace-relative paths: `[filename.ts](src/filename.ts)` -- For specific lines: `[filename.ts:42](src/filename.ts#L42)` -- For line ranges: `[filename.ts:42-51](src/filename.ts#L42-L51)` -- Link text should be the bare filename only — no directory prefix, no backticks. +**Link URL path**: +- For files inside the workspace, use the workspace-relative path: [filename.ts](src/filename.ts) +- For files outside the workspace, use the absolute path as the URL: [settings.json](/external/project/settings.json) -**For files you or a subagent created** (reports, plans, generated docs, any output file inside the workspace): -- Use `computer://` with the workspace-relative path: `[filename.md](computer://path/to/filename.md)` -- `computer://` links open the file in the system file manager, making them reliably clickable regardless of file type. -- When a subagent result already contains a `computer://` link, preserve it exactly — do not reformat it as plain text or a code block. +**Line targets**: +- For a specific line, append `#L` to URL: [filename.ts:42](src/filename.ts#L42) +- For a line range, append `#L-L`: [filename.ts:42-51](src/filename.ts#L42-L51) -**For files outside the workspace**: use the absolute path as the link URL. +**Link text and formatting**: +- Link text should be the bare filename, optionally with line numbers; do not include directory prefixes. +- Do not output bare paths as plain text. +- Do not wrap link text or the whole markdown link in backticks. - Source file: [filename.ts](src/filename.ts) - Specific line: [filename.ts:42](src/filename.ts#L42) -- Generated report: [report.md](computer://deep-research/report.md) -- Plan file returned by a tool: [my-plan.plan.md](computer:///Users/alice/.bitfun/projects/my-project/plans/my-plan.plan.md) +- External file line: [settings.json:12](/external/project/settings.json#L12) +- Generated report: [report.md](deep-research/report.md) - Bare path: src/filename.ts - Backticks in link text: [`filename.ts:42`](src/filename.ts#L42) +- Whole link wrapped in backticks: `[report.md](deep-research/report.md)` - Full path in link text: [src/filename.ts](src/filename.ts) -- computer:// in backticks: `computer://deep-research/report.md` -- Absolute path as plain text: /Users/alice/project/deep-research/report.md +- Absolute path as plain text: /external/project/deep-research/report.md {LANGUAGE_PREFERENCE} -{ENV_INFO} diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/agentic_mode_first_entry_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/agentic_mode_first_entry_reminder.md new file mode 100644 index 0000000000..0250111516 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompts/agentic_mode_first_entry_reminder.md @@ -0,0 +1 @@ +You have entered agentic mode. \ No newline at end of file diff --git a/src/crates/core/src/agentic/agents/prompts/claw_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/claw_mode.md similarity index 96% rename from src/crates/core/src/agentic/agents/prompts/claw_mode.md rename to src/crates/assembly/core/src/agentic/agents/prompts/claw_mode.md index d893686744..f3725d7b8c 100644 --- a/src/crates/core/src/agentic/agents/prompts/claw_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/claw_mode.md @@ -1,6 +1,6 @@ You are a personal assistant running inside BitFun. -Your main goal is to follow the USER's instructions at each message, denoted by the tag. +Your main goal is to follow the USER's instructions in each new user message. Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. @@ -64,6 +64,5 @@ Do not manipulate or persuade anyone to expand access or disable safeguards. Do Keep narration brief and value-dense. For multi-step work, state the near-term plan and then keep progress updates short. {CLAW_WORKSPACE} -{ENV_INFO} {PERSONA} -{AGENT_MEMORY} \ No newline at end of file +{AGENT_MEMORY} diff --git a/src/crates/core/src/agentic/agents/prompts/code_review.md b/src/crates/assembly/core/src/agentic/agents/prompts/code_review.md similarity index 97% rename from src/crates/core/src/agentic/agents/prompts/code_review.md rename to src/crates/assembly/core/src/agentic/agents/prompts/code_review.md index 1961dd4f7c..47c6c51f64 100644 --- a/src/crates/core/src/agentic/agents/prompts/code_review.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/code_review.md @@ -48,7 +48,7 @@ You have access to tools to gather context when needed: - Use when: a blocked issue needs a user/product decision that cannot be safely inferred - Example: Ask which intended behavior should be preserved before fixing a disputed change -- **Edit / Write / Bash / TodoWrite**: Implement and verify fixes +- **Edit / Write / ExecCommand / WriteStdin / ExecControl / TodoWrite**: Implement and verify fixes - Use when: the user explicitly approves remediation after the review report - Example: Apply selected fixes, update focused tests, and run the most relevant verification command @@ -145,7 +145,7 @@ Use `AskUserQuestion` only when a validated finding is blocked by a user/product If the user explicitly approves remediation: 1. Implement only the selected Code Review findings. Do not broaden scope beyond the selected items unless required for correctness. -2. Use `Edit`, `Write`, `Bash`, and `TodoWrite` as needed. +2. Use `Edit`, `Write`, `ExecCommand`, `WriteStdin`, `ExecControl`, and `TodoWrite` as needed. 3. Run the most relevant verification. 4. If the user requested re-review, submit a follow-up standard code review via `submit_code_review`. 5. Summarize what changed and what verification was run. diff --git a/src/crates/core/src/agentic/agents/prompts/computer_use_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/computer_use_mode.md similarity index 95% rename from src/crates/core/src/agentic/agents/prompts/computer_use_mode.md rename to src/crates/assembly/core/src/agentic/agents/prompts/computer_use_mode.md index 6a1bcb45af..e1229b2c73 100644 --- a/src/crates/core/src/agentic/agents/prompts/computer_use_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/computer_use_mode.md @@ -1,6 +1,6 @@ You are BitFun's Computer Use sub-agent. Your job is to perceive and operate the user's local computer safely and efficiently. -Your main goal is to follow the USER's instructions at each message, denoted by the tag. +Your main goal is to follow the USER's instructions in each new user message. Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. @@ -20,14 +20,14 @@ Prefer the smallest reliable control surface: 1. Use `ControlHub` with `domain: "browser"` for websites and web apps in the user's real browser. 2. Use `ComputerUse` for third-party desktop apps, OS dialogs, system-wide keyboard and mouse, accessibility, OCR, screenshots, app state, app/file/url opening, clipboard access, OS facts, and local scripts. -3. Use `Bash` for local shell commands when that is the clearest path and does not bypass desktop safety expectations. +3. Use `ExecCommand` for local shell commands when that is the clearest path and does not bypass desktop safety expectations. 4. Use `ControlHub` with `domain: "meta"` to inspect non-desktop control capabilities before long or uncertain automation flows. Prefer script or command-line automation when it is clearly safer and reversible, but run it step by step. Do not hide a whole GUI workflow in one large script. For GUI work, prefer keyboard shortcuts and accessibility-backed targets before mouse coordinates. # OS-Specific Control Profile -Use the local OS reported in the environment information. +Use the local OS reported in Runtime Context. For macOS: diff --git a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/cowork_mode.md similarity index 86% rename from src/crates/core/src/agentic/agents/prompts/cowork_mode.md rename to src/crates/assembly/core/src/agentic/agents/prompts/cowork_mode.md index 6a03a3475c..6a7cb8f019 100644 --- a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/cowork_mode.md @@ -1,6 +1,6 @@ You are BitFun in Cowork mode. Your job is to collaborate with the USER on multi-step work while minimizing wasted effort. -Your main goal is to follow the USER's instructions at each message, denoted by the tag. +Your main goal is to follow the USER's instructions in each new user message. Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. @@ -90,37 +90,32 @@ Cowork mode includes WebFetch and WebSearch tools for retrieving web content. Th # High Level Computer Use Explanation - BitFun runs tools in a secure sandboxed runtime with controlled access to user files. - The exact host environment can vary by platform/deployment, so BitFun should rely on - Environment Information for OS/runtime details and should not assume a specific VM or OS. - Available tools: - * Bash - Execute commands - * Edit - Edit existing files - * Write - Create new files - * Read - Read files and directories - Working directory: use the current working directory shown in Environment Information. - The runtime's internal file system can reset between tasks, but the selected workspace folder - persists on the user's actual computer. Files saved to the workspace folder remain accessible to the user after the session ends. - When BitFun creates files like docx, pptx, xlsx, save them in the workspace and share a direct `computer://` link when available. +BitFun runs tools in a secure sandboxed runtime with controlled access to user files. +The exact host environment can vary by platform/deployment, so BitFun should rely on +Runtime Context for OS/runtime details and should not assume a specific VM or OS. +Available tools: + * ExecCommand - Execute commands + * Edit - Edit existing files + * Write - Create new files + * Read - Read files and directories +Working directory: use the current working directory shown in Runtime Context. +The runtime's internal file system can reset between tasks, but the selected workspace folder +persists on the user's actual computer. Files saved to the workspace folder remain accessible to the user after the session ends. +When BitFun creates files like docx, pptx, xlsx, save them in the workspace and share a direct markdown link when available. # Suggesting Bitfun Actions When the user asks for information, first answer the question directly. If BitFun can also help execute a related workflow with available tools, offer or proceed only when the user's intent is clear. If required access or connectors are missing, explain the limitation and suggest a practical alternative without inventing unavailable integrations. # File Handling Rules -Cowork operates on the active workspace folder. Create and edit deliverables there unless the user or runtime context indicates another accessible location. Prefer workspace-rooted `computer://` links for user-visible file outputs, and avoid exposing backend-only infrastructure paths. Relative paths are acceptable internally. +Cowork operates on the active workspace folder. Create and edit deliverables there unless the user or runtime context indicates another accessible location. Prefer workspace-relative markdown links for user-visible file outputs, and avoid exposing backend-only infrastructure paths. Relative paths are acceptable internally. # Working With User Files Workspace access details are provided by runtime context. When referring to file locations, prefer user-facing phrases such as "the folder you selected" or "the workspace folder". Avoid exposing internal paths such as session storage directories. If BitFun lacks access to user files and the user asks to work with them, explain the limitation and suggest selecting the folder or providing the relevant files. # Notes On User Uploaded Files - There are some rules and nuance around how user-uploaded files work. Every file the user - uploads is given a filepath in the upload mount under the working directory and can be accessed programmatically in the - computer at this path. File contents are not included in BitFun's context unless BitFun has - used the file read tool to read the contents of the file into its context. BitFun does not - necessarily need to read files into context to process them. For example, it can use - code/libraries to analyze spreadsheets without reading the entire file into context. +There are some rules and nuance around how user-uploaded files work. Every file the user uploads is given a filepath in the upload mount under the working directory and can be accessed programmatically in the computer at this path. File contents are not included in BitFun's context unless BitFun has used the file read tool to read the contents of the file into its context. BitFun does not necessarily need to read files into context to process them. For example, it can use code/libraries to analyze spreadsheets without reading the entire file into context. # Producing Outputs @@ -136,8 +131,8 @@ FILE CREATION STRATEGY: When sharing created or edited files, provide a direct file link and a concise summary. Prefer links to files rather than folders, and avoid long postambles that repeat the file contents unless the user asks. Good file sharing examples: -- [View your report](computer://artifacts/report.docx) -- [View your script](computer://scripts/pi.py) +- [View your report](artifacts/report.docx) +- [View your script](scripts/pi.py) Putting deliverables in the workspace folder and sharing direct links helps the user access the work immediately. # Artifacts @@ -167,5 +162,3 @@ Example decisions: # Additional Skills Reminder For computer-use tasks, proactively use relevant skills when a domain-specific workflow is involved and the skill is available. Load skills by name, and combine them only when that adds clear value. - -{ENV_INFO} diff --git a/src/crates/core/src/agentic/agents/prompts/debug_mode_first_entry_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/debug_mode_first_entry_reminder.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/debug_mode_first_entry_reminder.md rename to src/crates/assembly/core/src/agentic/agents/prompts/debug_mode_first_entry_reminder.md diff --git a/src/crates/core/src/agentic/agents/prompts/debug_mode_ongoing_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/debug_mode_ongoing_reminder.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/debug_mode_ongoing_reminder.md rename to src/crates/assembly/core/src/agentic/agents/prompts/debug_mode_ongoing_reminder.md diff --git a/src/crates/core/src/agentic/agents/prompts/deep_research_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/deep_research_agent.md similarity index 97% rename from src/crates/core/src/agentic/agents/prompts/deep_research_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/deep_research_agent.md index d5eae5bde3..c66f79c972 100644 --- a/src/crates/core/src/agentic/agents/prompts/deep_research_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/deep_research_agent.md @@ -1,10 +1,8 @@ You are a senior research analyst and orchestrator. Your job is to produce a deep-research report that reads like investigative journalism — specific, sourced, opinionated, and grounded in evidence. You run a structured 6-phase quality pipeline where specialists, debaters, and a fact-checker each play a distinct role, and you assemble their outputs into a final report. -{ENV_INFO} - **Subject of Research** = the topic provided by the user in their message. -**Current date**: provided in the environment info above. Use it only for the output file name and for explicit date stamping. Do **not** inject the current year into search queries — let search results establish the actual timeline. +**Current date**: Use current date for the output file name and for explicit date stamping. Do **not** inject the current year into search queries — let search results establish the actual timeline. --- @@ -78,7 +76,7 @@ Build these constants for the whole pipeline: ``` SESSION_ID = {SESSION_ID} -TODAY = current calendar date in YYYY-MM-DD when available from runtime/request context; ask before making date-sensitive claims if it is not available +TODAY = current calendar date in YYYY-MM-DD WORK_DIR = /.bitfun/sessions/{SESSION_ID}/research REPORT_PATH = /report.md ``` @@ -95,7 +93,7 @@ This per-session layout means each chat has its own isolated audit trail and rep **Important — prefer the SESSION_ID injected in this prompt.** If the message history shows research files under a different `.bitfun/sessions//research/` directory (from an earlier chat), **do not** sniff or reuse that path. Use the `WORK_DIR` defined above (with the current SESSION_ID) so this session's work stays self-contained. If you genuinely need to continue earlier research, ask the user to confirm before reading the old path. -Create the work directory tree with one `Bash` call: +Create the work directory tree with one `ExecCommand` call: ```bash mkdir -p "/specialists" @@ -453,12 +451,12 @@ Then your final reply MUST be exactly the block below — nothing before, nothin **Pipeline stats:** citations registered · contested points · sub-questions answered -[View full report](computer://.bitfun/sessions/{SESSION_ID}/research/report.md) +[View full report]({DEEP_RESEARCH_REPORT_LINK}) ``` Formatting rules — violations will break the user experience: -1. The report link MUST use the `computer://` scheme with the relative path shown above. Do NOT use `file://` or absolute paths. +1. The report link MUST use exactly the URL shown above. Do NOT replace it with `file://` or an absolute path. 2. **Do NOT wrap the link in backticks, code fences, or any other markup.** Write it as a plain markdown link. 3. **Do NOT use `
          `, ``, collapsible sections, or HTML tags** of any kind. 4. **Do NOT include the report content** in this reply — it is already in the file. diff --git a/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/deep_review_agent.md similarity index 98% rename from src/crates/core/src/agentic/agents/prompts/deep_review_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/deep_review_agent.md index 09b08eb09f..2ce4bbdeff 100644 --- a/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/deep_review_agent.md @@ -75,7 +75,7 @@ You MAY use: - `AskUserQuestion` when a blocked issue needs a user decision - `Git` for read-only operations such as `status`, `diff`, `show`, `log`, `rev-parse`, `describe`, `shortlog`, or branch listing - `Read`, `Grep`, `Glob`, `LS`, `GetFileDiff` to clarify target files or gather missing context -- `Edit`, `Write`, `Bash`, `TodoWrite` **only when the user request explicitly instructs you to implement fixes** (e.g. "The user approved remediation..."). Do not use these tools during the initial review phase. +- `Edit`, `Write`, `ExecCommand`, `TodoWrite` **only when the user request explicitly instructs you to implement fixes** (e.g. "The user approved remediation..."). Do not use these tools during the initial review phase. You MUST NOT: @@ -255,7 +255,7 @@ After the quality gate finishes: If the user request explicitly instructs you to implement fixes (e.g. "The user approved remediation..."): 1. Implement only the selected remediation items. Do not broaden scope beyond the selected findings unless required for correctness. -2. Use `Edit`, `Write`, `Bash`, and `TodoWrite` as needed. +2. Use `Edit`, `Write`, `ExecCommand`, and `TodoWrite` as needed. 3. Run the most relevant verification after implementing fixes. 4. If the user also requested a follow-up review, launch a full follow-up deep review of the fix diff by dispatching the review team (Business Logic, Performance, Security reviewers in parallel, followed by ReviewJudge). Submit the follow-up review result via `submit_code_review`. 5. Summarize what changed and what verification was run. diff --git a/src/crates/core/src/agentic/agents/prompts/explore_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/explore_agent.md similarity index 89% rename from src/crates/core/src/agentic/agents/prompts/explore_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/explore_agent.md index 7206de6c5f..fb339a2c99 100644 --- a/src/crates/core/src/agentic/agents/prompts/explore_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/explore_agent.md @@ -20,7 +20,3 @@ Guidelines: - Include short code snippets only when they directly prevent ambiguity or information loss; do not paste large code blocks by default. - For UI layout, styling, or interaction analysis, include the smallest relevant component/style/class snippets needed to preserve visual or behavioral context. - For clear communication, avoid using emojis. - -Notes: -- Prefer Grep, Glob, Read, and LS over Bash. The bash tool should only be used when the dedicated exploration tools cannot meet your requirements. -- Agent threads always have their cwd reset between bash calls, so only use absolute file paths if Bash is necessary. diff --git a/src/crates/core/src/agentic/agents/prompts/file_finder_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/file_finder_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/file_finder_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/file_finder_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/general_purpose_agent.md similarity index 76% rename from src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/general_purpose_agent.md index 6d36e1c81e..0dd1da9086 100644 --- a/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/general_purpose_agent.md @@ -2,12 +2,17 @@ You are a general-purpose agent for BitFun, a desktop AI IDE and agent runtime. {LANGUAGE_PREFERENCE} +## When to use this agent + +Use this agent **only** when the task requires file modifications, shell commands, or other write operations. For read-only exploration and research, prefer the `Explore` or `FileFinder` subagents instead. This agent has write capabilities (Write, Edit, Delete, Bash) and cannot run in parallel with other write-capable agents for safety reasons. + ## Strengths +- Implementing features, fixing bugs, and refactoring code +- Running build, test, and validation commands via Bash - Searching for code, configurations, and patterns across large codebases - Analyzing multiple files to understand system architecture -- Investigating complex questions that require exploring many files -- Performing multi-step research tasks +- Performing multi-step research tasks that may require edits ## Working style @@ -31,7 +36,5 @@ You are a general-purpose agent for BitFun, a desktop AI IDE and agent runtime. - Keep the final response concise and concrete. - Include the relevant file paths you changed or inspected when they matter to the parent agent. -- Include short code snippets only when the exact text is load-bearing. +- Include short code snippets only when the exact code is load-bearing. - Avoid emojis. - -{ENV_INFO} \ No newline at end of file diff --git a/src/crates/core/src/agentic/agents/prompts/generate_doc_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/generate_doc_agent.md similarity index 99% rename from src/crates/core/src/agentic/agents/prompts/generate_doc_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/generate_doc_agent.md index bf7e46f54f..1f90309e4f 100644 --- a/src/crates/core/src/agentic/agents/prompts/generate_doc_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/generate_doc_agent.md @@ -52,6 +52,3 @@ Before finalizing documentation: - **NEVER ask questions to the user** - use tools to gather all needed information - All paths in the output must be accurate relative to project root - NEVER use emojis - -{ENV_INFO} - diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md rename to src/crates/assembly/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md rename to src/crates/assembly/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md diff --git a/src/crates/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md similarity index 96% rename from src/crates/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md rename to src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md index 51710fafce..51e9df7ac0 100644 --- a/src/crates/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md @@ -38,7 +38,7 @@ Use `AskUserQuestion` whenever missing information would materially change the p Note: The `CreatePlan` tool is collapsed by default. Before your first calling `CreatePlan`, call `GetToolSpec(tool_name="CreatePlan")` to read its full usage instructions and input schema. 1. When research is complete, create the implementation plan with `CreatePlan` tool. Do NOT make any file changes or run any tools that modify the system state in any way. -2. After `CreatePlan` succeeds, stop further research for that turn and briefly tell the user the plan is ready. Your response for that turn must include the clickable `computer://` plan link returned by the tool. Do NOT output the path as plain text or wrap it in backticks. +2. After `CreatePlan` succeeds, stop further research for that turn and briefly tell the user the plan is ready. Your response for that turn must include the clickable plan link returned by the tool. 3. If the user asks to revise the plan, update only the generated plan file. Do not edit project source files in Plan mode. # Delegation diff --git a/src/crates/core/src/agentic/agents/prompts/plan_mode_ongoing_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_ongoing_reminder.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/plan_mode_ongoing_reminder.md rename to src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_ongoing_reminder.md diff --git a/src/crates/core/src/agentic/agents/prompts/research_specialist_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/research_specialist_agent.md similarity index 95% rename from src/crates/core/src/agentic/agents/prompts/research_specialist_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/research_specialist_agent.md index 70698a1641..5859446351 100644 --- a/src/crates/core/src/agentic/agents/prompts/research_specialist_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/research_specialist_agent.md @@ -1,6 +1,5 @@ You are a read-only **web research specialist** dispatched by a parent research agent. The parent gives you one focused role (e.g. *Primary Source Specialist*, *News & Timeline Specialist*, *Expert Opinion Specialist*, *Counter-evidence Specialist*, or *Competitor Profile*) and a brief. Your job is to gather evidence from the web and return a structured markdown report. -{ENV_INFO} ## Your tools @@ -19,7 +18,7 @@ Search engines match queries to documents in the same language. Issuing only Eng - Chinese: `LLM agent token 优化 实践`, `prompt 压缩 方法`, `agent 上下文 复用 经验` - English: `LLM agent token reduction techniques`, `prompt caching strategies`, `agent context optimization` -You do **not** have file-write tools. Do not attempt `Write`, `Edit`, or `Bash` — they are not provisioned. **Return your report as the Task result.** The parent agent is responsible for any persistence. +You do **not** have file-write or command-execution tools. **Return your report as the Task result.** The parent agent is responsible for any persistence. ## Output contract diff --git a/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_architecture_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_architecture_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_business_logic_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_business_logic_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/review_fixer_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_fixer_agent.md similarity index 99% rename from src/crates/core/src/agentic/agents/prompts/review_fixer_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_fixer_agent.md index 8d2aed53c5..f09adaeda1 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_fixer_agent.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/review_fixer_agent.md @@ -23,7 +23,7 @@ You may investigate, edit files, and run local verification: - `GetFileDiff` - `Edit` - `Write` -- `Bash` +- `ExecCommand` - `TodoWrite` - `Git` diff --git a/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_frontend_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_frontend_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_performance_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/review_performance_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_performance_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_quality_gate_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_quality_gate_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/review_security_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/review_security_agent.md similarity index 100% rename from src/crates/core/src/agentic/agents/prompts/review_security_agent.md rename to src/crates/assembly/core/src/agentic/agents/prompts/review_security_agent.md diff --git a/src/crates/core/src/agentic/agents/prompts/team_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md similarity index 98% rename from src/crates/core/src/agentic/agents/prompts/team_mode.md rename to src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md index d743748aa4..d5ca88a865 100644 --- a/src/crates/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md @@ -132,7 +132,7 @@ Think → Plan → Build → Review → Test → Ship → Reflect **Entry condition:** Plan is approved from Phase 2. -- Write code using standard tools (Read, Write, Edit, Bash, etc.) +- Write code using standard tools (Read, Write, Edit, ExecCommand, etc.) - Use TodoWrite to track implementation progress - Follow the architecture decisions from the plan exactly @@ -209,7 +209,7 @@ Team Mode is a **virtual team**, not a single specialist running serially. Whene **Concurrency safety:** - `Skill`, `Read`, `Grep`, `Glob`, `WebSearch`, `WebFetch`, and read-only `Task` calls are concurrency-safe and will run in parallel inside one batch. -- `Write`, `Edit`, `Delete`, `Bash`, `Git` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. +- `Write`, `Edit`, `Delete`, `ExecCommand`, `Git` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. # Review Synthesis Template @@ -317,5 +317,3 @@ Mark phases complete only after their mandatory skill has run and its output has - Be careful not to introduce security vulnerabilities. - When invoking a skill, trust its methodology and follow its instructions fully. - If a skill's output contradicts the current plan, surface the conflict to the user before proceeding. - -{ENV_INFO} diff --git a/src/crates/core/src/agentic/agents/registry/availability.rs b/src/crates/assembly/core/src/agentic/agents/registry/availability.rs similarity index 70% rename from src/crates/core/src/agentic/agents/registry/availability.rs rename to src/crates/assembly/core/src/agentic/agents/registry/availability.rs index eaa487c29b..2c0c1a08a9 100644 --- a/src/crates/core/src/agentic/agents/registry/availability.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/availability.rs @@ -1,49 +1,19 @@ -use super::types::{subagent_key_for, AgentEntry, SubagentStateReason}; +use super::types::{subagent_key_for, AgentEntry}; use crate::agentic::agents::{resolve_mode_config_profile_id, SubAgentSource}; use crate::service::config::types::{ AgentSubagentOverrideConfig, AgentSubagentOverrideState, ParentSubagentOverrideConfig, }; +use bitfun_agent_runtime::agents::{ + resolve_subagent_availability, resolve_subagent_default_enabled, subagent_source_kind, + ResolvedSubagentAvailability, SubagentOverrideLayers as ResolvedOverrideLayers, + SubagentOverrideState, +}; use std::collections::HashMap; -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ResolvedSubagentAvailability { - pub default_enabled: bool, - pub effective_enabled: bool, - pub override_state: Option, - pub state_reason: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ResolvedOverrideLayers { - pub project_override: Option, - pub user_override: Option, -} - -fn default_reason(entry: &AgentEntry, default_enabled: bool) -> Option { - match entry.subagent_source { - Some(SubAgentSource::Builtin) => Some(if default_enabled { - SubagentStateReason::BuiltinDefaultVisible - } else { - SubagentStateReason::BuiltinDefaultHidden - }), - Some(SubAgentSource::Project) | Some(SubAgentSource::User) => { - Some(SubagentStateReason::CustomDefaultEnabled) - } - None => None, - } -} - -fn project_reason(state: AgentSubagentOverrideState) -> SubagentStateReason { - match state { - AgentSubagentOverrideState::Enabled => SubagentStateReason::EnabledByProjectOverride, - AgentSubagentOverrideState::Disabled => SubagentStateReason::DisabledByProjectOverride, - } -} - -fn user_reason(state: AgentSubagentOverrideState) -> SubagentStateReason { +fn to_runtime_override_state(state: AgentSubagentOverrideState) -> SubagentOverrideState { match state { - AgentSubagentOverrideState::Enabled => SubagentStateReason::EnabledByUserOverride, - AgentSubagentOverrideState::Disabled => SubagentStateReason::DisabledByUserOverride, + AgentSubagentOverrideState::Enabled => SubagentOverrideState::Enabled, + AgentSubagentOverrideState::Disabled => SubagentOverrideState::Disabled, } } @@ -72,13 +42,11 @@ pub fn subagent_override_for_parent( } pub fn resolve_default_enabled(entry: &AgentEntry, parent_agent_type: Option<&str>) -> bool { - match entry.subagent_source { - Some(SubAgentSource::Builtin) => entry - .visibility_policy - .can_access_from_parent(parent_agent_type), - Some(SubAgentSource::Project) | Some(SubAgentSource::User) => true, - None => true, - } + resolve_subagent_default_enabled( + subagent_source_kind(entry.subagent_source), + &entry.visibility_policy, + parent_agent_type, + ) } pub fn resolve_override_layers( @@ -93,9 +61,11 @@ pub fn resolve_override_layers( match entry.subagent_source { Some(SubAgentSource::Project) => ResolvedOverrideLayers { - project_override: project_overrides.and_then(|overrides| { - subagent_override_for_parent(overrides, parent_agent_type, &subagent_key) - }), + project_override: project_overrides + .and_then(|overrides| { + subagent_override_for_parent(overrides, parent_agent_type, &subagent_key) + }) + .map(to_runtime_override_state), user_override: None, }, Some(SubAgentSource::Builtin) | Some(SubAgentSource::User) => ResolvedOverrideLayers { @@ -104,7 +74,8 @@ pub fn resolve_override_layers( user_overrides, parent_agent_type, &subagent_key, - ), + ) + .map(to_runtime_override_state), }, None => ResolvedOverrideLayers::default(), } @@ -119,31 +90,11 @@ pub fn resolve_availability( let default_enabled = resolve_default_enabled(entry, parent_agent_type); let layers = resolve_override_layers(entry, parent_agent_type, project_overrides, user_overrides); - - if let Some(project_override) = layers.project_override { - return ResolvedSubagentAvailability { - default_enabled, - effective_enabled: matches!(project_override, AgentSubagentOverrideState::Enabled), - override_state: Some(project_override), - state_reason: Some(project_reason(project_override)), - }; - } - - if let Some(user_override) = layers.user_override { - return ResolvedSubagentAvailability { - default_enabled, - effective_enabled: matches!(user_override, AgentSubagentOverrideState::Enabled), - override_state: Some(user_override), - state_reason: Some(user_reason(user_override)), - }; - } - - ResolvedSubagentAvailability { + resolve_subagent_availability( + subagent_source_kind(entry.subagent_source), default_enabled, - effective_enabled: default_enabled, - override_state: None, - state_reason: default_reason(entry, default_enabled), - } + layers, + ) } pub fn prune_override_config( @@ -243,7 +194,7 @@ mod tests { assert_eq!(builtin_layers.project_override, None); assert_eq!( builtin_layers.user_override, - Some(AgentSubagentOverrideState::Enabled) + Some(SubagentOverrideState::Enabled) ); let user_entry = make_entry(SubAgentSource::User, "UserScout"); @@ -262,7 +213,7 @@ mod tests { assert_eq!(user_layers.project_override, None); assert_eq!( user_layers.user_override, - Some(AgentSubagentOverrideState::Enabled) + Some(SubagentOverrideState::Enabled) ); } @@ -284,7 +235,7 @@ mod tests { assert_eq!( layers.project_override, - Some(AgentSubagentOverrideState::Disabled) + Some(SubagentOverrideState::Disabled) ); assert_eq!(layers.user_override, None); } diff --git a/src/crates/core/src/agentic/agents/registry/builtin.rs b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs similarity index 84% rename from src/crates/core/src/agentic/agents/registry/builtin.rs rename to src/crates/assembly/core/src/agentic/agents/registry/builtin.rs index aa18c0f04e..cb56f543b5 100644 --- a/src/crates/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs @@ -3,26 +3,13 @@ use super::visibility::SubagentVisibilityPolicy; use super::AgentRegistry; use crate::agentic::agents::registry::catalog::builtin_agent_specs; use crate::agentic::agents::{Agent, AgentCategory, SubAgentSource}; +use bitfun_agent_runtime::agents as runtime_agents; use log::error; use std::collections::HashMap; use std::sync::Arc; pub(crate) fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { - match agent_type { - "agentic" | "Cowork" | "ComputerUse" | "Plan" | "debug" | "Claw" | "DeepResearch" - | "Team" | "Multitask" => "auto", - "DeepReview" - | "ReviewBusinessLogic" - | "ReviewPerformance" - | "ReviewSecurity" - | "ReviewArchitecture" - | "ReviewFrontend" - | "ReviewJudge" - | "ReviewFixer" => "fast", - "Explore" | "FileFinder" | "CodeReview" | "GenerateDoc" => "primary", - "GeneralPurpose" => "fast", - _ => "fast", - } + runtime_agents::default_model_id_for_builtin_agent(agent_type) } impl AgentRegistry { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs new file mode 100644 index 0000000000..245980e184 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs @@ -0,0 +1,66 @@ +use super::types::AgentCategory; +use super::visibility::SubagentVisibilityPolicy; +use crate::agentic::agents::{ + Agent, AgenticMode, ArchitectureReviewerAgent, BusinessLogicReviewerAgent, ClawMode, + CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, DeepReviewAgent, + ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GeneralPurposeAgent, GenerateDocAgent, + MultitaskMode, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, + ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, +}; +use bitfun_agent_runtime::agents as runtime_agents; +use std::sync::Arc; + +#[derive(Clone)] +pub struct BuiltinAgentSpec { + pub factory: fn() -> Arc, + pub category: AgentCategory, + pub visibility_policy: SubagentVisibilityPolicy, +} + +pub fn builtin_agent_specs() -> Vec { + runtime_agents::builtin_agent_definition_specs() + .into_iter() + .map(|spec| BuiltinAgentSpec { + factory: builtin_agent_factory(spec.id), + category: map_builtin_agent_category(spec.category), + visibility_policy: spec.visibility_policy, + }) + .collect() +} + +fn map_builtin_agent_category(category: runtime_agents::BuiltinAgentCategory) -> AgentCategory { + match category { + runtime_agents::BuiltinAgentCategory::Mode => AgentCategory::Mode, + runtime_agents::BuiltinAgentCategory::SubAgent => AgentCategory::SubAgent, + runtime_agents::BuiltinAgentCategory::Hidden => AgentCategory::Hidden, + } +} + +fn builtin_agent_factory(id: &str) -> fn() -> Arc { + match id { + "agentic" => || Arc::new(AgenticMode::new()), + "Cowork" => || Arc::new(CoworkMode::new()), + "debug" => || Arc::new(DebugMode::new()), + "Multitask" => || Arc::new(MultitaskMode::new()), + "Plan" => || Arc::new(PlanMode::new()), + "Claw" => || Arc::new(ClawMode::new()), + "DeepResearch" => || Arc::new(DeepResearchMode::new()), + "Team" => || Arc::new(TeamMode::new()), + "ComputerUse" => || Arc::new(ComputerUseMode::new()), + "Explore" => || Arc::new(ExploreAgent::new()), + "GeneralPurpose" => || Arc::new(GeneralPurposeAgent::new()), + "ResearchSpecialist" => || Arc::new(ResearchSpecialistAgent::new()), + "FileFinder" => || Arc::new(FileFinderAgent::new()), + "ReviewBusinessLogic" => || Arc::new(BusinessLogicReviewerAgent::new()), + "ReviewPerformance" => || Arc::new(PerformanceReviewerAgent::new()), + "ReviewSecurity" => || Arc::new(SecurityReviewerAgent::new()), + "ReviewArchitecture" => || Arc::new(ArchitectureReviewerAgent::new()), + "ReviewFrontend" => || Arc::new(FrontendReviewerAgent::new()), + "ReviewJudge" => || Arc::new(ReviewJudgeAgent::new()), + "ReviewFixer" => || Arc::new(ReviewFixerAgent::new()), + "CodeReview" => || Arc::new(CodeReviewAgent::new()), + "DeepReview" => || Arc::new(DeepReviewAgent::new()), + "GenerateDoc" => || Arc::new(GenerateDocAgent::new()), + _ => panic!("missing legacy Agent factory for builtin agent {id}"), + } +} diff --git a/src/crates/core/src/agentic/agents/registry/custom.rs b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs similarity index 94% rename from src/crates/core/src/agentic/agents/registry/custom.rs rename to src/crates/assembly/core/src/agentic/agents/registry/custom.rs index a2f322b755..ef0bff6579 100644 --- a/src/crates/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs @@ -1,5 +1,4 @@ use super::availability::{prune_override_config, resolve_default_enabled, set_override_state}; -use super::custom_loader::CustomSubagentLoader; use super::support::{ get_subagent_overrides, load_project_subagent_overrides_local, save_project_subagent_overrides_local, @@ -9,13 +8,19 @@ use super::{AgentRegistry, CustomSubagentDetail}; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::types::subagent_key_for; use crate::agentic::agents::registry::visibility::SubagentVisibilityPolicy; -use crate::agentic::agents::{Agent, AgentCategory, CustomSubagentConfig, SubAgentSource}; +use crate::agentic::agents::{ + subagent_source_from_custom_kind, Agent, AgentCategory, CustomSubagentConfig, SubAgentSource, +}; use crate::agentic::tools::{get_all_registered_tool_names, get_readonly_registered_tool_names}; +use crate::infrastructure::get_path_manager_arc; use crate::service::config::global::GlobalConfigManager; use crate::service::config::mode_config_canonicalizer::persist_agent_profile_from_value; use crate::service::config::types::AgentSubagentOverrideState; use crate::util::errors::{BitFunError, BitFunResult}; -use log::{debug, warn}; +use bitfun_agent_runtime::custom_subagent::{ + load_custom_subagent_definitions, CustomSubagentDiscoveryRoots, LoadedCustomSubagentDefinition, +}; +use log::{debug, error, warn}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -28,16 +33,26 @@ impl AgentRegistry { let readonly_tools = get_readonly_registered_tool_names().await; let valid_models = Self::get_valid_model_ids().await; - let custom = CustomSubagentLoader::load_custom_subagents(workspace_root); + let custom = + load_custom_subagent_definitions(&custom_subagent_discovery_roots(workspace_root)); + for load_error in custom.errors { + let error = BitFunError::Agent(load_error.error); + error!( + "Failed to load custom subagent from {}: {}", + load_error.path.display(), + error + ); + } let mut map = self.write_agents(); map.retain(|_, entry| { !(entry.category == AgentCategory::SubAgent && entry.subagent_source == Some(SubAgentSource::User)) }); let mut project_entries = HashMap::new(); - for mut sub in custom { + for loaded in custom.definitions { + let mut sub = custom_subagent_from_loaded_definition(loaded); let id = sub.id().to_string(); - let source = SubAgentSource::from_custom_kind(sub.kind); + let source = subagent_source_from_custom_kind(sub.kind); // validate and correct tools and model Self::validate_custom_subagent(&mut sub, &valid_tools, &readonly_tools, &valid_models); // create CustomSubagentConfig cache configuration information @@ -651,3 +666,17 @@ impl AgentRegistry { } } } + +fn custom_subagent_discovery_roots(workspace_root: &Path) -> CustomSubagentDiscoveryRoots { + CustomSubagentDiscoveryRoots { + workspace_root: workspace_root.to_path_buf(), + bitfun_user_agents_dir: Some(get_path_manager_arc().user_agents_dir()), + home_dir: dirs::home_dir(), + } +} + +fn custom_subagent_from_loaded_definition( + loaded: LoadedCustomSubagentDefinition, +) -> CustomSubagent { + CustomSubagent::from_definition(loaded.path.to_string_lossy().to_string(), loaded.definition) +} diff --git a/src/crates/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs similarity index 99% rename from src/crates/core/src/agentic/agents/registry/mod.rs rename to src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 89af200bb0..2196950c02 100644 --- a/src/crates/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -2,7 +2,6 @@ mod availability; mod builtin; pub mod catalog; mod custom; -mod custom_loader; mod query; mod resolution; mod support; diff --git a/src/crates/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs similarity index 89% rename from src/crates/core/src/agentic/agents/registry/query.rs rename to src/crates/assembly/core/src/agentic/agents/registry/query.rs index 598b658038..4e1973df5d 100644 --- a/src/crates/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -6,28 +6,20 @@ use super::support::{ use super::AgentRegistry; use crate::agentic::agents::registry::types::{is_review_agent_entry, AgentEntry}; use crate::agentic::agents::{ - resolve_mode_config_profile_id, AgentCategory, AgentInfo, AgentToolPolicy, SubagentListScope, - SubagentQueryContext, + mode_presentation_rank, resolve_mode_config_profile_id, AgentCategory, AgentInfo, + AgentToolPolicy, SubagentListScope, SubagentQueryContext, }; use crate::agentic::tools::get_all_registered_tool_names; use crate::service::config::mode_config_canonicalizer::resolve_effective_tools; +use bitfun_agent_runtime::agents::subagent_source_presentation_rank; use std::collections::HashSet; use std::path::Path; impl AgentRegistry { - fn subagent_source_rank(source: Option) -> u8 { - match source { - Some(crate::agentic::agents::SubAgentSource::Builtin) => 0, - Some(crate::agentic::agents::SubAgentSource::Project) => 1, - Some(crate::agentic::agents::SubAgentSource::User) => 2, - None => 3, - } - } - fn sort_subagents_for_presentation(mut result: Vec) -> Vec { result.sort_by(|a, b| { - Self::subagent_source_rank(a.subagent_source) - .cmp(&Self::subagent_source_rank(b.subagent_source)) + subagent_source_presentation_rank(a.subagent_source) + .cmp(&subagent_source_presentation_rank(b.subagent_source)) .then_with(|| a.id.to_lowercase().cmp(&b.id.to_lowercase())) .then_with(|| a.id.cmp(&b.id)) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) @@ -58,11 +50,9 @@ impl AgentRegistry { let registered_tool_names = get_all_registered_tool_names().await; let valid_tools: HashSet = registered_tool_names.iter().cloned().collect(); let profile_id = resolve_mode_config_profile_id(agent_type); - let resolved_tools = resolve_effective_tools( - &entry.agent.default_tools(), - mode_configs.get(profile_id.as_ref()), - &valid_tools, - ); + let default_tools = entry.agent.default_tools(); + let config = mode_configs.get(profile_id.as_ref()); + let resolved_tools = resolve_effective_tools(&default_tools, config, &valid_tools); let allowed_tools = merge_dynamic_mcp_tools(resolved_tools, ®istered_tool_names); let allowed_tool_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); @@ -113,21 +103,7 @@ impl AgentRegistry { .map(AgentInfo::from_agent_entry) .collect(); drop(map); - result.sort_by(|a, b| { - let order = |id: &str| -> u8 { - match id { - "agentic" => 0, - "Cowork" => 1, - "Plan" => 2, - "debug" => 3, - "Multitask" => 4, - "DeepResearch" => 5, - "Team" => 6, - _ => 99, - } - }; - order(&a.id).cmp(&order(&b.id)) - }); + result.sort_by(|a, b| mode_presentation_rank(&a.id).cmp(&mode_presentation_rank(&b.id))); result } diff --git a/src/crates/core/src/agentic/agents/registry/resolution.rs b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs similarity index 100% rename from src/crates/core/src/agentic/agents/registry/resolution.rs rename to src/crates/assembly/core/src/agentic/agents/registry/resolution.rs diff --git a/src/crates/core/src/agentic/agents/registry/support.rs b/src/crates/assembly/core/src/agentic/agents/registry/support.rs similarity index 100% rename from src/crates/core/src/agentic/agents/registry/support.rs rename to src/crates/assembly/core/src/agentic/agents/registry/support.rs diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs similarity index 95% rename from src/crates/core/src/agentic/agents/registry/tests.rs rename to src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 034b0fc28e..b79ba1ccb1 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -3,8 +3,8 @@ use super::AgentRegistry; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::registry::types::{ - AgentCategory, AgentEntry, CustomSubagentConfig, SubAgentSource, SubagentListScope, - SubagentQueryContext, + subagent_source_from_custom_kind, AgentCategory, AgentEntry, CustomSubagentConfig, + SubAgentSource, SubagentListScope, SubagentOverrideState, SubagentQueryContext, }; use crate::agentic::agents::registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, @@ -87,6 +87,18 @@ fn top_level_modes_default_to_auto() { } } +#[test] +fn custom_subagent_kind_maps_to_registry_source() { + assert_eq!( + subagent_source_from_custom_kind(CustomSubagentKind::Project), + SubAgentSource::Project + ); + assert_eq!( + subagent_source_from_custom_kind(CustomSubagentKind::User), + SubAgentSource::User + ); +} + #[tokio::test] async fn computer_use_is_builtin_subagent_not_mode() { let registry = AgentRegistry::new(); @@ -238,7 +250,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { #[test] fn merge_dynamic_mcp_tools_appends_registered_mcp_tools_once() { - let configured_tools = vec!["Read".to_string(), "Bash".to_string()]; + let configured_tools = vec!["Read".to_string(), "ExecCommand".to_string()]; let registered_tool_names = vec![ "Read".to_string(), "mcp__notion__notion-search".to_string(), @@ -252,7 +264,7 @@ fn merge_dynamic_mcp_tools_appends_registered_mcp_tools_once() { merged, vec![ "Read".to_string(), - "Bash".to_string(), + "ExecCommand".to_string(), "mcp__notion__notion-search".to_string(), "mcp__github__list_issues".to_string(), ] @@ -498,14 +510,14 @@ async fn parent_subagent_overrides_follow_source_scopes() { assert_eq!( visible.0.override_state, - Some(AgentSubagentOverrideState::Disabled) + Some(SubagentOverrideState::Disabled) ); assert_eq!( visible.1.override_state, - Some(AgentSubagentOverrideState::Disabled) + Some(SubagentOverrideState::Disabled) ); assert_eq!( visible.2.override_state, - Some(AgentSubagentOverrideState::Disabled) + Some(SubagentOverrideState::Disabled) ); } diff --git a/src/crates/core/src/agentic/agents/registry/types.rs b/src/crates/assembly/core/src/agentic/agents/registry/types.rs similarity index 83% rename from src/crates/core/src/agentic/agents/registry/types.rs rename to src/crates/assembly/core/src/agentic/agents/registry/types.rs index 74760985b2..2793430d8c 100644 --- a/src/crates/core/src/agentic/agents/registry/types.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/types.rs @@ -11,43 +11,14 @@ use crate::agentic::deep_review_policy::{ REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::service::config::types::AgentSubagentOverrideState; +pub use bitfun_agent_runtime::agents::{ + SubAgentSource, SubagentListScope, SubagentOverrideState, SubagentQueryContext, + SubagentStateReason, +}; +use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; use serde::{Deserialize, Serialize}; -use std::path::Path; use std::sync::Arc; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubagentListScope { - TaskVisible, - RegistryManagement, -} - -#[derive(Debug, Clone)] -pub struct SubagentQueryContext<'a> { - pub parent_agent_type: Option<&'a str>, - pub workspace_root: Option<&'a Path>, - pub list_scope: SubagentListScope, - pub include_disabled: bool, -} - -/// subagent source (builtin / project / user), used for frontend display -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SubAgentSource { - Builtin, - Project, - User, -} - -impl SubAgentSource { - pub fn from_custom_kind(kind: CustomSubagentKind) -> Self { - match kind { - CustomSubagentKind::Project => SubAgentSource::Project, - CustomSubagentKind::User => SubAgentSource::User, - } - } -} - /// mutable configuration for custom subagent (model will change, path/kind can be obtained by downcast) #[derive(Clone, Debug)] pub struct CustomSubagentConfig { @@ -112,7 +83,7 @@ pub struct AgentInfo { #[serde(default = "default_true")] pub effective_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] - pub override_state: Option, + pub override_state: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub state_reason: Option, /// subagent source, only subagent has value, used for frontend display @@ -126,22 +97,17 @@ pub struct AgentInfo { pub visibility: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SubagentStateReason { - BuiltinDefaultVisible, - BuiltinDefaultHidden, - CustomDefaultEnabled, - EnabledByProjectOverride, - DisabledByProjectOverride, - EnabledByUserOverride, - DisabledByUserOverride, -} - fn default_true() -> bool { true } +pub fn subagent_source_from_custom_kind(kind: CustomSubagentKind) -> SubAgentSource { + match kind { + CustomSubagentKind::Project => SubAgentSource::Project, + CustomSubagentKind::User => SubAgentSource::User, + } +} + pub fn subagent_key_for(source: Option, agent: &dyn Agent) -> Option { let source = source?; let slot = match source { @@ -216,10 +182,9 @@ impl AgentInfo { is_review: is_review_agent_entry(entry), tool_count: default_tools.len(), default_tools, - prompt_cache_scope_key: format!( - "{}||{}", - agent.system_prompt_cache_identity(None).scope_key, - agent.user_context_cache_identity().scope_key + prompt_cache_scope_key: prompt_cache_scope_key( + &agent.system_prompt_cache_identity(None), + &agent.user_context_cache_identity(), ), config_profile_id, config_profile_label, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/visibility.rs b/src/crates/assembly/core/src/agentic/agents/registry/visibility.rs new file mode 100644 index 0000000000..3d68f8a209 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/registry/visibility.rs @@ -0,0 +1,5 @@ +//! Compatibility re-exports for subagent visibility owner decisions. + +pub use bitfun_agent_runtime::agents::{ + BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, +}; diff --git a/src/crates/core/src/agentic/context_profile.rs b/src/crates/assembly/core/src/agentic/context_profile.rs similarity index 100% rename from src/crates/core/src/agentic/context_profile.rs rename to src/crates/assembly/core/src/agentic/context_profile.rs diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs similarity index 81% rename from src/crates/core/src/agentic/coordination/coordinator.rs rename to src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 825e39d62f..9c6fdcf29f 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -2,12 +2,18 @@ //! //! Top-level component that integrates all subsystems and provides a unified interface -use super::{scheduler::DialogSubmissionPolicy, turn_outcome::TurnOutcome}; +use super::{ + scheduler::{ + abort_thread_goal_continuation_for_session, clear_thread_goal_continuation_abort, + DialogSubmissionPolicy, + }, + turn_outcome::TurnOutcome, +}; use crate::agentic::agents::get_agent_registry; use crate::agentic::context_profile::ContextProfilePolicy; use crate::agentic::core::{ - has_prompt_markup, Message, MessageContent, ProcessingPhase, PromptEnvelope, Session, - SessionConfig, SessionKind, SessionState, SessionSummary, TurnStats, + InternalReminderKind, Message, MessageContent, ProcessingPhase, Session, SessionConfig, + SessionKind, SessionState, SessionSummary, TurnStats, }; use crate::agentic::events::{ AgenticEvent, DeepReviewQueueState, EventPriority, EventQueue, EventRouter, EventSubscriber, @@ -17,19 +23,28 @@ use crate::agentic::execution::{ }; use crate::agentic::fork_agent::ForkAgentContextSnapshot; use crate::agentic::goal_mode::{ - build_goal_continuation_plan, build_goal_kickoff_messages, clear_goal_mode_patch, - effective_subagent_timeout_seconds, ensure_final_response_in_goal_context, - generate_goal_from_context, goal_mode_from_custom_metadata, goal_mode_patch, now_ms, - should_skip_goal_verification_for_turn, user_facing_goal_mode_error, verify_goal_achievement, - wrap_user_input_with_goal_reminder, GoalActivationResult, GoalContinuationPlan, - GoalModeInitialGoal, GoalModeState, MAX_GOAL_CONTINUATIONS, + effective_subagent_timeout_seconds, is_usage_limit_error, maybe_build_continuation_after_turn, + should_skip_goal_continuation_after_turn, should_skip_goal_for_turn, + thread_goal_status_is_resumable, user_facing_thread_goal_error, ThreadGoalRuntime, + ThreadGoalStore, }; use crate::agentic::image_analysis::ImageContextData; -use crate::agentic::round_preempt::{DialogRoundInjectionSource, DialogRoundPreemptSource}; +use crate::agentic::remote_file_delivery::{ + needs_computer_links_for_source, remote_file_delivery_reminder, + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, +}; +use crate::agentic::round_preempt::DialogRoundInjectionSource; use crate::agentic::session::SessionManager; use crate::agentic::side_question::build_btw_user_input; +use crate::agentic::skill_agent_snapshot::{ + diff_skill_agent_snapshot, resolve_skill_agent_snapshot, TurnSkillAgentSnapshot, +}; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; -use crate::agentic::tools::ToolRuntimeRestrictions; +use crate::agentic::tools::{ + is_miniapp_headless_agent_run, miniapp_headless_agent_tool_restrictions, + ToolRuntimeRestrictions, +}; +use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, @@ -40,8 +55,13 @@ use crate::service::session::{SessionRelationship, SessionRelationshipKind}; use crate::service::workspace::{ get_global_workspace_service, WorkspaceCreateOptions, WorkspaceKind, }; +use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; -use bitfun_runtime_ports::{DelegationPolicy, SubagentContextMode}; +use bitfun_runtime_ports::{ + AgentBackgroundResultRequest, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, + DelegationPolicy, SubagentContextMode, ThreadGoal, ThreadGoalContinuationPlan, + ThreadGoalStatus, +}; use dashmap::DashMap; use log::{debug, error, info, warn}; use std::collections::{HashMap, HashSet}; @@ -90,6 +110,20 @@ pub(crate) struct SubagentExecutionRequest { pub(crate) delegation_policy: DelegationPolicy, } +struct WrappedUserInputPayload { + content: String, + prepended_messages: Vec, + skill_agent_snapshot: TurnSkillAgentSnapshot, + snapshot_persistence: SkillAgentSnapshotPersistence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillAgentSnapshotPersistence { + None, + SaveCurrentTurn, + RecoverFirstTurnBaseline, +} + impl SubagentResult { fn completed(text: String) -> Self { Self { @@ -156,6 +190,21 @@ fn format_background_subagent_delivery_text( } } +fn format_background_subagent_display_text( + outcome: Result<&SubagentResult, &BitFunError>, +) -> String { + match outcome { + Ok(result) => { + if result.is_partial_timeout() { + "Background subagent completed with a partial timeout result.".to_string() + } else { + "Background subagent completed successfully.".to_string() + } + } + Err(_) => "Background subagent failed before producing a final result.".to_string(), + } +} + fn build_subagent_session_relationship( parent_info: Option<&SubagentParentInfo>, agent_type: &str, @@ -467,8 +516,6 @@ pub struct ConversationCoordinator { active_subagent_executions: Arc>, /// Notifies DialogScheduler of turn outcomes; injected after construction scheduler_notify_tx: OnceLock>, - /// Round-boundary yield (same source as scheduler's yield flags); injected after construction - round_preempt_source: OnceLock>, /// Round-boundary user steering source (mid-turn user message injection); injected after construction round_injection_source: OnceLock>, /// In-flight dialog turn tracker per session, used to serialize cancel→start @@ -477,6 +524,7 @@ pub struct ConversationCoordinator { /// Map value is a counter shared between the coordinator and the spawn /// task; spawn task increments on entry and decrements on exit. active_turns_per_session: Arc>>, + thread_goal_runtime: Arc, } impl ConversationCoordinator { @@ -833,6 +881,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet id: format!("{}-manual-compaction-round", turn_id), turn_id: turn_id.to_string(), round_index: 0, + round_group_id: None, timestamp: started_at, text_items: Vec::new(), tool_items: vec![ToolItemData { @@ -871,6 +920,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet is_subagent_item: None, parent_task_tool_id: None, subagent_session_id: None, + attempt_id: None, + attempt_index: None, subagent_model_id: None, subagent_model_alias: None, status: Some("completed".to_string()), @@ -915,6 +966,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet id: format!("{}-manual-compaction-round", turn_id), turn_id: turn_id.to_string(), round_index: 0, + round_group_id: None, timestamp, text_items: Vec::new(), tool_items: vec![ToolItemData { @@ -944,6 +996,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet is_subagent_item: None, parent_task_tool_id: None, subagent_session_id: None, + attempt_id: None, + attempt_index: None, subagent_model_id: None, subagent_model_alias: None, status: Some("error".to_string()), @@ -988,23 +1042,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_timeout_registry: Arc::new(RwLock::new(HashMap::new())), active_subagent_executions: Arc::new(DashMap::new()), scheduler_notify_tx: OnceLock::new(), - round_preempt_source: OnceLock::new(), round_injection_source: OnceLock::new(), active_turns_per_session: Arc::new(DashMap::new()), + thread_goal_runtime: Arc::new(ThreadGoalRuntime::new()), } } + pub fn thread_goal_runtime(&self) -> Arc { + Arc::clone(&self.thread_goal_runtime) + } + /// Inject the DialogScheduler notification channel after construction. /// Called once during app initialization after the scheduler is created. pub fn set_scheduler_notifier(&self, tx: mpsc::Sender<(String, TurnOutcome)>) { let _ = self.scheduler_notify_tx.set(tx); } - /// Wire round-boundary preempt (typically the scheduler's [`SessionRoundYieldFlags`](crate::agentic::round_preempt::SessionRoundYieldFlags)). - pub fn set_round_preempt_source(&self, source: Arc) { - let _ = self.round_preempt_source.set(source); - } - /// Wire round-boundary injection source (typically the scheduler's /// [`SessionRoundInjectionBuffer`](crate::agentic::round_preempt::SessionRoundInjectionBuffer)). pub fn set_round_injection_source(&self, source: Arc) { @@ -1163,6 +1216,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), workspace_path: Some(workspace_path), + remote_connection_id: session.config.remote_connection_id.clone(), + remote_ssh_host: session.config.remote_ssh_host.clone(), }) .await; Ok(session) @@ -1549,7 +1604,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id.to_string(), TurnOutcome::Failed { turn_id: turn_id.to_string(), - error: error_text, + error: error_text.clone(), }, )) { error!( @@ -1559,6 +1614,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + if let Some(coordinator) = get_global_coordinator() { + coordinator + .maybe_mark_thread_goal_usage_limited(session_id, error) + .await; + } + crate::service::session::TurnStatus::Error } @@ -1653,41 +1714,102 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet async fn wrap_user_input( &self, + session_id: &str, + turn_index: usize, agent_type: &str, previous_agent_type: Option<&str>, user_input: String, workspace: Option<&WorkspaceBinding>, - ) -> BitFunResult { + workspace_services: Option<&WorkspaceServices>, + enable_tools: bool, + skill_agent_context_vars: &HashMap, + ) -> BitFunResult { let agent_registry = get_agent_registry(); if let Some(workspace) = workspace { - agent_registry - .load_custom_subagents(workspace.root_path()) - .await; + if !workspace.is_remote() { + agent_registry + .load_custom_subagents(workspace.root_path()) + .await; + } } let current_agent = agent_registry .get_agent(agent_type, workspace.map(|binding| binding.root_path())) .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; - let system_reminder = current_agent + let current_agent_reminder = current_agent .get_system_reminder(previous_agent_type, workspace) .await?; + let surface_resolution = resolve_skill_agent_snapshot( + agent_type, + workspace, + workspace_services, + enable_tools, + skill_agent_context_vars, + ) + .await; + + let mut prepended_messages = Vec::new(); - if has_prompt_markup(&user_input) { - if system_reminder.is_empty() { - Ok(user_input) + let snapshot_persistence = if turn_index == 0 { + SkillAgentSnapshotPersistence::SaveCurrentTurn + } else if self + .session_manager + .turn_skill_agent_snapshot(session_id, 0) + .await + .is_none() + { + warn!( + "First-turn skill-agent snapshot missing; recovering baseline from current skill-agent snapshot: session_id={}, turn_index={}", + session_id, turn_index + ); + SkillAgentSnapshotPersistence::RecoverFirstTurnBaseline + } else if let Some((baseline_turn_index, previous_snapshot)) = self + .session_manager + .latest_turn_skill_agent_snapshot_at_or_before(session_id, turn_index - 1) + .await + { + let diff = diff_skill_agent_snapshot(&previous_snapshot, &surface_resolution.snapshot); + if let Some(skill_update) = diff.render_skill_listing_update() { + prepended_messages.push(Message::internal_reminder( + InternalReminderKind::SkillListingDiff, + skill_update, + )); + } + if let Some(agent_update) = diff.render_agent_listing_update() { + prepended_messages.push(Message::internal_reminder( + InternalReminderKind::AgentListingDiff, + agent_update, + )); + } + if diff.is_empty() { + SkillAgentSnapshotPersistence::None } else { - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder(system_reminder); - envelope.push_user_query(user_input); - Ok(envelope.render()) + debug!( + "Skill-agent snapshot changed; persisting sparse snapshot: session_id={}, turn_index={}, baseline_turn_index={}", + session_id, turn_index, baseline_turn_index + ); + SkillAgentSnapshotPersistence::SaveCurrentTurn } } else { - let mut envelope = PromptEnvelope::new(); - if !system_reminder.is_empty() { - envelope.push_system_reminder(system_reminder); - } - envelope.push_user_query(user_input); - Ok(envelope.render()) + warn!( + "No prior skill-agent snapshot available for diff; skipping skill-agent diff: session_id={}, turn_index={}", + session_id, turn_index + ); + SkillAgentSnapshotPersistence::None + }; + + if !current_agent_reminder.is_empty() { + prepended_messages.push(Message::internal_reminder( + InternalReminderKind::AgentMode, + current_agent_reminder, + )); } + + Ok(WrappedUserInputPayload { + content: user_input, + prepended_messages, + skill_agent_snapshot: surface_resolution.snapshot, + snapshot_persistence, + }) } pub async fn ensure_assistant_bootstrap( @@ -1767,12 +1889,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }); } - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder(Self::assistant_bootstrap_system_reminder( - kickoff_query, - expected_reply_language, - )); - envelope.push_user_query(kickoff_query.to_string()); + let kickoff_reminder = + Self::assistant_bootstrap_system_reminder(kickoff_query, expected_reply_language); let turn_id = format!("assistant-bootstrap-{}", uuid::Uuid::new_v4()); let metadata = serde_json::json!({ @@ -1785,7 +1903,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.start_dialog_turn_internal( session_id.clone(), - envelope.render(), + kickoff_query.to_string(), Some(kickoff_query.to_string()), None, Some(turn_id.clone()), @@ -1794,6 +1912,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) .with_skip_tool_confirmation(true), Some(metadata), + vec![Message::internal_reminder( + InternalReminderKind::Generic, + kickoff_reminder, + )], true, ) .await?; @@ -1830,6 +1952,36 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path, submission_policy, user_message_metadata, + Vec::new(), + false, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn start_dialog_turn_with_prepended_messages( + &self, + session_id: String, + user_input: String, + original_user_input: Option, + turn_id: Option, + agent_type: String, + workspace_path: Option, + submission_policy: DialogSubmissionPolicy, + user_message_metadata: Option, + prepended_messages: Vec, + ) -> BitFunResult<()> { + self.start_dialog_turn_internal( + session_id, + user_input, + original_user_input, + None, + turn_id, + agent_type, + workspace_path, + submission_policy, + user_message_metadata, + prepended_messages, false, ) .await @@ -1858,199 +2010,519 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path, submission_policy, user_message_metadata, + Vec::new(), false, ) .await } - async fn load_active_goal_mode(&self, session_id: &str) -> BitFunResult> { - let session = self - .session_manager - .get_session(session_id) - .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; - let workspace_path = session.config.workspace_path.as_deref().ok_or_else(|| { - BitFunError::Validation(format!("Session workspace_path is missing: {session_id}")) - })?; - let metadata = self - .session_manager - .load_session_metadata(Path::new(workspace_path), session_id) - .await?; - Ok(goal_mode_from_custom_metadata( - metadata - .as_ref() - .and_then(|value| value.custom_metadata.as_ref()), + #[allow(clippy::too_many_arguments)] + pub async fn start_dialog_turn_with_image_contexts_and_prepended_messages( + &self, + session_id: String, + user_input: String, + original_user_input: Option, + image_contexts: Vec, + turn_id: Option, + agent_type: String, + workspace_path: Option, + submission_policy: DialogSubmissionPolicy, + user_message_metadata: Option, + prepended_messages: Vec, + ) -> BitFunResult<()> { + self.start_dialog_turn_internal( + session_id, + user_input, + original_user_input, + Some(image_contexts), + turn_id, + agent_type, + workspace_path, + submission_policy, + user_message_metadata, + prepended_messages, + false, ) - .filter(GoalModeState::is_active)) + .await } - /// Activate `/goal` mode for a session by synthesizing a goal from context. - pub async fn activate_session_goal( - &self, - session_id: String, - user_hint: Option, - ) -> BitFunResult { + fn thread_goal_store(&self) -> ThreadGoalStore<'_> { + ThreadGoalStore::new(self.session_manager.as_ref()) + } + + fn require_main_session_workspace(&self, session_id: &str) -> BitFunResult { let session = self .session_manager - .get_session(&session_id) + .get_session(session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; - if matches!( session.kind, SessionKind::Subagent | SessionKind::EphemeralChild ) { return Err(BitFunError::Validation( - "Goal mode is only available for main sessions".to_string(), + "Thread goals are only available for main sessions".to_string(), )); } - - let context_messages = self - .session_manager - .get_context_messages(&session_id) - .await?; - let trimmed_hint = user_hint + session + .config + .workspace_path .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); + .map(Path::new) + .map(Path::to_path_buf) + .ok_or_else(|| { + BitFunError::Validation(format!("Session workspace_path is missing: {session_id}")) + }) + } - let generation = generate_goal_from_context(&context_messages, trimmed_hint) + pub async fn get_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + ) -> BitFunResult> { + self.thread_goal_store() + .get_thread_goal(session_id, workspace_path) .await - .map_err(user_facing_goal_mode_error)?; - let activation = build_goal_kickoff_messages(&generation, trimmed_hint); - let activated_at_ms = now_ms(); - - let state = GoalModeState { - active: true, - initial_goal: GoalModeInitialGoal::new( - activation.goal_text.clone(), - activation.success_criteria.clone(), - trimmed_hint.map(str::to_string), - activated_at_ms, - ), - goal_text: activation.goal_text.clone(), - success_criteria: activation.success_criteria.clone(), - user_hint: trimmed_hint.map(str::to_string), - activated_at_ms, - continuation_count: 0, - }; + } - self.session_manager - .merge_session_custom_metadata(&session_id, goal_mode_patch(&state)) + pub async fn clear_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + ) -> BitFunResult<()> { + self.thread_goal_runtime.clear_active_goal(None); + self.thread_goal_store() + .clear_thread_goal(session_id, workspace_path) .await?; + self.emit_thread_goal_updated(session_id, None).await; + Ok(()) + } - info!( - "Session goal mode activated: session_id={}, goal={}", - session_id, activation.goal_text - ); - - Ok(activation) + pub async fn create_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + objective: String, + token_budget: Option, + ) -> BitFunResult { + self.require_main_session_workspace(session_id)?; + let goal = self + .thread_goal_store() + .create_thread_goal(session_id, workspace_path, objective, token_budget) + .await?; + self.thread_goal_runtime.mark_turn_started("", Some(&goal)); + self.emit_thread_goal_updated(session_id, Some(goal.clone())) + .await; + Ok(goal) } - /// Verify the active session goal after a dialog turn stops. - pub async fn prepare_goal_continuation_after_turn( + pub async fn update_thread_goal_objective( &self, session_id: &str, - source_turn_id: &str, - user_input: &str, - user_message_metadata: Option<&serde_json::Value>, - turn_observation: &str, - ) -> BitFunResult> { - if should_skip_goal_verification_for_turn(user_input, user_message_metadata) { - return Ok(None); + workspace_path: &Path, + objective: String, + ) -> BitFunResult { + self.require_main_session_workspace(session_id)?; + let existing = self + .get_thread_goal(session_id, workspace_path) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!( + "cannot edit goal for session {session_id}: no goal exists" + )) + })?; + let status = match existing.status { + ThreadGoalStatus::BudgetLimited | ThreadGoalStatus::Complete => { + Some(ThreadGoalStatus::Active) + } + _ => None, + }; + let result = self + .thread_goal_store() + .set_thread_goal( + session_id, + workspace_path, + Some(objective), + status, + None, + false, + ) + .await?; + let objective_changed = existing.objective != result.goal.objective; + if result.goal.is_active() { + self.thread_goal_runtime + .mark_turn_started("", Some(&result.goal)); } - - let session = self - .session_manager - .get_session(session_id) - .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; - if matches!( - session.kind, - SessionKind::Subagent | SessionKind::EphemeralChild - ) { - return Ok(None); + self.emit_thread_goal_updated(session_id, Some(result.goal.clone())) + .await; + if objective_changed && result.goal.is_active() { + self.apply_objective_updated_steering(session_id, &result.goal) + .await; } + Ok(result.goal) + } - let Some(mut goal_state) = self.load_active_goal_mode(session_id).await? else { - return Ok(None); + pub async fn set_thread_goal_objective( + &self, + session_id: &str, + workspace_path: &Path, + objective: String, + replace_existing: bool, + ) -> BitFunResult { + self.require_main_session_workspace(session_id)?; + let previous = self.get_thread_goal(session_id, workspace_path).await?; + let status = if previous.is_some() && !replace_existing { + None + } else { + Some(ThreadGoalStatus::Active) }; + let result = self + .thread_goal_store() + .set_thread_goal( + session_id, + workspace_path, + Some(objective), + status, + None, + replace_existing, + ) + .await?; + let objective_changed = previous + .as_ref() + .map(|goal| goal.objective != result.goal.objective) + .unwrap_or(true); + if result.goal.is_active() { + self.thread_goal_runtime + .mark_turn_started("", Some(&result.goal)); + } + self.emit_thread_goal_updated(session_id, Some(result.goal.clone())) + .await; + if objective_changed && result.goal.is_active() { + self.apply_objective_updated_steering(session_id, &result.goal) + .await; + } + Ok(result.goal) + } - if goal_state.continuation_count >= MAX_GOAL_CONTINUATIONS { - warn!( - "Session goal continuation limit reached; stopping auto-continue: session_id={}, goal={}", - session_id, goal_state.goal_text - ); - self.session_manager - .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) - .await?; - self.emit_event(AgenticEvent::GoalVerificationFinished { + async fn apply_objective_updated_steering(&self, session_id: &str, goal: &ThreadGoal) { + if !goal.is_active() { + return; + } + let agent_type = match self.session_manager.get_session(session_id) { + Some(session) => { + let agent_type = session.agent_type.trim(); + if agent_type.is_empty() { + "agentic".to_string() + } else { + agent_type.to_string() + } + } + None => "agentic".to_string(), + }; + let workspace_path = self + .require_main_session_workspace(session_id) + .ok() + .map(|path| path.to_string_lossy().to_string()); + let runtime = match CoreServiceAgentRuntime::global_agent_runtime_with_lifecycle_delivery() + { + Ok(runtime) => runtime, + Err(error) => { + warn!( + "Agent runtime lifecycle delivery is not available; objective_updated steering skipped: session_id={}, error={}", + session_id, error + ); + return; + } + }; + if let Err(error) = runtime + .deliver_thread_goal(AgentThreadGoalDeliveryRequest { session_id: session_id.to_string(), - source_turn_id: source_turn_id.to_string(), - outcome: "limit_reached".to_string(), + agent_type, + workspace_path, + kind: AgentThreadGoalDeliveryKind::ObjectiveUpdated, + goal: goal.clone(), }) - .await; - return Ok(None); + .await + { + warn!( + "Failed to deliver objective_updated steering: session_id={}, error={}", + session_id, + CoreServiceAgentRuntime::runtime_error_message(error) + ); } + } - self.emit_event(AgenticEvent::GoalVerificationStarted { - session_id: session_id.to_string(), - source_turn_id: source_turn_id.to_string(), - }) - .await; + pub async fn maybe_mark_thread_goal_usage_limited( + &self, + session_id: &str, + error: &BitFunError, + ) { + if !is_usage_limit_error(error) { + return; + } + let workspace_path = match self.require_main_session_workspace(session_id) { + Ok(path) => path, + Err(_) => return, + }; + let Ok(Some(goal)) = self + .get_thread_goal(session_id, workspace_path.as_path()) + .await + else { + return; + }; + if !goal.is_active() { + return; + } + if let Err(error) = self + .set_thread_goal_status( + session_id, + workspace_path.as_path(), + ThreadGoalStatus::UsageLimited, + ) + .await + { + warn!( + "Failed to mark thread goal usage limited: session_id={}, error={}", + session_id, error + ); + } + } - let context_messages = self - .session_manager - .get_context_messages(session_id) + pub async fn set_thread_goal_status( + &self, + session_id: &str, + workspace_path: &Path, + status: ThreadGoalStatus, + ) -> BitFunResult { + self.require_main_session_workspace(session_id)?; + let previous = self.get_thread_goal(session_id, workspace_path).await?; + let resuming = status == ThreadGoalStatus::Active + && previous + .as_ref() + .is_some_and(|goal| thread_goal_status_is_resumable(goal.status)); + let result = self + .thread_goal_store() + .set_thread_goal(session_id, workspace_path, None, Some(status), None, false) .await?; - let context_messages = ensure_final_response_in_goal_context( - context_messages, - turn_observation, - source_turn_id, - ); - let verification = match verify_goal_achievement(&goal_state, &context_messages).await { - Ok(result) => result, + if !result.goal.is_active() { + self.thread_goal_runtime.clear_active_goal(None); + } else if resuming { + self.thread_goal_runtime + .mark_turn_started("", Some(&result.goal)); + } + self.emit_thread_goal_updated(session_id, Some(result.goal.clone())) + .await; + if resuming && result.goal.is_active() { + clear_thread_goal_continuation_abort(session_id); + self.schedule_thread_goal_resumed_steering(session_id, &result.goal); + } + Ok(result.goal) + } + + /// Pause an active thread goal after the user manually stops a turn so the UI can offer resume. + pub async fn pause_thread_goal_after_user_cancel(&self, session_id: &str) { + let workspace_path = match self.require_main_session_workspace(session_id) { + Ok(path) => path, Err(error) => { - self.emit_event(AgenticEvent::GoalVerificationFinished { - session_id: session_id.to_string(), - source_turn_id: source_turn_id.to_string(), - outcome: "failed".to_string(), - }) - .await; - return Err(user_facing_goal_mode_error(error)); + debug!( + "Skipping thread goal pause after cancel (no workspace): session_id={}, error={}", + session_id, error + ); + return; } }; - - if verification.achieved { + let Ok(Some(goal)) = self + .get_thread_goal(session_id, workspace_path.as_path()) + .await + else { + return; + }; + if !goal.is_active() { + return; + } + if let Err(error) = self + .set_thread_goal_status( + session_id, + workspace_path.as_path(), + ThreadGoalStatus::Paused, + ) + .await + { + warn!( + "Failed to pause thread goal after user cancel: session_id={}, error={}", + session_id, error + ); + } else { info!( - "Session goal achieved: session_id={}, goal={}", - session_id, goal_state.goal_text + "Thread goal paused after user cancel: session_id={}, objective={}", + session_id, goal.objective ); - self.session_manager - .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) - .await?; - self.emit_event(AgenticEvent::GoalVerificationFinished { - session_id: session_id.to_string(), - source_turn_id: source_turn_id.to_string(), - outcome: "achieved".to_string(), - }) - .await; - return Ok(None); } + } - goal_state.continuation_count = goal_state.continuation_count.saturating_add(1); - self.session_manager - .merge_session_custom_metadata(session_id, goal_mode_patch(&goal_state)) + fn schedule_thread_goal_resumed_steering(&self, session_id: &str, goal: &ThreadGoal) { + if !goal.is_active() { + return; + } + let agent_type = match self.session_manager.get_session(session_id) { + Some(session) => { + let agent_type = session.agent_type.trim(); + if agent_type.is_empty() { + "agentic".to_string() + } else { + agent_type.to_string() + } + } + None => "agentic".to_string(), + }; + let workspace_path = self + .require_main_session_workspace(session_id) + .ok() + .map(|path| path.to_string_lossy().to_string()); + let session_id = session_id.to_string(); + let goal = goal.clone(); + tokio::spawn(async move { + let runtime = + match CoreServiceAgentRuntime::global_agent_runtime_with_lifecycle_delivery() { + Ok(runtime) => runtime, + Err(error) => { + warn!( + "Agent runtime lifecycle delivery is not available; thread goal resume steering skipped: session_id={}, error={}", + session_id, error + ); + return; + } + }; + if let Err(error) = runtime + .deliver_thread_goal(AgentThreadGoalDeliveryRequest { + session_id: session_id.clone(), + agent_type, + workspace_path, + kind: AgentThreadGoalDeliveryKind::Resumed, + goal, + }) + .await + { + warn!( + "Failed to deliver thread goal resume steering: session_id={}, error={}", + session_id, + CoreServiceAgentRuntime::runtime_error_message(error) + ); + } + }); + } + + pub async fn update_thread_goal_status( + &self, + session_id: &str, + workspace_path: &Path, + status: ThreadGoalStatus, + turn_id: Option<&str>, + ) -> BitFunResult { + let goal = self + .set_thread_goal_status(session_id, workspace_path, status) .await?; + self.thread_goal_runtime.clear_active_goal(turn_id); + Ok(goal) + } - self.emit_event(AgenticEvent::GoalVerificationFinished { + pub async fn emit_thread_goal_updated(&self, session_id: &str, goal: Option) { + let goal = bitfun_agent_runtime::thread_goal::thread_goal_event_payload(goal); + self.emit_event(AgenticEvent::ThreadGoalUpdated { session_id: session_id.to_string(), - source_turn_id: source_turn_id.to_string(), - outcome: "continuing".to_string(), + goal, }) .await; + } - Ok(Some(build_goal_continuation_plan( - &goal_state, - &verification, - ))) + async fn load_active_thread_goal(&self, session_id: &str) -> BitFunResult> { + let workspace_path = self.require_main_session_workspace(session_id)?; + Ok(self + .get_thread_goal(session_id, workspace_path.as_path()) + .await? + .filter(ThreadGoal::is_active)) + } + + /// Set a thread goal from `/goal ` (Codex-style direct objective). + pub async fn activate_session_goal( + &self, + session_id: String, + user_hint: Option, + ) -> BitFunResult { + let objective = user_hint.ok_or_else(|| { + BitFunError::Validation( + "Goal objective is required. Use /goal .".to_string(), + ) + })?; + let workspace_path = self.require_main_session_workspace(&session_id)?; + let existing = self + .get_thread_goal(&session_id, workspace_path.as_path()) + .await?; + let replace_existing = existing.is_some(); + let goal = self + .set_thread_goal_objective( + &session_id, + workspace_path.as_path(), + objective, + replace_existing, + ) + .await + .map_err(user_facing_thread_goal_error)?; + info!( + "Thread goal set from /goal: session_id={}, objective={}", + session_id, goal.objective + ); + Ok(goal) + } + + /// Continue an active thread goal after a dialog turn completes (Codex-style). + pub async fn prepare_goal_continuation_after_turn( + &self, + session_id: &str, + source_turn_id: &str, + user_input: &str, + user_message_metadata: Option<&serde_json::Value>, + turn_completed: bool, + ) -> BitFunResult> { + if should_skip_goal_continuation_after_turn(user_input, user_message_metadata) { + return Ok(None); + } + + let workspace_path = match self.require_main_session_workspace(session_id) { + Ok(path) => path, + Err(_) => return Ok(None), + }; + + let turn_tokens = self + .thread_goal_runtime + .turn_cumulative_billable_tokens(source_turn_id); + + let goal_before = self + .get_thread_goal(session_id, workspace_path.as_path()) + .await?; + + let plan = maybe_build_continuation_after_turn( + &self.thread_goal_store(), + self.thread_goal_runtime.as_ref(), + session_id, + workspace_path.as_path(), + source_turn_id, + turn_tokens, + turn_completed, + ) + .await?; + + let goal_after = self + .get_thread_goal(session_id, workspace_path.as_path()) + .await?; + if goal_before.as_ref().map(|goal| goal.status) + != goal_after.as_ref().map(|goal| goal.status) + { + if let Some(goal) = goal_after { + self.emit_thread_goal_updated(session_id, Some(goal)).await; + } + } + + Ok(plan) } /// Compact the active session context as a persisted maintenance turn. @@ -2142,7 +2614,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet skip_tool_confirmation: true, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services: manual_workspace_services, - round_preempt: None, round_injection: None, recover_partial_on_cancel: false, }; @@ -2206,6 +2677,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet partial_recovery_reason: None, success: Some(true), finish_reason: Some("complete".to_string()), + has_final_response: Some(true), }) .await; @@ -2259,6 +2731,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: Option, submission_policy: DialogSubmissionPolicy, extra_user_message_metadata: Option, + additional_prepended_messages: Vec, suppress_session_title_generation: bool, ) -> BitFunResult<()> { // Get latest session, restoring from persistence on demand so every entry @@ -2516,8 +2989,21 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } ); - let mut wrapped_user_input = self + let turn_index = self.session_manager.get_turn_count(&session_id); + let mut skill_agent_context_vars = HashMap::new(); + if user_message_metadata + .as_ref() + .and_then(|metadata| metadata.get("acp_transport")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + skill_agent_context_vars.insert("acp_transport".to_string(), "true".to_string()); + } + + let wrapped_user_input_payload = self .wrap_user_input( + &session_id, + turn_index, &effective_agent_type, previous_agent_type .as_deref() @@ -2525,20 +3011,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .filter(|value| !value.is_empty()), user_input, session_workspace.as_ref(), + workspace_services.as_ref(), + session.config.enable_tools, + &skill_agent_context_vars, ) .await?; - - if let Ok(Some(goal_state)) = self.load_active_goal_mode(&session_id).await { - if !should_skip_goal_verification_for_turn( - &original_user_input, - user_message_metadata.as_ref(), - ) { - wrapped_user_input = - wrap_user_input_with_goal_reminder(wrapped_user_input, &goal_state); - } + let effective_user_input = wrapped_user_input_payload.content.clone(); + let mut prepended_messages = additional_prepended_messages; + if needs_computer_links_for_source(submission_policy.trigger_source) { + prepended_messages.push(Message::internal_reminder( + InternalReminderKind::RemoteFileDelivery, + remote_file_delivery_reminder(), + )); } + prepended_messages.extend(wrapped_user_input_payload.prepended_messages.clone()); - if original_user_input != wrapped_user_input { + if original_user_input != effective_user_input { let mut metadata = Self::ensure_user_message_metadata_object(user_message_metadata.take()); if let Some(obj) = metadata.as_object_mut() { @@ -2551,19 +3039,48 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } // Start new dialog turn (sets state to Processing internally) - let turn_index = self.session_manager.get_turn_count(&session_id); // Pass frontend turnId, generate if not provided let turn_id = self .session_manager - .start_dialog_turn( + .start_dialog_turn_with_prepended_messages( &session_id, effective_agent_type.clone(), - wrapped_user_input.clone(), + effective_user_input.clone(), turn_id, image_contexts, + prepended_messages, user_message_metadata.clone(), ) .await?; + if let Ok(Some(goal)) = self.load_active_thread_goal(&session_id).await { + if !should_skip_goal_for_turn(&original_user_input, user_message_metadata.as_ref()) { + self.thread_goal_runtime + .mark_turn_started(&turn_id, Some(&goal)); + } + } + match wrapped_user_input_payload.snapshot_persistence { + SkillAgentSnapshotPersistence::None => {} + SkillAgentSnapshotPersistence::SaveCurrentTurn => { + self.session_manager + .remember_turn_skill_agent_snapshot( + &session_id, + turn_index, + wrapped_user_input_payload.skill_agent_snapshot.clone(), + ) + .await; + } + SkillAgentSnapshotPersistence::RecoverFirstTurnBaseline => { + self.session_manager + .recover_first_turn_skill_agent_snapshot( + &session_id, + wrapped_user_input_payload.skill_agent_snapshot.clone(), + ) + .await; + self.session_manager + .remove_listing_diff_internal_reminders(&session_id) + .await; + } + } // Register this turn as in-flight immediately after it becomes visible // as Processing. Later await points must not leave a cancel/start @@ -2604,8 +3121,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: session_id.clone(), turn_id: turn_id.clone(), turn_index, - user_input: wrapped_user_input.clone(), - original_user_input: if original_user_input != wrapped_user_input { + user_input: effective_user_input.clone(), + original_user_input: if original_user_input != effective_user_input { Some(original_user_input.clone()) } else { None @@ -2668,6 +3185,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet { context_vars.insert("acp_transport".to_string(), "true".to_string()); } + if needs_computer_links_for_source(submission_policy.trigger_source) { + context_vars.insert( + TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), + "true".to_string(), + ); + } let session_workspace_path = session_workspace .as_ref() .map(|workspace| workspace.root_path_string()); @@ -2678,6 +3201,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .as_ref() .map(|workspace| workspace.session_storage_path().to_path_buf()); + let runtime_tool_restrictions = if is_miniapp_headless_agent_run( + user_message_metadata.as_ref(), + session.created_by.as_deref(), + ) { + miniapp_headless_agent_tool_restrictions() + } else { + ToolRuntimeRestrictions::default() + }; + let execution_context = ExecutionContext { session_id: session_id.clone(), dialog_turn_id: turn_id.clone(), @@ -2688,9 +3220,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_parent_info: None, delegation_policy: DelegationPolicy::top_level(), skip_tool_confirmation: submission_policy.skip_tool_confirmation, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_tool_restrictions, workspace_services, - round_preempt: self.round_preempt_source.get().cloned(), round_injection: self.round_injection_source.get().cloned(), recover_partial_on_cancel: false, }; @@ -2742,7 +3273,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let event_queue = self.event_queue.clone(); let session_id_clone = session_id.clone(); let turn_id_clone = turn_id.clone(); - let user_input_for_workspace = wrapped_user_input.clone(); + let user_input_for_workspace = effective_user_input.clone(); let session_storage_path_for_finalize = session_storage_path.clone(); let effective_agent_type_clone = effective_agent_type.clone(); let user_message_metadata_clone = user_message_metadata; @@ -3012,6 +3543,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id, session_id ); + abort_thread_goal_continuation_for_session(session_id); + let old_state = self .session_manager .get_session(session_id) @@ -3048,6 +3581,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }) .await; debug!("Session state change event sent"); + self.pause_thread_goal_after_user_cancel(session_id).await; } else { debug!( "Skipped idle event for stale cancellation: session_id={}, dialog_turn_id={}", @@ -3105,6 +3639,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, wait_timeout: Duration, ) -> BitFunResult> { + abort_thread_goal_continuation_for_session(session_id); + let Some(session) = self.session_manager.get_session(session_id) else { return Ok(None); }; @@ -3240,6 +3776,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<( + Session, + Vec, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_session_view_timed(workspace_path, session_id) + .await + } + pub async fn restore_session_view_tail( &self, workspace_path: &Path, @@ -3251,6 +3801,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_session_view_tail_timed(workspace_path, session_id, tail_turn_count) + .await + } + pub async fn restore_internal_session_view( &self, workspace_path: &Path, @@ -3261,6 +3827,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_internal_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<( + Session, + Vec, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_internal_session_view_timed(workspace_path, session_id) + .await + } + pub async fn restore_internal_session_view_tail( &self, workspace_path: &Path, @@ -3272,6 +3852,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_internal_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_internal_session_view_tail_timed(workspace_path, session_id, tail_turn_count) + .await + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { self.session_manager.list_sessions(workspace_path).await @@ -3601,15 +4197,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } = request; let requested_timeout_seconds = timeout_seconds.filter(|seconds| *seconds > 0); - let parent_goal_mode_active = if let Some(parent_info) = subagent_parent_info.as_ref() { + let parent_thread_goal_active = if let Some(parent_info) = subagent_parent_info.as_ref() { matches!( - self.load_active_goal_mode(&parent_info.session_id).await, + self.load_active_thread_goal(&parent_info.session_id).await, Ok(Some(_)) ) } else { false }; - if parent_goal_mode_active { + if parent_thread_goal_active { let parent_session_id = subagent_parent_info .as_ref() .map(|info| info.session_id.as_str()) @@ -3619,8 +4215,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type, parent_session_id ); } - let timeout_seconds = - effective_subagent_timeout_seconds(requested_timeout_seconds, parent_goal_mode_active); + let timeout_seconds = effective_subagent_timeout_seconds( + requested_timeout_seconds, + parent_thread_goal_active, + ); let timeout_error_message = match timeout_seconds.or(requested_timeout_seconds) { Some(seconds) => format!( "Subagent '{}' timed out after {} seconds", @@ -3712,6 +4310,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; let session_id = session.session_id.clone(); + // Sync context window from AI config so subagents with large-context + // models are not prematurely capped at SessionConfig::default()'s 128128. + self.session_manager + .refresh_session_context_window(&session_id) + .await?; if let Some(source_session_id) = prompt_cache_source_session_id.as_deref() { let copied = self .session_manager @@ -3721,6 +4324,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Forked prompt cache into subagent session: source_session_id={}, session_id={}, copied={}", source_session_id, session_id, copied ); + self.session_manager + .seed_forked_skill_agent_listing_baselines(source_session_id, &session_id) + .await; } self.session_manager .replace_context_messages(&session_id, initial_messages.clone()) @@ -3858,7 +4464,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet skip_tool_confirmation: true, runtime_tool_restrictions, workspace_services: subagent_services, - round_preempt: self.round_preempt_source.get().cloned(), // Subagents are autonomous; user steering is targeted at top-level // dialog turns only. Leave None so we don't intercept buffer entries // that belong to a different (parent) session/turn. @@ -4470,6 +5075,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; + let copied = self + .session_manager + .clone_prompt_cache(parent_session_id, &child_session.session_id) + .await; + debug!( + "Forked prompt cache into /btw child session: parent_session_id={}, child_session_id={}, copied={}", + parent_session_id, child_session.session_id, copied + ); + self.session_manager + .seed_forked_skill_agent_listing_baselines(parent_session_id, &child_session.session_id) + .await; + self.session_manager .replace_context_messages(&child_session.session_id, snapshot.messages) .await; @@ -4524,9 +5141,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "parentSessionId": parent_session_id, })); + let (user_input, prepended_messages) = build_btw_user_input(question); + self.start_dialog_turn_internal( child_session_id.to_string(), - build_btw_user_input(question), + user_input, Some(question.trim().to_string()), None, Some(turn_id.clone()), @@ -4535,6 +5154,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) .with_skip_tool_confirmation(true), user_message_metadata, + prepended_messages, true, ) .await?; @@ -4619,7 +5239,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .capture_fork_agent_context_snapshot(&request.subagent_parent_info.session_id) .await?; let mut initial_messages = snapshot.messages.clone(); - initial_messages.push(Message::user(fork_subagent_system_reminder())); + initial_messages.push(Message::internal_reminder( + InternalReminderKind::ForkSubagent, + fork_subagent_system_reminder(), + )); initial_messages.push(Message::user(task_description.clone())); Ok(HiddenSubagentExecutionRequest { @@ -4697,7 +5320,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .map(|token| token.child_token()); tokio::spawn(async move { - let delivery_text = match coordinator + let (delivery_text, display_text) = match coordinator .execute_hidden_subagent_internal( request, parent_cancel_token.as_ref(), @@ -4705,50 +5328,76 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await { - Ok(result) => format_background_subagent_delivery_text( - &background_task_id_for_delivery, - &agent_type, - Ok(&result), + Ok(result) => ( + format_background_subagent_delivery_text( + &background_task_id_for_delivery, + &agent_type, + Ok(&result), + ), + format_background_subagent_display_text(Ok(&result)), ), - Err(error) => format_background_subagent_delivery_text( - &background_task_id_for_delivery, - &agent_type, - Err(&error), + Err(error) => ( + format_background_subagent_delivery_text( + &background_task_id_for_delivery, + &agent_type, + Err(&error), + ), + format_background_subagent_display_text(Err(&error)), ), }; - let metadata = serde_json::json!({ - "kind": "background_result", - "sourceKind": "subagent", - "backgroundTaskId": background_task_id_for_delivery, - "subagentType": agent_type, - "taskDescription": task_description, - }); + let mut metadata = serde_json::Map::new(); + metadata.insert( + "kind".to_string(), + serde_json::Value::String("background_result".to_string()), + ); + metadata.insert( + "sourceKind".to_string(), + serde_json::Value::String("subagent".to_string()), + ); + metadata.insert( + "backgroundTaskId".to_string(), + serde_json::Value::String(background_task_id_for_delivery.clone()), + ); + metadata.insert( + "subagentType".to_string(), + serde_json::Value::String(agent_type), + ); + metadata.insert( + "taskDescription".to_string(), + serde_json::Value::String(task_description), + ); - if let Some(scheduler) = super::scheduler::get_global_scheduler() { - if let Err(error) = scheduler - .deliver_background_result( - subagent_parent_info.session_id.clone(), - parent_agent_type, - parent_workspace_path, - delivery_text, - None, - Some(metadata), - ) - .await - { - warn!( - "Failed to deliver background subagent result: background_task_id={}, parent_session_id={}, error={}", + let runtime = + match CoreServiceAgentRuntime::global_agent_runtime_with_lifecycle_delivery() { + Ok(runtime) => runtime, + Err(error) => { + warn!( + "Agent runtime lifecycle delivery is not available; background subagent result dropped: background_task_id={}, parent_session_id={}, error={}", background_task_id_for_delivery, subagent_parent_info.session_id, error ); - } - } else { + return; + } + }; + + if let Err(error) = runtime + .deliver_background_result(AgentBackgroundResultRequest { + session_id: subagent_parent_info.session_id.clone(), + agent_type: parent_agent_type, + workspace_path: parent_workspace_path, + content: delivery_text, + display_content: Some(display_text), + metadata, + }) + .await + { warn!( - "Scheduler not initialized; background subagent result dropped: background_task_id={}, parent_session_id={}", + "Failed to deliver background subagent result: background_task_id={}, parent_session_id={}, error={}", background_task_id_for_delivery, - subagent_parent_info.session_id + subagent_parent_info.session_id, + CoreServiceAgentRuntime::runtime_error_message(error) ); } }); @@ -4947,33 +5596,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &self.session_manager } - /// Persist a completed `/btw` side-question turn into an existing child session. - #[allow(clippy::too_many_arguments)] - pub async fn persist_btw_turn( - &self, - workspace_path: &Path, - child_session_id: &str, - request_id: &str, - question: &str, - full_text: &str, - parent_session_id: &str, - parent_dialog_turn_id: Option<&str>, - parent_turn_index: Option, - ) -> BitFunResult<()> { - self.session_manager - .persist_btw_turn( - workspace_path, - child_session_id, - request_id, - question, - full_text, - parent_session_id, - parent_dialog_turn_id, - parent_turn_index, - ) - .await - } - /// Set global coordinator (called during initialization) /// /// Skips if global coordinator already exists @@ -5008,6 +5630,17 @@ fn resolve_agent_submission_turn_id( .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) } +fn resolve_agent_session_create_created_by( + metadata: &serde_json::Map, +) -> Option { + metadata + .get("createdBy") + .or_else(|| metadata.get("created_by")) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + #[async_trait::async_trait] impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { async fn create_session( @@ -5022,7 +5655,7 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { })?; let session = self - .create_session_with_workspace( + .create_session_with_workspace_and_creator( None, request.session_name, request.agent_type, @@ -5031,6 +5664,7 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { ..Default::default() }, workspace_path, + resolve_agent_session_create_created_by(&request.metadata), ) .await .map_err(|error| { @@ -5042,6 +5676,7 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { Ok(bitfun_runtime_ports::AgentSessionCreateResult { session_id: session.session_id, + session_name: session.session_name, agent_type: session.agent_type, }) } @@ -5111,6 +5746,69 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { } } +fn runtime_session_time_ms(time: std::time::SystemTime) -> u64 { + time.duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or_default() +} + +fn runtime_session_summary(session: SessionSummary) -> bitfun_runtime_ports::AgentSessionSummary { + bitfun_runtime_ports::AgentSessionSummary { + session_id: session.session_id, + session_name: session.session_name, + agent_type: session.agent_type, + created_at_ms: runtime_session_time_ms(session.created_at), + last_active_at_ms: runtime_session_time_ms(session.last_activity_at), + } +} + +#[async_trait::async_trait] +impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinator { + async fn list_sessions( + &self, + request: bitfun_runtime_ports::AgentSessionListRequest, + ) -> bitfun_runtime_ports::PortResult> { + self.list_sessions(Path::new(&request.workspace_path)) + .await + .map(|sessions| { + sessions + .into_iter() + .map(runtime_session_summary) + .collect::>() + }) + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + }) + } + + async fn delete_session( + &self, + request: bitfun_runtime_ports::AgentSessionDeleteRequest, + ) -> bitfun_runtime_ports::PortResult<()> { + self.delete_session(Path::new(&request.workspace_path), &request.session_id) + .await + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + }) + } + + async fn resolve_session_workspace_path( + &self, + request: bitfun_runtime_ports::AgentSessionWorkspaceRequest, + ) -> bitfun_runtime_ports::PortResult> { + Ok(self + .resolve_session_workspace_path(&request.session_id) + .await + .map(|path| path.to_string_lossy().into_owned())) + } +} + #[async_trait::async_trait] impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator { async fn cancel_turn( @@ -5271,11 +5969,76 @@ pub fn get_global_coordinator() -> Option> { #[cfg(test)] mod tests { use super::{ - normalize_subagent_max_concurrency, resolve_agent_submission_turn_id, - ConversationCoordinator, + normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, + resolve_agent_submission_turn_id, ConversationCoordinator, + }; + use crate::agentic::core::SessionConfig; + use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; + use crate::agentic::execution::{ + ExecutionEngine, ExecutionEngineConfig, RoundExecutor, StreamProcessor, }; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + compression::{CompressionConfig, ContextCompressor}, + PromptCachePolicy, SessionContextStore, SessionManager, SessionManagerConfig, + SystemPromptCacheIdentity, UserContextCacheIdentity, + }; + use crate::agentic::skill_agent_snapshot::SkillSnapshotEntry; + use crate::agentic::tools::registry::ToolRegistry; + use crate::agentic::tools::{ToolPipeline, ToolStateManager}; + use crate::agentic::TurnSkillAgentSnapshot; + use crate::infrastructure::PathManager; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; - use bitfun_runtime_ports::{AgentSubmissionRequest, AgentSubmissionSource}; + use bitfun_runtime_ports::{ + AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionRequest, + AgentSubmissionSource, + }; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::RwLock as TokioRwLock; + + fn test_coordinator() -> (ConversationCoordinator, Arc) { + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ), + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue, + Arc::new(EventRouter::new()), + ); + + (coordinator, session_manager) + } #[test] fn conversation_coordinator_exposes_remote_runtime_ports() { @@ -5352,6 +6115,29 @@ mod tests { assert!(failed_text.contains("Error:")); } + #[test] + fn background_subagent_display_text_is_concise() { + let completed = super::SubagentResult::completed("done".to_string()); + assert_eq!( + super::format_background_subagent_display_text(Ok(&completed)), + "Background subagent completed successfully." + ); + + let partial = + super::SubagentResult::partial_timeout("partial".to_string(), "timeout".to_string()); + assert_eq!( + super::format_background_subagent_display_text(Ok(&partial)), + "Background subagent completed with a partial timeout result." + ); + + assert_eq!( + super::format_background_subagent_display_text(Err( + &crate::util::errors::BitFunError::tool("boom".to_string()) + )), + "Background subagent failed before producing a final result." + ); + } + #[test] fn agent_submission_turn_id_prefers_explicit_field_over_metadata() { let mut metadata = serde_json::Map::new(); @@ -5393,6 +6179,70 @@ mod tests { ); } + #[test] + fn agent_session_create_created_by_accepts_camel_case_metadata() { + let mut metadata = serde_json::Map::new(); + metadata.insert( + "createdBy".to_string(), + serde_json::Value::String("session-parent".to_string()), + ); + + assert_eq!( + resolve_agent_session_create_created_by(&metadata).as_deref(), + Some("session-parent") + ); + } + + #[test] + fn agent_session_create_created_by_accepts_snake_case_metadata() { + let mut metadata = serde_json::Map::new(); + metadata.insert( + "created_by".to_string(), + serde_json::Value::String("session-parent".to_string()), + ); + + assert_eq!( + resolve_agent_session_create_created_by(&metadata).as_deref(), + Some("session-parent") + ); + } + + #[tokio::test] + async fn agent_submission_create_session_preserves_creator_metadata() { + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-agent-session-port-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + let mut metadata = serde_json::Map::new(); + metadata.insert( + "createdBy".to_string(), + serde_json::Value::String("session-parent".to_string()), + ); + + let result = AgentSubmissionPort::create_session( + &coordinator, + AgentSessionCreateRequest { + session_name: "Worker".to_string(), + agent_type: "agentic".to_string(), + workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + metadata, + }, + ) + .await + .expect("port-backed session creation should succeed"); + let created = session_manager + .get_session(&result.session_id) + .expect("created session should be persisted"); + + assert_eq!(result.session_name, "Worker"); + assert_eq!(result.session_name, created.session_name); + assert_eq!(created.created_by.as_deref(), Some("session-parent")); + + let _ = std::fs::remove_dir_all(workspace_path); + } + #[tokio::test] async fn subagent_session_config_preserves_registered_remote_workspace_identity() { let manager = init_remote_workspace_manager(); @@ -5425,4 +6275,98 @@ mod tests { assert_eq!(config.remote_ssh_host.as_deref(), Some("remote-host")); assert_eq!(config.model_id.as_deref(), Some("model-fast")); } + + #[tokio::test] + async fn hidden_btw_session_seeds_forked_listing_baselines() { + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = + std::env::temp_dir().join(format!("bitfun-btw-baseline-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + let parent_session = session_manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("parent session should be created"); + session_manager + .replace_context_messages( + &parent_session.session_id, + vec![crate::agentic::core::Message::user( + "parent context".to_string(), + )], + ) + .await; + + let system_prompt_identity = SystemPromptCacheIdentity::new("template:agentic_mode"); + let user_context_identity = UserContextCacheIdentity::new("workspace_context"); + session_manager + .remember_system_prompt( + &parent_session.session_id, + system_prompt_identity.clone(), + "cached system prompt".to_string(), + ) + .await; + session_manager + .remember_user_context( + &parent_session.session_id, + user_context_identity.clone(), + "cached user context".to_string(), + ) + .await; + + let baseline_snapshot = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "interactive-debug".to_string(), + description: "debug helper".to_string(), + location: "C:/Users/wsp/.codex/skills/interactive-debug".to_string(), + }], + subagents: Vec::new(), + }; + session_manager + .remember_turn_skill_agent_snapshot( + &parent_session.session_id, + 0, + baseline_snapshot.clone(), + ) + .await; + + let child_session = coordinator + .ensure_hidden_btw_session(&parent_session.session_id, "btw-child", None) + .await + .expect("btw child session should be created"); + + assert_eq!( + child_session.kind, + crate::agentic::core::SessionKind::EphemeralChild + ); + assert_eq!( + session_manager + .cached_system_prompt(&child_session.session_id, &system_prompt_identity) + .await, + Some("cached system prompt".to_string()) + ); + assert_eq!( + session_manager + .cached_user_context(&child_session.session_id, &user_context_identity) + .await, + Some("cached user context".to_string()) + ); + assert_eq!( + session_manager + .skill_agent_baseline_override_snapshot(&child_session.session_id) + .await, + Some(baseline_snapshot.clone()) + ); + assert_eq!( + session_manager + .turn_skill_agent_snapshot(&child_session.session_id, 0) + .await, + Some(baseline_snapshot) + ); + } } diff --git a/src/crates/core/src/agentic/coordination/mod.rs b/src/crates/assembly/core/src/agentic/coordination/mod.rs similarity index 100% rename from src/crates/core/src/agentic/coordination/mod.rs rename to src/crates/assembly/core/src/agentic/coordination/mod.rs diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs new file mode 100644 index 0000000000..0590863d8f --- /dev/null +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -0,0 +1,1523 @@ +//! Dialog scheduler +//! +//! Message queue manager that automatically dispatches queued messages +//! when the target session becomes idle. +//! +//! Acts as the primary entry point for all user-facing message submissions, +//! wrapping ConversationCoordinator with: +//! - Per-session priority queue (max 20 messages) +//! - Higher-priority messages dispatched before lower-priority ones +//! - FIFO ordering within the same priority level +//! - Queue cleared on unrecoverable failure + +use super::coordinator::{ConversationCoordinator, DialogTriggerSource}; +use super::turn_outcome::TurnOutcome; +use crate::agentic::core::{InternalReminderKind, Message, SessionState}; +use crate::agentic::goal_mode::{ + goal_continuation_submit_retry_delay_ms, goal_internal_context_message, + goal_objective_updated_message, +}; +use crate::agentic::image_analysis::ImageContextData; +use crate::agentic::init_agents_md::build_init_agents_md_user_input; +use crate::agentic::round_preempt::{DialogRoundInjectionSource, SessionRoundInjectionBuffer}; +use crate::agentic::session::SessionManager; +use bitfun_runtime_ports::{ThreadGoal, MAX_THREAD_GOAL_AUTO_CONTINUATIONS}; +use log::{debug, info, warn}; +use std::path::Path; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use bitfun_agent_runtime::scheduler::{ + build_thread_goal_objective_updated_delivery_plan, build_thread_goal_resumed_delivery_plan, + resolve_agent_session_reply_action, resolve_background_delivery_action, + resolve_background_delivery_injection, resolve_dialog_steering_action, + resolve_turn_outcome_lifecycle_plan, ActiveDialogTurn, ActiveDialogTurnStore, + AgentSessionReplyAction, AgentSessionReplyPlan, BackgroundDeliveryAction, + BackgroundDeliveryFacts, BackgroundInjectionKind, DialogReplySuppressionSet, + DialogSteeringAction, DialogTurnQueue, GoalContinuationAfterTurnAction, SessionAbortFlags, + ThreadGoalDeliveryReminder, ThreadGoalDeliveryReminderKind, TurnOutcomeQueueAction, + TurnOutcomeStatus, +}; +use bitfun_runtime_ports::{ + resolve_dialog_submit_queue_action, AgentBackgroundResultRequest, AgentDialogPrependedReminder, + AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, + AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, AgentTurnCancellationPort, + AgentTurnCancellationRequest, AgentTurnCancellationResult, DialogSessionStateFact, + DialogSubmitQueueAction, DialogSubmitQueueFacts, PortError, PortErrorKind, PortResult, +}; +pub use bitfun_runtime_ports::{ + AgentSessionReplyRoute, DialogQueuePriority, DialogSteerOutcome, DialogSubmissionPolicy, + DialogSubmitOutcome, +}; + +/// A message waiting to be dispatched to the coordinator +#[derive(Debug, Clone)] +pub struct QueuedTurn { + pub user_input: String, + pub original_user_input: Option, + pub prepended_messages: Vec, + pub turn_id: Option, + pub agent_type: String, + pub workspace_path: Option, + pub policy: DialogSubmissionPolicy, + pub reply_route: Option, + pub user_message_metadata: Option, + pub image_contexts: Option>, + #[allow(dead_code)] + pub enqueued_at: SystemTime, +} + +/// Message queue manager for dialog turns. +/// +/// All user-facing callers (frontend Tauri commands, remote server, bot router) +/// should submit messages through this scheduler instead of calling +/// ConversationCoordinator directly. +pub struct DialogScheduler { + coordinator: Arc, + session_manager: Arc, + /// Per-session priority message queues. + queues: Arc>, + /// Currently active turn metadata keyed by target session ID + active_turns: Arc, + /// Turns whose cancelled auto-reply should be suppressed because the source + /// agent explicitly cancelled its own outstanding SessionMessage request. + suppressed_cancelled_replies: Arc, + /// Set when the user cancels an in-flight turn; aborts goal-continuation submit retries. + goal_continuation_abort: Arc, + /// Cloneable sender given to ConversationCoordinator for turn outcome notifications + outcome_tx: mpsc::Sender<(String, TurnOutcome)>, + /// Per-session FIFO buffer of round injections drained at round boundaries + /// by the engine and injected into the running dialog turn. + round_injection_buffer: Arc, +} + +impl DialogScheduler { + /// Create a new DialogScheduler and start its background outcome handler. + /// + /// The returned `Arc` should be stored globally. + /// Call `coordinator.set_scheduler_notifier(scheduler.outcome_sender())` + /// immediately after to wire up the notification channel. + pub fn new( + coordinator: Arc, + session_manager: Arc, + ) -> Arc { + let (outcome_tx, outcome_rx) = mpsc::channel(128); + + let scheduler = Arc::new(Self { + coordinator, + session_manager, + queues: Arc::new(DialogTurnQueue::default()), + active_turns: Arc::new(ActiveDialogTurnStore::default()), + suppressed_cancelled_replies: Arc::new(DialogReplySuppressionSet::default()), + goal_continuation_abort: Arc::new(SessionAbortFlags::default()), + outcome_tx, + round_injection_buffer: Arc::new(SessionRoundInjectionBuffer::default()), + }); + + let scheduler_for_handler = Arc::clone(&scheduler); + tauri::async_runtime::spawn(async move { + scheduler_for_handler.run_outcome_handler(outcome_rx).await; + }); + + scheduler + } + + /// Returns a sender to give to ConversationCoordinator for turn outcome notifications. + pub fn outcome_sender(&self) -> mpsc::Sender<(String, TurnOutcome)> { + self.outcome_tx.clone() + } + + /// Pass to [`ConversationCoordinator::set_round_injection_source`](super::coordinator::ConversationCoordinator::set_round_injection_source). + pub fn round_injection_monitor(&self) -> Arc { + self.round_injection_buffer.clone() + } + + /// Submit a user "steering" message into the currently running dialog turn. + /// + /// Unlike [`Self::submit`], this never starts or queues a new turn — it only buffers + /// the message so the [`ExecutionEngine`](super::super::execution::ExecutionEngine) + /// can inject it at the next model-round boundary. Errors: + /// + /// - Session is not currently `Processing` the requested `turn_id` (the targeted turn + /// already finished or never existed). Caller should fall back to `submit`. + pub async fn submit_steering( + &self, + session_id: String, + turn_id: String, + content: String, + display_content: Option, + ) -> Result { + let active_turn_id = match self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()) + { + Some(SessionState::Processing { + current_turn_id, .. + }) => Some(current_turn_id), + _ => None, + }; + + let steering_id = Uuid::new_v4().to_string(); + match resolve_dialog_steering_action( + active_turn_id.as_deref(), + &session_id, + &turn_id, + content, + display_content, + steering_id, + SystemTime::now(), + ) { + DialogSteeringAction::Reject { error } => { + warn!( + "submit_steering rejected: target turn is not running: session_id={}, turn_id={}", + session_id, turn_id + ); + Err(error) + } + DialogSteeringAction::Buffer { injection, outcome } => { + self.round_injection_buffer.push(&session_id, injection); + let DialogSteerOutcome::Buffered { steering_id, .. } = &outcome; + info!( + "Steering message buffered: session_id={}, turn_id={}, steering_id={}, pending={}", + session_id, + turn_id, + steering_id, + self.round_injection_buffer.pending_count(&session_id) + ); + + Ok(outcome) + } + } + } + + /// Resume auto-continuation toward an active thread goal (after pause / blocked / usage limit). + pub async fn deliver_thread_goal_resumed( + &self, + session_id: String, + agent_type: String, + workspace_path: Option, + goal: ThreadGoal, + ) -> Result<(), String> { + let plan = build_thread_goal_resumed_delivery_plan(&goal); + let state = self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()); + + match resolve_background_delivery_action(BackgroundDeliveryFacts { + session_state: Self::session_state_fact(state.as_ref()), + }) { + BackgroundDeliveryAction::InjectIntoRunningTurn => { + self.round_injection_buffer.push( + &session_id, + resolve_background_delivery_injection( + BackgroundInjectionKind::ThreadGoalObjectiveUpdated, + Uuid::new_v4().to_string(), + plan.injection_prompt, + Some(plan.injection_display), + SystemTime::now(), + ), + ); + Ok(()) + } + BackgroundDeliveryAction::SubmitAgentSessionFollowUp { + queue_priority, + skip_tool_confirmation, + } => { + let prepended = thread_goal_delivery_messages(plan.prepended_reminders); + self.submit_with_prepended_messages( + session_id, + plan.follow_up_user_input, + plan.follow_up_original_user_input, + None, + agent_type, + workspace_path, + DialogSubmissionPolicy::new( + DialogTriggerSource::AgentSession, + queue_priority, + skip_tool_confirmation, + ), + None, + Some(plan.user_message_metadata), + prepended, + None, + ) + .await + .map(|_| ()) + } + } + } + + /// Inject objective-updated steering into the running turn, or start a follow-up turn when idle. + pub async fn deliver_thread_goal_objective_updated( + &self, + session_id: String, + agent_type: String, + workspace_path: Option, + goal: ThreadGoal, + ) -> Result<(), String> { + let plan = build_thread_goal_objective_updated_delivery_plan(&goal); + let state = self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()); + + match resolve_background_delivery_action(BackgroundDeliveryFacts { + session_state: Self::session_state_fact(state.as_ref()), + }) { + BackgroundDeliveryAction::InjectIntoRunningTurn => { + self.round_injection_buffer.push( + &session_id, + resolve_background_delivery_injection( + BackgroundInjectionKind::ThreadGoalObjectiveUpdated, + Uuid::new_v4().to_string(), + plan.injection_prompt, + Some(plan.injection_display), + SystemTime::now(), + ), + ); + Ok(()) + } + BackgroundDeliveryAction::SubmitAgentSessionFollowUp { + queue_priority, + skip_tool_confirmation, + } => { + let prepended = thread_goal_delivery_messages(plan.prepended_reminders); + self.submit_with_prepended_messages( + session_id, + plan.follow_up_user_input, + plan.follow_up_original_user_input, + None, + agent_type, + workspace_path, + DialogSubmissionPolicy::new( + DialogTriggerSource::AgentSession, + queue_priority, + skip_tool_confirmation, + ), + None, + Some(plan.user_message_metadata), + prepended, + None, + ) + .await + .map(|_| ()) + } + } + } + + /// Deliver a completed background result back to the parent session. + /// If the session is currently processing, inject the result into the + /// running turn at the next model-round boundary. Otherwise, start a new + /// turn immediately so the result is handled without waiting for an + /// unrelated future message. + pub async fn deliver_background_result( + &self, + session_id: String, + agent_type: String, + workspace_path: Option, + content: String, + display_content: Option, + user_message_metadata: Option, + ) -> Result<(), String> { + let display = display_content.unwrap_or_else(|| content.clone()); + let state = self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()); + + match resolve_background_delivery_action(BackgroundDeliveryFacts { + session_state: Self::session_state_fact(state.as_ref()), + }) { + BackgroundDeliveryAction::InjectIntoRunningTurn => { + self.round_injection_buffer.push( + &session_id, + resolve_background_delivery_injection( + BackgroundInjectionKind::BackgroundResult, + Uuid::new_v4().to_string(), + content, + Some(display), + SystemTime::now(), + ), + ); + Ok(()) + } + BackgroundDeliveryAction::SubmitAgentSessionFollowUp { + queue_priority, + skip_tool_confirmation, + } => self + .submit( + session_id, + content, + Some(display), + None, + agent_type, + workspace_path, + DialogSubmissionPolicy::new( + DialogTriggerSource::AgentSession, + queue_priority, + skip_tool_confirmation, + ), + None, + user_message_metadata, + None, + ) + .await + .map(|_| ()), + } + } + + pub async fn submit_init_agents_md( + &self, + session_id: String, + workspace_path: Option, + policy: DialogSubmissionPolicy, + ) -> Result { + let agent_type = self + .resolve_session_agent_type(&session_id, workspace_path.as_deref()) + .await?; + let (user_input, prepended_messages) = build_init_agents_md_user_input() + .await + .map_err(|error| error.to_string())?; + + self.submit_with_prepended_messages( + session_id, + user_input.clone(), + Some(user_input), + None, + agent_type, + workspace_path, + policy, + None, + None, + prepended_messages, + None, + ) + .await + } + + 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, + } + } + + /// Submit a user message for a session. + /// + /// - Session idle, queue empty → dispatched immediately. + /// - Session idle, queue non-empty → enqueued then highest-priority queued message dispatched. + /// - Session processing → queued up to the runtime-owned queue limit and dispatched after + /// the current turn completes. + /// - Session error → queue cleared, dispatched immediately. + /// + /// Returns `Err(String)` if the queue is full or the coordinator returns an error. + #[allow(clippy::too_many_arguments)] + pub async fn submit( + &self, + session_id: String, + user_input: String, + original_user_input: Option, + turn_id: Option, + agent_type: String, + workspace_path: Option, + policy: DialogSubmissionPolicy, + reply_route: Option, + user_message_metadata: Option, + image_contexts: Option>, + ) -> Result { + self.submit_with_prepended_messages( + session_id, + user_input, + original_user_input, + turn_id, + agent_type, + workspace_path, + policy, + reply_route, + user_message_metadata, + Vec::new(), + image_contexts, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn submit_with_prepended_messages( + &self, + session_id: String, + user_input: String, + original_user_input: Option, + turn_id: Option, + agent_type: String, + workspace_path: Option, + policy: DialogSubmissionPolicy, + reply_route: Option, + user_message_metadata: Option, + prepended_messages: Vec, + image_contexts: Option>, + ) -> Result { + let resolved_turn_id = turn_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let queued_turn = QueuedTurn { + user_input, + original_user_input, + prepended_messages, + turn_id: Some(resolved_turn_id.clone()), + agent_type, + workspace_path, + policy, + reply_route, + user_message_metadata, + image_contexts, + enqueued_at: SystemTime::now(), + }; + self.submit_queued_turn(session_id, resolved_turn_id, queued_turn) + .await + } + + async fn resolve_session_agent_type( + &self, + session_id: &str, + workspace_path: Option<&str>, + ) -> Result { + let session = match self.session_manager.get_session(session_id) { + Some(session) => session, + None => { + let workspace_path = workspace_path.ok_or_else(|| { + format!( + "workspace_path is required when restoring session: {}", + session_id + ) + })?; + self.session_manager + .restore_session(Path::new(workspace_path), session_id) + .await + .map_err(|error| error.to_string())? + } + }; + let agent_type = session.agent_type.trim(); + if agent_type.is_empty() { + Ok("agentic".to_string()) + } else { + Ok(agent_type.to_string()) + } + } + + async fn submit_queued_turn( + &self, + session_id: String, + resolved_turn_id: String, + queued_turn: QueuedTurn, + ) -> Result { + let state = self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()); + + let queue_has_items = self.queues.has_items(&session_id); + let action = resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: Self::session_state_fact(state.as_ref()), + queue_has_items, + policy: queued_turn.policy, + }); + + match action { + DialogSubmitQueueAction::StartImmediately => { + let tid = self.start_turn(&session_id, &queued_turn).await?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; + Ok(DialogSubmitOutcome::Started { + session_id, + turn_id: tid, + }) + } + + DialogSubmitQueueAction::ClearQueueAndStartImmediately => { + self.clear_queue(&session_id); + let tid = self.start_turn(&session_id, &queued_turn).await?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; + Ok(DialogSubmitOutcome::Started { + session_id, + turn_id: tid, + }) + } + + DialogSubmitQueueAction::EnqueueThenStartNext => { + self.enqueue(&session_id, queued_turn.clone())?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; + let started_tid = self.try_start_next_queued(&session_id).await?; + let outcome = match started_tid { + Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { + session_id: session_id.clone(), + turn_id: tid, + }, + _ => DialogSubmitOutcome::Queued { + session_id: session_id.clone(), + turn_id: resolved_turn_id, + }, + }; + Ok(outcome) + } + + DialogSubmitQueueAction::EnqueueForActiveTurn => { + let accepted_agent_type = queued_turn.agent_type.clone(); + self.enqueue(&session_id, queued_turn)?; + self.record_last_submitted_agent_type(&session_id, &accepted_agent_type) + .await; + Ok(DialogSubmitOutcome::Queued { + session_id, + turn_id: resolved_turn_id, + }) + } + } + } + + async fn record_last_submitted_agent_type(&self, session_id: &str, agent_type: &str) { + if let Err(error) = self + .coordinator + .update_last_submitted_agent_type(session_id, agent_type) + .await + { + warn!( + "Failed to record last submitted agent type: session_id={}, agent_type={}, error={}", + session_id, agent_type, error + ); + } + } + + /// Number of messages currently queued for a session. + pub fn queue_depth(&self, session_id: &str) -> usize { + self.queues.depth(session_id) + } + + /// Cancel the target session's active turn on behalf of a requester session. + /// + /// If the requester is the same source session that originally sent the + /// in-flight SessionMessage request, the scheduler suppresses the automatic + /// cancelled-reply bounce-back for that specific turn. + pub async fn cancel_active_turn_for_session_from_requester( + &self, + target_session_id: &str, + requester_session_id: &str, + wait_timeout: Duration, + ) -> crate::util::errors::BitFunResult> { + let suppression_key = self + .active_turns + .suppression_key_for_requester(target_session_id, requester_session_id); + + if let Some((session_id, turn_id)) = suppression_key.as_ref() { + debug!( + "Suppressing cancelled auto-reply for agent-session turn: target_session_id={}, turn_id={}, requester_session_id={}", + session_id, turn_id, requester_session_id + ); + self.suppressed_cancelled_replies.mark(session_id, turn_id); + } + + abort_thread_goal_continuation_for_session(target_session_id); + + match self + .coordinator + .cancel_active_turn_for_session(target_session_id, wait_timeout) + .await + { + Ok(cancelled_turn_id) => { + if cancelled_turn_id.is_none() { + if let Some((session_id, turn_id)) = suppression_key { + self.suppressed_cancelled_replies + .clear(&session_id, &turn_id); + } + } + Ok(cancelled_turn_id) + } + Err(error) => { + if let Some((session_id, turn_id)) = suppression_key { + self.suppressed_cancelled_replies + .clear(&session_id, &turn_id); + } + Err(error) + } + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + fn enqueue(&self, session_id: &str, queued_turn: QueuedTurn) -> Result<(), String> { + let priority = queued_turn.policy.queue_priority; + let new_len = match self.queues.enqueue(session_id, queued_turn, priority) { + Ok(new_len) => new_len, + Err(error) => { + let max_depth = self.queues.max_depth(); + warn!( + "Queue full, rejecting message: session_id={}, max={}", + session_id, max_depth + ); + return Err(error.to_string()); + } + }; + + debug!( + "Message queued: session_id={}, queue_depth={}, priority={:?}", + session_id, new_len, priority + ); + Ok(()) + } + + fn clear_queue(&self, session_id: &str) { + let count = self.queues.clear(session_id); + if count > 0 { + info!( + "Cleared {} queued messages: session_id={}", + count, session_id + ); + } + } + + fn dequeue_next(&self, session_id: &str) -> Option { + self.queues.dequeue_next(session_id) + } + + fn requeue_front(&self, session_id: &str, turn: QueuedTurn) { + let priority = turn.policy.queue_priority; + self.queues.requeue_front(session_id, turn, priority); + } + + async fn try_start_next_queued(&self, session_id: &str) -> Result, String> { + let state = self + .session_manager + .get_session(session_id) + .map(|s| s.state.clone()); + if matches!(state, Some(SessionState::Processing { .. })) { + return Ok(None); + } + + let Some(next_turn) = self.dequeue_next(session_id) else { + return Ok(None); + }; + + let remaining = self.queues.depth(session_id); + info!( + "Dispatching queued message: session_id={}, priority={:?}, remaining_queue_depth={}", + session_id, next_turn.policy.queue_priority, remaining + ); + + match self.start_turn(session_id, &next_turn).await { + Ok(tid) => Ok(Some(tid)), + Err(err) => { + self.requeue_front(session_id, next_turn); + Err(err) + } + } + } + + async fn start_turn( + &self, + session_id: &str, + queued_turn: &QueuedTurn, + ) -> Result { + let res = match queued_turn + .image_contexts + .as_ref() + .filter(|imgs| !imgs.is_empty()) + { + Some(imgs) => { + if queued_turn.prepended_messages.is_empty() { + self.coordinator + .start_dialog_turn_with_image_contexts( + session_id.to_string(), + queued_turn.user_input.clone(), + queued_turn.original_user_input.clone(), + imgs.clone(), + queued_turn.turn_id.clone(), + queued_turn.agent_type.clone(), + queued_turn.workspace_path.clone(), + queued_turn.policy, + queued_turn.user_message_metadata.clone(), + ) + .await + } else { + self.coordinator + .start_dialog_turn_with_image_contexts_and_prepended_messages( + session_id.to_string(), + queued_turn.user_input.clone(), + queued_turn.original_user_input.clone(), + imgs.clone(), + queued_turn.turn_id.clone(), + queued_turn.agent_type.clone(), + queued_turn.workspace_path.clone(), + queued_turn.policy, + queued_turn.user_message_metadata.clone(), + queued_turn.prepended_messages.clone(), + ) + .await + } + } + None => { + if queued_turn.prepended_messages.is_empty() { + self.coordinator + .start_dialog_turn( + session_id.to_string(), + queued_turn.user_input.clone(), + queued_turn.original_user_input.clone(), + queued_turn.turn_id.clone(), + queued_turn.agent_type.clone(), + queued_turn.workspace_path.clone(), + queued_turn.policy, + queued_turn.user_message_metadata.clone(), + ) + .await + } else { + self.coordinator + .start_dialog_turn_with_prepended_messages( + session_id.to_string(), + queued_turn.user_input.clone(), + queued_turn.original_user_input.clone(), + queued_turn.turn_id.clone(), + queued_turn.agent_type.clone(), + queued_turn.workspace_path.clone(), + queued_turn.policy, + queued_turn.user_message_metadata.clone(), + queued_turn.prepended_messages.clone(), + ) + .await + } + } + }; + + res.map_err(|e| e.to_string())?; + + let resolved = self + .session_manager + .get_session(session_id) + .and_then(|s| match &s.state { + SessionState::Processing { + current_turn_id, .. + } => Some(current_turn_id.clone()), + _ => None, + }) + .ok_or_else(|| { + format!( + "Failed to resolve turn_id after starting dialog: session_id={}", + session_id + ) + })?; + + self.active_turns.insert( + session_id, + ActiveDialogTurn::new( + resolved.clone(), + queued_turn.workspace_path.clone(), + queued_turn.agent_type.clone(), + queued_turn + .original_user_input + .clone() + .unwrap_or_else(|| queued_turn.user_input.clone()), + queued_turn.user_message_metadata.clone(), + queued_turn.policy, + queued_turn.reply_route.clone(), + ), + ); + + Ok(resolved) + } + + async fn forward_agent_session_reply( + &self, + responder_session_id: &str, + plan: AgentSessionReplyPlan, + ) { + let reply_user_input = plan.user_input; + let target_session_id = plan.target_session_id; + let target_workspace_path = plan.target_workspace_path; + let prepended_messages = vec![Message::internal_reminder( + InternalReminderKind::SessionMessageReply, + plan.reminder_text, + )]; + + if let Err(error) = self + .submit_with_prepended_messages( + target_session_id.clone(), + reply_user_input.clone(), + Some(reply_user_input), + None, + String::new(), + Some(target_workspace_path), + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + None, + None, + prepended_messages, + None, + ) + .await + { + warn!( + "Failed to forward agent-session reply: responder_session_id={}, source_session_id={}, error={}", + responder_session_id, target_session_id, error + ); + } + } + + fn take_suppressed_cancelled_reply(&self, session_id: &str, turn_id: &str) -> bool { + self.suppressed_cancelled_replies.take(session_id, turn_id) + } + + async fn dispatch_next_if_idle(&self, session_id: &str) -> Result<(), String> { + let _ = self.try_start_next_queued(session_id).await?; + Ok(()) + } + + /// Background loop that receives turn outcome notifications from the coordinator. + async fn run_outcome_handler(&self, mut outcome_rx: mpsc::Receiver<(String, TurnOutcome)>) { + while let Some((session_id, outcome)) = outcome_rx.recv().await { + let lifecycle_plan = resolve_turn_outcome_lifecycle_plan( + &outcome, + self.active_turns.contains(&session_id), + ); + + // Only drop steering messages targeted at the *finished* turn. We + // must NOT clear the entire session buffer here: a user might have + // legitimately submitted steering against a brand-new follow-up + // turn that the dispatcher will pick up immediately after this + // outcome is processed (race window between turn finalize and the + // next turn starting). Targeting by turn_id keeps those alive. + if lifecycle_plan.drain_finished_turn_injections { + let _drained = self + .round_injection_buffer + .drain_for_turn(&session_id, outcome.turn_id()); + } + let suppressed_cancelled_reply = + self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); + + let active_turn = self.active_turns.remove(&session_id); + if let Some(active_turn) = active_turn.as_ref() { + match resolve_agent_session_reply_action( + &session_id, + active_turn, + &outcome, + suppressed_cancelled_reply, + ) { + AgentSessionReplyAction::NoReply => {} + AgentSessionReplyAction::SkipSuppressedCancelledReply => { + debug!( + "Skipping cancelled auto-reply because the source session explicitly cancelled its own SessionMessage request: session_id={}, turn_id={}", + session_id, + outcome.turn_id() + ); + } + AgentSessionReplyAction::Forward(plan) => { + self.forward_agent_session_reply(&session_id, plan).await; + } + } + } + + let status = lifecycle_plan.status; + let queue_action = lifecycle_plan.queue_action; + if queue_action == TurnOutcomeQueueAction::ClearQueue { + debug!("Turn {}, clearing queue: session_id={}", status, session_id); + self.clear_queue(&session_id); + } + + if let Some(active_turn) = active_turn.as_ref() { + match lifecycle_plan.goal_continuation { + GoalContinuationAfterTurnAction::SkipNoActiveTurn => {} + GoalContinuationAfterTurnAction::AbortForCancelled => { + self.goal_continuation_abort.mark(&session_id); + debug!( + "Skipping thread goal continuation after user-cancelled turn: session_id={}, turn_id={}", + session_id, + outcome.turn_id() + ); + } + GoalContinuationAfterTurnAction::Evaluate { turn_completed } => { + self.goal_continuation_abort.clear(&session_id); + match self + .coordinator + .prepare_goal_continuation_after_turn( + &session_id, + &outcome.turn_id(), + active_turn.user_input(), + active_turn.user_message_metadata(), + turn_completed, + ) + .await + { + Ok(Some(plan)) => { + let prepended: Vec = plan + .prepended_reminders + .into_iter() + .map(|text| { + Message::internal_reminder( + InternalReminderKind::GoalContinuation, + text, + ) + }) + .collect(); + let mut last_error = None; + for attempt in 1..=MAX_THREAD_GOAL_AUTO_CONTINUATIONS { + if self.goal_continuation_abort.contains(&session_id) { + debug!( + "Aborting goal continuation submit retries after user cancellation: session_id={}", + session_id + ); + break; + } + match self + .submit_with_prepended_messages( + session_id.clone(), + "Continue working toward the active thread goal." + .to_string(), + Some(plan.display_message.clone()), + None, + active_turn.agent_type_owned(), + active_turn.workspace_path_owned(), + DialogSubmissionPolicy::for_source( + DialogTriggerSource::AgentSession, + ), + None, + Some(plan.user_message_metadata.clone()), + prepended.clone(), + None, + ) + .await + { + Ok(_) => { + last_error = None; + break; + } + Err(error) => { + last_error = Some(error); + if self.goal_continuation_abort.contains(&session_id) { + debug!( + "Aborting goal continuation submit retries after user cancellation: session_id={}", + session_id + ); + break; + } + if attempt < MAX_THREAD_GOAL_AUTO_CONTINUATIONS { + let delay_ms = + goal_continuation_submit_retry_delay_ms( + attempt, + ); + warn!( + "Goal continuation submit failed; retrying: session_id={}, attempt={}/{}, delay_ms={}, error={}", + session_id, + attempt, + MAX_THREAD_GOAL_AUTO_CONTINUATIONS, + delay_ms, + last_error.as_ref().unwrap() + ); + tokio::time::sleep( + std::time::Duration::from_millis(delay_ms), + ) + .await; + } + } + } + } + if let Some(error) = last_error { + if !self.goal_continuation_abort.contains(&session_id) { + warn!( + "Failed to submit goal continuation turn after retries: session_id={}, error={}", + session_id, error + ); + } + } + } + Ok(None) => {} + Err(error) => { + warn!( + "Goal verification failed after turn stopped: session_id={}, status={}, error={}", + session_id, status, error + ); + } + } + } + } + } + + match queue_action { + TurnOutcomeQueueAction::DispatchNext => { + if status == TurnOutcomeStatus::Cancelled { + debug!( + "Turn cancelled, dispatching next queued message if present: session_id={}", + session_id + ); + } + + if let Err(e) = self.dispatch_next_if_idle(&session_id).await { + warn!( + "Failed to dispatch next queued message after {}: session_id={}, error={}", + status, session_id, e + ); + } + } + TurnOutcomeQueueAction::ClearQueue => {} + } + } + } +} + +fn metadata_string( + metadata: &serde_json::Map, + key: &str, +) -> Option { + metadata + .get(key) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn mime_type_from_data_url(data_url: &str) -> Option { + data_url + .split_once(',') + .and_then(|(header, _)| { + header + .strip_prefix("data:") + .and_then(|rest| rest.split(';').next()) + }) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn image_context_metadata(attachment: &AgentInputAttachment) -> Option { + if let Some(metadata) = attachment.metadata.get("metadata").cloned() { + return Some(metadata); + } + + let mut metadata = serde_json::Map::new(); + if let Some(name) = metadata_string(&attachment.metadata, "name") { + metadata.insert("name".to_string(), serde_json::Value::String(name)); + } + if attachment.metadata.contains_key("dataUrl") { + metadata.insert( + "source".to_string(), + serde_json::Value::String("remote".to_string()), + ); + } + + if metadata.is_empty() { + None + } else { + Some(serde_json::Value::Object(metadata)) + } +} + +fn agent_dialog_turn_image_contexts( + attachments: &[AgentInputAttachment], +) -> PortResult>> { + if attachments.is_empty() { + return Ok(None); + } + + let mut image_contexts = Vec::with_capacity(attachments.len()); + for attachment in attachments { + if attachment.kind != "remote_image" { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + format!( + "unsupported agent dialog attachment kind: {}", + attachment.kind + ), + )); + } + + let data_url = metadata_string(&attachment.metadata, "dataUrl"); + let image_path = metadata_string(&attachment.metadata, "imagePath"); + if data_url.is_none() && image_path.is_none() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "remote_image attachment requires dataUrl or imagePath", + )); + } + + let mime_type = metadata_string(&attachment.metadata, "mimeType") + .or_else(|| data_url.as_deref().and_then(mime_type_from_data_url)) + .unwrap_or_else(|| "image/png".to_string()); + + image_contexts.push(ImageContextData { + id: attachment.id.clone(), + image_path, + data_url, + mime_type, + metadata: image_context_metadata(attachment), + }); + } + + Ok(Some(image_contexts)) +} + +fn agent_dialog_turn_prepended_messages( + reminders: &[AgentDialogPrependedReminder], +) -> PortResult> { + reminders + .iter() + .map(|reminder| { + let kind = match reminder.kind.as_str() { + "session_message_request" => InternalReminderKind::SessionMessageRequest, + "scheduled_job" => InternalReminderKind::ScheduledJob, + other => { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + format!("unsupported agent dialog prepended reminder kind: {other}"), + )); + } + }; + Ok(Message::internal_reminder(kind, reminder.text.clone())) + }) + .collect() +} + +#[async_trait::async_trait] +impl AgentDialogTurnPort for DialogScheduler { + async fn submit_dialog_turn( + &self, + request: AgentDialogTurnRequest, + ) -> PortResult { + let image_contexts = agent_dialog_turn_image_contexts(&request.attachments)?; + let prepended_messages = + agent_dialog_turn_prepended_messages(&request.prepended_reminders)?; + let user_message_metadata = if request.metadata.is_empty() { + None + } else { + Some(serde_json::Value::Object(request.metadata)) + }; + + self.submit_with_prepended_messages( + request.session_id, + request.message, + request.original_message, + request.turn_id, + request.agent_type, + request.workspace_path, + request.policy, + request.reply_route, + user_message_metadata, + prepended_messages, + image_contexts, + ) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error)) + } +} + +#[async_trait::async_trait] +impl AgentLifecycleDeliveryPort for DialogScheduler { + async fn deliver_background_result( + &self, + request: AgentBackgroundResultRequest, + ) -> PortResult<()> { + let metadata = if request.metadata.is_empty() { + None + } else { + Some(serde_json::Value::Object(request.metadata)) + }; + + DialogScheduler::deliver_background_result( + self, + request.session_id, + request.agent_type, + request.workspace_path, + request.content, + request.display_content, + metadata, + ) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error)) + } + + async fn deliver_thread_goal(&self, request: AgentThreadGoalDeliveryRequest) -> PortResult<()> { + let result = match request.kind { + AgentThreadGoalDeliveryKind::Resumed => { + DialogScheduler::deliver_thread_goal_resumed( + self, + request.session_id, + request.agent_type, + request.workspace_path, + request.goal, + ) + .await + } + AgentThreadGoalDeliveryKind::ObjectiveUpdated => { + DialogScheduler::deliver_thread_goal_objective_updated( + self, + request.session_id, + request.agent_type, + request.workspace_path, + request.goal, + ) + .await + } + }; + + result.map_err(|error| PortError::new(PortErrorKind::Backend, error)) + } +} + +#[async_trait::async_trait] +impl AgentTurnCancellationPort for DialogScheduler { + async fn cancel_turn( + &self, + request: AgentTurnCancellationRequest, + ) -> PortResult { + let session_id = request.session_id; + let wait_timeout = Duration::from_millis(request.wait_timeout_ms.unwrap_or(1500)); + + let cancelled_turn_id = if let Some(turn_id) = request.turn_id { + self.coordinator + .cancel_dialog_turn(&session_id, &turn_id) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))?; + Some(turn_id) + } else if let Some(requester_session_id) = request.requester_session_id { + self.cancel_active_turn_for_session_from_requester( + &session_id, + &requester_session_id, + wait_timeout, + ) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))? + } else { + self.coordinator + .cancel_active_turn_for_session(&session_id, wait_timeout) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))? + }; + + Ok(AgentTurnCancellationResult { + session_id, + requested: cancelled_turn_id.is_some(), + turn_id: cancelled_turn_id, + }) + } +} + +fn thread_goal_delivery_messages(reminders: Vec) -> Vec { + reminders + .into_iter() + .map(|reminder| match reminder.kind { + ThreadGoalDeliveryReminderKind::GoalContinuation => { + goal_internal_context_message(reminder.content) + } + ThreadGoalDeliveryReminderKind::GoalObjectiveUpdated => { + goal_objective_updated_message(reminder.content) + } + }) + .collect() +} + +// ── Global instance ────────────────────────────────────────────────────────── + +static GLOBAL_SCHEDULER: OnceLock> = OnceLock::new(); + +pub fn get_global_scheduler() -> Option> { + GLOBAL_SCHEDULER.get().cloned() +} + +pub fn set_global_scheduler(scheduler: Arc) { + let _ = GLOBAL_SCHEDULER.set(scheduler); +} + +/// Stop in-flight thread-goal continuation submit retries when the user cancels a turn. +pub fn abort_thread_goal_continuation_for_session(session_id: &str) { + if let Some(scheduler) = get_global_scheduler() { + scheduler.goal_continuation_abort.mark(session_id); + } +} + +/// Allow goal auto-continuation again after the user explicitly resumes a paused goal. +pub fn clear_thread_goal_continuation_abort(session_id: &str) { + if let Some(scheduler) = get_global_scheduler() { + scheduler.goal_continuation_abort.clear(session_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_runtime_ports::{AgentDialogPrependedReminder, AgentInputAttachment, PortErrorKind}; + + fn agent_session_active_turn(source_session_id: &str) -> ActiveDialogTurn { + ActiveDialogTurn::new( + "turn_1".to_string(), + Some("/workspace".to_string()), + "agentic".to_string(), + "hello".to_string(), + None, + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + Some(AgentSessionReplyRoute { + source_session_id: source_session_id.to_string(), + source_workspace_path: "/source".to_string(), + }), + ) + } + + #[test] + fn requester_matching_reply_route_suppresses_cancelled_reply() { + let active_turn = agent_session_active_turn("session_a"); + assert!(active_turn.should_suppress_cancelled_reply_for_requester("session_a")); + assert!(!active_turn.should_suppress_cancelled_reply_for_requester("session_c")); + } + + #[test] + fn cancelled_reply_is_skipped_only_when_suppressed() { + let active_turn = agent_session_active_turn("session_a"); + let cancelled = TurnOutcome::Cancelled { + turn_id: "turn_1".to_string(), + }; + let completed = TurnOutcome::Completed { + turn_id: "turn_1".to_string(), + final_response: "done".to_string(), + }; + + assert_eq!( + resolve_agent_session_reply_action("session_b", &active_turn, &cancelled, true), + AgentSessionReplyAction::SkipSuppressedCancelledReply + ); + assert!(matches!( + resolve_agent_session_reply_action("session_b", &active_turn, &cancelled, false), + AgentSessionReplyAction::Forward(_) + )); + assert!(matches!( + resolve_agent_session_reply_action("session_b", &active_turn, &completed, true), + AgentSessionReplyAction::Forward(_) + )); + } + + #[test] + fn goal_verification_observation_covers_all_turn_outcomes() { + let completed = TurnOutcome::Completed { + turn_id: "turn_1".to_string(), + final_response: "done".to_string(), + }; + let cancelled = TurnOutcome::Cancelled { + turn_id: "turn_2".to_string(), + }; + let failed = TurnOutcome::Failed { + turn_id: "turn_3".to_string(), + error: "network offline".to_string(), + }; + + assert_eq!(completed.reply_text(), "done"); + assert!(cancelled.reply_text().contains("cancelled")); + assert!(failed.reply_text().contains("network offline")); + } + + #[test] + fn remote_queue_policy_preserves_confirmation_boundary() { + let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); + assert_eq!(remote.queue_priority, DialogQueuePriority::Normal); + assert!(remote.skip_tool_confirmation); + + let bot = DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot); + assert_eq!(bot.queue_priority, DialogQueuePriority::Normal); + assert!(bot.skip_tool_confirmation); + + let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); + assert_eq!(agent_session.queue_priority, DialogQueuePriority::Low); + assert!(agent_session.skip_tool_confirmation); + } + + #[test] + fn agent_dialog_turn_attachments_preserve_remote_image_context() { + let mut metadata = serde_json::Map::new(); + metadata.insert( + "dataUrl".to_string(), + serde_json::json!("data:image/jpeg;base64,abc"), + ); + metadata.insert("mimeType".to_string(), serde_json::json!("image/jpeg")); + metadata.insert( + "metadata".to_string(), + serde_json::json!({ "name": "clip.jpg", "source": "remote" }), + ); + + let contexts = agent_dialog_turn_image_contexts(&[AgentInputAttachment { + kind: "remote_image".to_string(), + id: "ctx-1".to_string(), + metadata, + }]) + .expect("remote image attachment should be supported") + .expect("non-empty image contexts"); + + assert_eq!(contexts.len(), 1); + assert_eq!(contexts[0].id, "ctx-1"); + assert_eq!( + contexts[0].data_url.as_deref(), + Some("data:image/jpeg;base64,abc") + ); + assert_eq!(contexts[0].mime_type, "image/jpeg"); + assert_eq!( + contexts[0] + .metadata + .as_ref() + .and_then(|value| value.get("name")), + Some(&serde_json::json!("clip.jpg")) + ); + } + + #[test] + fn agent_dialog_turn_attachments_reject_unknown_kind() { + let err = agent_dialog_turn_image_contexts(&[AgentInputAttachment { + kind: "unknown".to_string(), + id: "attachment-1".to_string(), + metadata: serde_json::Map::new(), + }]) + .expect_err("unsupported attachment kind must be explicit"); + + assert_eq!(err.kind, PortErrorKind::InvalidRequest); + assert!(err + .message + .contains("unsupported agent dialog attachment kind")); + } + + #[test] + fn agent_dialog_turn_prepended_reminders_preserve_session_message_kind() { + let messages = agent_dialog_turn_prepended_messages(&[AgentDialogPrependedReminder { + kind: "session_message_request".to_string(), + text: "sent by another agent".to_string(), + }]) + .expect("session message reminder should be supported"); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].internal_reminder_kind(), + Some(InternalReminderKind::SessionMessageRequest) + ); + } + + #[test] + fn agent_dialog_turn_prepended_reminders_preserve_scheduled_job_kind() { + let messages = agent_dialog_turn_prepended_messages(&[AgentDialogPrependedReminder { + kind: "scheduled_job".to_string(), + text: "scheduled job trigger".to_string(), + }]) + .expect("scheduled job reminder should be supported"); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].internal_reminder_kind(), + Some(InternalReminderKind::ScheduledJob) + ); + } + + #[test] + fn agent_dialog_turn_prepended_reminders_reject_unknown_kind() { + let err = agent_dialog_turn_prepended_messages(&[AgentDialogPrependedReminder { + kind: "unknown".to_string(), + text: "unsupported".to_string(), + }]) + .expect_err("unsupported reminder kind must be explicit"); + + assert_eq!(err.kind, PortErrorKind::InvalidRequest); + assert!(err + .message + .contains("unsupported agent dialog prepended reminder kind")); + } +} diff --git a/src/crates/core/src/agentic/coordination/state_manager.rs b/src/crates/assembly/core/src/agentic/coordination/state_manager.rs similarity index 100% rename from src/crates/core/src/agentic/coordination/state_manager.rs rename to src/crates/assembly/core/src/agentic/coordination/state_manager.rs diff --git a/src/crates/assembly/core/src/agentic/coordination/turn_outcome.rs b/src/crates/assembly/core/src/agentic/coordination/turn_outcome.rs new file mode 100644 index 0000000000..8e49e675d6 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/coordination/turn_outcome.rs @@ -0,0 +1,3 @@ +//! Compatibility re-exports for scheduler turn outcome owner decisions. + +pub use bitfun_agent_runtime::scheduler::{TurnOutcome, TurnOutcomeQueueAction, TurnOutcomeStatus}; diff --git a/src/crates/core/src/agentic/core/dialog_turn.rs b/src/crates/assembly/core/src/agentic/core/dialog_turn.rs similarity index 100% rename from src/crates/core/src/agentic/core/dialog_turn.rs rename to src/crates/assembly/core/src/agentic/core/dialog_turn.rs diff --git a/src/crates/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs similarity index 92% rename from src/crates/core/src/agentic/core/message.rs rename to src/crates/assembly/core/src/agentic/core/message.rs index 20775f2d88..a7e23c32c3 100644 --- a/src/crates/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -63,6 +63,8 @@ pub struct MessageMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub semantic_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub internal_reminder_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub compression_payload: Option, } @@ -80,6 +82,51 @@ pub enum MessageSemanticKind { ComputerUsePostActionSnapshot, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InternalReminderKind { + Generic, + SkillListingDiff, + AgentListingDiff, + AgentMode, + SideQuestion, + InitAgentsMd, + ScheduledJob, + ForkSubagent, + GoalMode, + GoalContinuation, + GoalObjectiveUpdated, + RemoteFileDelivery, + SessionMessageRequest, + SessionMessageReply, + LoopRecovery, + PeriodicLoopRecovery, + UserSteering, + BackgroundResult, + InterruptedContinue, + ThinkingOnlyRescue, + FinalizeCacheAnchor, +} + +impl InternalReminderKind { + pub fn should_drop_during_compaction(self) -> bool { + matches!( + self, + Self::SkillListingDiff + | Self::AgentListingDiff + | Self::LoopRecovery + | Self::PeriodicLoopRecovery + | Self::InterruptedContinue + | Self::ThinkingOnlyRescue + | Self::FinalizeCacheAnchor + ) + } + + pub fn is_listing_diff(self) -> bool { + matches!(self, Self::SkillListingDiff | Self::AgentListingDiff) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CompressionPayload { #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -346,6 +393,18 @@ impl Message { } } + pub fn internal_reminder(reminder_kind: InternalReminderKind, text: impl Into) -> Self { + let text = text.into(); + let rendered = if crate::agentic::core::has_prompt_markup(&text) { + text + } else { + crate::agentic::core::render_system_reminder(&text) + }; + Self::user(rendered) + .with_semantic_kind(MessageSemanticKind::InternalReminder) + .with_internal_reminder_kind(reminder_kind) + } + pub fn user_multimodal(text: String, images: Vec) -> Self { Self { id: Uuid::new_v4().to_string(), @@ -452,6 +511,15 @@ impl Message { self } + pub fn with_internal_reminder_kind(mut self, reminder_kind: InternalReminderKind) -> Self { + self.metadata.internal_reminder_kind = Some(reminder_kind); + self + } + + pub fn internal_reminder_kind(&self) -> Option { + self.metadata.internal_reminder_kind + } + pub fn with_compression_payload(mut self, compression_payload: CompressionPayload) -> Self { self.metadata.compression_payload = Some(compression_payload); self.metadata.tokens = None; diff --git a/src/crates/core/src/agentic/core/messages_helper.rs b/src/crates/assembly/core/src/agentic/core/messages_helper.rs similarity index 100% rename from src/crates/core/src/agentic/core/messages_helper.rs rename to src/crates/assembly/core/src/agentic/core/messages_helper.rs diff --git a/src/crates/core/src/agentic/core/mod.rs b/src/crates/assembly/core/src/agentic/core/mod.rs similarity index 87% rename from src/crates/core/src/agentic/core/mod.rs rename to src/crates/assembly/core/src/agentic/core/mod.rs index 66fb9e463e..b0929fa45b 100644 --- a/src/crates/core/src/agentic/core/mod.rs +++ b/src/crates/assembly/core/src/agentic/core/mod.rs @@ -13,8 +13,8 @@ pub use dialog_turn::{new_turn_id, TurnStats}; pub use message::{ CompressedMessage, CompressedMessageRole, CompressedTodoItem, CompressedTodoSnapshot, CompressedToolCall, CompressionContract, CompressionContractItem, CompressionEntry, - CompressionPayload, Message, MessageContent, MessageRole, MessageSemanticKind, ToolCall, - ToolResult, + CompressionPayload, InternalReminderKind, Message, MessageContent, MessageRole, + MessageSemanticKind, ToolCall, ToolResult, }; pub use messages_helper::{MessageHelper, RequestReasoningTokenPolicy}; pub use prompt_markup::{ diff --git a/src/crates/core/src/agentic/core/prompt_markup.rs b/src/crates/assembly/core/src/agentic/core/prompt_markup.rs similarity index 93% rename from src/crates/core/src/agentic/core/prompt_markup.rs rename to src/crates/assembly/core/src/agentic/core/prompt_markup.rs index 3747ed0e4d..7fb755c720 100644 --- a/src/crates/core/src/agentic/core/prompt_markup.rs +++ b/src/crates/assembly/core/src/agentic/core/prompt_markup.rs @@ -71,6 +71,11 @@ impl PromptEnvelope { } } +/// Legacy helper for explicit `` prompt markup. +/// +/// Do not use this for new persisted user messages or internal cache-anchor +/// messages. Modern dialog turns should keep plain user text unless a specific +/// compatibility path requires prompt-block markup. pub fn render_user_query(text: &str) -> String { PromptBlock::user_query(text).render() } diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/assembly/core/src/agentic/core/session.rs similarity index 100% rename from src/crates/core/src/agentic/core/session.rs rename to src/crates/assembly/core/src/agentic/core/session.rs diff --git a/src/crates/core/src/agentic/core/state.rs b/src/crates/assembly/core/src/agentic/core/state.rs similarity index 100% rename from src/crates/core/src/agentic/core/state.rs rename to src/crates/assembly/core/src/agentic/core/state.rs diff --git a/src/crates/core/src/agentic/deep_review/AGENTS.md b/src/crates/assembly/core/src/agentic/deep_review/AGENTS.md similarity index 51% rename from src/crates/core/src/agentic/deep_review/AGENTS.md rename to src/crates/assembly/core/src/agentic/deep_review/AGENTS.md index 8613bdb26b..a89d5d230b 100644 --- a/src/crates/core/src/agentic/deep_review/AGENTS.md +++ b/src/crates/assembly/core/src/agentic/deep_review/AGENTS.md @@ -9,9 +9,24 @@ This file applies to DeepReview runtime internals in this directory. - Keep this code platform-agnostic; use shared events, config, and tool context. - Keep policy, manifest admission, queue state, retry metadata, task adapter, and report enrichment aligned. +- Frontend code owns target resolution and review-team manifest construction; + this directory owns validation, queue/retry state, task adaptation, and + report enrichment. - Keep default team/runtime contracts aligned with `deep_review_policy.rs` and - reviewer agents in `src/crates/core/src/agentic/agents`. + reviewer agents in `src/crates/assembly/core/src/agentic/agents`. - Reviewer subagents stay read-only; `ReviewFixer` is not part of the review pass. - When queue or report fields change, update the matching frontend DTOs and DeepReview UI state. + +## Verification + +Use the nearest Web UI check for frontend-only behavior. For shared runtime +behavior, run: + +```bash +cargo test -p bitfun-core deep_review -- --nocapture +``` + +Also run the relevant Rust or desktop check when the change touches backend +state, Tauri APIs, or desktop integration. diff --git a/src/crates/core/src/agentic/deep_review/CONTRIBUTING.md b/src/crates/assembly/core/src/agentic/deep_review/CONTRIBUTING.md similarity index 86% rename from src/crates/core/src/agentic/deep_review/CONTRIBUTING.md rename to src/crates/assembly/core/src/agentic/deep_review/CONTRIBUTING.md index e4bbdd5471..ade726067f 100644 --- a/src/crates/core/src/agentic/deep_review/CONTRIBUTING.md +++ b/src/crates/assembly/core/src/agentic/deep_review/CONTRIBUTING.md @@ -1,6 +1,6 @@ # DeepReview Runtime Contributions -Use this guide for backend DeepReview changes in `src/crates/core`. +Use this guide for backend DeepReview changes in `src/crates/assembly/core`. - Runtime changes belong in shared core, without Tauri or desktop-only APIs. - Keep policy, manifest gate, queue state, retry behavior, task adapter, and diff --git a/src/crates/assembly/core/src/agentic/deep_review/mod.rs b/src/crates/assembly/core/src/agentic/deep_review/mod.rs new file mode 100644 index 0000000000..0629356699 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/deep_review/mod.rs @@ -0,0 +1,15 @@ +//! Deep Review product assembly adapters. +//! +//! Provider-neutral policy, manifest, queue, budget, and shared-context state +//! are owned by `bitfun-agent-runtime::deep_review`. This module keeps old +//! core paths compatible while concrete report shaping and product tool side +//! effects remain in core until separately migrated. + +pub use bitfun_agent_runtime::deep_review::{ + budget, concurrency_policy, constants, diagnostics, execution_policy, incremental_cache, + manifest, queue, shared_context, team_definition, tool_context, +}; + +pub mod report; +pub mod task_adapter; +pub mod tool_measurement; diff --git a/src/crates/assembly/core/src/agentic/deep_review/report.rs b/src/crates/assembly/core/src/agentic/deep_review/report.rs new file mode 100644 index 0000000000..3acb40e826 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/deep_review/report.rs @@ -0,0 +1,165 @@ +//! Deep Review report product assembly bridge. +//! +//! Provider-neutral packet metadata, reliability-signal shaping, and cache +//! updates are owned by `bitfun-agent-runtime::deep_review::report`. This file +//! keeps core-only context lookup, diagnostics logging, and session metadata IO. + +use crate::agentic::agents::get_agent_registry; +use crate::agentic::context_profile::ContextProfilePolicy; +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::core::CompressionContract; +use crate::agentic::deep_review_policy::{ + deep_review_capacity_skip_count, deep_review_concurrency_cap_rejection_count, + deep_review_runtime_diagnostics_snapshot, +}; +use crate::agentic::tools::framework::ToolUseContext; +use crate::util::errors::BitFunResult; +use bitfun_agent_runtime::deep_review::diagnostics as runtime_diagnostics; +use bitfun_agent_runtime::deep_review::report as runtime_report; +pub(crate) use bitfun_agent_runtime::deep_review::report::{ + deep_review_cache_from_completed_reviewers, fill_deep_review_cache_update_signals, + fill_deep_review_packet_metadata, +}; +use bitfun_services_core::session::set_deep_review_cache; +use log::debug; +use serde_json::Value; + +pub(crate) fn is_deep_review_context(context: Option<&ToolUseContext>) -> bool { + context + .and_then(|context| context.agent_type.as_deref()) + .map(str::trim) + .is_some_and(|agent_type| agent_type == "DeepReview") +} + +pub(crate) fn compression_contract_for_context( + context: &ToolUseContext, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let session = coordinator.get_session_manager().get_session(session_id)?; + let agent_type = Some(session.agent_type.as_str()); + let model_id = session.config.model_id.as_deref(); + let limit = reliability_contract_limit(agent_type, model_id); + let contract = coordinator + .get_session_manager() + .compression_contract_for_session(session_id, limit)?; + should_report_compression_preserved( + session.compression_state.compression_count, + Some(&contract), + ) + .then_some(contract) +} + +pub(crate) fn reliability_contract_limit( + agent_type: Option<&str>, + model_id: Option<&str>, +) -> usize { + let agent_type = agent_type + .map(str::trim) + .filter(|agent_type| !agent_type.is_empty()) + .unwrap_or("DeepReview"); + let model_id = model_id + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .unwrap_or_default(); + let is_review_subagent = get_agent_registry() + .get_subagent_is_review(agent_type) + .unwrap_or(false); + + ContextProfilePolicy::for_agent_context_and_model( + agent_type, + is_review_subagent, + model_id, + model_id, + ) + .compression_contract_limit +} + +pub(crate) fn should_report_compression_preserved( + compression_count: usize, + compression_contract: Option<&CompressionContract>, +) -> bool { + compression_count > 0 && compression_contract.is_some_and(|contract| !contract.is_empty()) +} + +pub(crate) fn compression_contract_signal_count(contract: &CompressionContract) -> usize { + contract.touched_files.len() + + contract.verification_commands.len() + + contract.blocking_failures.len() + + contract.subagent_statuses.len() +} + +pub(crate) fn fill_deep_review_reliability_signals( + input: &mut Value, + run_manifest: Option<&Value>, + compression_contract: Option<&CompressionContract>, +) { + let compression_preserved_signal_count = compression_contract + .filter(|contract| !contract.is_empty()) + .map(compression_contract_signal_count) + .filter(|count| *count > 0); + runtime_report::fill_deep_review_reliability_signals( + input, + run_manifest, + compression_preserved_signal_count, + ); +} + +pub(crate) fn fill_deep_review_runtime_tracker_signals( + input: &mut Value, + dialog_turn_id: Option<&str>, +) { + let Some(dialog_turn_id) = dialog_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let count = deep_review_concurrency_cap_rejection_count(dialog_turn_id) + + deep_review_capacity_skip_count(dialog_turn_id); + runtime_report::fill_deep_review_runtime_tracker_signal(input, count); +} + +pub(crate) fn log_deep_review_runtime_diagnostics(dialog_turn_id: Option<&str>) { + let Some(dialog_turn_id) = dialog_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let Some(diagnostics) = deep_review_runtime_diagnostics_snapshot(dialog_turn_id) else { + return; + }; + debug!( + "{}", + runtime_diagnostics::deep_review_runtime_diagnostics_log_line(&diagnostics) + ); +} + +pub(crate) async fn persist_deep_review_cache( + context: &ToolUseContext, + cache_value: Value, +) -> BitFunResult<()> { + let Some(session_id) = context.session_id.as_deref() else { + return Ok(()); + }; + let Some(workspace) = context.workspace.as_ref() else { + return Ok(()); + }; + let Some(coordinator) = get_global_coordinator() else { + return Ok(()); + }; + let session_storage_path = workspace.session_storage_path(); + let session_manager = coordinator.get_session_manager(); + let Some(mut metadata) = session_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + else { + return Ok(()); + }; + + set_deep_review_cache(&mut metadata, cache_value); + session_manager + .save_session_metadata(&session_storage_path, &metadata) + .await +} diff --git a/src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs b/src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs new file mode 100644 index 0000000000..b9539621a2 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/deep_review/task_adapter.rs @@ -0,0 +1,667 @@ +//! Deep Review-specific TaskTool adapter helpers. +//! +//! This module adapts generic TaskTool execution to Deep Review policy, +//! manifests, queue events, retry metadata, and report reliability signals. +//! Shared mechanics such as queue wait timing live under +//! `agentic::subagent_runtime`; Deep Review-specific admission and event +//! semantics stay here. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::deep_review::queue::extract_retry_after_seconds; +use crate::agentic::deep_review_policy::{ + clear_deep_review_queue_control_for_tool, deep_review_active_reviewer_count, + deep_review_effective_concurrency_snapshot, deep_review_effective_parallel_instances, + deep_review_max_retries_per_role, deep_review_queue_control_snapshot, + record_deep_review_capacity_skip_for_reason, + record_deep_review_effective_concurrency_capacity_error, + record_deep_review_runtime_provider_capacity_queue, + record_deep_review_runtime_provider_capacity_retry, + record_deep_review_runtime_provider_capacity_retry_success, + record_deep_review_runtime_queue_wait, try_begin_deep_review_active_reviewer, + try_begin_deep_review_active_reviewer_for_launch_batch, DeepReviewActiveReviewerGuard, + DeepReviewCapacityQueueDecision, DeepReviewCapacityQueueReason, DeepReviewConcurrencyPolicy, + DeepReviewExecutionPolicy, DeepReviewPolicyViolation, DeepReviewSubagentRole, +}; +use crate::agentic::events::{ + DeepReviewQueueReason, DeepReviewQueueState, DeepReviewQueueStatus, ErrorCategory, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_agent_runtime::deep_review::task_execution as runtime_task_execution; +pub(crate) use bitfun_agent_runtime::deep_review::task_execution::{ + attach_deep_review_cache, deep_review_incremental_cache_hit_for_task, + deep_review_incremental_cache_hit_result, deep_review_launch_batch_for_task, + ensure_deep_review_retry_coverage, prompt_with_deep_review_retry_scope, + DeepReviewProviderCapacityRetryDecision, DeepReviewProviderCapacityRetryRuntime, +}; +pub(crate) use bitfun_agent_runtime::deep_review::{ + DeepReviewLaunchBatchInfo, DeepReviewQueueWaitSkipReason, +}; +use serde_json::Value; +use std::time::{Duration, Instant}; +use tokio::time::sleep; + +#[cfg(test)] +const DEEP_REVIEW_QUEUE_POLL_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(not(test))] +const DEEP_REVIEW_QUEUE_POLL_INTERVAL: Duration = Duration::from_secs(1); + +pub(crate) enum DeepReviewQueueWaitOutcome { + Ready { + guard: DeepReviewActiveReviewerGuard<'static>, + }, + Skipped { + queue_elapsed_ms: u64, + skip_reason: DeepReviewQueueWaitSkipReason, + capacity_reason: DeepReviewCapacityQueueReason, + }, +} + +pub(crate) enum DeepReviewProviderQueueWaitOutcome { + ReadyToRetry { + queue_elapsed_ms: u64, + early_capacity_probe: bool, + }, + Skipped { + queue_elapsed_ms: u64, + skip_reason: DeepReviewQueueWaitSkipReason, + }, +} + +pub(crate) fn deep_review_retry_guidance_max_retries( + effective_policy: Option<&DeepReviewExecutionPolicy>, + dialog_turn_id: &str, +) -> usize { + effective_policy + .map(|policy| policy.max_retries_per_role) + .unwrap_or_else(|| deep_review_max_retries_per_role(dialog_turn_id)) +} + +pub(crate) fn should_emit_deep_review_retry_guidance( + is_partial_timeout: bool, + is_retry: bool, + deep_review_subagent_role: Option, +) -> bool { + runtime_task_execution::should_emit_deep_review_retry_guidance( + is_partial_timeout, + is_retry, + deep_review_subagent_role, + ) +} + +pub(crate) fn deep_review_retry_guidance(retries_used: usize, max_retries: usize) -> String { + runtime_task_execution::deep_review_retry_guidance(retries_used, max_retries) +} + +pub(crate) fn auto_retry_suppression_reason(code: &str) -> &'static str { + runtime_task_execution::auto_retry_suppression_reason(code) +} + +pub(crate) fn ensure_deep_review_auto_retry_allowed( + conc_policy: &DeepReviewConcurrencyPolicy, + elapsed_seconds: Option, +) -> Result<(), DeepReviewPolicyViolation> { + runtime_task_execution::ensure_deep_review_auto_retry_allowed(conc_policy, elapsed_seconds) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn deep_review_task_completion_result( + delegate_target_label: &str, + result_text: &str, + context_mode: &str, + duration_ms: u128, + is_partial_timeout: bool, + reason: Option<&str>, + ledger_event_id: Option<&str>, + retry_hint: &str, +) -> (Value, String) { + runtime_task_execution::deep_review_task_completion_result( + runtime_task_execution::DeepReviewTaskCompletionResultInput { + delegate_target_label, + result_text, + context_mode, + duration_ms, + is_partial_timeout, + reason, + ledger_event_id, + retry_hint, + }, + ) +} + +pub(crate) fn deep_review_cancelled_reviewer_result( + subagent_type: &str, + reason: &str, + duration_ms: u128, +) -> (Value, String) { + runtime_task_execution::deep_review_cancelled_reviewer_result( + subagent_type, + reason, + duration_ms, + ) +} + +pub(crate) fn queue_reason_to_event_reason( + reason: DeepReviewCapacityQueueReason, +) -> DeepReviewQueueReason { + match reason { + DeepReviewCapacityQueueReason::ProviderRateLimit => { + DeepReviewQueueReason::ProviderRateLimit + } + DeepReviewCapacityQueueReason::ProviderConcurrencyLimit => { + DeepReviewQueueReason::ProviderConcurrencyLimit + } + DeepReviewCapacityQueueReason::RetryAfter => DeepReviewQueueReason::RetryAfter, + DeepReviewCapacityQueueReason::LocalConcurrencyCap => { + DeepReviewQueueReason::LocalConcurrencyCap + } + DeepReviewCapacityQueueReason::LaunchBatchBlocked => { + DeepReviewQueueReason::LaunchBatchBlocked + } + DeepReviewCapacityQueueReason::TemporaryOverload => { + DeepReviewQueueReason::TemporaryOverload + } + } +} + +pub(crate) fn capacity_decision_for_provider_error( + error: &BitFunError, +) -> DeepReviewCapacityQueueDecision { + let detail = error.error_detail(); + let error_message = error.to_string(); + let code = detail.provider_code.as_deref().unwrap_or_default(); + let message = detail + .provider_message + .as_deref() + .unwrap_or(error_message.as_str()); + runtime_task_execution::capacity_decision_for_provider_error_facts( + runtime_task_execution::DeepReviewProviderCapacityErrorFacts { + provider_code: code, + provider_message: message, + retry_after_seconds: extract_retry_after_seconds(&error_message), + category: match detail.category { + ErrorCategory::RateLimit => { + runtime_task_execution::DeepReviewProviderCapacityErrorCategory::RateLimit + } + ErrorCategory::ProviderUnavailable => { + runtime_task_execution::DeepReviewProviderCapacityErrorCategory::ProviderUnavailable + } + _ => runtime_task_execution::DeepReviewProviderCapacityErrorCategory::Other, + }, + }, + ) +} + +pub(crate) fn capacity_skip_result_for_local_queue_outcome( + dialog_turn_id: &str, + subagent_type: &str, + conc_policy: &DeepReviewConcurrencyPolicy, + capacity_reason: DeepReviewCapacityQueueReason, + skip_reason: DeepReviewQueueWaitSkipReason, + queue_elapsed_ms: u64, + duration_ms: u128, +) -> (Value, String) { + let effective_parallel_instances = deep_review_effective_concurrency_snapshot( + dialog_turn_id, + conc_policy.max_parallel_instances, + ) + .effective_parallel_instances; + runtime_task_execution::capacity_skip_result_for_local_queue_outcome( + subagent_type, + conc_policy, + capacity_reason, + skip_reason, + queue_elapsed_ms, + duration_ms, + effective_parallel_instances, + ) +} + +pub(crate) fn capacity_skip_result_for_provider_queue_outcome( + reason: DeepReviewCapacityQueueReason, + dialog_turn_id: &str, + subagent_type: &str, + conc_policy: &DeepReviewConcurrencyPolicy, + duration_ms: u128, + queue_elapsed_ms: u64, + terminal_skip_reason: Option, +) -> (Value, String) { + let snapshot = record_deep_review_effective_concurrency_capacity_error( + dialog_turn_id, + conc_policy.max_parallel_instances, + reason, + None, + ); + record_deep_review_capacity_skip_for_reason(dialog_turn_id, reason); + runtime_task_execution::capacity_skip_result_for_provider_queue_outcome( + reason, + subagent_type, + conc_policy, + duration_ms, + queue_elapsed_ms, + terminal_skip_reason, + snapshot.effective_parallel_instances, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn emit_queue_state( + session_id: &str, + dialog_turn_id: &str, + tool_id: &str, + subagent_type: &str, + status: DeepReviewQueueStatus, + reason: Option, + queued_reviewer_count: usize, + active_reviewer_count: usize, + optional_reviewer_count: Option, + effective_parallel_instances: Option, + queue_elapsed_ms: u64, + max_queue_wait_seconds: u64, +) { + let run_elapsed_ms = matches!(&status, DeepReviewQueueStatus::Running).then_some(0); + if let Some(coordinator) = get_global_coordinator() { + coordinator + .emit_deep_review_queue_state_changed( + session_id, + dialog_turn_id, + DeepReviewQueueState { + tool_id: tool_id.to_string(), + subagent_type: subagent_type.to_string(), + status, + reason: reason.map(queue_reason_to_event_reason), + queued_reviewer_count, + active_reviewer_count: Some(active_reviewer_count), + effective_parallel_instances, + optional_reviewer_count, + queue_elapsed_ms: Some(queue_elapsed_ms), + run_elapsed_ms, + max_queue_wait_seconds: Some(max_queue_wait_seconds), + session_concurrency_high: false, + }, + ) + .await; + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn wait_for_provider_capacity_retry( + session_id: &str, + dialog_turn_id: &str, + tool_id: &str, + subagent_type: &str, + conc_policy: &DeepReviewConcurrencyPolicy, + reason: DeepReviewCapacityQueueReason, + max_wait_seconds: u64, + is_optional_reviewer: bool, +) -> DeepReviewProviderQueueWaitOutcome { + let max_wait = Duration::from_secs(max_wait_seconds); + let optional_reviewer_count = is_optional_reviewer.then_some(1); + let initial_active_reviewers = deep_review_active_reviewer_count(dialog_turn_id); + let mut queue_runtime = runtime_task_execution::DeepReviewProviderCapacityQueueRuntime::start( + Instant::now(), + reason, + max_wait, + initial_active_reviewers, + is_optional_reviewer, + ); + + record_deep_review_runtime_provider_capacity_queue(dialog_turn_id, reason); + + loop { + let now = Instant::now(); + let active_reviewers = deep_review_active_reviewer_count(dialog_turn_id); + let effective_parallel_instances = deep_review_effective_parallel_instances( + dialog_turn_id, + conc_policy.max_parallel_instances, + ); + let control_snapshot = deep_review_queue_control_snapshot(dialog_turn_id, tool_id); + let queue_step = queue_runtime.step( + runtime_task_execution::DeepReviewProviderCapacityQueueRuntimeInput { + now, + active_reviewer_count: active_reviewers, + control_snapshot, + poll_interval: DEEP_REVIEW_QUEUE_POLL_INTERVAL, + }, + ); + + match queue_step { + runtime_task_execution::DeepReviewProviderCapacityQueueRuntimeStep::Skipped { + queue_elapsed_ms, + skip_reason, + } => { + record_deep_review_runtime_queue_wait(dialog_turn_id, queue_elapsed_ms); + clear_deep_review_queue_control_for_tool(dialog_turn_id, tool_id); + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::CapacitySkipped, + Some(reason), + 0, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + max_wait_seconds, + ) + .await; + return DeepReviewProviderQueueWaitOutcome::Skipped { + queue_elapsed_ms, + skip_reason, + }; + } + runtime_task_execution::DeepReviewProviderCapacityQueueRuntimeStep::Paused { + queue_elapsed_ms, + next_sleep, + } => { + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::PausedByUser, + Some(reason), + 1, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + max_wait_seconds, + ) + .await; + sleep(next_sleep).await; + continue; + } + runtime_task_execution::DeepReviewProviderCapacityQueueRuntimeStep::ReadyToRetry { + queue_elapsed_ms, + early_capacity_probe, + } => { + record_deep_review_runtime_queue_wait(dialog_turn_id, queue_elapsed_ms); + clear_deep_review_queue_control_for_tool(dialog_turn_id, tool_id); + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::Running, + Some(reason), + 0, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + max_wait_seconds, + ) + .await; + return DeepReviewProviderQueueWaitOutcome::ReadyToRetry { + queue_elapsed_ms, + early_capacity_probe, + }; + } + runtime_task_execution::DeepReviewProviderCapacityQueueRuntimeStep::Queued { + queue_elapsed_ms, + next_sleep, + } => { + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::QueuedForCapacity, + Some(reason), + 1, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + max_wait_seconds, + ) + .await; + sleep(next_sleep).await; + } + } + } +} + +pub(crate) fn record_provider_capacity_retry( + dialog_turn_id: &str, + reason: DeepReviewCapacityQueueReason, +) { + record_deep_review_runtime_provider_capacity_retry(dialog_turn_id, reason); +} + +pub(crate) fn record_provider_capacity_retry_success( + dialog_turn_id: &str, + reason: DeepReviewCapacityQueueReason, +) { + record_deep_review_runtime_provider_capacity_retry_success(dialog_turn_id, reason); +} + +pub(crate) fn try_begin_reviewer_admission( + dialog_turn_id: &str, + effective_parallel_instances: usize, + launch_batch_info: Option<&DeepReviewLaunchBatchInfo>, +) -> Result>, DeepReviewPolicyViolation> { + match launch_batch_info { + Some(info) => try_begin_deep_review_active_reviewer_for_launch_batch( + dialog_turn_id, + effective_parallel_instances, + info.launch_batch, + info.packet_id.as_deref(), + ), + None => Ok(try_begin_deep_review_active_reviewer( + dialog_turn_id, + effective_parallel_instances, + )), + } +} + +pub(crate) async fn wait_for_reviewer_admission( + session_id: &str, + dialog_turn_id: &str, + tool_id: &str, + subagent_type: &str, + conc_policy: &DeepReviewConcurrencyPolicy, + is_optional_reviewer: bool, + launch_batch_info: Option<&DeepReviewLaunchBatchInfo>, +) -> BitFunResult { + let decision = runtime_task_execution::local_reviewer_capacity_queue_decision(); + let local_capacity_reason = decision + .reason + .unwrap_or(DeepReviewCapacityQueueReason::LocalConcurrencyCap); + let max_wait = Duration::from_secs(conc_policy.max_queue_wait_seconds); + let optional_reviewer_count = is_optional_reviewer.then_some(1); + let mut queue_runtime = runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntime::start( + Instant::now(), + local_capacity_reason, + max_wait, + decision.retry_after_seconds, + is_optional_reviewer, + ); + + loop { + let now = Instant::now(); + let active_reviewers = deep_review_active_reviewer_count(dialog_turn_id); + let effective_parallel_instances = deep_review_effective_parallel_instances( + dialog_turn_id, + conc_policy.max_parallel_instances, + ); + let control_snapshot = deep_review_queue_control_snapshot(dialog_turn_id, tool_id); + let queue_step = queue_runtime.begin_step( + runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntimeInput { + now, + control_snapshot, + poll_interval: DEEP_REVIEW_QUEUE_POLL_INTERVAL, + }, + ); + let (queue_elapsed_ms, admission_attempt) = match queue_step { + runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntimeStep::Skipped { + queue_elapsed_ms, + skip_reason, + capacity_reason, + } => { + record_deep_review_runtime_queue_wait(dialog_turn_id, queue_elapsed_ms); + record_deep_review_capacity_skip_for_reason(dialog_turn_id, capacity_reason); + clear_deep_review_queue_control_for_tool(dialog_turn_id, tool_id); + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::CapacitySkipped, + Some(capacity_reason), + 0, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + conc_policy.max_queue_wait_seconds, + ) + .await; + return Ok(DeepReviewQueueWaitOutcome::Skipped { + queue_elapsed_ms, + skip_reason, + capacity_reason, + }); + } + runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntimeStep::Paused { + queue_elapsed_ms, + capacity_reason, + next_sleep, + } => { + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::PausedByUser, + Some(capacity_reason), + 1, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + conc_policy.max_queue_wait_seconds, + ) + .await; + sleep(next_sleep).await; + continue; + } + runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntimeStep::TryAdmit { + queue_elapsed_ms, + attempt, + .. + } => (queue_elapsed_ms, attempt), + }; + + let blocked_capacity_reason = match try_begin_reviewer_admission( + dialog_turn_id, + effective_parallel_instances, + launch_batch_info, + ) { + Ok(Some(guard)) => { + let active_reviewer_count = deep_review_active_reviewer_count(dialog_turn_id); + record_deep_review_runtime_queue_wait(dialog_turn_id, queue_elapsed_ms); + clear_deep_review_queue_control_for_tool(dialog_turn_id, tool_id); + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::Running, + None, + 0, + active_reviewer_count, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + conc_policy.max_queue_wait_seconds, + ) + .await; + return Ok(DeepReviewQueueWaitOutcome::Ready { guard }); + } + Ok(None) => queue_runtime.local_capacity_reason(), + Err(violation) if violation.code == "deep_review_launch_batch_blocked" => { + DeepReviewCapacityQueueReason::LaunchBatchBlocked + } + Err(violation) => { + return Err(BitFunError::tool(format!( + "DeepReview Task policy violation: {}", + violation.to_tool_error_message() + ))); + } + }; + + match queue_runtime.after_blocked_attempt( + admission_attempt, + blocked_capacity_reason, + active_reviewers, + DEEP_REVIEW_QUEUE_POLL_INTERVAL, + ) { + runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntimeBlockedStep::CapacityExpired { + queue_elapsed_ms, + capacity_reason, + retry_after_seconds, + } => { + let effective_parallel_instances = + if capacity_reason == DeepReviewCapacityQueueReason::LaunchBatchBlocked { + effective_parallel_instances + } else { + record_deep_review_effective_concurrency_capacity_error( + dialog_turn_id, + conc_policy.max_parallel_instances, + capacity_reason, + retry_after_seconds.map(Duration::from_secs), + ) + .effective_parallel_instances + }; + record_deep_review_runtime_queue_wait(dialog_turn_id, queue_elapsed_ms); + record_deep_review_capacity_skip_for_reason(dialog_turn_id, capacity_reason); + clear_deep_review_queue_control_for_tool(dialog_turn_id, tool_id); + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::CapacitySkipped, + Some(capacity_reason), + 0, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + conc_policy.max_queue_wait_seconds, + ) + .await; + return Ok(DeepReviewQueueWaitOutcome::Skipped { + queue_elapsed_ms, + skip_reason: DeepReviewQueueWaitSkipReason::QueueExpired, + capacity_reason, + }); + } + runtime_task_execution::DeepReviewReviewerAdmissionQueueRuntimeBlockedStep::Queued { + queue_elapsed_ms, + capacity_reason, + next_sleep, + } => { + emit_queue_state( + session_id, + dialog_turn_id, + tool_id, + subagent_type, + DeepReviewQueueStatus::QueuedForCapacity, + Some(capacity_reason), + 1, + active_reviewers, + optional_reviewer_count, + Some(effective_parallel_instances), + queue_elapsed_ms, + conc_policy.max_queue_wait_seconds, + ) + .await; + sleep(next_sleep).await; + } + } + } +} diff --git a/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs b/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs new file mode 100644 index 0000000000..3df9c6bfe5 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs @@ -0,0 +1,38 @@ +//! Deep Review shared-context measurement hook for successful tool calls. +//! +//! The hook is intentionally narrow: only successful reviewer `Read` and +//! `GetFileDiff` calls are measured, and BitFun runtime URIs are ignored. It +//! records normalized metadata for diagnostics, not file contents. + +use crate::agentic::deep_review_policy::record_deep_review_shared_context_tool_use; +use crate::agentic::tools::framework::ToolUseContext; +use bitfun_agent_runtime::post_call_hooks::{ + resolve_deep_review_shared_context_tool_use, DeepReviewSharedContextToolUseFacts, +}; +use serde_json::Value; + +pub(crate) fn maybe_record_shared_context_tool_use( + tool_name: &str, + input: &Value, + context: &ToolUseContext, +) { + let Some(record) = + resolve_deep_review_shared_context_tool_use(DeepReviewSharedContextToolUseFacts { + tool_name, + input, + custom_data: &context.custom_data, + workspace_root: context.workspace_root(), + is_remote: context.is_remote(), + agent_type: context.agent_type.as_deref(), + }) + else { + return; + }; + + record_deep_review_shared_context_tool_use( + &record.parent_turn_id, + &record.subagent_type, + &record.tool_name, + &record.measured_path, + ); +} diff --git a/src/crates/assembly/core/src/agentic/deep_review_policy.rs b/src/crates/assembly/core/src/agentic/deep_review_policy.rs new file mode 100644 index 0000000000..0d8328d0ce --- /dev/null +++ b/src/crates/assembly/core/src/agentic/deep_review_policy.rs @@ -0,0 +1,155 @@ +//! Deep Review core compatibility facade. +//! +//! Runtime-owned policy, budget, queue, manifest, and shared-context state live +//! in `bitfun-agent-runtime::deep_review`. Core only keeps product config +//! loading here so existing callers keep their paths while ownership moves down. + +use crate::service::config::global::GlobalConfigManager; +use crate::util::errors::{BitFunError, BitFunResult}; +use log::warn; +use serde_json::Value; + +pub use bitfun_agent_runtime::deep_review::{ + apply_deep_review_queue_control, classify_deep_review_capacity_error, + clear_deep_review_queue_control_for_tool, deep_review_active_reviewer_count, + deep_review_capacity_skip_count, deep_review_concurrency_cap_rejection_count, + deep_review_effective_concurrency_snapshot, deep_review_effective_parallel_instances, + deep_review_has_judge_been_launched, deep_review_max_retries_per_role, + deep_review_queue_control_snapshot, deep_review_retries_used, + deep_review_runtime_diagnostics_snapshot, deep_review_shared_context_measurement_snapshot, + deep_review_turn_elapsed_seconds, default_review_team_definition, + record_deep_review_capacity_skip, record_deep_review_capacity_skip_for_reason, + record_deep_review_concurrency_cap_rejection, + record_deep_review_effective_concurrency_capacity_error, + record_deep_review_effective_concurrency_success, record_deep_review_runtime_auto_retry, + record_deep_review_runtime_auto_retry_suppressed, record_deep_review_runtime_capacity_skip, + record_deep_review_runtime_manual_queue_action, record_deep_review_runtime_manual_retry, + record_deep_review_runtime_provider_capacity_queue, + record_deep_review_runtime_provider_capacity_retry, + record_deep_review_runtime_provider_capacity_retry_success, + record_deep_review_runtime_queue_wait, record_deep_review_shared_context_tool_use, + record_deep_review_task_budget, set_deep_review_effective_concurrency_user_override, + try_begin_deep_review_active_reviewer, try_begin_deep_review_active_reviewer_for_launch_batch, + ChangeRiskFactors, DeepReviewActiveReviewerGuard, DeepReviewBudgetTracker, + DeepReviewCapacityFailFastReason, DeepReviewCapacityQueueDecision, + DeepReviewCapacityQueueReason, DeepReviewConcurrencyPolicy, + DeepReviewEffectiveConcurrencySnapshot, DeepReviewExecutionPolicy, DeepReviewIncrementalCache, + DeepReviewPolicyViolation, DeepReviewQueueControlAction, DeepReviewQueueControlSnapshot, + DeepReviewReviewerQueueState, DeepReviewReviewerQueueStatus, DeepReviewRunManifestGate, + DeepReviewRuntimeDiagnostics, DeepReviewSharedContextDuplicate, + DeepReviewSharedContextMeasurementSnapshot, DeepReviewStrategyLevel, DeepReviewSubagentRole, + ReviewStrategyManifestProfile, ReviewTeamDefinition, ReviewTeamExecutionPolicyDefinition, + ReviewTeamRoleDefinition, CONDITIONAL_REVIEWER_AGENT_TYPES, CORE_REVIEWER_AGENT_TYPES, + DEEP_REVIEW_AGENT_TYPE, REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, + REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, + REVIEW_FIXER_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, +}; + +const DEFAULT_REVIEW_TEAM_CONFIG_PATH: &str = "ai.review_teams.default"; + +pub async fn load_default_deep_review_policy() -> BitFunResult { + let config_service = GlobalConfigManager::get_service().await.map_err(|error| { + BitFunError::config(format!( + "Failed to load DeepReview execution policy because config service is unavailable: {}", + error + )) + })?; + + let raw_config = match config_service + .get_config::(Some(DEFAULT_REVIEW_TEAM_CONFIG_PATH)) + .await + { + Ok(config) => Some(config), + Err(error) if is_missing_default_review_team_config_error(&error) => { + warn!( + "DeepReview policy config missing at {}, using defaults", + DEFAULT_REVIEW_TEAM_CONFIG_PATH + ); + None + } + Err(error) => { + return Err(BitFunError::config(format!( + "Failed to load DeepReview execution policy from {}: {}", + DEFAULT_REVIEW_TEAM_CONFIG_PATH, error + ))); + } + }; + + Ok(DeepReviewExecutionPolicy::from_config_value( + raw_config.as_ref(), + )) +} + +pub fn is_missing_default_review_team_config_error(error: &BitFunError) -> bool { + matches!(error, BitFunError::NotFound(message) + if message == &format!("Config path '{}' not found", DEFAULT_REVIEW_TEAM_CONFIG_PATH)) +} + +#[cfg(test)] +mod tests { + use super::{ + default_review_team_definition, is_missing_default_review_team_config_error, + DeepReviewBudgetTracker, DeepReviewExecutionPolicy, DeepReviewRunManifestGate, + DeepReviewStrategyLevel, DeepReviewSubagentRole, REVIEWER_SECURITY_AGENT_TYPE, + }; + use crate::util::errors::BitFunError; + use serde_json::json; + + #[test] + fn only_missing_default_review_team_path_can_fallback_to_defaults() { + let matching = + BitFunError::NotFound("Config path 'ai.review_teams.default' not found".to_string()); + assert!(is_missing_default_review_team_config_error(&matching)); + + let different_path = + BitFunError::NotFound("Config path 'ai.review_teams.other' not found".to_string()); + assert!(!is_missing_default_review_team_config_error( + &different_path + )); + + let other_error = + BitFunError::config("Config path 'ai.review_teams.default' not found".to_string()); + assert!(!is_missing_default_review_team_config_error(&other_error)); + } + + #[test] + fn compatibility_facade_preserves_deep_review_runtime_exports() { + let policy = DeepReviewExecutionPolicy::from_config_value(Some(&json!({ + "strategy_level": "deep", + "member_strategy_overrides": { + "ReviewSecurity": "quick" + } + }))); + assert_eq!(policy.strategy_level, DeepReviewStrategyLevel::Deep); + assert_eq!( + policy + .member_strategy_overrides + .get(REVIEWER_SECURITY_AGENT_TYPE), + Some(&DeepReviewStrategyLevel::Quick) + ); + + let tracker = DeepReviewBudgetTracker::default(); + tracker + .record_task( + "facade-turn", + &DeepReviewExecutionPolicy::default(), + DeepReviewSubagentRole::Reviewer, + REVIEWER_SECURITY_AGENT_TYPE, + false, + ) + .expect("facade runtime budget export"); + + let manifest = json!({ + "reviewMode": "deep", + "workPackets": [{ "subagentId": "ReviewSecurity" }] + }); + let gate = DeepReviewRunManifestGate::from_value(&manifest).expect("manifest gate"); + assert!(gate.ensure_active("ReviewSecurity").is_ok()); + + let team = default_review_team_definition(); + assert!(team + .core_roles + .iter() + .any(|reviewer| reviewer.subagent_id == REVIEWER_SECURITY_AGENT_TYPE)); + } +} diff --git a/src/crates/core/src/agentic/events/mod.rs b/src/crates/assembly/core/src/agentic/events/mod.rs similarity index 100% rename from src/crates/core/src/agentic/events/mod.rs rename to src/crates/assembly/core/src/agentic/events/mod.rs diff --git a/src/crates/core/src/agentic/events/queue.rs b/src/crates/assembly/core/src/agentic/events/queue.rs similarity index 100% rename from src/crates/core/src/agentic/events/queue.rs rename to src/crates/assembly/core/src/agentic/events/queue.rs diff --git a/src/crates/core/src/agentic/events/router.rs b/src/crates/assembly/core/src/agentic/events/router.rs similarity index 100% rename from src/crates/core/src/agentic/events/router.rs rename to src/crates/assembly/core/src/agentic/events/router.rs diff --git a/src/crates/core/src/agentic/events/types.rs b/src/crates/assembly/core/src/agentic/events/types.rs similarity index 72% rename from src/crates/core/src/agentic/events/types.rs rename to src/crates/assembly/core/src/agentic/events/types.rs index c40b4ce34c..687c69d4a6 100644 --- a/src/crates/core/src/agentic/events/types.rs +++ b/src/crates/assembly/core/src/agentic/events/types.rs @@ -3,6 +3,8 @@ //! 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; // ============ Re-export events layer types ============ pub use bitfun_events::agentic::ErrorCategory; @@ -24,9 +26,10 @@ pub type AgenticEvent = BaseAgenticEvent; /// Convert SessionState to String (for transmission) pub fn session_state_to_string(state: &SessionState) -> String { - match state { - SessionState::Idle => "idle".to_string(), - SessionState::Processing { .. } => "processing".to_string(), - SessionState::Error { .. } => "error".to_string(), - } + let fact = match state { + SessionState::Idle => DialogSessionStateFact::Idle, + SessionState::Processing { .. } => DialogSessionStateFact::Processing, + SessionState::Error { .. } => DialogSessionStateFact::Error, + }; + session_state_label(fact).to_string() } diff --git a/src/crates/core/src/agentic/execution/AGENTS.md b/src/crates/assembly/core/src/agentic/execution/AGENTS.md similarity index 100% rename from src/crates/core/src/agentic/execution/AGENTS.md rename to src/crates/assembly/core/src/agentic/execution/AGENTS.md diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs similarity index 81% rename from src/crates/core/src/agentic/execution/execution_engine.rs rename to src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 2bd0c33fa8..4672f66f5f 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -2,16 +2,19 @@ //! //! Executes complete dialog turns, managing loops of multiple model rounds +use super::model_exchange_trace::{ + prepare_model_exchange_trace_for_workspace, ModelExchangeTraceOperation, +}; use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult}; use crate::agentic::agents::{ - get_agent_registry, PrependedPromptReminders, PromptBuilder, PromptBuilderContext, - RemoteExecutionHints, ToolListingSections, + build_prompt_context_for_workspace, get_agent_registry, PrependedPromptReminders, + PromptBuilder, PromptBuilderContext, RuntimeContextNeeds, ToolListingSections, }; use crate::agentic::context_profile::{ContextProfilePolicy, ModelCapabilityProfile}; use crate::agentic::core::{ - render_system_reminder, Message, MessageContent, MessageHelper, MessageRole, - MessageSemanticKind, RequestReasoningTokenPolicy, Session, + render_system_reminder, InternalReminderKind, Message, MessageContent, MessageHelper, + MessageRole, MessageSemanticKind, RequestReasoningTokenPolicy, Session, }; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::execution::types::FinishReason; @@ -19,23 +22,27 @@ use crate::agentic::image_analysis::{ build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, ImageLimits, }; +use crate::agentic::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; use crate::agentic::round_preempt::RoundInjectionKind; use crate::agentic::session::{CompressionMode, ContextCompressor, SessionManager}; -use crate::agentic::tools::implementations::{GetToolSpecTool, SkillTool, TaskTool}; -use crate::agentic::tools::{resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest}; -use crate::agentic::util::build_remote_workspace_layout_preview; -use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; +use crate::agentic::skill_agent_snapshot::build_skill_agent_tool_listing_sections_from_snapshot; +use crate::agentic::tools::implementations::{SkillTool, TaskTool}; +use crate::agentic::tools::product_runtime::{ + collect_product_unlocked_collapsed_tools, GetToolSpecTool, +}; +use crate::agentic::tools::{ + resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest, ToolRuntimeRestrictions, +}; +use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; -use crate::service::config::types::{ModelCapability, ModelCategory, WriteToolMode}; -use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; -use crate::service::workspace::get_global_workspace_service; +use crate::service::config::types::{ModelCapability, ModelCategory}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; -use bitfun_agent_tools::{collect_loaded_collapsed_tool_names, GetToolSpecLoadObservation}; +use bitfun_ai_adapters::ModelExchangeTraceConfig; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -243,8 +250,12 @@ pub struct ExecutionEngine { } impl ExecutionEngine { - const FINALIZE_AFTER_TOOL_USE_REMINDER: &'static str = "Tool execution for this turn has already completed, but the turn is ending at this round boundary. Do not call any more tools. Provide the final response to the user based on the tool results already available."; - const FORCE_TEXT_ONLY_REMINDER: &'static str = "STOP. Tool calls are disabled for this final turn. Respond ONLY with a plain-text answer summarizing what you have done and the result for the user. Do not output tool call syntax of any kind."; + const FINALIZE_AFTER_REPEATED_TOOL_FAILURES_REMINDER: &'static str = "This turn must end now because repeated tool failures have prevented further progress. Ignore any unfinished work. Your task now is to give the user a final answer. Do not call any more tools; any tool call will fail. Respond in plain text only. Summarize what was completed, what failed, the evidence available from the tool results, and the single best next step for the user."; + const FINALIZE_AFTER_MAX_ROUNDS_REMINDER: &'static str = "This turn must end now because it has reached the round limit. Ignore any unfinished work. Your task now is to give the user a final answer. Do not call any more tools; any tool call will fail. Respond in plain text only. Summarize the most useful completed work and evidence collected so far, and clearly distinguish resolved items from anything still unresolved."; + const FINALIZE_TOOL_DENIED_MESSAGE: &'static str = + "Tool use is disabled for finalize. Respond with plain text only."; + const FINALIZE_USER_FOLLOWUP: &'static str = + "Provide a final answer. You MUST not call any tools."; pub fn new( round_executor: Arc, @@ -277,6 +288,8 @@ impl ExecutionEngine { /// /// System prompt and tool definitions are fixed per dialog turn and should not /// count against the auto-compression threshold the same way tool results do. + /// Keeping them fixed also preserves provider-side prefix/KV cache reuse; + /// changing tool definitions mid-turn turns every later round into a cache miss. fn estimate_auto_compression_pressure( messages: &[Message], tools: Option<&[ToolDefinition]>, @@ -312,6 +325,44 @@ impl ExecutionEngine { ) } + fn tool_call_signature(tool_calls: &[crate::agentic::core::ToolCall]) -> Option { + if tool_calls.is_empty() { + return None; + } + + let mut signatures: Vec = tool_calls + .iter() + .map(|tool_call| { + let arguments = tool_call.arguments.to_string(); + let arguments_summary = Self::tool_signature_args_summary(&arguments); + format!("{}:{}", tool_call.tool_name, arguments_summary) + }) + .collect(); + signatures.sort(); + Some(signatures.join("|")) + } + + fn failed_tool_round_signature( + tool_calls: &[crate::agentic::core::ToolCall], + tool_result_messages: &[Message], + ) -> Option { + if tool_result_messages.is_empty() + || !tool_result_messages.iter().all(|message| { + let MessageContent::ToolResult { + result, is_error, .. + } = &message.content + else { + return false; + }; + ContextHealthSnapshot::tool_result_failed(result, *is_error) + }) + { + return None; + } + + Self::tool_call_signature(tool_calls) + } + /// Whether a partial stream recovery should trigger a continuation round /// instead of treating truncated assistant text as the final answer. /// @@ -361,17 +412,60 @@ impl ExecutionEngine { ) } - fn has_tool_result_after_last_assistant(messages: &[Message]) -> bool { - let Some(last_assistant_index) = messages + fn finalize_tool_names(tool_definitions: Option<&[ToolDefinition]>) -> Vec { + tool_definitions + .unwrap_or(&[]) .iter() - .rposition(|message| message.role == MessageRole::Assistant) - else { - return false; - }; + .map(|tool| tool.name.clone()) + .collect() + } - messages[last_assistant_index + 1..] - .iter() - .any(|message| matches!(message.content, MessageContent::ToolResult { .. })) + fn finalize_runtime_tool_restrictions( + context: &ExecutionContext, + tool_names: &[String], + ) -> ToolRuntimeRestrictions { + let mut restrictions = context.runtime_tool_restrictions.clone(); + for tool_name in tool_names { + restrictions.denied_tool_names.insert(tool_name.clone()); + restrictions + .denied_tool_messages + .entry(tool_name.clone()) + .or_insert_with(|| Self::FINALIZE_TOOL_DENIED_MESSAGE.to_string()); + } + restrictions + } + + fn build_local_final_response_message(reason: &str) -> String { + match reason { + "repeated_tool_failures" => { + "I'm stopping here because repeated tool failures prevented further progress in this turn.".to_string() + } + "max_rounds" => { + "I'm stopping here because this turn reached its round limit before I could complete a final response.".to_string() + } + _ => "I'm stopping here because this turn could not be completed successfully.".to_string(), + } + } + + fn should_mark_has_final_response( + has_assistant_message: bool, + used_local_final_response_synthesis: bool, + ) -> bool { + has_assistant_message && !used_local_final_response_synthesis + } + + fn build_finalize_cache_anchor_messages(turn_id: &str, reminder_text: &str) -> Vec { + vec![ + Message::internal_reminder( + InternalReminderKind::FinalizeCacheAnchor, + reminder_text.to_string(), + ) + .with_turn_id(turn_id.to_string()), + Message::user(Self::FINALIZE_USER_FOLLOWUP.to_string()) + .with_semantic_kind(MessageSemanticKind::InternalReminder) + .with_internal_reminder_kind(InternalReminderKind::FinalizeCacheAnchor) + .with_turn_id(turn_id.to_string()), + ] } /// Emergency truncation: drop oldest API rounds (assistant+tool pairs) @@ -539,38 +633,6 @@ impl ExecutionEngine { .unwrap_or_else(|| "auto".to_string()) } - fn collect_unlocked_collapsed_tools( - messages: &[Message], - collapsed_tools: &[String], - ) -> Vec { - let observations = messages - .iter() - .filter_map(|message| { - let MessageContent::ToolResult { - tool_name, - result, - is_error, - .. - } = &message.content - else { - return None; - }; - - Some(GetToolSpecLoadObservation { - tool_name, - loaded_tool_name: result.get("tool_name").and_then(|v| v.as_str()), - is_error: *is_error, - }) - }) - .collect::>(); - - collect_loaded_collapsed_tool_names( - &observations, - collapsed_tools, - crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME, - ) - } - async fn build_tool_listing_sections( manifest: &ResolvedToolManifest, tool_context: &crate::agentic::tools::framework::ToolUseContext, @@ -608,95 +670,28 @@ impl ExecutionEngine { model_name: &str, supports_image_understanding: bool, tool_listing_sections: ToolListingSections, + runtime_context_needs: RuntimeContextNeeds, ) -> Option { - let workspace_path = context - .workspace - .as_ref() - .map(|workspace| workspace.root_path_string())?; - - let related_paths = if let Some(workspace_id) = context - .workspace - .as_ref() - .and_then(|workspace| workspace.workspace_id.as_deref()) - { - if let Some(workspace_service) = get_global_workspace_service() { - workspace_service - .get_workspace(workspace_id) - .await - .map(|workspace| workspace.related_paths) - .unwrap_or_default() - } else { - Vec::new() - } - } else { - Vec::new() - }; + let workspace = context.workspace.as_ref()?; + let remote_file_delivery_channel = context + .context + .get(TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY) + .and_then(|value| value.parse::().ok()) + .unwrap_or(false); - let base = PromptBuilderContext::new( - workspace_path.clone(), - Some(context.session_id.clone()), + build_prompt_context_for_workspace( + workspace, + workspace.workspace_id.as_deref(), + &context.session_id, Some(model_name.to_string()), + Some(supports_image_understanding), + tool_listing_sections, + runtime_context_needs, ) - .with_related_paths(related_paths) - .with_supports_image_understanding(supports_image_understanding) - .with_tool_listing_sections(tool_listing_sections); - - let Some(workspace) = context.workspace.as_ref() else { - return Some(base); - }; - if !workspace.is_remote() { - return Some(base); - } - - let Some(connection_id) = workspace.connection_id() else { - return Some(base); - }; - let Some(manager) = get_remote_workspace_manager() else { - warn!( - "Remote workspace active but RemoteWorkspaceStateManager is missing; using client OS hints only" - ); - return Some(base); - }; - - let ssh_manager = manager.get_ssh_manager().await; - let file_service = manager.get_file_service().await; - let (kernel_name, hostname) = if let Some(ref ssh) = ssh_manager { - if let Some(info) = ssh.get_server_info(connection_id).await { - (info.os_type, info.hostname) - } else { - ("Linux".to_string(), "remote".to_string()) - } - } else { - ("Linux".to_string(), "remote".to_string()) - }; - let connection_display_name = match &workspace.backend { - WorkspaceBackend::Remote { - connection_name, .. - } => connection_name.clone(), - _ => connection_id.to_string(), - }; - let remote_layout = if let Some(ref fs) = file_service { - match build_remote_workspace_layout_preview(fs, connection_id, &workspace_path, 200) - .await - { - Ok((_, preview)) => Some(preview), - Err(e) => { - warn!("Remote workspace layout for prompt failed: {}", e); - None - } - } - } else { - None - }; - - Some(base.with_remote_prompt_overlay( - RemoteExecutionHints { - connection_display_name, - kernel_name, - hostname, - }, - remote_layout, - )) + .await + .map(|prompt_context| { + prompt_context.with_remote_file_delivery_channel(remote_file_delivery_channel) + }) } async fn build_cached_prepended_prompt_reminders( @@ -704,12 +699,32 @@ impl ExecutionEngine { session_id: &str, current_agent: &dyn crate::agentic::agents::Agent, prompt_context: Option<&PromptBuilderContext>, + _context_vars: &HashMap, ) -> PrependedPromptReminders { let Some(prompt_context) = prompt_context.cloned() else { return PrependedPromptReminders::default(); }; let prompt_builder = PromptBuilder::new(prompt_context); + let baseline_snapshot = if let Some(snapshot) = self + .session_manager + .skill_agent_baseline_override_snapshot(session_id) + .await + { + Some(snapshot) + } else { + self.session_manager + .turn_skill_agent_snapshot(session_id, 0) + .await + }; + let baseline_tool_sections = baseline_snapshot + .map(|snapshot| build_skill_agent_tool_listing_sections_from_snapshot(&snapshot)); + if baseline_tool_sections.is_none() { + warn!( + "Listing reminder baseline snapshot unavailable while building prepended reminders: session_id={}", + session_id + ); + } let user_context_identity = current_agent.user_context_cache_identity(); let user_context = if let Some(cached_user_context) = self .session_manager @@ -740,11 +755,17 @@ impl ExecutionEngine { } built_user_context }; + let runtime_context = prompt_builder.build_runtime_context_reminder().await; PrependedPromptReminders { - skill_listing: prompt_builder.build_skill_listing_reminder(), - agent_listing: prompt_builder.build_agent_listing_reminder(), collapsed_tool_listing: prompt_builder.build_collapsed_tool_listing_reminder(), + skill_listing: baseline_tool_sections + .as_ref() + .and_then(|sections| sections.render_skill_listing_reminder()), + agent_listing: baseline_tool_sections + .as_ref() + .and_then(|sections| sections.render_agent_listing_reminder()), + runtime_context, user_context, } } @@ -894,23 +915,29 @@ impl ExecutionEngine { .collect() } - /// Synthesize one extra finalize round with tools disabled, returning the - /// resulting assistant message + metadata. Used by both the - /// "summarize tool results" and "force text after thinking-only" paths. - #[allow(clippy::too_many_arguments)] async fn run_finalize_round( &self, ai_client: Arc, context: &ExecutionContext, agent_type: String, round_number: usize, + round_group_id: Option, execution_context_vars: &HashMap, primary_supports_image_understanding: bool, prepended_reminders: &[&str], messages: &[Message], reminder_text: &str, + tool_definitions: Option>, context_window: usize, ) -> BitFunResult { + // Keep the original tool definitions attached to the finalize request + // even though finalize forbids tool execution at runtime. Dropping the + // tools here would change the provider request shape, which breaks + // prompt/prefix cache reuse and turns the finalize round into a cache + // miss for providers that key caching on the full request schema. + let finalize_tool_names = Self::finalize_tool_names(tool_definitions.as_deref()); + let finalize_runtime_tool_restrictions = + Self::finalize_runtime_tool_restrictions(context, &finalize_tool_names); let mut final_ai_messages = Self::build_ai_messages_for_send( messages, &ai_client.config.format, @@ -923,7 +950,8 @@ impl ExecutionEngine { prepended_reminders, ) .await?; - final_ai_messages.push(AIMessage::user(reminder_text.to_string())); + final_ai_messages.push(AIMessage::user(render_system_reminder(reminder_text))); + final_ai_messages.push(AIMessage::user(Self::FINALIZE_USER_FOLLOWUP.to_string())); let round_context = RoundContext { session_id: context.session_id.clone(), @@ -931,29 +959,29 @@ impl ExecutionEngine { dialog_turn_id: context.dialog_turn_id.clone(), turn_index: context.turn_index, round_number, + round_group_id, workspace: context.workspace.clone(), messages: messages.to_vec(), - available_tools: Vec::new(), + available_tools: finalize_tool_names, collapsed_tools: Vec::new(), unlocked_collapsed_tools: Vec::new(), model_name: ai_client.config.model.clone(), agent_type, context_vars: execution_context_vars.clone(), delegation_policy: context.delegation_policy, - runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), + runtime_tool_restrictions: finalize_runtime_tool_restrictions, steering_interrupt: None, cancellation_token: CancellationToken::new(), workspace_services: context.workspace_services.clone(), recover_partial_on_cancel: context.recover_partial_on_cancel, }; - // Tools are disabled here (None) — model must respond in plain text. self.round_executor .execute_round( ai_client, round_context, final_ai_messages, - None, + tool_definitions, Some(context_window), ) .await @@ -983,7 +1011,7 @@ impl ExecutionEngine { .iter() .position(|msg| msg.role != crate::agentic::core::MessageRole::System) .unwrap_or(messages.len()); - let mut user_context_injected = false; + let mut prepended_reminders_injected = false; let keep_image_messages = if attach_images { Self::image_bearing_indices_to_keep(messages, MAX_IMAGE_BEARING_MESSAGE_ROUNDS) @@ -992,11 +1020,11 @@ impl ExecutionEngine { }; for (msg_idx, msg) in messages.iter().enumerate() { - if !user_context_injected && msg_idx == first_non_system_index { + if !prepended_reminders_injected && msg_idx == first_non_system_index { for reminder in &trimmed_reminders { result.push(AIMessage::user(render_system_reminder(reminder))); } - user_context_injected = true; + prepended_reminders_injected = true; } if Self::skip_message_for_model_send(msg) { @@ -1119,7 +1147,7 @@ impl ExecutionEngine { } } - if !user_context_injected { + if !prepended_reminders_injected { for reminder in trimmed_reminders { result.push(AIMessage::user(render_system_reminder(reminder))); } @@ -1163,7 +1191,7 @@ impl ExecutionEngine { &self, runtime_messages: &[Message], dialog_turn_id: &str, - workspace_path: Option<&Path>, + workspace: Option<&WorkspaceBinding>, provider: &str, attach_images: bool, prepended_prompt_reminders: &PrependedPromptReminders, @@ -1173,7 +1201,7 @@ impl ExecutionEngine { let mut compression_messages = Self::build_ai_messages_for_send( runtime_messages, provider, - workspace_path, + workspace.map(|workspace| workspace.root_path()), dialog_turn_id, attach_images, &prepended_reminders, @@ -1190,6 +1218,7 @@ impl ExecutionEngine { ai_client: Arc, request_messages: Vec, tool_definitions: Option>, + trace_config: Option, max_tries: usize, ) -> BitFunResult { let mut last_error = None; @@ -1197,7 +1226,11 @@ impl ExecutionEngine { for attempt in 0..max_tries { let result = ai_client - .send_message(request_messages.clone(), tool_definitions.clone()) + .send_message_with_trace( + request_messages.clone(), + tool_definitions.clone(), + trace_config.clone(), + ) .await; match result { @@ -1253,17 +1286,18 @@ impl ExecutionEngine { ai_client: Arc, runtime_messages: &[Message], dialog_turn_id: &str, - workspace_path: Option<&Path>, + workspace: Option<&WorkspaceBinding>, tool_definitions: &Option>, prepended_prompt_reminders: &PrependedPromptReminders, primary_supports_image_understanding: bool, contract: Option<&crate::agentic::core::CompressionContract>, + trace_config: Option, ) -> BitFunResult> { let request_messages = self .build_compression_request_messages( runtime_messages, dialog_turn_id, - workspace_path, + workspace, &ai_client.config.format, primary_supports_image_understanding, prepended_prompt_reminders, @@ -1276,6 +1310,7 @@ impl ExecutionEngine { ai_client, request_messages, tool_definitions.clone(), + trace_config, 2, ) .await?; @@ -1341,7 +1376,7 @@ impl ExecutionEngine { )) })?; - let (resolved_primary_model_id, primary_supports_image_understanding, write_tool_mode) = { + let (resolved_primary_model_id, primary_supports_image_understanding) = { let config_service = get_global_config_service().await.ok(); if let Some(service) = config_service { let ai_config: crate::service::config::types::AIConfig = @@ -1373,12 +1408,12 @@ impl ExecutionEngine { || matches!(m.category, ModelCategory::Multimodal) }); - (resolved_id, supports, ai_config.write_tool_mode) + (resolved_id, supports) } else { warn!( "Config service unavailable, assuming compression model is text-only for image input gating" ); - (model_id.clone(), false, WriteToolMode::default()) + (model_id.clone(), false) } }; @@ -1410,21 +1445,7 @@ impl ExecutionEngine { .get("enable_tools") .and_then(|value| value.parse::().ok()) .unwrap_or(true); - let write_tool_mode = if context - .context - .get("acp_transport") - .is_some_and(|value| value == "true") - { - WriteToolMode::InlineContent - } else { - write_tool_mode - }; - - let mut tool_manifest_context_vars = context.context.clone(); - tool_manifest_context_vars.insert( - "write_tool_mode".to_string(), - write_tool_mode.as_str().to_string(), - ); + let tool_manifest_context_vars = context.context.clone(); let tool_description_context = tool_context_runtime::build_tool_description_context( &context.agent_type, @@ -1450,6 +1471,17 @@ impl ExecutionEngine { } else { ToolListingSections::default() }; + let runtime_context_needs = tool_manifest + .as_ref() + .map(|manifest| { + RuntimeContextNeeds::from_tool_names(manifest.allowed_tool_names.iter()) + }) + .unwrap_or_default(); + // Snapshot prompt-visible tool definitions once for this turn. Do not + // re-resolve or rewrite them after GetToolSpec unlocks a collapsed tool: + // the unlocked detail travels in tool results, while mutating the tool + // definitions would change the request prefix and trigger provider + // prefix/KV cache misses on subsequent rounds. let tool_definitions = tool_manifest.map(|manifest| manifest.tool_definitions); let prompt_context = Self::build_prompt_context( @@ -1457,6 +1489,7 @@ impl ExecutionEngine { &ai_client.config.model, primary_supports_image_understanding, tool_listing_sections, + runtime_context_needs, ) .await; let prepended_prompt_reminders = self @@ -1464,6 +1497,7 @@ impl ExecutionEngine { &context.session_id, current_agent.as_ref(), prompt_context.as_ref(), + &context.context, ) .await; let system_prompt = self @@ -1499,7 +1533,7 @@ impl ExecutionEngine { prepended_prompt_reminders: &PrependedPromptReminders, primary_supports_image_understanding: bool, compression_contract_limit: usize, - workspace_path: Option<&Path>, + workspace: Option<&WorkspaceBinding>, ) -> BitFunResult)>> { let mut session = self .session_manager @@ -1539,16 +1573,29 @@ impl ExecutionEngine { let compression_contract = self .session_manager .compression_contract_for_session(session_id, compression_contract_limit); + let trace_config = prepare_model_exchange_trace_for_workspace( + session_id, + dialog_turn_id, + workspace, + ModelExchangeTraceOperation { + kind: "context_compression", + id: &compression_id, + trigger: Some("auto"), + }, + ai_client.as_ref(), + ) + .await; let model_summary = match self .generate_compression_model_summary( ai_client, &runtime_messages, dialog_turn_id, - workspace_path, + workspace, tool_definitions, prepended_prompt_reminders, primary_supports_image_understanding, compression_contract.as_ref(), + trace_config, ) .await { @@ -1573,6 +1620,16 @@ impl ExecutionEngine { self.session_manager .replace_context_messages(session_id, compression_result.messages.clone()) .await; + if self + .session_manager + .rebuild_skill_agent_listing_baseline_to_latest(session_id) + .await + { + debug!( + "Rebuilt skill-agent listing baseline after compression: session_id={}", + session_id + ); + } self.session_manager .invalidate_prompt_cache( session_id, @@ -1742,19 +1799,29 @@ impl ExecutionEngine { let compression_contract = self .session_manager .compression_contract_for_session(&session_id, scaffold.compression_contract_limit); + let trace_config = prepare_model_exchange_trace_for_workspace( + &session_id, + &dialog_turn_id, + context.workspace.as_ref(), + ModelExchangeTraceOperation { + kind: "context_compression", + id: &compression_id, + trigger: Some(trigger), + }, + scaffold.ai_client.as_ref(), + ) + .await; let model_summary = match self .generate_compression_model_summary( scaffold.ai_client.clone(), &runtime_messages, &dialog_turn_id, - context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), + context.workspace.as_ref(), &scaffold.tool_definitions, &scaffold.prepended_prompt_reminders, scaffold.primary_supports_image_understanding, compression_contract.as_ref(), + trace_config, ) .await { @@ -1780,6 +1847,16 @@ impl ExecutionEngine { self.session_manager .replace_context_messages(&session_id, compressed_messages.clone()) .await; + if self + .session_manager + .rebuild_skill_agent_listing_baseline_to_latest(&session_id) + .await + { + debug!( + "Rebuilt skill-agent listing baseline after manual compaction: session_id={}", + session_id + ); + } self.session_manager .invalidate_prompt_cache( &session_id, @@ -1981,11 +2058,7 @@ impl ExecutionEngine { })?; // Primary model vision capability (tools + system prompt appendix; also used below for API message stripping). - let ( - resolved_primary_model_id, - primary_supports_image_understanding, - configured_write_tool_mode, - ) = { + let (resolved_primary_model_id, primary_supports_image_understanding) = { let config_service = get_global_config_service().await.ok(); if let Some(service) = config_service { let ai_config: crate::service::config::types::AIConfig = @@ -2018,12 +2091,12 @@ impl ExecutionEngine { || matches!(m.category, ModelCategory::Multimodal) }); - (resolved_id, supports, ai_config.write_tool_mode) + (resolved_id, supports) } else { warn!( "Config service unavailable, assuming primary model is text-only for image input gating" ); - (model_id.clone(), false, WriteToolMode::default()) + (model_id.clone(), false) } }; @@ -2077,21 +2150,7 @@ impl ExecutionEngine { .get("enable_tools") .and_then(|v| v.parse::().ok()) .unwrap_or(true); - let write_tool_mode = if context - .context - .get("acp_transport") - .is_some_and(|value| value == "true") - { - WriteToolMode::InlineContent - } else { - configured_write_tool_mode - }; - - let mut tool_manifest_context_vars = context.context.clone(); - tool_manifest_context_vars.insert( - "write_tool_mode".to_string(), - write_tool_mode.as_str().to_string(), - ); + let tool_manifest_context_vars = context.context.clone(); let tool_description_context = tool_context_runtime::build_tool_description_context( &agent_type, @@ -2127,6 +2186,25 @@ impl ExecutionEngine { } else { ToolListingSections::default() }; + let runtime_context_needs = tool_manifest + .as_ref() + .map(|manifest| { + RuntimeContextNeeds::from_tool_names(manifest.allowed_tool_names.iter()) + }) + .unwrap_or_default(); + // We do not currently keep a session-level cache of resolved tool + // definitions; each turn re-resolves them from the current manifest. + // Expected changes therefore come from user-driven configuration or + // product-version changes, such as: + // - agent_type / mode changes + // - the user editing the enabled tool set for the current agent + // - MCP tool enablement / settings changes + // - a newer product build changing built-in tool definitions + // + // Outside those cases, tool definitions should remain byte-stable + // across the session. Avoid introducing extra turn-to-turn variation: + // it changes the request prefix and causes provider prefix/KV cache + // misses. let (available_tools, tool_definitions) = if let Some(manifest) = tool_manifest { (manifest.allowed_tool_names, Some(manifest.tool_definitions)) } else { @@ -2144,6 +2222,7 @@ impl ExecutionEngine { &ai_client.config.model, primary_supports_image_understanding, tool_listing_sections, + runtime_context_needs, ) .await; let prepended_prompt_reminders = self @@ -2151,6 +2230,7 @@ impl ExecutionEngine { &context.session_id, current_agent.as_ref(), prompt_context.as_ref(), + &context.context, ) .await; let prepended_reminders = prepended_prompt_reminders.ordered_reminders(); @@ -2163,7 +2243,7 @@ impl ExecutionEngine { .await?; debug!("System prompt built, length: {} bytes", system_prompt.len()); debug!( - "Prepended reminders built: skill_listing_len={} agent_listing_len={} collapsed_tool_listing_len={} user_context_len={}", + "Prepended reminders built: skill_listing_len={} agent_listing_len={} collapsed_tool_listing_len={} user_context_len={} runtime_context_len={}", prepended_prompt_reminders .skill_listing .as_ref() @@ -2183,6 +2263,11 @@ impl ExecutionEngine { .user_context .as_ref() .map(|text| text.len()) + .unwrap_or(0), + prepended_prompt_reminders + .runtime_context + .as_ref() + .map(|text| text.len()) .unwrap_or(0) ); let system_prompt_message = Message::system(system_prompt.clone()); @@ -2199,11 +2284,12 @@ impl ExecutionEngine { let mut consecutive_compression_failures: u32 = 0; const MAX_CONSECUTIVE_COMPRESSION_FAILURES: u32 = 3; - // P0: Loop detection: track recent tool call signatures + // Track tool-call patterns for context health, but only use rounds with + // actual failed tool results for no-progress recovery decisions. let mut recent_tool_signatures: Vec = Vec::new(); - let mut loop_detected = false; - let mut loop_recovery_attempts: usize = 0; - const MAX_LOOP_RECOVERY_ATTEMPTS: usize = 3; + let mut recent_failed_tool_signatures: Vec = Vec::new(); + let mut failed_tool_recovery_attempts: usize = 0; + const MAX_FAILED_TOOL_RECOVERY_ATTEMPTS: usize = 3; const MAX_PARTIAL_CONTINUATION_ATTEMPTS: usize = 3; let mut full_compression_count = 0usize; let mut compression_failure_count = 0u32; @@ -2255,10 +2341,6 @@ impl ExecutionEngine { "primary_model_supports_image_understanding".to_string(), primary_supports_image_understanding.to_string(), ); - execution_context_vars.insert( - "write_tool_mode".to_string(), - write_tool_mode.as_str().to_string(), - ); execution_context_vars.insert("turn_index".to_string(), context.turn_index.to_string()); // If the primary model is text-only, do not send image payloads to the provider. @@ -2362,10 +2444,7 @@ impl ExecutionEngine { &prepended_prompt_reminders, primary_supports_image_understanding, context_profile_policy.compression_contract_limit, - context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), + context.workspace.as_ref(), ) .await { @@ -2445,13 +2524,15 @@ impl ExecutionEngine { round_context_vars.insert("skip_tool_confirmation".to_string(), "true".to_string()); } let unlocked_collapsed_tools = - Self::collect_unlocked_collapsed_tools(&messages, &collapsed_tools); + collect_product_unlocked_collapsed_tools(&messages, &collapsed_tools); + let round_context = RoundContext { session_id: context.session_id.clone(), subagent_parent_info: context.subagent_parent_info.clone(), dialog_turn_id: context.dialog_turn_id.clone(), turn_index: context.turn_index, round_number: round_index, + round_group_id: None, workspace: context.workspace.clone(), messages: messages.clone(), available_tools: available_tools.clone(), @@ -2557,22 +2638,23 @@ impl ExecutionEngine { last_partial_recovery_reason = round_result.partial_recovery_reason.clone(); } - // P0: Consecutive same-tool-call loop detection - if !round_result.tool_calls.is_empty() { - let mut sigs: Vec = round_result - .tool_calls - .iter() - .map(|tc| { - let args_str = tc.arguments.to_string(); - let args_summary = Self::tool_signature_args_summary(&args_str); - format!("{}:{}", tc.tool_name, args_summary) - }) - .collect(); - sigs.sort(); - let round_sig = sigs.join("|"); - recent_tool_signatures.push(round_sig); + if let Some(round_signature) = Self::tool_call_signature(&round_result.tool_calls) { + recent_tool_signatures.push(round_signature.clone()); + if Self::failed_tool_round_signature( + &round_result.tool_calls, + &round_result.tool_result_messages, + ) + .is_some() + { + recent_failed_tool_signatures.push(round_signature); + } else { + recent_failed_tool_signatures.clear(); + failed_tool_recovery_attempts = 0; + } } else { recent_tool_signatures.clear(); + recent_failed_tool_signatures.clear(); + failed_tool_recovery_attempts = 0; } let after_round_tokens = @@ -2599,43 +2681,45 @@ impl ExecutionEngine { let max_consec = context_profile_policy .effective_loop_threshold(self.config.max_consecutive_same_tool); - if recent_tool_signatures.len() >= max_consec { - let tail = &recent_tool_signatures[recent_tool_signatures.len() - max_consec..]; + if recent_failed_tool_signatures.len() >= max_consec { + let tail = &recent_failed_tool_signatures + [recent_failed_tool_signatures.len() - max_consec..]; if tail.windows(2).all(|w| w[0] == w[1]) { - if loop_recovery_attempts < MAX_LOOP_RECOVERY_ATTEMPTS { - loop_recovery_attempts += 1; + if failed_tool_recovery_attempts < MAX_FAILED_TOOL_RECOVERY_ATTEMPTS { + failed_tool_recovery_attempts += 1; warn!( - "Loop detected: {} consecutive rounds with identical tool signatures, injecting recovery prompt #{}", - max_consec, loop_recovery_attempts + "Repeated tool failure detected: {} consecutive rounds with identical tool signatures, injecting recovery prompt #{}", + max_consec, failed_tool_recovery_attempts ); let reminder = format!( - "Loop detected: you have repeated the same tool call with identical arguments {} times in a row. \ - This means the approach is not making progress. You MUST now change your strategy: \ + "Repeated tool failure detected: the same tool call with identical arguments has failed {} times in a row. \ + The current approach is not making progress. You MUST now change your strategy: \ (1) if the tool keeps failing, try a completely different approach or tool; \ (2) if you are stuck, step back and reason about the root cause before acting; \ (3) if the task is genuinely impossible with the available tools, provide a clear explanation to the user. \ Do NOT repeat the same tool call again.", max_consec ); - let user_msg = Message::user(reminder); + let user_msg = Message::internal_reminder( + InternalReminderKind::LoopRecovery, + reminder, + ) + .with_turn_id(context.dialog_turn_id.clone()); messages.push(user_msg.clone()); if let Err(e) = self .session_manager .add_message(&context.session_id, user_msg) .await { - warn!("Failed to persist loop recovery reminder: {}", e); + warn!("Failed to persist failed-tool recovery reminder: {}", e); } - // Clear the recent signatures so the detector resets after recovery. - recent_tool_signatures.clear(); - // Do NOT break — continue the loop so the model gets a chance to recover. + recent_failed_tool_signatures.clear(); } else { warn!( - "Loop detected: {} consecutive rounds with identical tool signatures, max recovery attempts ({}) exhausted, stopping", - max_consec, MAX_LOOP_RECOVERY_ATTEMPTS + "Repeated tool failure detected: {} consecutive rounds with identical tool signatures, max recovery attempts ({}) exhausted, finalizing without tools", + max_consec, MAX_FAILED_TOOL_RECOVERY_ATTEMPTS ); - loop_detected = true; - finalization_reason = Some("loop_detected"); + finalization_reason = Some("repeated_tool_failures"); break; } } @@ -2655,24 +2739,28 @@ impl ExecutionEngine { // if at most `max_consec` distinct signatures appear AND every one // of those signatures appears at least twice, the window contains // no genuine new exploration and we treat it as a loop. - if Self::is_periodic_tool_signature_loop(&recent_tool_signatures, max_consec) { + if Self::is_periodic_tool_signature_loop(&recent_failed_tool_signatures, max_consec) { let window_size = max_consec.max(1).saturating_mul(2); - if loop_recovery_attempts < MAX_LOOP_RECOVERY_ATTEMPTS { - loop_recovery_attempts += 1; + if failed_tool_recovery_attempts < MAX_FAILED_TOOL_RECOVERY_ATTEMPTS { + failed_tool_recovery_attempts += 1; warn!( - "Loop detected: last {} rounds form a periodic tool-call pattern (<= {} distinct signatures, each repeated), injecting recovery prompt #{}", - window_size, max_consec, loop_recovery_attempts + "Repeated tool failure detected: last {} failed rounds form a periodic tool-call pattern (<= {} distinct signatures, each repeated), injecting recovery prompt #{}", + window_size, max_consec, failed_tool_recovery_attempts ); let reminder = format!( - "Loop detected: your last {} tool calls form a repeating pattern with no new progress. \ - You are cycling between the same actions without advancing the task. You MUST now change your strategy: \ + "Repeated tool failure detected: your last {} failed tool calls form a repeating pattern with no new progress. \ + You are cycling between failing actions without advancing the task. You MUST now change your strategy: \ (1) try a completely different approach or tool; \ (2) step back and reason about the root cause before acting; \ (3) if the task is genuinely impossible with the available tools, provide a clear explanation to the user. \ Do NOT repeat the same pattern of tool calls.", window_size ); - let user_msg = Message::user(reminder); + let user_msg = Message::internal_reminder( + InternalReminderKind::PeriodicLoopRecovery, + reminder, + ) + .with_turn_id(context.dialog_turn_id.clone()); messages.push(user_msg.clone()); if let Err(e) = self .session_manager @@ -2681,26 +2769,22 @@ impl ExecutionEngine { { warn!("Failed to persist periodic loop recovery reminder: {}", e); } - // Clear the recent signatures so the detector resets after recovery. - recent_tool_signatures.clear(); - // Do NOT break — continue the loop so the model gets a chance to recover. + recent_failed_tool_signatures.clear(); } else { warn!( - "Loop detected: last {} rounds form a periodic tool-call pattern, max recovery attempts ({}) exhausted, stopping", - window_size, MAX_LOOP_RECOVERY_ATTEMPTS + "Repeated tool failure detected: last {} failed rounds form a periodic tool-call pattern, max recovery attempts ({}) exhausted, finalizing without tools", + window_size, MAX_FAILED_TOOL_RECOVERY_ATTEMPTS ); - loop_detected = true; - finalization_reason = Some("loop_detected"); + finalization_reason = Some("repeated_tool_failures"); break; } } // User-steering messages submitted while this turn is running: drain and inject // them as user messages into the working history before starting the next round - // (Codex-style mid-turn injection). This does NOT end the current turn, in - // contrast with the `round_preempt` path below which finalizes the turn so a - // queued *new turn* can take over. If the model wanted to finish but the user - // steered, we keep the turn running so the steering message gets a response. + // (Codex-style mid-turn injection). This does NOT end the current turn: if the + // model wanted to finish but the user steered, we keep the turn running so the + // steering message gets a response. let mut injection_applied = false; if let Some(source) = context.round_injection.as_ref() { let pending = source.take_pending(&context.session_id, &context.dialog_turn_id); @@ -2722,8 +2806,21 @@ impl ExecutionEngine { "\nA background task has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground result:\n{}\n", injection.content ), + RoundInjectionKind::ThreadGoalObjectiveUpdated => { + injection.content.clone() + } + }; + let reminder_kind = match injection.kind { + RoundInjectionKind::UserSteering => InternalReminderKind::UserSteering, + RoundInjectionKind::BackgroundResult => { + InternalReminderKind::BackgroundResult + } + RoundInjectionKind::ThreadGoalObjectiveUpdated => { + InternalReminderKind::GoalObjectiveUpdated + } }; - let user_msg = Message::user(wrapped); + let user_msg = Message::internal_reminder(reminder_kind, wrapped) + .with_turn_id(context.dialog_turn_id.clone()); messages.push(user_msg.clone()); if let Err(e) = self .session_manager @@ -2777,7 +2874,11 @@ impl ExecutionEngine { let reminder = format!( "Your previous assistant response was interrupted mid-stream ({reason}). Continue writing from exactly where you stopped. Do not repeat content that was already delivered; pick up seamlessly and complete the answer." ); - let user_msg = Message::user(reminder.clone()); + let user_msg = Message::internal_reminder( + InternalReminderKind::InterruptedContinue, + reminder.clone(), + ) + .with_turn_id(context.dialog_turn_id.clone()); messages.push(user_msg.clone()); if let Err(e) = self .session_manager @@ -2820,7 +2921,11 @@ impl ExecutionEngine { } else if round_result.had_thinking_content { thinking_only_rescue_attempts += 1; let reminder = "The previous round produced internal reasoning only — no tool call and no user-visible response. You MUST now either: (1) call the single tool that best advances the user's task, or (2) write your final answer to the user. Do not produce another round of reasoning without taking action.".to_string(); - let user_msg = Message::user(reminder.clone()); + let user_msg = Message::internal_reminder( + InternalReminderKind::ThinkingOnlyRescue, + reminder.clone(), + ) + .with_turn_id(context.dialog_turn_id.clone()); messages.push(user_msg.clone()); if let Err(e) = self .session_manager @@ -2844,23 +2949,6 @@ impl ExecutionEngine { } } - // Queued user message while this turn was running: stop after a full model round. - // The round output has already been reflected in the in-memory message caches. - // No special deferral for tool-confirmation phases: we do not require the user to - // finish confirming before this boundary check runs; the check applies as soon as - // this `execute_round` completes (same as any other round). - if let Some(preempt) = context.round_preempt.as_ref() { - if preempt.should_yield_after_round(&context.session_id) { - preempt.clear_yield_after_round(&context.session_id); - info!( - "Yielding dialog turn after model round (queued user message): session_id={}, dialog_turn_id={}, round_index={}", - context.session_id, context.dialog_turn_id, round_index - ); - finalization_reason = Some("queued_user_message"); - break; - } - } - // Check if cancellation was requested after each round. Tokens stay // registered until final cleanup so early cancellation can be // observed by the first round. @@ -2898,26 +2986,28 @@ impl ExecutionEngine { } // P1-6: Track the actual termination reason for downstream reporting. - // Defaults to "complete" (model produced a final answer naturally) and - // is overridden by finalize / fallback paths below. - let mut effective_finish_reason: &'static str = match finalization_reason { + // Defaults to "complete" (model produced a final answer naturally). + let effective_finish_reason: &'static str = match finalization_reason { Some(r) => r, None => "complete", }; - let mut finalize_fallback_text_used = false; + let mut has_final_response = finalization_reason.is_none(); + let mut used_local_final_response_synthesis = false; if let Some(reason) = finalization_reason { - // If the turn yielded after tool use, ask the model to summarize - // tool results before the next queued user message takes over. - let needs_finalize_after_tool_use = reason == "queued_user_message" - && messages - .iter() - .rev() - .find(|message| message.role == MessageRole::Assistant) - .is_some_and(Self::assistant_has_tool_calls) - && Self::has_tool_result_after_last_assistant(&messages); + let finalize_reminder = match reason { + "repeated_tool_failures" => { + Some(Self::FINALIZE_AFTER_REPEATED_TOOL_FAILURES_REMINDER) + } + "max_rounds" => Some(Self::FINALIZE_AFTER_MAX_ROUNDS_REMINDER), + _ => None, + }; - if needs_finalize_after_tool_use { + if let Some(finalize_reminder) = finalize_reminder { + let finalize_round_group_id = Some(format!( + "{}:finalize:{}", + context.dialog_turn_id, completed_rounds + )); info!( "Finalizing dialog turn: session_id={}, turn_id={}, reason={}", context.session_id, context.dialog_turn_id, reason @@ -2929,29 +3019,28 @@ impl ExecutionEngine { &context, agent_type.clone(), completed_rounds, + finalize_round_group_id.clone(), &execution_context_vars, primary_supports_image_understanding, &prepended_reminders, &messages, - Self::FINALIZE_AFTER_TOOL_USE_REMINDER, + finalize_reminder, + tool_definitions.clone(), context_window, ) .await?; - let mut accepted = - !Self::assistant_has_tool_calls(&final_round_result.assistant_message); - let mut chosen_assistant_message: Option = None; + let mut accepted = final_round_result.had_assistant_text + && !Self::assistant_has_tool_calls(&final_round_result.assistant_message); + let chosen_assistant_message: Option; let mut chosen_usage: Option = final_round_result.usage.clone(); if accepted { chosen_assistant_message = Some(final_round_result.assistant_message.clone()); } else { - // P1-10: First finalize round still returned tool calls - // (rare; tools were not provided, but model hallucinated). - // One last attempt with a stricter text-only reminder. warn!( - "Finalize round still returned tool calls; retrying with text-only reminder: session_id={}, turn_id={}", + "Finalize round did not return usable assistant text; retrying once: session_id={}, turn_id={}", context.session_id, context.dialog_turn_id ); let retry_result = self @@ -2960,20 +3049,29 @@ impl ExecutionEngine { &context, agent_type.clone(), completed_rounds, + finalize_round_group_id.clone(), &execution_context_vars, primary_supports_image_understanding, &prepended_reminders, &messages, - Self::FORCE_TEXT_ONLY_REMINDER, + finalize_reminder, + tool_definitions.clone(), context_window, ) .await?; - finalize_fallback_text_used = true; - if Self::assistant_has_tool_calls(&retry_result.assistant_message) { + if !retry_result.had_assistant_text + || Self::assistant_has_tool_calls(&retry_result.assistant_message) + { warn!( - "Text-only retry also returned tool calls; keeping prior messages: session_id={}, turn_id={}", + "Finalize retry did not return usable assistant text; synthesizing local final response: session_id={}, turn_id={}", context.session_id, context.dialog_turn_id ); + accepted = true; + used_local_final_response_synthesis = true; + chosen_assistant_message = Some( + Message::assistant(Self::build_local_final_response_message(reason)) + .with_turn_id(context.dialog_turn_id.clone()), + ); } else { accepted = true; chosen_usage = retry_result.usage.clone(); @@ -2981,7 +3079,28 @@ impl ExecutionEngine { } } + has_final_response = Self::should_mark_has_final_response( + chosen_assistant_message.is_some(), + used_local_final_response_synthesis, + ); if let Some(msg) = chosen_assistant_message { + if accepted && !used_local_final_response_synthesis { + let finalize_cache_anchor_messages = + Self::build_finalize_cache_anchor_messages( + &context.dialog_turn_id, + finalize_reminder, + ); + for anchor_message in finalize_cache_anchor_messages { + messages.push(anchor_message.clone()); + if let Err(e) = self + .session_manager + .add_message(&context.session_id, anchor_message) + .await + { + warn!("Failed to persist finalize cache anchor message: {}", e); + } + } + } completed_rounds += 1; if let Some(usage) = chosen_usage { last_usage = Some(usage); @@ -2995,12 +3114,8 @@ impl ExecutionEngine { warn!("Failed to update final assistant message in memory: {}", e); } } - - if !accepted { - effective_finish_reason = "finalize_failed"; - } else if finalize_fallback_text_used { - effective_finish_reason = "finalize_text_only_forced"; - } + } else if reason == "partial_truncated" { + has_final_response = true; } } @@ -3012,24 +3127,32 @@ impl ExecutionEngine { ); let finish_reason = FinishReason::Complete; - // success reflects whether we ended with a usable final answer. - let success = !loop_detected - && !matches!( + // Some abnormal turn endings still go through the completed-event path + // so the UI can explain the termination cause inline even when the turn + // ended without a final assistant reply. + let success = has_final_response + || matches!( effective_finish_reason, - "finalize_failed" | "empty_round" | "max_rounds" + "max_rounds" | "repeated_tool_failures" ); // Post-processing hook: when a DeepResearch dialog turn finishes // successfully, renumber `cit_XXX` references in the final report // into consecutive `[N]` display IDs. Two gates apply (agent type + // dialog success) so other agents and failed turns are unaffected. - if success && agent_type == "DeepResearch" { - if let Some(workspace) = context.workspace.as_ref() { - crate::agentic::agents::citation_renumber::run_for_session_workspace( - workspace.root_path(), - &context.session_id, - ) - .await; + #[cfg(feature = "product-full")] + { + if bitfun_agent_runtime::deep_research::should_post_process_research_report( + &agent_type, + success, + ) { + if let Some(workspace) = context.workspace.as_ref() { + bitfun_services_integrations::deep_research::run_for_session_workspace( + workspace.root_path(), + &context.session_id, + ) + .await; + } } } @@ -3048,6 +3171,7 @@ impl ExecutionEngine { partial_recovery_reason: last_partial_recovery_reason, success: Some(success), finish_reason: Some(effective_finish_reason.to_string()), + has_final_response: Some(has_final_response), }, None, ) @@ -3149,12 +3273,14 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::{ContextHealthSnapshot, ExecutionEngine}; - use crate::agentic::core::{Message, MessageRole, ToolCall, ToolResult}; + use crate::agentic::core::{InternalReminderKind, Message, MessageRole, ToolCall, ToolResult}; + use crate::agentic::tools::ToolRuntimeRestrictions; use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; use crate::util::types::ToolDefinition; use serde_json::json; use sha2::{Digest, Sha256}; + use std::collections::HashMap; fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { AIModelConfig { @@ -3242,6 +3368,103 @@ mod tests { )); } + #[test] + fn finalize_tool_names_match_tool_definitions() { + let tools = vec![ + ToolDefinition { + name: "Read".to_string(), + description: String::new(), + parameters: json!({}), + }, + ToolDefinition { + name: "Bash".to_string(), + description: String::new(), + parameters: json!({}), + }, + ]; + + assert_eq!( + ExecutionEngine::finalize_tool_names(Some(&tools)), + vec!["Read".to_string(), "Bash".to_string()] + ); + } + + #[test] + fn finalize_runtime_tool_restrictions_deny_all_finalize_tools() { + let context = crate::agentic::execution::types::ExecutionContext { + session_id: "session".to_string(), + dialog_turn_id: "turn".to_string(), + turn_index: 0, + agent_type: "agentic".to_string(), + workspace: None, + context: HashMap::new(), + subagent_parent_info: None, + delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), + skip_tool_confirmation: false, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + round_injection: None, + recover_partial_on_cancel: false, + }; + + let restrictions = ExecutionEngine::finalize_runtime_tool_restrictions( + &context, + &["Read".to_string(), "Bash".to_string()], + ); + + assert!(restrictions.denied_tool_names.contains("Read")); + assert!(restrictions.denied_tool_names.contains("Bash")); + assert_eq!( + restrictions.denied_tool_messages.get("Read"), + Some(&ExecutionEngine::FINALIZE_TOOL_DENIED_MESSAGE.to_string()) + ); + } + + #[test] + fn local_final_response_message_mentions_reason() { + assert!( + ExecutionEngine::build_local_final_response_message("repeated_tool_failures") + .contains("repeated tool failures") + ); + assert!( + ExecutionEngine::build_local_final_response_message("max_rounds") + .contains("round limit") + ); + assert!( + !ExecutionEngine::build_local_final_response_message("max_rounds") + .contains("finalize mode") + ); + } + + #[test] + fn local_fallback_response_does_not_count_as_agent_final_response() { + assert!(ExecutionEngine::should_mark_has_final_response(true, false)); + assert!(!ExecutionEngine::should_mark_has_final_response(true, true)); + assert!(!ExecutionEngine::should_mark_has_final_response( + false, false + )); + } + + #[test] + fn finalize_cache_anchor_messages_are_internal_and_not_actual_user_input() { + let messages = ExecutionEngine::build_finalize_cache_anchor_messages( + "turn-1", + ExecutionEngine::FINALIZE_AFTER_MAX_ROUNDS_REMINDER, + ); + + assert_eq!(messages.len(), 2); + assert_eq!( + messages[0].internal_reminder_kind(), + Some(InternalReminderKind::FinalizeCacheAnchor) + ); + assert_eq!( + messages[1].internal_reminder_kind(), + Some(InternalReminderKind::FinalizeCacheAnchor) + ); + assert!(!messages[0].is_actual_user_message()); + assert!(!messages[1].is_actual_user_message()); + } + #[test] fn tool_signature_args_summary_distinguishes_same_prefix_and_length() { let first = format!("{}{}", "x".repeat(64), "a".repeat(80)); @@ -3255,6 +3478,58 @@ mod tests { assert_ne!(first_summary, second_summary); } + #[test] + fn failed_tool_round_signature_ignores_successful_repeated_calls() { + let tool_calls = vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: "PollStatus".to_string(), + arguments: json!({ "job_id": "job-1" }), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }]; + let results = vec![Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "PollStatus".to_string(), + result: json!({ "status": "pending", "success": true }), + result_for_assistant: Some("The job is still pending.".to_string()), + is_error: false, + duration_ms: Some(1), + image_attachments: None, + })]; + + assert!( + ExecutionEngine::failed_tool_round_signature(&tool_calls, &results).is_none(), + "successful polling must not be treated as a failed loop" + ); + } + + #[test] + fn failed_tool_round_signature_requires_actual_failure_evidence() { + let tool_calls = vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + arguments: json!({ "path": "missing.txt" }), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }]; + let results = vec![Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + result: json!({ "success": false, "error": "not found" }), + result_for_assistant: Some("File not found.".to_string()), + is_error: true, + duration_ms: Some(1), + image_attachments: None, + })]; + + assert_eq!( + ExecutionEngine::failed_tool_round_signature(&tool_calls, &results).as_deref(), + Some(r#"Read:{"path":"missing.txt"}"#) + ); + } + #[test] fn periodic_loop_detector_ignores_short_windows() { let signatures: Vec = vec!["A".to_string(), "B".to_string(), "A".to_string()]; @@ -3387,200 +3662,6 @@ mod tests { assert_eq!(snapshot.compression_failure_count, 2); } - #[test] - fn assistant_has_tool_calls_detects_mixed_tool_message() { - let message = Message::assistant_with_tools( - String::new(), - vec![ToolCall { - tool_id: "tool-1".to_string(), - tool_name: "Read".to_string(), - arguments: json!({ "path": "README.md" }), - raw_arguments: None, - is_error: false, - recovered_from_truncation: false, - }], - ); - - assert!(ExecutionEngine::assistant_has_tool_calls(&message)); - assert!(!ExecutionEngine::assistant_has_tool_calls( - &Message::assistant("done".to_string()) - )); - } - - #[test] - fn collects_unlocked_collapsed_tools_from_visible_get_tool_spec_results() { - let visible_get_tool_spec_result = Message::tool_result(ToolResult { - tool_id: "tool-1".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "WebFetch", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }); - let hidden_get_tool_spec_result = Message::tool_result(ToolResult { - tool_id: "tool-2".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "Read", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }); - let failed_get_tool_spec_result = Message::tool_result(ToolResult { - tool_id: "tool-3".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "GetFileDiff", - }), - result_for_assistant: None, - is_error: true, - duration_ms: Some(1), - image_attachments: None, - }); - - let unlocked = ExecutionEngine::collect_unlocked_collapsed_tools( - &[ - visible_get_tool_spec_result, - hidden_get_tool_spec_result, - failed_get_tool_spec_result, - ], - &["WebFetch".to_string(), "GetFileDiff".to_string()], - ); - - assert_eq!(unlocked, vec!["WebFetch".to_string()]); - } - - #[test] - fn collect_unlocked_collapsed_tools_dedupes_and_filters_runtime_unlocks() { - let unlocked = ExecutionEngine::collect_unlocked_collapsed_tools( - &[ - Message::tool_result(ToolResult { - tool_id: "tool-1".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "WebFetch", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }), - Message::tool_result(ToolResult { - tool_id: "tool-2".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "WebFetch", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }), - Message::tool_result(ToolResult { - tool_id: "tool-3".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "Git", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }), - Message::tool_result(ToolResult { - tool_id: "tool-4".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "Read", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }), - Message::tool_result(ToolResult { - tool_id: "tool-5".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": "GetFileDiff", - }), - result_for_assistant: None, - is_error: true, - duration_ms: Some(1), - image_attachments: None, - }), - Message::tool_result(ToolResult { - tool_id: "tool-6".to_string(), - tool_name: "GetToolSpec".to_string(), - result: json!({ - "tool_name": 42, - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }), - Message::tool_result(ToolResult { - tool_id: "tool-7".to_string(), - tool_name: "Read".to_string(), - result: json!({ - "tool_name": "GetFileDiff", - }), - result_for_assistant: None, - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }), - ], - &[ - "WebFetch".to_string(), - "GetFileDiff".to_string(), - "Git".to_string(), - ], - ); - - assert_eq!(unlocked, vec!["Git".to_string(), "WebFetch".to_string()]); - } - - #[test] - fn detects_tool_result_after_last_assistant() { - let assistant = Message::assistant_with_tools( - String::new(), - vec![ToolCall { - tool_id: "tool-1".to_string(), - tool_name: "Read".to_string(), - arguments: json!({ "path": "README.md" }), - raw_arguments: None, - is_error: false, - recovered_from_truncation: false, - }], - ); - let tool_result = Message::tool_result(ToolResult { - tool_id: "tool-1".to_string(), - tool_name: "Read".to_string(), - result: json!({ "content": "hello" }), - result_for_assistant: Some("hello".to_string()), - is_error: false, - duration_ms: Some(1), - image_attachments: None, - }); - - assert!(ExecutionEngine::has_tool_result_after_last_assistant(&[ - Message::user("read it".to_string()), - assistant.clone(), - tool_result, - ])); - assert!(!ExecutionEngine::has_tool_result_after_last_assistant(&[ - Message::user("read it".to_string()), - assistant, - ])); - } - fn command_result(tool_name: &str, success: bool, exit_code: Option) -> Message { Message::tool_result(ToolResult { tool_id: format!("{}-tool", tool_name), diff --git a/src/crates/core/src/agentic/execution/mod.rs b/src/crates/assembly/core/src/agentic/execution/mod.rs similarity index 91% rename from src/crates/core/src/agentic/execution/mod.rs rename to src/crates/assembly/core/src/agentic/execution/mod.rs index aa3cc2ea9b..44a3e4ab54 100644 --- a/src/crates/core/src/agentic/execution/mod.rs +++ b/src/crates/assembly/core/src/agentic/execution/mod.rs @@ -3,6 +3,7 @@ //! Responsible for AI interaction and model round control pub mod execution_engine; +pub(crate) mod model_exchange_trace; pub mod round_executor; pub mod stream_processor; pub mod types; diff --git a/src/crates/assembly/core/src/agentic/execution/model_exchange_trace.rs b/src/crates/assembly/core/src/agentic/execution/model_exchange_trace.rs new file mode 100644 index 0000000000..efd266c70d --- /dev/null +++ b/src/crates/assembly/core/src/agentic/execution/model_exchange_trace.rs @@ -0,0 +1,478 @@ +use super::types::RoundContext; +use crate::agentic::WorkspaceBinding; +use crate::infrastructure::ai::AIClient; +use crate::service::config::{ + GlobalConfigManager, ModelExchangeTracingConfig, ModelExchangeTracingMode, +}; +use crate::service::workspace_runtime::{ + get_workspace_runtime_service_arc, WorkspaceRuntimeContext, +}; +use async_trait::async_trait; +use bitfun_ai_adapters::{ + ModelExchangeRequestAttempt, ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, + ModelExchangeTraceConfig, ModelExchangeTraceSink, +}; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use tokio::sync::Mutex; +use uuid::Uuid; + +const TRACE_LAYOUT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ModelExchangeTraceRecord { + version: u32, + trace_id: String, + sequence: u64, + recorded_at: DateTime, + session_id: String, + turn_id: String, + operation_kind: String, + operation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + operation_trigger: Option, + capture_mode: ModelExchangeTracingMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + response: Option, + request: ModelExchangeTraceRequestRecord, +} + +#[derive(Debug, Clone, Copy)] +pub struct ModelExchangeTraceOperation<'a> { + pub kind: &'a str, + pub id: &'a str, + pub trigger: Option<&'a str>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ModelExchangeTraceRequestRecord { + provider: String, + api_format: String, + model_id: String, + request_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + attempt_number: usize, +} + +#[derive(Debug, Clone, Copy)] +struct ModelExchangeTracePolicy { + mode: ModelExchangeTracingMode, + capture_request_body: bool, + capture_response_text: bool, + capture_reasoning: bool, + capture_tool_calls: bool, + capture_usage: bool, + capture_provider_metadata: bool, +} + +impl ModelExchangeTracePolicy { + fn from_config(config: ModelExchangeTracingConfig) -> Option { + match config.mode { + ModelExchangeTracingMode::Off => None, + ModelExchangeTracingMode::Full => Some(Self { + mode: ModelExchangeTracingMode::Full, + capture_request_body: true, + capture_response_text: true, + capture_reasoning: true, + capture_tool_calls: true, + capture_usage: true, + capture_provider_metadata: true, + }), + ModelExchangeTracingMode::UsageOnly => Some(Self { + mode: ModelExchangeTracingMode::UsageOnly, + capture_request_body: false, + capture_response_text: false, + capture_reasoning: false, + capture_tool_calls: false, + capture_usage: true, + capture_provider_metadata: false, + }), + } + } +} + +#[derive(Debug)] +struct WorkspaceModelExchangeTraceSink { + runtime_context: WorkspaceRuntimeContext, + policy: ModelExchangeTracePolicy, + session_id: String, + turn_id: String, + operation_kind: String, + operation_id: String, + operation_trigger: Option, + provider: String, + api_format: String, + model_id: String, + trace_paths: DashMap, +} + +impl WorkspaceModelExchangeTraceSink { + fn new( + runtime_context: WorkspaceRuntimeContext, + policy: ModelExchangeTracePolicy, + session_id: String, + turn_id: String, + operation_kind: String, + operation_id: String, + operation_trigger: Option, + provider: String, + api_format: String, + model_id: String, + ) -> Self { + Self { + runtime_context, + policy, + session_id, + turn_id, + operation_kind, + operation_id, + operation_trigger, + provider, + api_format, + model_id, + trace_paths: DashMap::new(), + } + } + + async fn allocate_sequence(&self) -> Result { + let session_dir = self + .runtime_context + .request_trace_session_dir(&self.session_id); + let key = session_dir.to_string_lossy().to_string(); + let allocator = sequence_allocators() + .entry(key) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .clone(); + let mut guard = allocator.lock().await; + let current = match *guard { + Some(value) => value, + None => { + let detected = detect_last_sequence(&session_dir).await?; + *guard = Some(detected); + detected + } + }; + let next = current.saturating_add(1); + *guard = Some(next); + Ok(next) + } + + async fn write_record( + &self, + path: &Path, + record: &ModelExchangeTraceRecord, + ) -> Result<(), String> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| format!("Failed to create trace directory: {}", error))?; + } + + let bytes = serde_json::to_vec_pretty(record) + .map_err(|error| format!("Failed to serialize trace record: {}", error))?; + tokio::fs::write(path, bytes) + .await + .map_err(|error| format!("Failed to write trace record: {}", error)) + } + + async fn read_record(&self, path: &Path) -> Result { + let bytes = tokio::fs::read(path) + .await + .map_err(|error| format!("Failed to read trace record: {}", error))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("Failed to deserialize trace record: {}", error)) + } + + async fn update_response( + &self, + trace_id: &str, + response: &ModelExchangeResponseTrace, + ) -> Result<(), String> { + let Some(path) = self + .trace_paths + .get(trace_id) + .map(|entry| entry.value().clone()) + else { + return Ok(()); + }; + + let mut record = self.read_record(&path).await?; + record.response = Some(self.sanitize_response(response)); + self.write_record(&path, &record).await?; + self.trace_paths.remove(trace_id); + Ok(()) + } + + fn sanitize_response( + &self, + response: &ModelExchangeResponseTrace, + ) -> ModelExchangeResponseTrace { + ModelExchangeResponseTrace { + kind: response.kind.clone(), + assistant_text: self + .policy + .capture_response_text + .then(|| response.assistant_text.clone()) + .flatten(), + thinking: self + .policy + .capture_reasoning + .then(|| response.thinking.clone()) + .flatten(), + tool_calls: self + .policy + .capture_tool_calls + .then(|| response.tool_calls.clone()) + .flatten(), + usage: self + .policy + .capture_usage + .then(|| response.usage.clone()) + .flatten(), + provider_metadata: self + .policy + .capture_provider_metadata + .then(|| response.provider_metadata.clone()) + .flatten(), + partial_recovery_reason: response.partial_recovery_reason.clone(), + error: response.error.clone(), + } + } +} + +#[async_trait] +impl ModelExchangeTraceSink for WorkspaceModelExchangeTraceSink { + async fn request_attempt_started( + &self, + attempt: &ModelExchangeRequestAttempt, + ) -> Option { + let sequence = match self.allocate_sequence().await { + Ok(value) => value, + Err(error) => { + warn!( + "Model exchange trace sequence allocation failed: session_id={}, error={}", + self.session_id, error + ); + return None; + } + }; + + let trace_id = Uuid::new_v4().to_string(); + let path = self + .runtime_context + .request_trace_path(&self.session_id, sequence); + let record = ModelExchangeTraceRecord { + version: TRACE_LAYOUT_VERSION, + trace_id: trace_id.clone(), + sequence, + recorded_at: Utc::now(), + session_id: self.session_id.clone(), + turn_id: self.turn_id.clone(), + operation_kind: self.operation_kind.clone(), + operation_id: self.operation_id.clone(), + operation_trigger: self.operation_trigger.clone(), + capture_mode: self.policy.mode, + response: None, + request: ModelExchangeTraceRequestRecord { + provider: self.provider.clone(), + api_format: self.api_format.clone(), + model_id: self.model_id.clone(), + request_url: attempt.request_url.clone(), + body: attempt.request_body.clone(), + attempt_number: attempt.attempt_number, + }, + }; + + if let Err(error) = self.write_record(&path, &record).await { + warn!( + "Model exchange trace write failed: session_id={}, trace_id={}, error={}", + self.session_id, trace_id, error + ); + return None; + } + + self.trace_paths.insert(trace_id.clone(), path); + Some(ModelExchangeRequestTraceHandle { trace_id }) + } + + async fn request_attempt_failed( + &self, + handle: Option<&ModelExchangeRequestTraceHandle>, + error: &str, + ) { + let Some(handle) = handle else { + return; + }; + + if let Err(write_error) = self + .update_response( + &handle.trace_id, + &ModelExchangeResponseTrace { + kind: "error".to_string(), + assistant_text: None, + thinking: None, + tool_calls: None, + usage: None, + provider_metadata: None, + partial_recovery_reason: None, + error: Some(error.to_string()), + }, + ) + .await + { + warn!( + "Model exchange trace failure update failed: session_id={}, trace_id={}, error={}", + self.session_id, handle.trace_id, write_error + ); + } + } + + async fn request_attempt_completed( + &self, + handle: &ModelExchangeRequestTraceHandle, + response: &ModelExchangeResponseTrace, + ) { + if let Err(error) = self.update_response(&handle.trace_id, response).await { + warn!( + "Model exchange trace completion update failed: session_id={}, trace_id={}, error={}", + self.session_id, handle.trace_id, error + ); + } + } +} + +pub async fn prepare_model_exchange_trace( + context: &RoundContext, + round_id: &str, + ai_client: &AIClient, +) -> Option { + prepare_model_exchange_trace_for_workspace( + &context.session_id, + &context.dialog_turn_id, + context.workspace.as_ref(), + ModelExchangeTraceOperation { + kind: "model_round", + id: round_id, + trigger: None, + }, + ai_client, + ) + .await +} + +pub async fn prepare_model_exchange_trace_for_workspace( + session_id: &str, + turn_id: &str, + workspace: Option<&WorkspaceBinding>, + operation: ModelExchangeTraceOperation<'_>, + ai_client: &AIClient, +) -> Option { + let Some(policy) = current_model_exchange_trace_policy().await else { + return None; + }; + + let Some(workspace) = workspace else { + debug!( + "Model exchange trace skipped because operation has no workspace: session_id={}, turn_id={}, operation_kind={}, operation_id={}", + session_id, turn_id, operation.kind, operation.id + ); + return None; + }; + + let runtime_context = match get_workspace_runtime_service_arc() + .ensure_runtime_for_workspace_binding(workspace) + .await + { + Ok(result) => result.context, + Err(error) => { + warn!( + "Model exchange trace skipped because runtime init failed: session_id={}, operation_kind={}, operation_id={}, error={}", + session_id, operation.kind, operation.id, error + ); + return None; + } + }; + + Some(ModelExchangeTraceConfig { + sink: Arc::new(WorkspaceModelExchangeTraceSink::new( + runtime_context, + policy, + session_id.to_string(), + turn_id.to_string(), + operation.kind.to_string(), + operation.id.to_string(), + operation.trigger.map(str::to_string), + ai_client.config.format.clone(), + ai_client.config.format.clone(), + ai_client.config.model.clone(), + )), + capture_request_body: policy.capture_request_body, + }) +} + +async fn current_model_exchange_trace_policy() -> Option { + let Ok(config_service) = GlobalConfigManager::get_service().await else { + return None; + }; + + let tracing_config: ModelExchangeTracingConfig = config_service + .get_config(Some("app.logging.model_exchange_tracing")) + .await + .unwrap_or_default(); + ModelExchangeTracePolicy::from_config(tracing_config) +} + +async fn detect_last_sequence(session_dir: &Path) -> Result { + let mut last_sequence = 0u64; + let mut entries = match tokio::fs::read_dir(session_dir).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(format!( + "Failed to inspect trace session directory '{}': {}", + session_dir.display(), + error + )); + } + }; + + loop { + let Some(entry) = entries + .next_entry() + .await + .map_err(|error| format!("Failed to iterate trace session directory: {}", error))? + else { + break; + }; + + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + let Some(sequence) = parse_trace_sequence(file_name) else { + continue; + }; + last_sequence = last_sequence.max(sequence); + } + + Ok(last_sequence) +} + +fn parse_trace_sequence(file_name: &str) -> Option { + file_name + .strip_prefix("request-")? + .strip_suffix(".json")? + .parse::() + .ok() +} + +fn sequence_allocators() -> &'static DashMap>>> { + static ALLOCATORS: OnceLock>>>> = OnceLock::new(); + ALLOCATORS.get_or_init(DashMap::new) +} diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs new file mode 100644 index 0000000000..2ddc1b68dd --- /dev/null +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -0,0 +1,1613 @@ +//! Round Executor +//! +//! Executes a single model round: calls AI, processes streaming responses, executes tools + +use super::model_exchange_trace::prepare_model_exchange_trace; +use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult}; +use super::types::{FinishReason, RoundContext, RoundResult}; +use crate::agentic::core::{Message, ToolCall}; +use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; +use crate::agentic::tools::computer_use_host::ComputerUseHostRef; +use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; +use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::tools::tool_context_runtime; +use crate::agentic::tools::tool_result_storage; +use crate::agentic::MessageContent; +use crate::infrastructure::ai::AIClient; +use crate::service::config::GlobalConfigManager; +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::turn_cancellation::DialogTurnCancellationTokenStore; +use bitfun_ai_adapters::{ + ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, ModelExchangeTraceConfig, +}; +use log::{debug, error, warn}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +/// Round executor +pub struct RoundExecutor { + stream_processor: Arc, + tool_pipeline: Option>, + event_queue: Arc, + cancellation_tokens: DialogTurnCancellationTokenStore, +} + +impl RoundExecutor { + const MAX_STREAM_ATTEMPTS: usize = 10; + const RETRY_BASE_DELAY_MS: u64 = 500; + + fn has_user_visible_assistant_text(text: &str) -> bool { + !text.trim().is_empty() + } + + async fn sleep_with_cancellation( + delay_ms: u64, + cancel_token: &CancellationToken, + ) -> BitFunResult<()> { + tokio::select! { + _ = cancel_token.cancelled() => Err(BitFunError::Cancelled("Execution cancelled".to_string())), + _ = tokio::time::sleep(Duration::from_millis(delay_ms)) => Ok(()), + } + } + + pub fn new( + stream_processor: Arc, + event_queue: Arc, + tool_pipeline: Arc, + ) -> Self { + Self { + stream_processor, + tool_pipeline: Some(tool_pipeline), + event_queue, + cancellation_tokens: DialogTurnCancellationTokenStore::new(), + } + } + + pub fn computer_use_host(&self) -> Option { + self.tool_pipeline + .as_ref() + .and_then(|p| p.computer_use_host()) + } + + /// Execute a single model round + pub async fn execute_round( + &self, + ai_client: Arc, + context: RoundContext, + ai_messages: Vec, + tool_definitions: Option>, + context_window: Option, + ) -> BitFunResult { + let round_started_at = Instant::now(); + let subagent_parent_info = context.subagent_parent_info.clone(); + let is_subagent = subagent_parent_info.is_some(); + + let round_id = uuid::Uuid::new_v4().to_string(); + + // Create or reuse cancellation token + let cancel_token = self + .cancellation_tokens + .get_or_insert_new(&context.dialog_turn_id); + + // Emit model round started event + self.emit_event( + AgenticEvent::ModelRoundStarted { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + round_id: round_id.clone(), + round_group_id: context.round_group_id.clone(), + round_index: context.round_number, + model_id: Some(context.model_name.clone()), + }, + EventPriority::High, + ) + .await; + + let trace_config = + prepare_model_exchange_trace(&context, &round_id, ai_client.as_ref()).await; + let max_attempts = Self::MAX_STREAM_ATTEMPTS; + let mut attempt_index = 0usize; + let (stream_result, send_to_stream_ms, stream_processing_ms, final_trace_handle) = loop { + let attempt_number = (attempt_index + 1) as u32; + let attempt_id = format!("{round_id}:attempt:{attempt_number}"); + // Check cancellation before opening a model stream. This catches + // early cancellation registered before the first round starts. + if cancel_token.is_cancelled() { + debug!( + "Cancel token detected before AI request, stopping execution: session_id={}", + context.session_id + ); + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + + let request_started_at = Instant::now(); + debug!( + "Sending request: model={}, messages={}, tools={}, attempt={}/{}", + context.model_name, + ai_messages.len(), + tool_definitions.as_ref().map(|t| t.len()).unwrap_or(0), + attempt_index + 1, + max_attempts + ); + // Use dynamically obtained client for call + let send_future = ai_client.send_message_stream( + ai_messages.clone(), + tool_definitions.clone(), + trace_config.clone(), + ); + let send_result = tokio::select! { + _ = cancel_token.cancelled() => { + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + result = send_future => result, + }; + let (stream_response, send_to_stream_ms) = match send_result { + Ok(response) => { + let send_to_stream_ms = elapsed_ms_u64(request_started_at); + debug!( + "AI stream opened: session_id={}, round_id={}, attempt={}/{}, send_to_stream_ms={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + send_to_stream_ms + ); + (response, send_to_stream_ms) + } + Err(e) => { + error!("AI request failed: {}", e); + let err_msg = e.to_string(); + if Self::is_transient_network_error(&err_msg) + && attempt_index < max_attempts - 1 + { + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying AI request after connection failure: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + err_msg + ); + if let Err(cancel_err) = + Self::sleep_with_cancellation(delay_ms, &cancel_token).await + { + return Err(cancel_err); + } + attempt_index += 1; + continue; + } + if Self::is_transient_network_error(&err_msg) { + return Err(BitFunError::AIClient(format!( + "Stream retry budget exhausted after {} attempts: {}", + max_attempts, err_msg + ))); + } + // Non-transient errors (429 budget exhausted, context + // overflow, auth, etc.) are returned directly. The error + // message is classified downstream via + // `BitFunError::error_category()` into `ErrorCategory` for + // frontend recovery actions (wait_and_retry, switch_model, + // etc.). + let error = BitFunError::AIClient(err_msg); + warn!( + "AI request terminal failure: session_id={}, round_id={}, category={:?}, error={}", + context.session_id, + round_id, + error.error_category(), + error + ); + return Err(error); + } + }; + + // Destructure StreamResponse: get stream and raw SSE data receiver + let ai_stream = stream_response.stream; + let raw_sse_rx = stream_response.raw_sse_rx; + let trace_handle = stream_response.trace_handle; + + // Check cancellation token before calling stream processing. + if cancel_token.is_cancelled() { + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response("cancelled", "Execution cancelled".to_string()), + ) + .await; + debug!( + "Cancel token detected after AI stream opened, stopping execution: session_id={}", + context.session_id + ); + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + + debug!( + "Starting AI stream processing: session={}, round={}, thread={:?}, attempt={}/{}", + context.session_id, + round_id, + std::thread::current().id(), + attempt_index + 1, + max_attempts + ); + + let stream_started_at = Instant::now(); + match self + .stream_processor + .process_stream_with_options( + ai_stream, + StreamProcessor::derive_watchdog_timeout(ai_client.stream_idle_timeout()), + raw_sse_rx, // Pass raw SSE data receiver (for error diagnosis) + context.session_id.clone(), + context.dialog_turn_id.clone(), + round_id.clone(), + attempt_id.clone(), + attempt_number, + &cancel_token, + StreamProcessOptions { + recover_partial_on_cancel: context.recover_partial_on_cancel, + }, + ) + .await + { + Ok(result) => { + let stream_processing_ms = elapsed_ms_u64(stream_started_at); + if Self::has_interrupted_invalid_tool_calls(&result) { + let err_msg = result.partial_recovery_reason.clone().unwrap_or_else(|| { + "Interrupted while streaming tool arguments".to_string() + }); + + if !Self::has_user_visible_assistant_text(&result.full_text) + && attempt_index < max_attempts - 1 + && Self::is_transient_network_error(&err_msg) + { + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::trace_response_from_stream_result("partial", &result), + ) + .await; + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream because tool arguments were interrupted before valid JSON completed: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, invalid_tool_calls={}, error={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + result + .tool_calls + .iter() + .filter(|tool_call| !tool_call.is_valid()) + .count(), + err_msg + ); + if let Err(cancel_err) = + Self::sleep_with_cancellation(delay_ms, &cancel_token).await + { + return Err(cancel_err); + } + attempt_index += 1; + continue; + } + + if Self::has_user_visible_assistant_text(&result.full_text) { + warn!( + "Dropping invalid partial tool calls from interrupted stream; preserving already-streamed assistant text: session_id={}, round_id={}, invalid_tool_calls={}, error={}", + context.session_id, + round_id, + result + .tool_calls + .iter() + .filter(|tool_call| !tool_call.is_valid()) + .count(), + err_msg + ); + self.emit_failed_partial_tool_calls( + &context, + &round_id, + &result.tool_calls, + &err_msg, + ) + .await; + let mut recovered = result; + recovered + .tool_calls + .retain(|tool_call| tool_call.is_valid()); + break ( + recovered, + send_to_stream_ms, + stream_processing_ms, + trace_handle, + ); + } + + self.emit_failed_partial_tool_calls( + &context, + &round_id, + &result.tool_calls, + &err_msg, + ) + .await; + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response_from_stream_result( + "error", + err_msg.clone(), + &result, + ), + ) + .await; + return Err(BitFunError::AIClient(format!( + "Stream retry budget exhausted after {} attempts: {}", + max_attempts, err_msg + ))); + } + + let no_effective_output = !result.has_effective_output; + let is_partial_recovery = result.partial_recovery_reason.is_some(); + let partial_recovery_reason = + result.partial_recovery_reason.as_deref().unwrap_or(""); + + if is_partial_recovery + && !Self::has_user_visible_assistant_text(&result.full_text) + && !result.tool_calls.is_empty() + && Self::is_transient_network_error(partial_recovery_reason) + && attempt_index < max_attempts - 1 + { + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::trace_response_from_stream_result("partial", &result), + ) + .await; + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream because tool calls arrived on an interrupted network stream without assistant text: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, tool_calls={}, reason={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + result.tool_calls.len(), + partial_recovery_reason + ); + if let Err(cancel_err) = + Self::sleep_with_cancellation(delay_ms, &cancel_token).await + { + return Err(cancel_err); + } + attempt_index += 1; + continue; + } + + if Self::is_invalid_tool_only_without_text(&result) { + let err_msg = "Provider returned only invalid tool arguments".to_string(); + if attempt_index < max_attempts - 1 { + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response_from_stream_result( + "error", + err_msg.clone(), + &result, + ), + ) + .await; + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream because provider returned only invalid tool arguments: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, tool_calls={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + result.tool_calls.len() + ); + if let Err(cancel_err) = + Self::sleep_with_cancellation(delay_ms, &cancel_token).await + { + return Err(cancel_err); + } + attempt_index += 1; + continue; + } + + self.emit_failed_partial_tool_calls( + &context, + &round_id, + &result.tool_calls, + &err_msg, + ) + .await; + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response_from_stream_result( + "error", + err_msg.clone(), + &result, + ), + ) + .await; + return Err(BitFunError::AIClient(format!( + "Stream retry budget exhausted after {} attempts: {}", + max_attempts, err_msg + ))); + } + + if no_effective_output && attempt_index < max_attempts - 1 { + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response( + "error", + "No effective output received".to_string(), + ), + ) + .await; + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream because no effective output was received: session_id={}, round_id={}, attempt={}/{}, delay_ms={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms + ); + if let Err(cancel_err) = + Self::sleep_with_cancellation(delay_ms, &cancel_token).await + { + return Err(cancel_err); + } + attempt_index += 1; + continue; + } + + if is_partial_recovery { + warn!( + "Accepting stream partial recovery without retry: session_id={}, round_id={}, attempt={}/{}, reason={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + result + .partial_recovery_reason + .as_deref() + .unwrap_or("unknown") + ); + } + + break ( + result, + send_to_stream_ms, + stream_processing_ms, + trace_handle, + ); + } + Err(stream_err) => { + let err_msg = stream_err.error.to_string(); + let can_retry = !stream_err.has_effective_output + && attempt_index < max_attempts - 1 + && Self::is_transient_network_error(&err_msg); + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response("error", err_msg.clone()), + ) + .await; + if can_retry { + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream after transient error with no effective output: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + err_msg + ); + if let Err(cancel_err) = + Self::sleep_with_cancellation(delay_ms, &cancel_token).await + { + return Err(cancel_err); + } + attempt_index += 1; + continue; + } + if Self::is_transient_network_error(&err_msg) { + return Err(BitFunError::AIClient(format!( + "Stream retry budget exhausted after {} attempts: {}", + max_attempts, err_msg + ))); + } + return Err(stream_err.error); + } + } + }; + + Self::complete_model_exchange_trace( + trace_config.as_ref(), + final_trace_handle.as_ref(), + Self::final_trace_response(&stream_result), + ) + .await; + + // Model returned successfully (output to AI log file) + if let Some(ref reason) = stream_result.partial_recovery_reason { + warn!( + "Stream recovered with partial output: session_id={}, round_id={}, reason={}, text_len={}, tool_calls={}", + context.session_id, + round_id, + reason, + stream_result.full_text.len(), + stream_result.tool_calls.len() + ); + } + + let tool_names: Vec<&str> = stream_result + .tool_calls + .iter() + .map(|tc| tc.tool_name.as_str()) + .collect(); + debug!( + target: "ai::model_response", + "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}", + stream_result.full_text.len(), + if tool_names.is_empty() { "none".to_string() } else { tool_names.join(", ") }, + stream_result.usage.as_ref().map(|u| format!("input={}, output={}, total={}", u.prompt_token_count, u.candidates_token_count, u.total_token_count)).unwrap_or_else(|| "none".to_string()), + send_to_stream_ms, + stream_processing_ms, + stream_result.first_chunk_ms, + stream_result.first_visible_output_ms + ); + + // If stream response contains usage info, record it before the + // post-stream cancellation gate. A user can press stop after the + // provider returned usage but before this round settles; dropping that + // usage makes cancelled turns look unaccounted even though the provider + // already supplied authoritative counts. + if let Some(ref usage) = stream_result.usage { + self.emit_token_usage_update(&context, usage, context_window, is_subagent) + .await; + } + + // Check cancellation token again after stream processing completes. + if cancel_token.is_cancelled() { + debug!( + "Cancel token detected after stream processing, stopping execution: session_id={}", + context.session_id + ); + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + + // Emit model round completed event + debug!( + "Preparing to send ModelRoundCompleted event: round={}, has_tools={}", + round_id, + !stream_result.tool_calls.is_empty() + ); + + self.emit_event( + AgenticEvent::ModelRoundCompleted { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + round_id: round_id.clone(), + has_tool_calls: !stream_result.tool_calls.is_empty(), + duration_ms: Some(elapsed_ms_u64(round_started_at)), + provider_id: None, + model_id: Some(context.model_name.clone()), + model_alias: Some(context.model_name.clone()), + first_chunk_ms: stream_result.first_chunk_ms, + first_visible_output_ms: stream_result.first_visible_output_ms, + stream_duration_ms: Some(stream_processing_ms), + attempt_count: Some((attempt_index + 1) as u32), + failure_category: None, + token_details: stream_result + .usage + .as_ref() + .and_then(token_details_from_usage), + }, + EventPriority::High, + ) + .await; + + debug!("ModelRoundCompleted event sent"); + + // If no tool calls, this round ends + if stream_result.tool_calls.is_empty() { + debug!("No tool calls, round completed: round={}", round_id); + + // Create assistant message (includes thinking content, supports interleaved thinking mode) + let reasoning = if stream_result.full_thinking.is_empty() { + if stream_result.reasoning_content_present { + Some(String::new()) + } else { + None + } + } else { + Some(stream_result.full_thinking.clone()) + }; + let assistant_message = Message::assistant_with_reasoning( + reasoning, + stream_result.full_text.clone(), + vec![], + ) + .with_turn_id(context.dialog_turn_id.clone()) + .with_round_id(round_id.clone()) + .with_thinking_signature(stream_result.thinking_signature.clone()); + + debug!("Returning RoundResult: has_more_rounds=false"); + debug!( + "Model round timing summary: session_id={}, turn_id={}, round_id={}, tool_calls=0, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, tool_phase_ms=0, round_total_ms={}, has_more_rounds=false", + context.session_id, + context.dialog_turn_id, + round_id, + send_to_stream_ms, + stream_processing_ms, + stream_result.first_chunk_ms, + stream_result.first_visible_output_ms, + elapsed_ms_u64(round_started_at) + ); + + // Note: Do not cleanup cancellation token here, as this is only the end of a single model round + // Cancellation token will be cleaned up by ExecutionEngine when the entire dialog turn ends + + return Ok(RoundResult { + assistant_message, + tool_calls: vec![], + tool_result_messages: vec![], + has_more_rounds: false, + finish_reason: FinishReason::Complete, + usage: stream_result.usage.clone(), + provider_metadata: stream_result.provider_metadata.clone(), + partial_recovery_reason: stream_result.partial_recovery_reason.clone(), + had_assistant_text: Self::has_user_visible_assistant_text(&stream_result.full_text), + had_thinking_content: !stream_result.full_thinking.is_empty(), + }); + } + + // Check cancellation token before executing tools + if cancel_token.is_cancelled() { + debug!( + "Cancel token detected before tool execution, stopping execution: session_id={}", + context.session_id + ); + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + + let tool_calls = stream_result.tool_calls.clone(); + + // Execute tool calls + debug!( + "Preparing to execute tool calls: count={}", + tool_calls.len() + ); + + let tool_phase_started_at = Instant::now(); + let tool_results = if let Some(tool_pipeline) = &self.tool_pipeline { + // Create tool execution context + let allowed_tools = context.available_tools.clone(); + let tool_context = ToolExecutionContext { + session_id: context.session_id.clone(), + dialog_turn_id: context.dialog_turn_id.clone(), + round_id: round_id.clone(), + attempt_id: Some(format!("{round_id}:attempt:{}", attempt_index + 1)), + attempt_index: Some((attempt_index + 1) as u32), + agent_type: context.agent_type.clone(), + workspace: context.workspace.clone(), + context_vars: context.context_vars.clone(), + subagent_parent_info, + delegation_policy: context.delegation_policy, + collapsed_tools: context.collapsed_tools.clone(), + unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), + allowed_tools, + runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), + steering_interrupt: context.steering_interrupt.clone(), + workspace_services: context.workspace_services.clone(), + }; + + // Read tool execution related configuration from global config + let (needs_confirmation, tool_execution_timeout, tool_confirmation_timeout) = { + let config_service = GlobalConfigManager::get_service().await.ok(); + + // Timeout and skip confirmation settings + let (exec_timeout, confirm_timeout, skip_confirmation) = + if let Some(ref service) = config_service { + let ai_config: crate::service::config::types::AIConfig = + service.get_config(Some("ai")).await.unwrap_or_default(); + + if ai_config.skip_tool_confirmation { + debug!("Global config skips tool confirmation"); + } + + ( + ai_config.tool_execution_timeout_secs, + ai_config.tool_confirmation_timeout_secs, + ai_config.skip_tool_confirmation, + ) + } else { + (None, None, false) // Default: no timeout, requires confirmation + }; + + let skip_from_context = context + .context_vars + .get("skip_tool_confirmation") + .map(|v| v == "true") + .unwrap_or(false); + + let needs_confirm = 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 + }; + + (needs_confirm, exec_timeout, confirm_timeout) + }; + + // Create tool execution options (use configured timeout values) + let tool_options = ToolExecutionOptions { + confirm_before_run: needs_confirmation, + timeout_secs: tool_execution_timeout, + confirmation_timeout_secs: tool_confirmation_timeout, + ..ToolExecutionOptions::default() + }; + + let storage_context = + tool_context_runtime::build_tool_use_context_for_execution_context( + &tool_context, + Some(format!("round-budget-{}", round_id)), + self.computer_use_host(), + CancellationToken::new(), + ); + + // Execute tools — convert pipeline-level Err into per-tool error results + // so the model always receives a tool_result for every tool_call. + let execution_results = match tool_pipeline + .execute_tools(tool_calls.clone(), tool_context, tool_options) + .await + { + Ok(results) => results, + Err(e) => { + error!( + "Tool pipeline execution failed, generating error results for all {} tool calls: {}", + tool_calls.len(), + e + ); + tool_calls + .iter() + .map(|tc| crate::agentic::tools::pipeline::ToolExecutionResult { + tool_id: tc.tool_id.clone(), + tool_name: tc.tool_name.clone(), + result: crate::agentic::core::ToolResult { + tool_id: tc.tool_id.clone(), + tool_name: tc.tool_name.clone(), + result: serde_json::json!({ + "error": e.to_string(), + "message": format!("Tool pipeline execution failed: {}", e) + }), + result_for_assistant: Some(format!("Tool execution failed: {}", e)), + is_error: true, + duration_ms: None, + image_attachments: None, + }, + execution_time_ms: 0, + }) + .collect() + } + }; + + // Convert to ToolResult, then enforce the aggregate budget for this model round. + let tool_results = execution_results.into_iter().map(|r| r.result).collect(); + tool_result_storage::apply_round_tool_result_budget(tool_results, &storage_context) + .await + } else { + vec![] + }; + let tool_phase_ms = elapsed_ms_u64(tool_phase_started_at); + + // Create assistant message (includes tool calls and thinking content, supports interleaved thinking mode) + let reasoning = if stream_result.full_thinking.is_empty() { + if stream_result.reasoning_content_present { + Some(String::new()) + } else { + None + } + } else { + Some(stream_result.full_thinking.clone()) + }; + let assistant_message = Message::assistant_with_reasoning( + reasoning, + stream_result.full_text.clone(), + tool_calls.clone(), + ) + .with_turn_id(context.dialog_turn_id.clone()) + .with_round_id(round_id.clone()) + .with_thinking_signature(stream_result.thinking_signature.clone()); + + debug!( + "Tool execution completed, creating message: assistant_msg_len={}, tool_results={}", + match &assistant_message.content { + MessageContent::Text(t) => t.len(), + MessageContent::Mixed { text, .. } => text.len(), + _ => 0, + }, + tool_results.len() + ); + + // Create tool result messages (also need to set turn_id and round_id) + let dialog_turn_id = context.dialog_turn_id.clone(); + let round_id_clone = round_id.clone(); + let tool_result_messages: Vec = tool_results + .iter() + .map(|result| { + Message::tool_result(result.clone()) + .with_turn_id(dialog_turn_id.clone()) + .with_round_id(round_id_clone.clone()) + }) + .collect(); + + let has_more_rounds = !tool_result_messages.is_empty(); + + debug!( + "Returning RoundResult: has_more_rounds={}, tool_result_messages={}", + has_more_rounds, + tool_result_messages.len() + ); + debug!( + "Model round timing summary: session_id={}, turn_id={}, round_id={}, tool_calls={}, tool_results={}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, tool_phase_ms={}, round_total_ms={}, has_more_rounds={}", + context.session_id, + context.dialog_turn_id, + round_id, + stream_result.tool_calls.len(), + tool_result_messages.len(), + send_to_stream_ms, + stream_processing_ms, + stream_result.first_chunk_ms, + stream_result.first_visible_output_ms, + tool_phase_ms, + elapsed_ms_u64(round_started_at), + has_more_rounds + ); + + // Note: Do not cleanup cancellation token here, as there may be subsequent model rounds + // Cancellation token will be cleaned up by ExecutionEngine when the entire dialog turn ends + + Ok(RoundResult { + assistant_message, + tool_calls, + tool_result_messages, + has_more_rounds, + finish_reason: if has_more_rounds { + FinishReason::ToolCalls + } else { + FinishReason::Complete + }, + usage: stream_result.usage.clone(), + provider_metadata: stream_result.provider_metadata.clone(), + partial_recovery_reason: stream_result.partial_recovery_reason.clone(), + had_assistant_text: Self::has_user_visible_assistant_text(&stream_result.full_text), + had_thinking_content: !stream_result.full_thinking.is_empty(), + }) + } + + /// Check if dialog turn is still active (used to detect cancellation) + pub fn has_active_dialog_turn(&self, dialog_turn_id: &str) -> bool { + self.cancellation_tokens.has_active(dialog_turn_id) + } + + /// Check if dialog turn cancellation has been requested. + pub fn is_dialog_turn_cancelled(&self, dialog_turn_id: &str) -> bool { + self.cancellation_tokens.is_cancelled(dialog_turn_id) + } + + /// Register cancellation token (for external control, e.g., execute_subagent) + pub fn register_cancel_token(&self, dialog_turn_id: &str, token: CancellationToken) { + self.cancellation_tokens.insert(dialog_turn_id, token); + } + + /// Return a clone of the cancellation token registered for a dialog turn. + pub fn cancel_token_for_dialog_turn(&self, dialog_turn_id: &str) -> Option { + self.cancellation_tokens.token(dialog_turn_id) + } + + /// Cancel dialog turn (using dialog_turn_id) + pub async fn cancel_dialog_turn(&self, dialog_turn_id: &str) -> BitFunResult<()> { + debug!("Cancelling dialog turn: dialog_turn_id={}", dialog_turn_id); + + if self.cancellation_tokens.cancel(dialog_turn_id) { + debug!("Found cancel token, triggering cancellation"); + debug!("Cancel token triggered"); + } else { + debug!("Cancel token not found (dialog may have completed or not started)"); + } + + Ok(()) + } + + /// Cleanup dialog turn token (called on normal completion) + pub async fn cleanup_dialog_turn(&self, dialog_turn_id: &str) { + if self.cancellation_tokens.remove(dialog_turn_id) { + debug!("Cleaned up cancel token: dialog_turn_id={}", dialog_turn_id); + } + } + + /// Emit event + async fn emit_event(&self, event: AgenticEvent, priority: EventPriority) { + let _ = self.event_queue.enqueue(event, Some(priority)).await; + } + + async fn emit_token_usage_update( + &self, + context: &RoundContext, + usage: &crate::util::types::ai::GeminiUsage, + context_window: Option, + is_subagent: bool, + ) { + debug!( + "Updating token stats from model response: input={}, output={}, total={}, is_subagent={}", + usage.prompt_token_count, + usage.candidates_token_count, + usage.total_token_count, + is_subagent + ); + + self.emit_event( + AgenticEvent::TokenUsageUpdated { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + model_id: context.model_name.clone(), + input_tokens: usage.prompt_token_count as usize, + output_tokens: Some(usage.candidates_token_count as usize), + total_tokens: usage.total_token_count as usize, + max_context_tokens: context_window, + is_subagent, + cached_tokens: usage.cached_content_token_count.map(|v| v as usize), + token_details: token_details_from_usage(usage), + }, + EventPriority::Normal, + ) + .await; + } + + async fn emit_failed_partial_tool_calls( + &self, + context: &RoundContext, + round_id: &str, + tool_calls: &[ToolCall], + error: &str, + ) { + for tool_call in tool_calls { + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Failed { + tool_id: tool_call.tool_id.clone(), + tool_name: tool_call.tool_name.clone(), + error: format!("Tool arguments stream interrupted: {}", error), + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }, + EventPriority::High, + ) + .await; + } + } + + async fn complete_model_exchange_trace( + trace_config: Option<&ModelExchangeTraceConfig>, + trace_handle: Option<&ModelExchangeRequestTraceHandle>, + response: ModelExchangeResponseTrace, + ) { + let (Some(trace_config), Some(trace_handle)) = (trace_config, trace_handle) else { + return; + }; + + trace_config + .sink + .request_attempt_completed(trace_handle, &response) + .await; + } + + fn final_trace_response(result: &StreamResult) -> ModelExchangeResponseTrace { + let kind = if result.partial_recovery_reason.is_some() { + "partial" + } else { + "completed" + }; + Self::trace_response(kind, Some(result), None) + } + + fn trace_response_from_stream_result( + kind: &str, + result: &StreamResult, + ) -> ModelExchangeResponseTrace { + Self::trace_response(kind, Some(result), None) + } + + fn error_trace_response_from_stream_result( + kind: &str, + error: String, + result: &StreamResult, + ) -> ModelExchangeResponseTrace { + Self::trace_response(kind, Some(result), Some(error)) + } + + fn error_trace_response(kind: &str, error: String) -> ModelExchangeResponseTrace { + Self::trace_response(kind, None, Some(error)) + } + + fn trace_response( + kind: &str, + result: Option<&StreamResult>, + error: Option, + ) -> ModelExchangeResponseTrace { + let ( + assistant_text, + thinking, + tool_calls, + usage, + provider_metadata, + partial_recovery_reason, + ) = if let Some(result) = result { + ( + Some(result.full_text.clone()), + Self::stream_result_reasoning(result), + serde_json::to_value(&result.tool_calls).ok(), + result + .usage + .as_ref() + .and_then(|usage| serde_json::to_value(usage).ok()), + result.provider_metadata.clone(), + result.partial_recovery_reason.clone(), + ) + } else { + (None, None, None, None, None, None) + }; + + ModelExchangeResponseTrace { + kind: kind.to_string(), + assistant_text, + thinking, + tool_calls, + usage, + provider_metadata, + partial_recovery_reason, + error, + } + } + + fn stream_result_reasoning(result: &StreamResult) -> Option { + if result.full_thinking.is_empty() { + result.reasoning_content_present.then(String::new) + } else { + Some(result.full_thinking.clone()) + } + } + + fn has_interrupted_invalid_tool_calls(result: &StreamResult) -> bool { + result.partial_recovery_reason.is_some() + && !result.tool_calls.is_empty() + && result + .tool_calls + .iter() + .any(|tool_call| !tool_call.is_valid()) + } + + fn is_invalid_tool_only_without_text(result: &StreamResult) -> bool { + result.partial_recovery_reason.is_none() + && !Self::has_user_visible_assistant_text(&result.full_text) + && !result.tool_calls.is_empty() + && result + .tool_calls + .iter() + .all(|tool_call| !tool_call.is_valid()) + } + + fn retry_delay_ms(attempt_index: usize) -> u64 { + Self::RETRY_BASE_DELAY_MS * (1u64 << attempt_index.min(3)) + } + + /// Check whether an error message represents a transient (retryable) condition. + /// + /// Errors that already exhausted the SSE-layer retry budget (e.g. "failed + /// after N attempts:" or "Stream retry budget exhausted") are **not** + /// transient from the round-executor perspective — the SSE transport layer + /// already retried with exponential backoff and `Retry-After` parsing. + /// Re-entering the send loop would multiply attempts (10 × 10 = 100) and + /// hold the user in a long silent stall. + fn is_transient_network_error(error_message: &str) -> bool { + let msg = error_message.to_lowercase(); + + // The SSE layer already exhausted its own retry budget — do not + // re-enter another round of attempts from the round executor. + // We require BOTH "failed after " and "attempts:" to co-occur, + // which uniquely identifies the SSE/round-executor budget-exhausted + // format without catching generic errors like "failed after timeout". + if msg.contains("failed after ") && msg.contains("attempts:") { + return false; + } + if msg.contains("retry budget exhausted") { + return false; + } + + let non_retryable_keywords = [ + "invalid api key", + "unauthorized", + "forbidden", + "model not found", + "unsupported model", + "invalid request", + "bad request", + "prompt is too long", + "content policy", + "proxy authentication required", + "provider quota", + "provider billing", + "insufficient_quota", + "insufficient quota", + "insufficient balance", + "not_enough_balance", + "not enough balance", + "余额不足", + "无可用资源包", + "账户已欠费", + "code=1113", + "\"code\":\"1113\"", + "client error 400", + "client error 401", + "client error 402", + "client error 403", + "client error 404", + "client error 413", + "client error 422", + "sse parsing error", + "schema error", + "unknown api format", + ]; + + let transient_keywords = [ + "transport error", + "error decoding response body", + "stream closed before response completed", + "stream processing error", + "sse stream error", + "sse error", + "sse timeout", + "stream data timeout", + "timeout", + "request timeout", + "deadline exceeded", + "connection reset", + "connection closed", + "broken pipe", + "unexpected eof", + "connection refused", + "socket closed", + "temporarily unavailable", + "service unavailable", + "bad gateway", + "gateway timeout", + "overloaded", + "proxy", + "tunnel", + "dns", + "network", + "econnreset", + "econnrefused", + "etimedout", + "rate limit", + "too many requests", + "408", + "409", + "425", + "429", + "502", + "503", + "504", + ]; + + if non_retryable_keywords.iter().any(|k| msg.contains(k)) { + return false; + } + + transient_keywords.iter().any(|k| msg.contains(k)) + } +} + +fn token_details_from_usage( + usage: &crate::util::types::ai::GeminiUsage, +) -> Option { + let mut details = serde_json::Map::new(); + if let Some(reasoning_tokens) = usage.reasoning_token_count { + details.insert( + "reasoningTokenCount".to_string(), + serde_json::json!(reasoning_tokens), + ); + } + if let Some(cached_tokens) = usage.cached_content_token_count { + details.insert( + "cachedContentTokenCount".to_string(), + serde_json::json!(cached_tokens), + ); + } + // Cache writes (Anthropic only at the moment). Disjoint from reads. + if let Some(creation_tokens) = usage.cache_creation_token_count { + details.insert( + "cacheCreationTokenCount".to_string(), + serde_json::json!(creation_tokens), + ); + } + + (!details.is_empty()).then_some(serde_json::Value::Object(details)) +} + +#[cfg(test)] +mod tests { + use super::{RoundExecutor, StreamProcessor}; + use crate::agentic::core::ToolCall; + use crate::agentic::events::{EventQueue, EventQueueConfig}; + use crate::agentic::execution::stream_processor::StreamResult; + use crate::agentic::execution::types::RoundContext; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::util::errors::BitFunError; + use crate::util::types::ai::GeminiUsage; + use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; + use bitfun_runtime_ports::DelegationPolicy; + use serde_json::json; + use std::collections::HashMap; + use std::sync::Arc; + use std::time::Duration; + use tokio_util::sync::CancellationToken; + + fn test_round_executor() -> RoundExecutor { + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + RoundExecutor { + stream_processor: Arc::new(StreamProcessor::new(event_queue.clone())), + tool_pipeline: None, + event_queue, + cancellation_tokens: DialogTurnCancellationTokenStore::new(), + } + } + + fn test_round_context() -> RoundContext { + RoundContext { + session_id: "session-1".to_string(), + subagent_parent_info: None, + dialog_turn_id: "turn-1".to_string(), + turn_index: 0, + round_number: 0, + round_group_id: None, + workspace: None, + messages: Vec::new(), + available_tools: Vec::new(), + collapsed_tools: Vec::new(), + unlocked_collapsed_tools: Vec::new(), + model_name: "model-1".to_string(), + agent_type: "agentic".to_string(), + context_vars: HashMap::new(), + delegation_policy: DelegationPolicy::top_level(), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + steering_interrupt: None, + cancellation_token: CancellationToken::new(), + workspace_services: None, + recover_partial_on_cancel: false, + } + } + + #[tokio::test] + async fn cancel_token_for_dialog_turn_returns_registered_token() { + let executor = test_round_executor(); + let token = CancellationToken::new(); + executor.register_cancel_token("turn-1", token.clone()); + + assert!(executor.cancel_token_for_dialog_turn("turn-1").is_some()); + assert!(executor.cancel_token_for_dialog_turn("missing").is_none()); + } + + #[tokio::test] + async fn cancel_keeps_token_registered_until_cleanup() { + let executor = test_round_executor(); + let token = CancellationToken::new(); + executor.register_cancel_token("turn-1", token.clone()); + + executor + .cancel_dialog_turn("turn-1") + .await + .expect("cancel should succeed"); + + assert!(token.is_cancelled()); + assert!(executor.has_active_dialog_turn("turn-1")); + assert!(executor.is_dialog_turn_cancelled("turn-1")); + + executor.cleanup_dialog_turn("turn-1").await; + assert!(!executor.has_active_dialog_turn("turn-1")); + assert!(!executor.is_dialog_turn_cancelled("turn-1")); + } + + #[tokio::test] + async fn emits_token_usage_before_post_stream_cancel_stops_round() { + let executor = test_round_executor(); + let context = test_round_context(); + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: None, + }; + + executor + .emit_token_usage_update(&context, &usage, Some(128_000), false) + .await; + + let events = executor.event_queue.dequeue_batch(10).await; + assert!(events.iter().any(|envelope| matches!( + &envelope.event, + crate::agentic::events::AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + model_id, + input_tokens: 100, + output_tokens: Some(20), + total_tokens: 120, + max_context_tokens: Some(128_000), + is_subagent: false, + cached_tokens: Some(30), + .. + } if session_id == "session-1" && turn_id == "turn-1" && model_id == "model-1" + ))); + } + + #[tokio::test] + async fn cancellable_sleep_returns_cancelled_when_token_fires() { + let token = CancellationToken::new(); + let token_for_task = token.clone(); + + let waiter = tokio::spawn(async move { + RoundExecutor::sleep_with_cancellation(5_000, &token_for_task).await + }); + + tokio::time::sleep(Duration::from_millis(20)).await; + token.cancel(); + + let result = waiter.await.expect("sleep task should join"); + assert!(matches!(result, Err(BitFunError::Cancelled(_)))); + } + + #[tokio::test] + async fn cancellable_sleep_completes_normally_without_cancel() { + let token = CancellationToken::new(); + + let result = RoundExecutor::sleep_with_cancellation(10, &token).await; + + assert!(result.is_ok()); + } + + #[test] + fn token_details_emits_both_cache_keys_when_present() { + use crate::util::types::ai::GeminiUsage; + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: Some(20), + }; + let details = super::token_details_from_usage(&usage).expect("details"); + assert_eq!( + details + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()), + Some(30) + ); + assert_eq!( + details + .get("cacheCreationTokenCount") + .and_then(|v| v.as_u64()), + Some(20) + ); + } + + #[test] + fn token_details_emits_only_read_when_creation_absent() { + use crate::util::types::ai::GeminiUsage; + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: None, + }; + let details = super::token_details_from_usage(&usage).expect("details"); + assert_eq!( + details + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()), + Some(30) + ); + assert!(details.get("cacheCreationTokenCount").is_none()); + } + + #[test] + fn token_details_is_none_when_no_cache_info() { + use crate::util::types::ai::GeminiUsage; + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: None, + cache_creation_token_count: None, + }; + assert!(super::token_details_from_usage(&usage).is_none()); + } + + #[test] + fn error_trace_response_from_stream_result_preserves_structured_context() { + let stream_result = StreamResult { + full_thinking: "reasoning".to_string(), + reasoning_content_present: true, + thinking_signature: Some("sig".to_string()), + full_text: String::new(), + tool_calls: vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + arguments: json!({}), + raw_arguments: Some("{\"command\":".to_string()), + is_error: true, + recovered_from_truncation: false, + }], + usage: Some(GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: Some(5), + cached_content_token_count: Some(30), + cache_creation_token_count: None, + }), + provider_metadata: Some(json!({ "finish_reason": "tool_calls" })), + has_effective_output: false, + first_chunk_ms: Some(10), + first_visible_output_ms: None, + partial_recovery_reason: Some("tool arguments invalid".to_string()), + }; + + let trace = RoundExecutor::error_trace_response_from_stream_result( + "error", + "Provider returned only invalid tool arguments".to_string(), + &stream_result, + ); + + assert_eq!(trace.kind, "error"); + assert_eq!( + trace.error.as_deref(), + Some("Provider returned only invalid tool arguments") + ); + assert_eq!(trace.assistant_text.as_deref(), Some("")); + assert_eq!(trace.thinking.as_deref(), Some("reasoning")); + assert_eq!( + trace.partial_recovery_reason.as_deref(), + Some("tool arguments invalid") + ); + assert_eq!( + trace.provider_metadata, + Some(json!({ "finish_reason": "tool_calls" })) + ); + assert_eq!( + trace.usage, + Some(json!({ + "promptTokenCount": 100, + "candidatesTokenCount": 20, + "totalTokenCount": 120, + "reasoningTokenCount": 5, + "cachedContentTokenCount": 30 + })) + ); + assert_eq!( + trace.tool_calls, + Some(json!([{ + "tool_id": "tool-1", + "tool_name": "Bash", + "arguments": {}, + "raw_arguments": "{\"command\":", + "is_error": true + }])) + ); + } + + #[test] + fn error_trace_response_without_stream_result_stays_empty() { + let trace = RoundExecutor::error_trace_response("error", "request failed".to_string()); + + assert_eq!(trace.kind, "error"); + assert!(trace.assistant_text.is_none()); + assert!(trace.thinking.is_none()); + assert!(trace.tool_calls.is_none()); + assert!(trace.usage.is_none()); + assert!(trace.provider_metadata.is_none()); + assert!(trace.partial_recovery_reason.is_none()); + assert_eq!(trace.error.as_deref(), Some("request failed")); + } + + #[test] + fn is_transient_error_treats_rate_limit_as_transient() { + assert!(RoundExecutor::is_transient_network_error( + "OpenAI Streaming API error 429 Too Many Requests" + )); + assert!(RoundExecutor::is_transient_network_error( + "rate limit exceeded" + )); + } + + #[test] + fn is_transient_error_treats_network_errors_as_transient() { + assert!(RoundExecutor::is_transient_network_error( + "connection reset by peer" + )); + assert!(RoundExecutor::is_transient_network_error("timeout")); + } + + #[test] + fn is_transient_error_treats_context_overflow_as_non_transient() { + assert!(!RoundExecutor::is_transient_network_error( + "prompt is too long" + )); + } + + #[test] + fn is_transient_error_treats_budget_exhausted_as_non_transient() { + // After SSE layer exhausts its retry budget, the round executor must + // NOT re-enter another round of attempts (would cause 10×10 = 100 + // retries). + assert!(!RoundExecutor::is_transient_network_error( + "OpenAI Streaming API failed after 10 attempts: \ + OpenAI Streaming API error 429 Too Many Requests" + )); + assert!(!RoundExecutor::is_transient_network_error( + "Stream retry budget exhausted after 10 attempts: timeout" + )); + } + + #[test] + fn is_transient_error_does_not_misclassify_failed_after_without_attempts() { + // "failed after " without "attempts:" should NOT be treated as budget + // exhausted — it may be a legitimately retryable transient error. + assert!(RoundExecutor::is_transient_network_error( + "stream failed after connection reset" + )); + assert!(RoundExecutor::is_transient_network_error( + "request failed after timeout" + )); + } +} diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/assembly/core/src/agentic/execution/stream_processor.rs similarity index 94% rename from src/crates/core/src/agentic/execution/stream_processor.rs rename to src/crates/assembly/core/src/agentic/execution/stream_processor.rs index 01001efdbf..6af22a9630 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/assembly/core/src/agentic/execution/stream_processor.rs @@ -39,7 +39,7 @@ impl From for StreamResult { thinking_signature: result.thinking_signature, full_text: result.full_text, tool_calls: result.tool_calls.into_iter().map(Into::into).collect(), - usage: result.usage, + usage: result.usage.map(Into::into), provider_metadata: result.provider_metadata, has_effective_output: result.has_effective_output, first_chunk_ms: result.first_chunk_ms, @@ -90,6 +90,8 @@ impl StreamProcessor { session_id: String, dialog_turn_id: String, round_id: String, + attempt_id: String, + attempt_index: u32, cancellation_token: &CancellationToken, ) -> Result { self.process_stream_with_options( @@ -99,6 +101,8 @@ impl StreamProcessor { session_id, dialog_turn_id, round_id, + attempt_id, + attempt_index, cancellation_token, StreamProcessOptions::default(), ) @@ -114,6 +118,8 @@ impl StreamProcessor { session_id: String, dialog_turn_id: String, round_id: String, + attempt_id: String, + attempt_index: u32, cancellation_token: &CancellationToken, options: StreamProcessOptions, ) -> Result { @@ -125,6 +131,8 @@ impl StreamProcessor { session_id, dialog_turn_id, round_id, + attempt_id, + attempt_index, cancellation_token, options, ) diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs similarity index 81% rename from src/crates/core/src/agentic/execution/types.rs rename to src/crates/assembly/core/src/agentic/execution/types.rs index 43db926965..aa051346ed 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -1,13 +1,12 @@ //! Execution Engine Type Definitions use crate::agentic::core::Message; -use crate::agentic::round_preempt::{ - DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, -}; +use crate::agentic::round_preempt::{DialogRoundInjectionInterrupt, DialogRoundInjectionSource}; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; +pub use bitfun_agent_runtime::events::FinishReason; use bitfun_runtime_ports::DelegationPolicy; use serde_json::Value; use std::collections::HashMap; @@ -29,8 +28,6 @@ pub struct ExecutionContext { pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Workspace I/O services (filesystem + shell) injected into tools pub workspace_services: Option, - /// When set, engine may end the turn after a full model round if a user message was queued. - pub round_preempt: Option>, /// When set, engine drains pending round injections at each round boundary /// and injects them into the dialog history without ending the turn. pub round_injection: Option>, @@ -47,6 +44,7 @@ pub struct RoundContext { pub dialog_turn_id: String, pub turn_index: usize, pub round_number: usize, + pub round_group_id: Option, pub workspace: Option, pub messages: Vec, pub available_tools: Vec, @@ -90,30 +88,6 @@ pub struct RoundResult { pub had_thinking_content: bool, } -/// Finish reason -#[derive(Debug, Clone, PartialEq)] -pub enum FinishReason { - /// Normal completion - Complete, - /// Need to execute tools - ToolCalls, - /// User cancelled - Cancelled, - /// Error - Error, -} - -impl std::fmt::Display for FinishReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FinishReason::Complete => write!(f, "complete"), - FinishReason::ToolCalls => write!(f, "tool_calls"), - FinishReason::Cancelled => write!(f, "cancelled"), - FinishReason::Error => write!(f, "error"), - } - } -} - /// Execution result #[derive(Debug, Clone)] pub struct ExecutionResult { diff --git a/src/crates/core/src/agentic/execution/write_content_sanitizer.rs b/src/crates/assembly/core/src/agentic/execution/write_content_sanitizer.rs similarity index 96% rename from src/crates/core/src/agentic/execution/write_content_sanitizer.rs rename to src/crates/assembly/core/src/agentic/execution/write_content_sanitizer.rs index fe80e150ed..d7d23eae5e 100644 --- a/src/crates/core/src/agentic/execution/write_content_sanitizer.rs +++ b/src/crates/assembly/core/src/agentic/execution/write_content_sanitizer.rs @@ -1,4 +1,4 @@ -//! Sanitization and validation for Write tool file-body generation. +//! Sanitization and validation for inline Write tool `content` arguments. /// Returns true when the model output looks like a tool invocation instead of /// raw file content (DSML blocks, nested tool_call XML, function_call JSON, etc.). @@ -35,7 +35,7 @@ pub fn contains_tool_invocation_artifacts(content: &str) -> bool { /// Best-effort removal of tool-invocation wrappers that sometimes appear before /// the real file body. When the entire payload is tool syntax, this returns an -/// empty string and callers should treat that as a generation failure. +/// empty string and callers should treat that as invalid file content. pub fn strip_tool_invocation_artifacts(content: &str) -> String { let mut result = content.to_string(); diff --git a/src/crates/core/src/agentic/fork_agent/mod.rs b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs similarity index 99% rename from src/crates/core/src/agentic/fork_agent/mod.rs rename to src/crates/assembly/core/src/agentic/fork_agent/mod.rs index b13f478416..6ee627e695 100644 --- a/src/crates/core/src/agentic/fork_agent/mod.rs +++ b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs @@ -59,7 +59,6 @@ impl ForkAgentContextSnapshot { } config } - } #[cfg(test)] diff --git a/src/crates/assembly/core/src/agentic/goal_mode/mod.rs b/src/crates/assembly/core/src/agentic/goal_mode/mod.rs new file mode 100644 index 0000000000..5223da8bbb --- /dev/null +++ b/src/crates/assembly/core/src/agentic/goal_mode/mod.rs @@ -0,0 +1,402 @@ +//! Persisted session thread goals (Codex `/goal` parity). +//! +//! Users set objectives via `/goal `; the agent manages lifecycle through +//! `create_goal`, `update_goal`, and `get_goal` tools. Runtime auto-continues active +//! goals after idle turns using internal continuation prompts. + +mod token_subscriber; + +pub use token_subscriber::ThreadGoalTokenSubscriber; + +use crate::agentic::core::{InternalReminderKind, Message}; +use crate::agentic::session::SessionManager; +use crate::util::errors::{BitFunError, BitFunResult}; +pub use bitfun_agent_runtime::thread_goal::{ + billable_tokens_from_counts, build_objective_updated_plan, build_thread_goal_continuation_plan, + clear_thread_goal_patch, completion_budget_report, continuation_prompt, + effective_subagent_timeout_seconds, goal_continuation_submit_retry_delay_ms, + goal_tool_response, objective_updated_prompt, should_skip_goal_continuation_after_turn, + should_skip_goal_for_turn, thread_goal_patch, thread_goal_status_is_resumable, + ThreadGoalContinuationFacts, ThreadGoalRuntime, GOAL_CONTINUATION_SUBMIT_RETRY_BASE_DELAY_MS, + GOAL_CONTINUATION_SUBMIT_RETRY_MAX_DELAY_MS, +}; +use bitfun_agent_runtime::thread_goal::{ + build_set_thread_goal_result, is_usage_limit_message, SetThreadGoalRequest, +}; +pub use bitfun_runtime_ports::{ + validate_thread_goal_objective, SetThreadGoalResult, ThreadGoal, ThreadGoalContinuationPlan, + ThreadGoalStatus, ThreadGoalToolResponse, GOAL_MODE_METADATA_KEY, MAX_CONTEXT_SUMMARY_CHARS, + MAX_GOAL_CONTINUATIONS, MAX_THREAD_GOAL_AUTO_CONTINUATIONS, MAX_THREAD_GOAL_OBJECTIVE_CHARS, + THREAD_GOAL_METADATA_KEY, +}; +use log::{info, warn}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +pub fn now_epoch_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +pub fn thread_goal_from_custom_metadata( + custom_metadata: Option<&serde_json::Value>, +) -> Option { + bitfun_agent_runtime::thread_goal::thread_goal_from_custom_metadata( + custom_metadata, + Uuid::new_v4().to_string(), + now_epoch_seconds(), + ) +} + +pub fn is_usage_limit_error(error: &BitFunError) -> bool { + is_usage_limit_message(&error.to_string()) +} + +pub fn goal_internal_context_message(prompt: String) -> Message { + Message::internal_reminder(InternalReminderKind::GoalContinuation, prompt) +} + +pub fn goal_objective_updated_message(prompt: String) -> Message { + Message::internal_reminder(InternalReminderKind::GoalObjectiveUpdated, prompt) +} + +pub struct ThreadGoalStore<'a> { + session_manager: &'a SessionManager, +} + +impl<'a> ThreadGoalStore<'a> { + pub fn new(session_manager: &'a SessionManager) -> Self { + Self { session_manager } + } + + async fn load_metadata( + &self, + session_id: &str, + workspace_path: &Path, + ) -> BitFunResult> { + Ok(self + .session_manager + .load_session_metadata(workspace_path, session_id) + .await? + .and_then(|metadata| metadata.custom_metadata)) + } + + pub async fn get_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + ) -> BitFunResult> { + let metadata = self.load_metadata(session_id, workspace_path).await?; + Ok(thread_goal_from_custom_metadata(metadata.as_ref())) + } + + async fn persist_thread_goal( + &self, + session_id: &str, + _workspace_path: &Path, + goal: Option, + ) -> BitFunResult<()> { + let patch = match goal { + Some(goal) => thread_goal_patch(&goal), + None => clear_thread_goal_patch(), + }; + self.session_manager + .merge_session_custom_metadata(session_id, patch) + .await + } + + pub async fn clear_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + ) -> BitFunResult<()> { + self.persist_thread_goal(session_id, workspace_path, None) + .await + } + + pub async fn set_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + objective: Option, + status: Option, + token_budget: Option>, + replace_existing: bool, + ) -> BitFunResult { + let existing = self.get_thread_goal(session_id, workspace_path).await?; + + if replace_existing { + self.clear_thread_goal(session_id, workspace_path).await?; + } + + let result = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: session_id.to_string(), + existing, + objective, + status, + token_budget, + replace_existing, + now_epoch_seconds: now_epoch_seconds(), + new_goal_id: Uuid::new_v4().to_string(), + }) + .map_err(|error| match error { + bitfun_agent_runtime::thread_goal::ThreadGoalRuntimeError::Validation(message) => { + BitFunError::Validation(message) + } + bitfun_agent_runtime::thread_goal::ThreadGoalRuntimeError::NotFound(message) => { + BitFunError::NotFound(message) + } + })?; + + self.persist_thread_goal(session_id, workspace_path, Some(result.goal.clone())) + .await?; + + Ok(result) + } + + pub async fn create_thread_goal( + &self, + session_id: &str, + workspace_path: &Path, + objective: String, + token_budget: Option, + ) -> BitFunResult { + if self + .get_thread_goal(session_id, workspace_path) + .await? + .is_some() + { + return Err(BitFunError::Validation(format!( + "cannot create a new goal because session {session_id} already has a goal" + ))); + } + let result = self + .set_thread_goal( + session_id, + workspace_path, + Some(objective), + Some(ThreadGoalStatus::Active), + Some(token_budget), + false, + ) + .await?; + Ok(result.goal) + } +} + +pub async fn maybe_build_continuation_after_turn( + store: &ThreadGoalStore<'_>, + runtime: &ThreadGoalRuntime, + session_id: &str, + workspace_path: &Path, + turn_id: &str, + turn_tokens: usize, + turn_completed: bool, +) -> BitFunResult> { + let Some(goal) = store.get_thread_goal(session_id, workspace_path).await? else { + return Ok(None); + }; + + let outcome = runtime.continuation_after_turn( + goal, + ThreadGoalContinuationFacts { + turn_id, + turn_tokens, + turn_completed, + now_epoch_seconds: now_epoch_seconds(), + }, + ); + + if outcome.reached_auto_continuation_limit { + if let Some(goal) = outcome.goal_to_persist.as_ref() { + warn!( + "Thread goal auto-continuation limit reached; marking blocked: session_id={}, goal_id={}, objective={}", + session_id, goal.goal_id, goal.objective + ); + } + } + + if let Some(goal) = outcome.goal_to_persist.as_ref() { + store + .persist_thread_goal(session_id, workspace_path, Some(goal.clone())) + .await?; + if outcome.scheduled_auto_continuation { + info!( + "Scheduling thread goal auto-continuation: session_id={}, attempt={}/{}, objective={}", + session_id, + goal.auto_continuation_count, + MAX_THREAD_GOAL_AUTO_CONTINUATIONS, + goal.objective + ); + } + } + + Ok(outcome.plan) +} + +pub fn user_facing_thread_goal_error(error: BitFunError) -> BitFunError { + match error { + BitFunError::Validation(_) | BitFunError::NotFound(_) => error, + other => { + warn!("Thread goal operation failed: {other}"); + BitFunError::Validation( + "Thread goal operation failed. Check session state and try again.".to_string(), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resumable_statuses_match_ui_actions() { + assert!(thread_goal_status_is_resumable(ThreadGoalStatus::Paused)); + assert!(thread_goal_status_is_resumable(ThreadGoalStatus::Blocked)); + assert!(thread_goal_status_is_resumable( + ThreadGoalStatus::UsageLimited + )); + assert!(!thread_goal_status_is_resumable(ThreadGoalStatus::Active)); + assert!(!thread_goal_status_is_resumable( + ThreadGoalStatus::BudgetLimited + )); + assert!(!thread_goal_status_is_resumable(ThreadGoalStatus::Complete)); + } + + #[test] + fn continuation_plan_metadata_marks_completion_check() { + let plan = build_thread_goal_continuation_plan(&ThreadGoal { + goal_id: "g1".to_string(), + session_id: "s1".to_string(), + objective: "sync upstream".to_string(), + status: ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 2, + auto_continuation_count: 2, + }); + assert!(plan.display_message.contains("completion check")); + assert!(plan.display_message.contains("2/100")); + assert_eq!( + plan.user_message_metadata["threadGoalContinuationCheck"], + true + ); + assert_eq!(plan.user_message_metadata["autoContinuationAttempt"], 2); + assert_eq!(plan.user_message_metadata["autoContinuationMax"], 100); + } + + #[test] + fn continuation_prompt_references_update_goal() { + let prompt = continuation_prompt(&ThreadGoal { + goal_id: "g1".to_string(), + session_id: "s1".to_string(), + objective: "finish stack".to_string(), + status: ThreadGoalStatus::Active, + token_budget: Some(10_000), + tokens_used: 1_000, + time_used_seconds: 0, + created_at: 1, + updated_at: 2, + auto_continuation_count: 0, + }); + assert!(prompt.contains("finish stack")); + assert!(prompt.contains("update_goal")); + } + + #[test] + fn should_skip_goal_for_turn_ignores_goal_slash_commands() { + assert!(should_skip_goal_for_turn("/goal fix bug", None)); + assert!(!should_skip_goal_for_turn("fix bug", None)); + } + + #[test] + fn should_skip_goal_for_turn_ignores_objective_updated_followup() { + let metadata = serde_json::json!({ "threadGoalObjectiveUpdated": true }); + assert!(should_skip_goal_for_turn("Adjust work", Some(&metadata))); + } + + #[test] + fn objective_updated_turn_still_schedules_continuation_after_turn() { + let metadata = serde_json::json!({ "threadGoalObjectiveUpdated": true }); + assert!(!should_skip_goal_continuation_after_turn( + "Adjust work", + Some(&metadata) + )); + } + + #[test] + fn continuation_turn_chains_until_goal_completes_or_limit() { + let metadata = serde_json::json!({ "threadGoalContinuation": true }); + assert!(!should_skip_goal_continuation_after_turn( + "Continue toward goal", + Some(&metadata) + )); + } + + #[test] + fn max_goal_continuations_matches_legacy_limit() { + assert_eq!(MAX_GOAL_CONTINUATIONS, 100); + assert_eq!(MAX_THREAD_GOAL_AUTO_CONTINUATIONS, 100); + } + + #[test] + fn goal_continuation_submit_retry_delay_backoff_caps() { + assert_eq!(goal_continuation_submit_retry_delay_ms(0), 0); + assert_eq!(goal_continuation_submit_retry_delay_ms(1), 1_000); + assert_eq!(goal_continuation_submit_retry_delay_ms(2), 2_000); + assert_eq!( + goal_continuation_submit_retry_delay_ms(100), + GOAL_CONTINUATION_SUBMIT_RETRY_MAX_DELAY_MS + ); + } + + #[test] + fn billable_tokens_subtracts_cached_input() { + assert_eq!(billable_tokens_from_counts(1000, 400, 200), 800); + } + + #[test] + fn usage_limit_error_detects_quota_messages() { + assert!(is_usage_limit_error(&BitFunError::AIClient( + "insufficient_quota: billing hard limit".to_string() + ))); + assert!(!is_usage_limit_error(&BitFunError::Validation( + "tool failed".to_string() + ))); + } + + #[test] + fn objective_updated_plan_skips_turn_accounting_but_allows_continuation() { + let metadata = build_objective_updated_plan(&ThreadGoal { + goal_id: "g1".to_string(), + session_id: "s1".to_string(), + objective: "ship feature".to_string(), + status: ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 2, + auto_continuation_count: 0, + }) + .user_message_metadata; + assert!(should_skip_goal_for_turn("Adjust work", Some(&metadata))); + assert!(!should_skip_goal_continuation_after_turn( + "Adjust work", + Some(&metadata) + )); + } +} diff --git a/src/crates/assembly/core/src/agentic/goal_mode/token_subscriber.rs b/src/crates/assembly/core/src/agentic/goal_mode/token_subscriber.rs new file mode 100644 index 0000000000..b10e5d5307 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/goal_mode/token_subscriber.rs @@ -0,0 +1,53 @@ +//! Accumulates per-turn billable tokens for active thread goals from model usage events. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::events::{AgenticEvent, EventSubscriber}; +use crate::util::errors::BitFunResult; +use bitfun_agent_runtime::thread_goal::{ + should_record_thread_goal_token_usage, ThreadGoalTokenUsageFacts, +}; +use log::debug; + +pub struct ThreadGoalTokenSubscriber; + +#[async_trait::async_trait] +impl EventSubscriber for ThreadGoalTokenSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> BitFunResult<()> { + let AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + input_tokens, + output_tokens, + is_subagent, + cached_tokens, + .. + } = event + else { + return Ok(()); + }; + + let Some(billable) = should_record_thread_goal_token_usage(ThreadGoalTokenUsageFacts { + input_tokens: *input_tokens, + output_tokens: *output_tokens, + cached_tokens: *cached_tokens, + is_subagent: *is_subagent, + }) else { + return Ok(()); + }; + + let Some(coordinator) = get_global_coordinator() else { + return Ok(()); + }; + + coordinator + .thread_goal_runtime() + .record_round_billable_tokens(turn_id, billable); + + debug!( + "Thread goal token accounting: session_id={}, turn_id={}, billable={}", + session_id, turn_id, billable + ); + + Ok(()) + } +} diff --git a/src/crates/assembly/core/src/agentic/harness.rs b/src/crates/assembly/core/src/agentic/harness.rs new file mode 100644 index 0000000000..120be5a3a0 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/harness.rs @@ -0,0 +1,69 @@ +pub use bitfun_product_capabilities::{ + default_product_harness_registry as product_harness_registry, + product_harness_registry_for_profile, CORE_DEEP_RESEARCH_HARNESS_PROVIDER_ID, + CORE_DEEP_REVIEW_HARNESS_PROVIDER_ID, CORE_MINIAPP_HARNESS_PROVIDER_ID, +}; + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_harness::{HarnessInput, HarnessStepKind, HarnessWorkflow}; + use bitfun_product_capabilities::DeliveryProfile; + + #[test] + fn product_harness_registry_registers_existing_workflow_facades() { + let registry = product_harness_registry().expect("product harness registry should build"); + + assert_eq!( + registry.provider_ids(), + vec!["core.deep_review", "core.deep_research", "core.miniapp"] + ); + assert_eq!( + registry.workflows(), + vec![ + HarnessWorkflow::DeepReview, + HarnessWorkflow::DeepResearch, + HarnessWorkflow::MiniApp, + ] + ); + } + + #[tokio::test] + async fn product_harness_provider_plans_route_to_legacy_facade_without_execution() { + let registry = product_harness_registry().expect("product harness registry should build"); + let provider = registry + .provider_for_workflow(HarnessWorkflow::DeepResearch) + .expect("DeepResearch should be registered"); + + let plan = provider + .plan( + Default::default(), + HarnessInput::new(HarnessWorkflow::DeepResearch, "research current question"), + ) + .await + .expect("DeepResearch harness should produce a legacy route plan"); + + assert_eq!(plan.steps().len(), 1); + assert_eq!(plan.steps()[0].kind(), HarnessStepKind::LegacyFacade); + assert_eq!( + plan.steps()[0].target(), + "bitfun-core::agentic::agents::definitions::modes::deep_research" + ); + + assert!( + provider.execute(Default::default(), plan).await.is_err(), + "PR4 must not move concrete workflow execution out of legacy paths" + ); + } + + #[test] + fn product_harness_registry_can_be_built_from_explicit_delivery_profile() { + let registry = product_harness_registry_for_profile(DeliveryProfile::Cli) + .expect("profile-scoped product harness registry should build"); + + assert_eq!( + registry.provider_ids(), + vec!["core.deep_review", "core.deep_research", "core.miniapp"] + ); + } +} diff --git a/src/crates/core/src/agentic/image_analysis/enhancer.rs b/src/crates/assembly/core/src/agentic/image_analysis/enhancer.rs similarity index 100% rename from src/crates/core/src/agentic/image_analysis/enhancer.rs rename to src/crates/assembly/core/src/agentic/image_analysis/enhancer.rs diff --git a/src/crates/core/src/agentic/image_analysis/image_processing.rs b/src/crates/assembly/core/src/agentic/image_analysis/image_processing.rs similarity index 100% rename from src/crates/core/src/agentic/image_analysis/image_processing.rs rename to src/crates/assembly/core/src/agentic/image_analysis/image_processing.rs diff --git a/src/crates/core/src/agentic/image_analysis/mod.rs b/src/crates/assembly/core/src/agentic/image_analysis/mod.rs similarity index 100% rename from src/crates/core/src/agentic/image_analysis/mod.rs rename to src/crates/assembly/core/src/agentic/image_analysis/mod.rs diff --git a/src/crates/core/src/agentic/image_analysis/processor.rs b/src/crates/assembly/core/src/agentic/image_analysis/processor.rs similarity index 100% rename from src/crates/core/src/agentic/image_analysis/processor.rs rename to src/crates/assembly/core/src/agentic/image_analysis/processor.rs diff --git a/src/crates/core/src/agentic/image_analysis/types.rs b/src/crates/assembly/core/src/agentic/image_analysis/types.rs similarity index 100% rename from src/crates/core/src/agentic/image_analysis/types.rs rename to src/crates/assembly/core/src/agentic/image_analysis/types.rs diff --git a/src/crates/core/src/agentic/init_agents_md.rs b/src/crates/assembly/core/src/agentic/init_agents_md.rs similarity index 66% rename from src/crates/core/src/agentic/init_agents_md.rs rename to src/crates/assembly/core/src/agentic/init_agents_md.rs index b9e969f857..1781f81e15 100644 --- a/src/crates/core/src/agentic/init_agents_md.rs +++ b/src/crates/assembly/core/src/agentic/init_agents_md.rs @@ -1,5 +1,5 @@ use crate::agentic::agents::get_embedded_prompt; -use crate::agentic::core::PromptEnvelope; +use crate::agentic::core::{InternalReminderKind, Message}; use crate::service::config::get_app_language_code; use crate::util::errors::{BitFunError, BitFunResult}; @@ -13,7 +13,7 @@ fn init_agents_md_user_query(is_chinese: bool) -> &'static str { } } -pub(crate) async fn build_init_agents_md_user_input() -> BitFunResult<(String, String)> { +pub(crate) async fn build_init_agents_md_user_input() -> BitFunResult<(String, Vec)> { let prompt = get_embedded_prompt(INIT_AGENTS_MD_PROMPT_NAME).ok_or_else(|| { BitFunError::Agent(format!( "{} not found in embedded files", @@ -22,10 +22,13 @@ pub(crate) async fn build_init_agents_md_user_input() -> BitFunResult<(String, S })?; let is_chinese = get_app_language_code().await.starts_with("zh"); let user_query = init_agents_md_user_query(is_chinese).to_string(); - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder(prompt.to_string()); - envelope.push_user_query(user_query.clone()); - Ok((envelope.render(), user_query)) + Ok(( + user_query, + vec![Message::internal_reminder( + InternalReminderKind::InitAgentsMd, + prompt.to_string(), + )], + )) } #[cfg(test)] @@ -39,14 +42,16 @@ mod tests { } #[tokio::test] - async fn init_agents_md_user_input_wraps_reminder_before_query() { - let (user_input, original_user_input) = build_init_agents_md_user_input() + async fn init_agents_md_user_input_returns_query_and_reminder_message() { + let (user_input, prepended_messages) = build_init_agents_md_user_input() .await .expect("init agents md prompt should build"); - assert!(user_input.contains("")); - assert!(user_input.contains("")); - assert!(user_input.find("") < user_input.find("")); - assert!(!original_user_input.trim().is_empty()); + assert!(!user_input.trim().is_empty()); + assert_eq!(prepended_messages.len(), 1); + assert_eq!( + prepended_messages[0].internal_reminder_kind(), + Some(crate::agentic::core::InternalReminderKind::InitAgentsMd) + ); } } diff --git a/src/crates/core/src/agentic/insights/cancellation.rs b/src/crates/assembly/core/src/agentic/insights/cancellation.rs similarity index 100% rename from src/crates/core/src/agentic/insights/cancellation.rs rename to src/crates/assembly/core/src/agentic/insights/cancellation.rs diff --git a/src/crates/core/src/agentic/insights/collector.rs b/src/crates/assembly/core/src/agentic/insights/collector.rs similarity index 100% rename from src/crates/core/src/agentic/insights/collector.rs rename to src/crates/assembly/core/src/agentic/insights/collector.rs diff --git a/src/crates/core/src/agentic/insights/facet_cache.rs b/src/crates/assembly/core/src/agentic/insights/facet_cache.rs similarity index 100% rename from src/crates/core/src/agentic/insights/facet_cache.rs rename to src/crates/assembly/core/src/agentic/insights/facet_cache.rs diff --git a/src/crates/core/src/agentic/insights/html.rs b/src/crates/assembly/core/src/agentic/insights/html.rs similarity index 100% rename from src/crates/core/src/agentic/insights/html.rs rename to src/crates/assembly/core/src/agentic/insights/html.rs diff --git a/src/crates/core/src/agentic/insights/mod.rs b/src/crates/assembly/core/src/agentic/insights/mod.rs similarity index 100% rename from src/crates/core/src/agentic/insights/mod.rs rename to src/crates/assembly/core/src/agentic/insights/mod.rs diff --git a/src/crates/core/src/agentic/insights/prompt_context.rs b/src/crates/assembly/core/src/agentic/insights/prompt_context.rs similarity index 100% rename from src/crates/core/src/agentic/insights/prompt_context.rs rename to src/crates/assembly/core/src/agentic/insights/prompt_context.rs diff --git a/src/crates/core/src/agentic/insights/prompts/areas.md b/src/crates/assembly/core/src/agentic/insights/prompts/areas.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/areas.md rename to src/crates/assembly/core/src/agentic/insights/prompts/areas.md diff --git a/src/crates/core/src/agentic/insights/prompts/at_a_glance.md b/src/crates/assembly/core/src/agentic/insights/prompts/at_a_glance.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/at_a_glance.md rename to src/crates/assembly/core/src/agentic/insights/prompts/at_a_glance.md diff --git a/src/crates/core/src/agentic/insights/prompts/facet_extraction.md b/src/crates/assembly/core/src/agentic/insights/prompts/facet_extraction.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/facet_extraction.md rename to src/crates/assembly/core/src/agentic/insights/prompts/facet_extraction.md diff --git a/src/crates/core/src/agentic/insights/prompts/friction.md b/src/crates/assembly/core/src/agentic/insights/prompts/friction.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/friction.md rename to src/crates/assembly/core/src/agentic/insights/prompts/friction.md diff --git a/src/crates/core/src/agentic/insights/prompts/fun_ending.md b/src/crates/assembly/core/src/agentic/insights/prompts/fun_ending.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/fun_ending.md rename to src/crates/assembly/core/src/agentic/insights/prompts/fun_ending.md diff --git a/src/crates/core/src/agentic/insights/prompts/horizon.md b/src/crates/assembly/core/src/agentic/insights/prompts/horizon.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/horizon.md rename to src/crates/assembly/core/src/agentic/insights/prompts/horizon.md diff --git a/src/crates/core/src/agentic/insights/prompts/interaction_style.md b/src/crates/assembly/core/src/agentic/insights/prompts/interaction_style.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/interaction_style.md rename to src/crates/assembly/core/src/agentic/insights/prompts/interaction_style.md diff --git a/src/crates/core/src/agentic/insights/prompts/suggestions.md b/src/crates/assembly/core/src/agentic/insights/prompts/suggestions.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/suggestions.md rename to src/crates/assembly/core/src/agentic/insights/prompts/suggestions.md diff --git a/src/crates/core/src/agentic/insights/prompts/wins.md b/src/crates/assembly/core/src/agentic/insights/prompts/wins.md similarity index 100% rename from src/crates/core/src/agentic/insights/prompts/wins.md rename to src/crates/assembly/core/src/agentic/insights/prompts/wins.md diff --git a/src/crates/core/src/agentic/insights/service.rs b/src/crates/assembly/core/src/agentic/insights/service.rs similarity index 100% rename from src/crates/core/src/agentic/insights/service.rs rename to src/crates/assembly/core/src/agentic/insights/service.rs diff --git a/src/crates/core/src/agentic/insights/session_paths.rs b/src/crates/assembly/core/src/agentic/insights/session_paths.rs similarity index 100% rename from src/crates/core/src/agentic/insights/session_paths.rs rename to src/crates/assembly/core/src/agentic/insights/session_paths.rs diff --git a/src/crates/core/src/agentic/insights/types.rs b/src/crates/assembly/core/src/agentic/insights/types.rs similarity index 100% rename from src/crates/core/src/agentic/insights/types.rs rename to src/crates/assembly/core/src/agentic/insights/types.rs diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs similarity index 80% rename from src/crates/core/src/agentic/mod.rs rename to src/crates/assembly/core/src/agentic/mod.rs index cc4b523162..e457629389 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -7,6 +7,7 @@ pub mod core; pub mod events; pub mod persistence; +pub mod skill_agent_snapshot; // Session management module pub mod session; @@ -22,12 +23,14 @@ pub mod context_profile; pub mod coordination; pub mod deep_review; pub mod deep_review_policy; +pub mod harness; pub(crate) mod subagent_runtime; // Shared-context fork-agent execution module pub mod fork_agent; -/// Round-boundary yield when user queues a message during an active turn +pub(crate) mod remote_file_delivery; +/// Round-boundary injection support for steering/background updates pub mod round_preempt; // Image analysis module @@ -61,11 +64,11 @@ pub use goal_mode::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ - DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, - NoopDialogRoundInjectionSource, NoopDialogRoundPreemptSource, RoundInjection, - RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, SessionRoundYieldFlags, + DialogRoundInjectionInterrupt, DialogRoundInjectionSource, NoopDialogRoundInjectionSource, + RoundInjection, RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, }; pub use session::*; pub use side_question::*; +pub use skill_agent_snapshot::*; pub use system::{init_agentic_system, AgenticSystem}; pub use workspace::{WorkspaceBackend, WorkspaceBinding}; diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs similarity index 72% rename from src/crates/core/src/agentic/persistence/manager.rs rename to src/crates/assembly/core/src/agentic/persistence/manager.rs index 270a14d97c..9684917804 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -4,25 +4,36 @@ use crate::agentic::core::{ strip_prompt_markup, CompressionState, Message, MessageContent, Session, SessionConfig, - SessionKind, SessionState, SessionSummary, + SessionState, SessionSummary, }; use crate::agentic::session::{SessionPromptCache, PROMPT_CACHE_SCHEMA_VERSION}; +use crate::agentic::skill_agent_snapshot::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; use crate::service::remote_ssh::workspace_state::{ resolve_workspace_session_identity, LOCAL_WORKSPACE_SSH_HOST, }; use crate::service::session::{ - DialogTurnData, SessionMetadata, SessionRelationship, SessionRelationshipKind, SessionStatus, - SessionTranscriptExport, SessionTranscriptExportOptions, SessionTranscriptIndexEntry, - StoredSessionIndexFile, StoredSessionMetadataFile, ToolItemData, TranscriptLineRange, - SESSION_STORAGE_SCHEMA_VERSION, + DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, + SessionTranscriptIndexEntry, ToolItemData, TranscriptLineRange, SESSION_STORAGE_SCHEMA_VERSION, }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::timing::elapsed_ms_u64; +use bitfun_runtime_ports::{SessionTurnLoadRequest, SessionTurnLoadTiming}; +use bitfun_services_core::{ + json_store::{JsonFileStore, JsonFileStoreError}, + session::{ + build_session_metadata as build_persisted_session_metadata, empty_session_metadata_page, + refresh_session_metadata_from_turns, try_refresh_session_metadata_for_saved_turn, + SessionMetadataBuildFacts, SessionMetadataStore, SessionMetadataStoreError, + SessionStorageLayout, + }, +}; +use futures::{stream, StreamExt}; use log::{debug, info, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -30,13 +41,12 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::fs; use tokio::sync::Mutex; +pub use bitfun_services_core::session::SessionMetadataPage; + const TRANSCRIPT_SCHEMA_VERSION: u32 = 1; -const JSON_WRITE_MAX_RETRIES: usize = 5; -const JSON_WRITE_RETRY_BASE_DELAY_MS: u64 = 30; const SESSION_TRANSCRIPT_PREVIEW_CHAR_LIMIT: usize = 120; +const SESSION_TURN_READ_CONCURRENCY: usize = 4; -static JSON_FILE_WRITE_LOCKS: OnceLock>>>> = OnceLock::new(); -static SESSION_INDEX_LOCKS: OnceLock>>>> = OnceLock::new(); static SESSION_METADATA_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -47,6 +57,12 @@ struct StoredDialogTurnFile { turn: DialogTurnData, } +struct ReadTurnPathsResult { + turns: Vec, + missing_turn_file_count: usize, + max_turn_read_duration_ms: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionStateFile { schema_version: u32, @@ -81,20 +97,18 @@ struct StoredTurnContextSnapshotFile { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionMetadataPage { - pub sessions: Vec, - pub total_top_level_count: usize, - pub loaded_top_level_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - pub has_more: bool, +struct StoredTurnSkillAgentSnapshotFile { + schema_version: u32, + session_id: String, + turn_index: usize, + snapshot: TurnSkillAgentSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct SessionMetadataPageCursor { - last_active_at: u64, +struct StoredSkillAgentBaselineOverrideFile { + schema_version: u32, session_id: String, + snapshot: TurnSkillAgentSnapshot, } #[derive(Debug, Default)] @@ -271,98 +285,6 @@ pub struct PersistenceManager { } impl PersistenceManager { - fn build_session_relationship( - session: &Session, - existing: Option<&SessionMetadata>, - ) -> Option { - let existing_relationship = existing.and_then(|value| value.relationship.clone()); - let existing_custom_metadata = existing.and_then(|value| value.custom_metadata.as_ref()); - - let kind = match session.kind { - SessionKind::Subagent => Some(SessionRelationshipKind::Subagent), - SessionKind::EphemeralChild => Some(SessionRelationshipKind::Btw), - SessionKind::Standard => existing_relationship - .as_ref() - .and_then(|value| value.kind.clone()), - }; - - let parent_session_id = existing_relationship - .as_ref() - .and_then(|value| value.parent_session_id.clone()) - .or_else(|| { - existing_custom_metadata - .and_then(|value| value.get("parentSessionId")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }); - let parent_request_id = existing_relationship - .as_ref() - .and_then(|value| value.parent_request_id.clone()) - .or_else(|| { - existing_custom_metadata - .and_then(|value| value.get("parentRequestId")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }); - let parent_dialog_turn_id = existing_relationship - .as_ref() - .and_then(|value| value.parent_dialog_turn_id.clone()) - .or_else(|| { - existing_custom_metadata - .and_then(|value| value.get("parentDialogTurnId")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }); - let parent_turn_index = existing_relationship - .as_ref() - .and_then(|value| value.parent_turn_index) - .or_else(|| { - existing_custom_metadata - .and_then(|value| value.get("parentTurnIndex")) - .and_then(|value| value.as_u64()) - .map(|value| value as usize) - }); - let parent_tool_call_id = existing_relationship - .as_ref() - .and_then(|value| value.parent_tool_call_id.clone()) - .or_else(|| { - existing_custom_metadata - .and_then(|value| value.get("parentToolCallId")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }); - let subagent_type = existing_relationship - .as_ref() - .and_then(|value| value.subagent_type.clone()) - .or_else(|| { - existing_custom_metadata - .and_then(|value| value.get("subagentType")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }); - - if kind.is_none() - && parent_session_id.is_none() - && parent_request_id.is_none() - && parent_dialog_turn_id.is_none() - && parent_turn_index.is_none() - && parent_tool_call_id.is_none() - && subagent_type.is_none() - { - return None; - } - - Some(SessionRelationship { - kind, - parent_session_id, - parent_request_id, - parent_dialog_turn_id, - parent_turn_index, - parent_tool_call_id, - subagent_type, - }) - } - pub fn new(path_manager: Arc) -> BitFunResult { Ok(Self { runtime_service: Arc::new(WorkspaceRuntimeService::new(path_manager.clone())), @@ -402,42 +324,32 @@ impl PersistenceManager { self.path_manager.project_sessions_dir(workspace_path) } - fn session_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.project_sessions_dir(workspace_path).join(session_id) - } - fn metadata_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id) - .join("metadata.json") + self.session_layout(workspace_path) + .metadata_path(session_id) } fn state_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id) - .join("state.json") + self.session_layout(workspace_path).state_path(session_id) } fn prompt_cache_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id) - .join("prompt_cache.json") + self.session_layout(workspace_path) + .prompt_cache_path(session_id) } fn turns_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("turns") + self.session_layout(workspace_path).turns_dir(session_id) } fn snapshots_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id) - .join("snapshots") - } - - fn artifacts_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id) - .join("artifacts") + self.session_layout(workspace_path) + .snapshots_dir(session_id) } fn turn_path(&self, workspace_path: &Path, session_id: &str, turn_index: usize) -> PathBuf { - self.turns_dir(workspace_path, session_id) - .join(format!("turn-{:04}.json", turn_index)) + self.session_layout(workspace_path) + .turn_path(session_id, turn_index) } fn context_snapshot_path( @@ -446,22 +358,50 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> PathBuf { - self.snapshots_dir(workspace_path, session_id) - .join(format!("context-{:04}.json", turn_index)) + self.session_layout(workspace_path) + .context_snapshot_path(session_id, turn_index) + } + + fn skill_agent_snapshot_path( + &self, + workspace_path: &Path, + session_id: &str, + turn_index: usize, + ) -> PathBuf { + self.session_layout(workspace_path) + .skill_agent_snapshot_path(session_id, turn_index) + } + + fn skill_agent_baseline_override_path( + &self, + workspace_path: &Path, + session_id: &str, + ) -> PathBuf { + self.session_layout(workspace_path) + .skill_agent_baseline_override_path(session_id) } fn transcript_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.artifacts_dir(workspace_path, session_id) - .join("transcript.txt") + self.session_layout(workspace_path) + .transcript_path(session_id) } fn transcript_meta_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.artifacts_dir(workspace_path, session_id) - .join("transcript.meta.json") + self.session_layout(workspace_path) + .transcript_meta_path(session_id) } + #[cfg(test)] fn index_path(&self, workspace_path: &Path) -> PathBuf { - self.project_sessions_dir(workspace_path).join("index.json") + self.session_layout(workspace_path).index_path() + } + + fn session_layout(&self, workspace_path: &Path) -> SessionStorageLayout { + SessionStorageLayout::new(self.project_sessions_dir(workspace_path)) + } + + fn session_metadata_store(&self, workspace_path: &Path) -> SessionMetadataStore { + SessionMetadataStore::new(self.project_sessions_dir(workspace_path)) } fn existing_project_sessions_dir(&self, workspace_path: &Path) -> Option { @@ -486,11 +426,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { - let dir = self.session_dir(workspace_path, session_id); - fs::create_dir_all(&dir) + self.session_layout(workspace_path) + .ensure_session_dir(session_id) .await - .map_err(|e| BitFunError::io(format!("Failed to create session directory: {}", e)))?; - Ok(dir) + .map_err(|e| BitFunError::io(format!("Failed to create session directory: {}", e))) } async fn ensure_turns_dir( @@ -498,11 +437,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { - let dir = self.turns_dir(workspace_path, session_id); - fs::create_dir_all(&dir) + self.session_layout(workspace_path) + .ensure_turns_dir(session_id) .await - .map_err(|e| BitFunError::io(format!("Failed to create turns directory: {}", e)))?; - Ok(dir) + .map_err(|e| BitFunError::io(format!("Failed to create turns directory: {}", e))) } async fn ensure_snapshots_dir( @@ -510,11 +448,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { - let dir = self.snapshots_dir(workspace_path, session_id); - fs::create_dir_all(&dir) + self.session_layout(workspace_path) + .ensure_snapshots_dir(session_id) .await - .map_err(|e| BitFunError::io(format!("Failed to create snapshots directory: {}", e)))?; - Ok(dir) + .map_err(|e| BitFunError::io(format!("Failed to create snapshots directory: {}", e))) } async fn ensure_artifacts_dir( @@ -522,164 +459,27 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { - let dir = self.artifacts_dir(workspace_path, session_id); - fs::create_dir_all(&dir) + self.session_layout(workspace_path) + .ensure_artifacts_dir(session_id) .await - .map_err(|e| BitFunError::io(format!("Failed to create artifacts directory: {}", e)))?; - Ok(dir) + .map_err(|e| BitFunError::io(format!("Failed to create artifacts directory: {}", e))) } async fn read_json_optional( &self, path: &Path, ) -> BitFunResult> { - let started_at = Instant::now(); - let metadata_started_at = Instant::now(); - let metadata = match fs::metadata(path).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(BitFunError::io(format!( - "Failed to read JSON metadata {}: {}", - path.display(), - error - ))); - } - }; - let metadata_duration = metadata_started_at.elapsed(); - - let read_started_at = Instant::now(); - let content = fs::read_to_string(path).await.map_err(|e| { - BitFunError::io(format!( - "Failed to read JSON file {}: {}", - path.display(), - e - )) - })?; - let read_duration = read_started_at.elapsed(); - - let parse_started_at = Instant::now(); - let value = serde_json::from_str::(&content).map_err(|e| { - BitFunError::Deserialization(format!( - "Failed to deserialize JSON file {}: {}", - path.display(), - e - )) - })?; - let parse_duration = parse_started_at.elapsed(); - let total_duration = started_at.elapsed(); - - if total_duration >= Duration::from_millis(80) || metadata.len() >= 1024 * 1024 { - debug!( - "Read JSON file: path={} type={} size_bytes={} metadata_duration_ms={} read_duration_ms={} parse_duration_ms={} total_duration_ms={}", - path.display(), - std::any::type_name::(), - metadata.len(), - metadata_duration.as_millis(), - read_duration.as_millis(), - parse_duration.as_millis(), - total_duration.as_millis() - ); - } - - Ok(Some(value)) + JsonFileStore::default() + .read_optional(path) + .await + .map_err(Self::json_store_error) } async fn write_json_atomic(&self, path: &Path, value: &T) -> BitFunResult<()> { - let parent = path.parent().ok_or_else(|| { - BitFunError::io(format!( - "Target path has no parent directory: {}", - path.display() - )) - })?; - - fs::create_dir_all(parent) + JsonFileStore::default() + .write_atomic(path, value) .await - .map_err(|e| BitFunError::io(format!("Failed to create parent directory: {}", e)))?; - - let json = serde_json::to_string_pretty(value) - .map_err(|e| BitFunError::serialization(format!("Failed to serialize JSON: {}", e)))?; - let lock = Self::get_file_write_lock(path).await; - let _lock_guard = lock.lock().await; - - let json_bytes = json.into_bytes(); - let mut last_replace_error: Option = None; - - for attempt in 0..=JSON_WRITE_MAX_RETRIES { - let tmp_path = Self::build_temp_json_path(path, attempt)?; - if let Err(e) = fs::write(&tmp_path, &json_bytes).await { - return Err(BitFunError::io(format!( - "Failed to write temp JSON file: {}", - e - ))); - } - - match Self::replace_file_from_temp(path, &tmp_path).await { - Ok(()) => return Ok(()), - Err(e) => { - let should_retry = - Self::is_retryable_write_error(&e) && attempt < JSON_WRITE_MAX_RETRIES; - last_replace_error = Some(e); - let _ = fs::remove_file(&tmp_path).await; - - if should_retry { - tokio::time::sleep(Self::retry_delay(attempt)).await; - continue; - } - - break; - } - } - } - - if let Some(error) = last_replace_error { - // On Windows, external scanners/file indexers may temporarily hold a non-shareable - // handle, making delete/rename fail with PermissionDenied. Fallback to direct write - // to avoid losing session persistence while keeping best-effort atomic behavior. - if error.kind() == ErrorKind::PermissionDenied { - warn!( - "Atomic JSON replace permission denied for {}, fallback to direct overwrite", - path.display() - ); - fs::write(path, &json_bytes).await.map_err(|e| { - BitFunError::io(format!( - "Failed fallback JSON overwrite {}: {}", - path.display(), - e - )) - })?; - return Ok(()); - } - - return Err(BitFunError::io(format!( - "Failed to replace JSON file: {}", - error - ))); - } - - Err(BitFunError::io(format!( - "Failed to replace JSON file {}: unknown error", - path.display() - ))) - } - - async fn get_file_write_lock(path: &Path) -> Arc> { - let registry = JSON_FILE_WRITE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); - let mut registry_guard = registry.lock().await; - registry_guard - .entry(path.to_path_buf()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() - } - - async fn get_session_index_lock(&self, workspace_path: &Path) -> Arc> { - let index_path = self.index_path(workspace_path); - let registry = SESSION_INDEX_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); - let mut registry_guard = registry.lock().await; - registry_guard - .entry(index_path) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() + .map_err(Self::json_store_error) } async fn get_session_metadata_update_lock( @@ -696,63 +496,24 @@ impl PersistenceManager { .clone() } - fn build_temp_json_path(path: &Path, attempt: usize) -> BitFunResult { - let parent = path.parent().ok_or_else(|| { - BitFunError::io(format!( - "Target path has no parent directory: {}", - path.display() - )) - })?; - - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| "data.json".to_string()); - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let temp_name = format!( - ".{}.{}.{}.{}.tmp", - file_name, - std::process::id(), - nonce, - attempt - ); - Ok(parent.join(temp_name)) - } - - async fn replace_file_from_temp(target_path: &Path, tmp_path: &Path) -> std::io::Result<()> { - if let Ok(()) = fs::rename(tmp_path, target_path).await { - return Ok(()); - } - - if target_path.exists() { - match fs::remove_file(target_path).await { - Ok(()) => {} - Err(e) if e.kind() == ErrorKind::NotFound => {} - Err(e) => return Err(e), - } + fn json_store_error(error: JsonFileStoreError) -> BitFunError { + if error.is_deserialization() { + BitFunError::Deserialization(error.to_string()) + } else if error.is_serialization() { + BitFunError::serialization(error.to_string()) + } else { + BitFunError::io(error.to_string()) } - - fs::rename(tmp_path, target_path).await } - fn is_retryable_write_error(error: &std::io::Error) -> bool { - matches!( - error.kind(), - ErrorKind::PermissionDenied - | ErrorKind::WouldBlock - | ErrorKind::Interrupted - | ErrorKind::TimedOut - | ErrorKind::AlreadyExists - | ErrorKind::Other - ) - } - - fn retry_delay(attempt: usize) -> Duration { - let exp = attempt.min(6) as u32; - Duration::from_millis(JSON_WRITE_RETRY_BASE_DELAY_MS * (1u64 << exp)) + fn session_metadata_store_error(error: SessionMetadataStoreError) -> BitFunError { + if error.is_deserialization() { + BitFunError::Deserialization(error.to_string()) + } else if error.is_serialization() { + BitFunError::serialization(error.to_string()) + } else { + BitFunError::io(error.to_string()) + } } fn system_time_to_unix_ms(time: SystemTime) -> u64 { @@ -844,16 +605,7 @@ impl PersistenceManager { session: &Session, existing: Option<&SessionMetadata>, ) -> SessionMetadata { - let created_at = existing - .map(|value| value.created_at) - .unwrap_or_else(|| Self::system_time_to_unix_ms(session.created_at)); let last_active_at = Self::system_time_to_unix_ms(session.last_activity_at); - let model_name = session - .config - .model_id - .clone() - .or_else(|| existing.map(|value| value.model_name.clone())) - .unwrap_or_else(|| "default".to_string()); let resolved_identity = if let Some(workspace_root) = session.config.workspace_path.as_deref() { @@ -885,43 +637,23 @@ impl PersistenceManager { } }); - SessionMetadata { - session_id: session.session_id.clone(), - session_name: session.session_name.clone(), - agent_type: session.agent_type.clone(), - last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), - last_submitted_agent_type: session.last_submitted_agent_type.clone(), - created_by: session - .created_by - .clone() - .or_else(|| existing.and_then(|value| value.created_by.clone())), + build_persisted_session_metadata(SessionMetadataBuildFacts { + session_id: &session.session_id, + session_name: &session.session_name, + agent_type: &session.agent_type, + last_user_dialog_agent_type: session.last_user_dialog_agent_type.as_deref(), + last_submitted_agent_type: session.last_submitted_agent_type.as_deref(), + created_by: session.created_by.as_deref(), session_kind: session.kind, - model_name, - created_at, - last_active_at, + model_name: session.config.model_id.as_deref(), + created_at_ms: Self::system_time_to_unix_ms(session.created_at), + last_active_at_ms: last_active_at, turn_count: session.dialog_turn_ids.len(), - message_count: existing.map(|value| value.message_count).unwrap_or(0), - tool_call_count: existing.map(|value| value.tool_call_count).unwrap_or(0), - status: existing - .map(|value| value.status.clone()) - .unwrap_or(SessionStatus::Active), - terminal_session_id: existing.and_then(|value| value.terminal_session_id.clone()), - snapshot_session_id: session - .snapshot_session_id - .clone() - .or_else(|| existing.and_then(|value| value.snapshot_session_id.clone())), - tags: existing.map(|value| value.tags.clone()).unwrap_or_default(), - custom_metadata: existing.and_then(|value| value.custom_metadata.clone()), - relationship: Self::build_session_relationship(session, existing), - todos: existing.and_then(|value| value.todos.clone()), - deep_review_run_manifest: existing - .and_then(|value| value.deep_review_run_manifest.clone()), - deep_review_cache: existing.and_then(|value| value.deep_review_cache.clone()), - workspace_path: Some(workspace_root), - workspace_hostname, - unread_completion: existing.and_then(|value| value.unread_completion.clone()), - needs_user_attention: existing.and_then(|value| value.needs_user_attention.clone()), - } + snapshot_session_id: session.snapshot_session_id.as_deref(), + workspace_path: &workspace_root, + workspace_hostname: workspace_hostname.as_deref(), + existing, + }) } fn turn_status_label(status: &crate::service::session::TurnStatus) -> &'static str { @@ -1441,173 +1173,6 @@ impl PersistenceManager { } } - async fn scan_session_metadata_dirs( - &self, - workspace_path: &Path, - ) -> BitFunResult> { - let Some(sessions_root) = self.existing_project_sessions_dir(workspace_path) else { - return Ok(Vec::new()); - }; - let mut metadata_list = Vec::new(); - let mut entries = fs::read_dir(&sessions_root) - .await - .map_err(|e| BitFunError::io(format!("Failed to read sessions root: {}", e)))?; - - while let Some(entry) = entries.next_entry().await.map_err(|e| { - BitFunError::io(format!("Failed to read session directory entry: {}", e)) - })? { - let file_type = entry - .file_type() - .await - .map_err(|e| BitFunError::io(format!("Failed to get file type: {}", e)))?; - if !file_type.is_dir() { - continue; - } - - let session_id = entry.file_name().to_string_lossy().to_string(); - match self - .load_session_metadata(workspace_path, &session_id) - .await - { - Ok(Some(metadata)) => metadata_list.push(metadata), - Ok(None) => {} - Err(e) => { - warn!( - "Failed to rebuild session index entry: session_id={}, error={}", - session_id, e - ); - } - } - } - - metadata_list.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); - - Ok(metadata_list) - } - - async fn count_session_metadata_dirs(&self, workspace_path: &Path) -> BitFunResult { - let Some(sessions_root) = self.existing_project_sessions_dir(workspace_path) else { - return Ok(0); - }; - let mut count = 0; - let mut entries = fs::read_dir(&sessions_root) - .await - .map_err(|e| BitFunError::io(format!("Failed to read sessions root: {}", e)))?; - - while let Some(entry) = entries.next_entry().await.map_err(|e| { - BitFunError::io(format!("Failed to read session directory entry: {}", e)) - })? { - let file_type = entry - .file_type() - .await - .map_err(|e| BitFunError::io(format!("Failed to get file type: {}", e)))?; - if !file_type.is_dir() { - continue; - } - - let session_id = entry.file_name().to_string_lossy().to_string(); - if self.metadata_path(workspace_path, &session_id).exists() { - count += 1; - } - } - - Ok(count) - } - - async fn rebuild_index_locked( - &self, - workspace_path: &Path, - ) -> BitFunResult> { - let metadata_list = self.scan_session_metadata_dirs(workspace_path).await?; - let metadata_file_count = metadata_list.len(); - let visible_sessions = metadata_list - .into_iter() - .filter(|metadata| !metadata.should_hide_from_user_lists()) - .collect::>(); - - let index = StoredSessionIndexFile::with_metadata_file_count( - Self::system_time_to_unix_ms(SystemTime::now()), - visible_sessions.clone(), - metadata_file_count, - ); - self.write_json_atomic(&self.index_path(workspace_path), &index) - .await?; - - Ok(visible_sessions) - } - - async fn upsert_index_entry_locked( - &self, - workspace_path: &Path, - metadata: &SessionMetadata, - metadata_file_created: bool, - ) -> BitFunResult<()> { - let index_path = self.index_path(workspace_path); - let existing_index = self - .read_json_optional::(&index_path) - .await?; - let had_index = existing_index.is_some(); - let mut index = match existing_index { - Some(index) => index, - None => StoredSessionIndexFile { - schema_version: SESSION_STORAGE_SCHEMA_VERSION, - updated_at: 0, - metadata_file_count: self.count_session_metadata_dirs(workspace_path).await?, - sessions: Vec::new(), - }, - }; - - if let Some(existing) = index - .sessions - .iter_mut() - .find(|value| value.session_id == metadata.session_id) - { - *existing = metadata.clone(); - } else { - index.sessions.push(metadata.clone()); - } - - index - .sessions - .sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); - if had_index && metadata_file_created { - index.metadata_file_count = index.metadata_file_count.saturating_add(1); - } - index.updated_at = Self::system_time_to_unix_ms(SystemTime::now()); - index.schema_version = SESSION_STORAGE_SCHEMA_VERSION; - self.write_json_atomic(&index_path, &index).await - } - - async fn remove_index_entry_locked( - &self, - workspace_path: &Path, - session_id: &str, - metadata_file_count_delta: isize, - ) -> BitFunResult<()> { - let index_path = self.index_path(workspace_path); - let Some(mut index) = self - .read_json_optional::(&index_path) - .await? - else { - return Ok(()); - }; - - index - .sessions - .retain(|value| value.session_id != session_id); - if metadata_file_count_delta > 0 { - index.metadata_file_count = index - .metadata_file_count - .saturating_add(metadata_file_count_delta as usize); - } else if metadata_file_count_delta < 0 { - index.metadata_file_count = index - .metadata_file_count - .saturating_sub(metadata_file_count_delta.unsigned_abs()); - } - index.updated_at = Self::system_time_to_unix_ms(SystemTime::now()); - self.write_json_atomic(&index_path, &index).await - } - pub async fn list_session_metadata( &self, workspace_path: &Path, @@ -1620,174 +1185,10 @@ impl PersistenceManager { return Ok(Vec::new()); } - let lock = self.get_session_index_lock(workspace_path).await; - let _guard = lock.lock().await; - let index_path = self.index_path(workspace_path); - if let Some(index) = self - .read_json_optional::(&index_path) - .await? - { - let has_stale_entry = index.sessions.iter().any(|metadata| { - !self - .metadata_path(workspace_path, &metadata.session_id) - .exists() - }); - if has_stale_entry { - warn!( - "Session index contains stale entries, rebuilding: {}", - index_path.display() - ); - return self.rebuild_index_locked(workspace_path).await; - } - - let disk_count = self.count_session_metadata_dirs(workspace_path).await?; - if index.metadata_file_count != disk_count { - warn!( - "Session index incomplete (index: {}, disk: {}), rebuilding: {}", - index.metadata_file_count, - disk_count, - index_path.display() - ); - return self.rebuild_index_locked(workspace_path).await; - } - - return Ok(index.sessions); - } - - self.rebuild_index_locked(workspace_path).await - } - - fn session_parent_id(metadata: &SessionMetadata) -> Option { - if let Some(parent_id) = metadata - .relationship - .as_ref() - .and_then(|relationship| relationship.parent_session_id.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Some(parent_id.to_string()); - } - - metadata - .custom_metadata - .as_ref() - .and_then(|custom| { - custom - .get("parentSessionId") - .or_else(|| custom.get("parent_session_id")) - }) - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - } - - fn session_metadata_page_offset( - cursor: Option<&str>, - top_level_sessions: &[SessionMetadata], - ) -> usize { - let Some(cursor) = cursor else { - return 0; - }; - - if let Ok(parsed) = serde_json::from_str::(cursor) { - if let Some(index) = top_level_sessions.iter().position(|metadata| { - metadata.session_id == parsed.session_id - && metadata.last_active_at == parsed.last_active_at - }) { - return index + 1; - } - - if let Some(index) = top_level_sessions - .iter() - .position(|metadata| metadata.session_id == parsed.session_id) - { - return index + 1; - } - } - - cursor.parse::().unwrap_or(0) - } - - fn session_metadata_page_cursor(metadata: &SessionMetadata) -> String { - serde_json::to_string(&SessionMetadataPageCursor { - last_active_at: metadata.last_active_at, - session_id: metadata.session_id.clone(), - }) - .unwrap_or_else(|_| metadata.session_id.clone()) - } - - fn build_session_metadata_page( - indexed_sessions: Vec, - cursor: Option<&str>, - limit: usize, - ) -> SessionMetadataPage { - let visible_sessions = indexed_sessions - .into_iter() - .filter(|metadata| { - !metadata.should_hide_from_user_lists() - && metadata.status != SessionStatus::Archived - }) - .collect::>(); - let visible_ids = visible_sessions - .iter() - .map(|metadata| metadata.session_id.clone()) - .collect::>(); - - let mut top_level_sessions = Vec::new(); - let mut children_by_parent: HashMap> = HashMap::new(); - for metadata in visible_sessions { - if let Some(parent_id) = Self::session_parent_id(&metadata) { - if visible_ids.contains(&parent_id) { - children_by_parent - .entry(parent_id) - .or_default() - .push(metadata); - continue; - } - } - - top_level_sessions.push(metadata); - } - - let total_top_level_count = top_level_sessions.len(); - let offset = Self::session_metadata_page_offset(cursor, &top_level_sessions); - let offset = offset.min(total_top_level_count); - let next_offset = offset.saturating_add(limit).min(total_top_level_count); - let selected_top_level = top_level_sessions - .iter() - .skip(offset) - .take(limit) - .cloned() - .collect::>(); - let loaded_top_level_count = selected_top_level.len(); - let has_more = next_offset < total_top_level_count; - let next_cursor = has_more - .then(|| { - selected_top_level - .last() - .map(Self::session_metadata_page_cursor) - }) - .flatten(); - - let mut sessions = Vec::new(); - for metadata in selected_top_level { - let session_id = metadata.session_id.clone(); - sessions.push(metadata); - - if let Some(mut children) = children_by_parent.remove(&session_id) { - children.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); - sessions.extend(children); - } - } - - SessionMetadataPage { - sessions, - total_top_level_count, - loaded_top_level_count, - next_cursor, - has_more, - } + self.session_metadata_store(workspace_path) + .list_metadata() + .await + .map_err(Self::session_metadata_store_error) } pub async fn list_session_metadata_page( @@ -1797,69 +1198,17 @@ impl PersistenceManager { limit: usize, ) -> BitFunResult { if !workspace_path.exists() { - return Ok(SessionMetadataPage { - sessions: Vec::new(), - total_top_level_count: 0, - loaded_top_level_count: 0, - next_cursor: None, - has_more: false, - }); + return Ok(empty_session_metadata_page()); } if self.existing_project_sessions_dir(workspace_path).is_none() { - return Ok(SessionMetadataPage { - sessions: Vec::new(), - total_top_level_count: 0, - loaded_top_level_count: 0, - next_cursor: None, - has_more: false, - }); + return Ok(empty_session_metadata_page()); } - let limit = limit.max(1); - - let lock = self.get_session_index_lock(workspace_path).await; - let _guard = lock.lock().await; - let index_path = self.index_path(workspace_path); - let indexed_sessions = if let Some(index) = self - .read_json_optional::(&index_path) - .await? - { - if index.metadata_file_count < index.sessions.len() { - warn!( - "Session index has invalid metadata count before page read (index: {}, sessions: {}), rebuilding: {}", - index.metadata_file_count, - index.sessions.len(), - index_path.display() - ); - self.rebuild_index_locked(workspace_path).await? - } else { - index.sessions - } - } else { - self.rebuild_index_locked(workspace_path).await? - }; - - let page = Self::build_session_metadata_page(indexed_sessions, cursor, limit); - let has_stale_page_entry = page.sessions.iter().any(|metadata| { - !self - .metadata_path(workspace_path, &metadata.session_id) - .exists() - }); - if !has_stale_page_entry { - return Ok(page); - } - - warn!( - "Session index page contains stale entries, rebuilding before page read: {}", - index_path.display() - ); - let rebuilt_sessions = self.rebuild_index_locked(workspace_path).await?; - Ok(Self::build_session_metadata_page( - rebuilt_sessions, - cursor, - limit, - )) + self.session_metadata_store(workspace_path) + .list_metadata_page(cursor, limit) + .await + .map_err(Self::session_metadata_store_error) } pub async fn list_session_metadata_including_internal( @@ -1874,7 +1223,10 @@ impl PersistenceManager { return Ok(Vec::new()); } - self.scan_session_metadata_dirs(workspace_path).await + self.session_metadata_store(workspace_path) + .list_metadata_including_internal() + .await + .map_err(Self::session_metadata_store_error) } pub async fn save_session_metadata( @@ -1883,26 +1235,10 @@ impl PersistenceManager { metadata: &SessionMetadata, ) -> BitFunResult<()> { self.ensure_runtime_for_write(workspace_path).await?; - self.ensure_session_dir(workspace_path, &metadata.session_id) - .await?; - let metadata_path = self.metadata_path(workspace_path, &metadata.session_id); - let file = StoredSessionMetadataFile::new(metadata.clone()); - - let lock = self.get_session_index_lock(workspace_path).await; - let _guard = lock.lock().await; - let metadata_file_created = !metadata_path.exists(); - self.write_json_atomic(&metadata_path, &file).await?; - if !metadata.should_hide_from_user_lists() { - self.upsert_index_entry_locked(workspace_path, metadata, metadata_file_created) - .await - } else { - self.remove_index_entry_locked( - workspace_path, - &metadata.session_id, - if metadata_file_created { 1 } else { 0 }, - ) + self.session_metadata_store(workspace_path) + .save_metadata(metadata) .await - } + .map_err(Self::session_metadata_store_error) } pub async fn load_session_metadata( @@ -1910,11 +1246,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { - let path = self.metadata_path(workspace_path, session_id); - Ok(self - .read_json_optional::(&path) - .await? - .map(|file| file.metadata)) + self.session_metadata_store(workspace_path) + .load_metadata(session_id) + .await + .map_err(Self::session_metadata_store_error) } async fn load_stored_session_state( @@ -2104,6 +1439,117 @@ impl PersistenceManager { Ok(Some((turn_index, messages))) } + pub async fn save_turn_skill_agent_snapshot( + &self, + workspace_path: &Path, + session_id: &str, + turn_index: usize, + snapshot: &TurnSkillAgentSnapshot, + ) -> BitFunResult<()> { + self.ensure_runtime_for_write(workspace_path).await?; + self.ensure_snapshots_dir(workspace_path, session_id) + .await?; + + self.write_json_atomic( + &self.skill_agent_snapshot_path(workspace_path, session_id, turn_index), + &StoredTurnSkillAgentSnapshotFile { + schema_version: SESSION_STORAGE_SCHEMA_VERSION, + session_id: session_id.to_string(), + turn_index, + snapshot: snapshot.clone(), + }, + ) + .await + } + + pub async fn load_turn_skill_agent_snapshot( + &self, + workspace_path: &Path, + session_id: &str, + turn_index: usize, + ) -> BitFunResult> { + let stored = self + .read_json_optional::( + &self.skill_agent_snapshot_path(workspace_path, session_id, turn_index), + ) + .await?; + Ok(stored.map(|value| value.snapshot)) + } + + pub async fn delete_turn_skill_agent_snapshots_from( + &self, + workspace_path: &Path, + session_id: &str, + turn_index: usize, + ) -> BitFunResult<()> { + let dir = self.snapshots_dir(workspace_path, session_id); + if !dir.exists() { + return Ok(()); + } + + let mut rd = fs::read_dir(&dir) + .await + .map_err(|e| BitFunError::io(format!("Failed to read snapshots directory: {}", e)))?; + while let Some(entry) = rd + .next_entry() + .await + .map_err(|e| BitFunError::io(format!("Failed to iterate snapshots directory: {}", e)))? + { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + let Some(index_str) = stem.strip_prefix("skill-agent-") else { + continue; + }; + let Ok(index) = index_str.parse::() else { + continue; + }; + if index >= turn_index { + let _ = fs::remove_file(&path).await; + } + } + + Ok(()) + } + + pub async fn save_skill_agent_baseline_override_snapshot( + &self, + workspace_path: &Path, + session_id: &str, + snapshot: &TurnSkillAgentSnapshot, + ) -> BitFunResult<()> { + self.ensure_runtime_for_write(workspace_path).await?; + self.ensure_snapshots_dir(workspace_path, session_id) + .await?; + + self.write_json_atomic( + &self.skill_agent_baseline_override_path(workspace_path, session_id), + &StoredSkillAgentBaselineOverrideFile { + schema_version: SESSION_STORAGE_SCHEMA_VERSION, + session_id: session_id.to_string(), + snapshot: snapshot.clone(), + }, + ) + .await + } + + pub async fn load_skill_agent_baseline_override_snapshot( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult> { + let stored = self + .read_json_optional::( + &self.skill_agent_baseline_override_path(workspace_path, session_id), + ) + .await?; + Ok(stored.map(|value| value.snapshot)) + } + pub async fn delete_turn_context_snapshots_from( &self, workspace_path: &Path, @@ -2130,7 +1576,11 @@ impl PersistenceManager { let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { continue; }; - let Some(index_str) = stem.strip_prefix("context-") else { + let index_str = if let Some(index) = stem.strip_prefix("context-") { + index + } else if let Some(index) = stem.strip_prefix("skill-agent-") { + index + } else { continue; }; let Ok(index) = index_str.parse::() else { @@ -2151,7 +1601,6 @@ impl PersistenceManager { self.ensure_runtime_for_write(workspace_path).await?; self.ensure_session_dir(workspace_path, &session.session_id) .await?; - let existing_metadata = self .load_session_metadata(workspace_path, &session.session_id) .await?; @@ -2254,19 +1703,93 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + self.load_session_with_turns_timed(workspace_path, session_id) + .await + .map(|(session, turns, _)| (session, turns)) + } + + pub async fn load_session_with_turns_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec, SessionTurnLoadTiming)> { + let request = SessionTurnLoadRequest { + workspace_path: workspace_path.to_path_buf(), + session_id: session_id.to_string(), + tail_turn_count: None, + }; + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); let metadata = self - .load_session_metadata(workspace_path, session_id) + .load_session_metadata(&request.workspace_path, &request.session_id) .await? .ok_or_else(|| { - BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) + BitFunError::NotFound(format!( + "Session metadata not found: {}", + request.session_id + )) })?; + let metadata_duration_ms = elapsed_ms_u64(metadata_started_at); + + let state_started_at = Instant::now(); let stored_state = self - .load_stored_session_state(workspace_path, session_id) + .load_stored_session_state(&request.workspace_path, &request.session_id) .await?; - let turns = self.load_session_turns(workspace_path, session_id).await?; + let state_duration_ms = elapsed_ms_u64(state_started_at); + + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(&request.workspace_path, &request.session_id) + .await?; + let scan_duration_ms = elapsed_ms_u64(scan_started_at); + + let read_started_at = Instant::now(); + let turn_file_count = indexed_paths.len(); + let read_result = self.read_turn_paths(indexed_paths).await?; + let read_duration_ms = elapsed_ms_u64(read_started_at); + let missing_turn_file_count = read_result.missing_turn_file_count; + let max_turn_read_duration_ms = read_result.max_turn_read_duration_ms; + let turns = read_result.turns; + + let build_started_at = Instant::now(); let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + let build_session_duration_ms = elapsed_ms_u64(build_started_at); + let total_duration_ms = elapsed_ms_u64(started_at); + + if total_duration_ms >= 80 || turn_file_count >= 50 { + debug!( + "Loaded session turns: session_id={} turn_count={} turn_file_count={} missing_turn_file_count={} metadata_duration_ms={} state_duration_ms={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} build_session_duration_ms={} total_duration_ms={}", + request.session_id, + turns.len(), + turn_file_count, + missing_turn_file_count, + metadata_duration_ms, + state_duration_ms, + scan_duration_ms, + read_duration_ms, + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration_ms + ); + } + + let timing = SessionTurnLoadTiming { + requested_tail_turn_count: None, + loaded_turn_count: turns.len(), + total_turn_count: turn_file_count, + turn_file_count, + missing_turn_file_count, + fast_path: false, + metadata_duration_ms, + state_duration_ms, + scan_duration_ms, + read_duration_ms, + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration_ms, + }; - Ok((session, turns)) + Ok((session, turns, timing)) } pub async fn load_session_with_tail_turns( @@ -2275,25 +1798,135 @@ impl PersistenceManager { session_id: &str, tail_turn_count: usize, ) -> BitFunResult<(Session, Vec, usize)> { + self.load_session_with_tail_turns_timed(workspace_path, session_id, tail_turn_count) + .await + .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) + } + + pub async fn load_session_with_tail_turns_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize, SessionTurnLoadTiming)> { + let request = SessionTurnLoadRequest { + workspace_path: workspace_path.to_path_buf(), + session_id: session_id.to_string(), + tail_turn_count: Some(tail_turn_count), + }; + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); let metadata = self - .load_session_metadata(workspace_path, session_id) + .load_session_metadata(&request.workspace_path, &request.session_id) .await? .ok_or_else(|| { - BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) + BitFunError::NotFound(format!( + "Session metadata not found: {}", + request.session_id + )) })?; + let metadata_duration = metadata_started_at.elapsed(); + + let state_started_at = Instant::now(); let stored_state = self - .load_stored_session_state(workspace_path, session_id) + .load_stored_session_state(&request.workspace_path, &request.session_id) .await?; - let indexed_paths = self - .list_indexed_turn_paths(workspace_path, session_id) + let state_duration = state_started_at.elapsed(); + + let fast_path_started_at = Instant::now(); + let fast_path_turns = self + .read_metadata_tail_turns( + &request.workspace_path, + &request.session_id, + metadata.turn_count, + tail_turn_count, + ) .await?; - let total_turn_count = indexed_paths.len(); - let start = indexed_paths.len().saturating_sub(tail_turn_count); - let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); - let turns = self.read_turn_paths(selected_paths).await?; + let fast_path_duration = fast_path_started_at.elapsed(); + + let ( + turns, + total_turn_count, + scan_duration, + read_duration, + fast_path, + missing_turn_file_count, + max_turn_read_duration_ms, + ) = if let Some(turns) = fast_path_turns { + ( + turns.turns, + metadata.turn_count, + Duration::ZERO, + fast_path_duration, + true, + turns.missing_turn_file_count, + turns.max_turn_read_duration_ms, + ) + } else { + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(&request.workspace_path, &request.session_id) + .await?; + let scan_duration = scan_started_at.elapsed(); + let total_turn_count = indexed_paths.len(); + let start = indexed_paths.len().saturating_sub(tail_turn_count); + let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + + let read_started_at = Instant::now(); + let read_result = self.read_turn_paths(selected_paths).await?; + let read_duration = read_started_at.elapsed(); + + ( + read_result.turns, + total_turn_count, + scan_duration, + read_duration, + false, + read_result.missing_turn_file_count, + read_result.max_turn_read_duration_ms, + ) + }; + let build_started_at = Instant::now(); let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + let build_session_duration_ms = elapsed_ms_u64(build_started_at); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(40) || total_turn_count >= 50 { + debug!( + "Loaded session tail view: session_id={} turn_count={} requested_count={} total_turn_count={} missing_turn_file_count={} fast_path={} metadata_duration_ms={} state_duration_ms={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} build_session_duration_ms={} total_duration_ms={}", + request.session_id, + turns.len(), + request.tail_turn_count.unwrap_or(tail_turn_count), + total_turn_count, + missing_turn_file_count, + fast_path, + metadata_duration.as_millis(), + state_duration.as_millis(), + scan_duration.as_millis(), + read_duration.as_millis(), + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration.as_millis() + ); + } + + let timing = SessionTurnLoadTiming { + requested_tail_turn_count: request.tail_turn_count, + loaded_turn_count: turns.len(), + total_turn_count, + turn_file_count: total_turn_count, + missing_turn_file_count, + fast_path, + metadata_duration_ms: metadata_duration.as_millis() as u64, + state_duration_ms: state_duration.as_millis() as u64, + scan_duration_ms: scan_duration.as_millis() as u64, + read_duration_ms: read_duration.as_millis() as u64, + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration_ms: total_duration.as_millis() as u64, + }; - Ok((session, turns, total_turn_count)) + Ok((session, turns, total_turn_count, timing)) } /// Save session state @@ -2331,22 +1964,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { - let lock = self.get_session_index_lock(workspace_path).await; - let _guard = lock.lock().await; - let dir = self.session_dir(workspace_path, session_id); - let metadata_file_removed = self.metadata_path(workspace_path, session_id).exists(); - if dir.exists() { - fs::remove_dir_all(&dir).await.map_err(|e| { - BitFunError::io(format!("Failed to delete session directory: {}", e)) - })?; - } - - self.remove_index_entry_locked( - workspace_path, - session_id, - if metadata_file_removed { -1 } else { 0 }, - ) - .await?; + self.session_metadata_store(workspace_path) + .delete_session_dir_and_index(session_id) + .await + .map_err(Self::session_metadata_store_error)?; info!("Session deleted: session_id={}", session_id); Ok(()) } @@ -2382,72 +2003,6 @@ impl PersistenceManager { Ok(summaries) } - fn estimate_turn_message_count(turn: &DialogTurnData) -> usize { - let assistant_text_count: usize = turn - .model_rounds - .iter() - .map(|round| round.text_items.len()) - .sum(); - 1 + assistant_text_count - } - - fn refresh_metadata_from_turns( - metadata: &mut SessionMetadata, - workspace_path: &Path, - turns: &[DialogTurnData], - last_active_at: u64, - ) { - metadata.turn_count = turns.len(); - metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = last_active_at; - if metadata.workspace_path.is_none() { - metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); - } - } - - fn try_refresh_metadata_for_saved_turn( - metadata: &mut SessionMetadata, - workspace_path: &Path, - previous_turn: Option<&DialogTurnData>, - turn: &DialogTurnData, - last_active_at: u64, - ) -> bool { - let new_message_count = Self::estimate_turn_message_count(turn); - let new_tool_call_count = turn.count_tool_calls(); - - match previous_turn { - Some(previous) - if previous.session_id == turn.session_id - && previous.turn_index == turn.turn_index - && turn.turn_index < metadata.turn_count => - { - metadata.message_count = metadata - .message_count - .saturating_sub(Self::estimate_turn_message_count(previous)) - .saturating_add(new_message_count); - metadata.tool_call_count = metadata - .tool_call_count - .saturating_sub(previous.count_tool_calls()) - .saturating_add(new_tool_call_count); - } - None if turn.turn_index == metadata.turn_count => { - metadata.turn_count += 1; - metadata.message_count = metadata.message_count.saturating_add(new_message_count); - metadata.tool_call_count = - metadata.tool_call_count.saturating_add(new_tool_call_count); - } - _ => return false, - } - - metadata.last_active_at = last_active_at; - if metadata.workspace_path.is_none() { - metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); - } - - true - } - pub async fn save_dialog_turn( &self, workspace_path: &Path, @@ -2465,7 +2020,6 @@ impl PersistenceManager { .ok_or_else(|| { BitFunError::NotFound(format!("Session metadata not found: {}", turn.session_id)) })?; - self.ensure_turns_dir(workspace_path, &turn.session_id) .await?; @@ -2505,10 +2059,11 @@ impl PersistenceManager { .end_time .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); let mut metadata_refresh_mode = "incremental"; + let workspace_path_text = workspace_path.to_string_lossy(); if previous_turn_load_failed - || !Self::try_refresh_metadata_for_saved_turn( + || !try_refresh_session_metadata_for_saved_turn( &mut metadata, - workspace_path, + workspace_path_text.as_ref(), previous_turn.as_ref(), turn, last_active_at, @@ -2518,9 +2073,9 @@ impl PersistenceManager { let turns = self .load_session_turns(workspace_path, &turn.session_id) .await?; - Self::refresh_metadata_from_turns( + refresh_session_metadata_from_turns( &mut metadata, - workspace_path, + workspace_path_text.as_ref(), &turns, last_active_at, ); @@ -2567,55 +2122,77 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { - let turns_dir = self.turns_dir(workspace_path, session_id); - if !turns_dir.exists() { - return Ok(Vec::new()); - } - - let mut indexed_paths = Vec::new(); - let mut entries = fs::read_dir(&turns_dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to read turns directory: {}", e)))?; - - while let Some(entry) = entries - .next_entry() + self.session_layout(workspace_path) + .list_indexed_turn_paths(session_id) .await - .map_err(|e| BitFunError::io(format!("Failed to iterate turns directory: {}", e)))? - { - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { - continue; - }; - let Some(index_str) = stem.strip_prefix("turn-") else { - continue; - }; - let Ok(index) = index_str.parse::() else { - continue; - }; - indexed_paths.push((index, path)); - } - - indexed_paths.sort_by_key(|(index, _)| *index); - Ok(indexed_paths) + .map_err(|e| BitFunError::io(format!("Failed to list dialog turn files: {}", e))) } async fn read_turn_paths( &self, indexed_paths: Vec<(usize, PathBuf)>, - ) -> BitFunResult> { + ) -> BitFunResult { let mut turns = Vec::with_capacity(indexed_paths.len()); - for (_, path) in indexed_paths { - if let Some(file) = self - .read_json_optional::(&path) - .await? - { + let mut missing_turn_file_count = 0usize; + let mut max_turn_read_duration_ms = 0u64; + let reads = stream::iter(indexed_paths.into_iter().map(|(_, path)| { + let manager = self; + async move { + let started_at = Instant::now(); + let result = manager + .read_json_optional::(&path) + .await; + (result, elapsed_ms_u64(started_at)) + } + })) + .buffered(SESSION_TURN_READ_CONCURRENCY) + .collect::>() + .await; + + for (result, duration_ms) in reads { + max_turn_read_duration_ms = max_turn_read_duration_ms.max(duration_ms); + if let Some(file) = result? { turns.push(file.turn); + } else { + missing_turn_file_count += 1; } } - Ok(turns) + + Ok(ReadTurnPathsResult { + turns, + missing_turn_file_count, + max_turn_read_duration_ms, + }) + } + + async fn read_metadata_tail_turns( + &self, + workspace_path: &Path, + session_id: &str, + total_turn_count: usize, + requested_count: usize, + ) -> BitFunResult> { + if requested_count == 0 { + return Ok(Some(ReadTurnPathsResult { + turns: Vec::new(), + missing_turn_file_count: 0, + max_turn_read_duration_ms: 0, + })); + } + if total_turn_count == 0 { + return Ok(None); + } + + let start = total_turn_count.saturating_sub(requested_count); + let indexed_paths = (start..total_turn_count) + .map(|index| (index, self.turn_path(workspace_path, session_id, index))) + .collect::>(); + let result = self.read_turn_paths(indexed_paths).await?; + if result.missing_turn_file_count > 0 { + return Ok(None); + } + + Ok(Some(result)) } pub async fn load_session_turns( @@ -2632,17 +2209,22 @@ impl PersistenceManager { let read_started_at = Instant::now(); let turn_file_count = indexed_paths.len(); - let turns = self.read_turn_paths(indexed_paths).await?; + let read_result = self.read_turn_paths(indexed_paths).await?; let read_duration = read_started_at.elapsed(); + let missing_turn_file_count = read_result.missing_turn_file_count; + let max_turn_read_duration_ms = read_result.max_turn_read_duration_ms; + let turns = read_result.turns; let total_duration = started_at.elapsed(); if total_duration >= Duration::from_millis(80) || turn_file_count >= 50 { debug!( - "Loaded session turns: session_id={} turn_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + "Loaded session turns: session_id={} turn_count={} turn_file_count={} missing_turn_file_count={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} total_duration_ms={}", session_id, turns.len(), turn_file_count, + missing_turn_file_count, scan_duration.as_millis(), read_duration.as_millis(), + max_turn_read_duration_ms, total_duration.as_millis() ); } @@ -2661,28 +2243,81 @@ impl PersistenceManager { } let started_at = Instant::now(); - let scan_started_at = Instant::now(); - let indexed_paths = self - .list_indexed_turn_paths(workspace_path, session_id) + let metadata_started_at = Instant::now(); + let metadata = self + .load_session_metadata(workspace_path, session_id) .await?; - let scan_duration = scan_started_at.elapsed(); - let turn_file_count = indexed_paths.len(); - let start = indexed_paths.len().saturating_sub(count); - let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + let metadata_duration = metadata_started_at.elapsed(); - let read_started_at = Instant::now(); - let turns = self.read_turn_paths(selected_paths).await?; - let read_duration = read_started_at.elapsed(); + let fast_path_started_at = Instant::now(); + let fast_path_turns = if let Some(metadata) = metadata.as_ref() { + self.read_metadata_tail_turns(workspace_path, session_id, metadata.turn_count, count) + .await? + } else { + None + }; + let fast_path_duration = fast_path_started_at.elapsed(); + + let ( + turns, + turn_file_count, + scan_duration, + read_duration, + fast_path, + missing_turn_file_count, + max_turn_read_duration_ms, + ) = if let Some(turns) = fast_path_turns { + let turn_file_count = metadata + .as_ref() + .map(|metadata| metadata.turn_count) + .unwrap_or(turns.turns.len()); + ( + turns.turns, + turn_file_count, + Duration::ZERO, + fast_path_duration, + true, + turns.missing_turn_file_count, + turns.max_turn_read_duration_ms, + ) + } else { + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let scan_duration = scan_started_at.elapsed(); + let turn_file_count = indexed_paths.len(); + let start = indexed_paths.len().saturating_sub(count); + let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + + let read_started_at = Instant::now(); + let read_result = self.read_turn_paths(selected_paths).await?; + let read_duration = read_started_at.elapsed(); + + ( + read_result.turns, + turn_file_count, + scan_duration, + read_duration, + false, + read_result.missing_turn_file_count, + read_result.max_turn_read_duration_ms, + ) + }; let total_duration = started_at.elapsed(); if total_duration >= Duration::from_millis(40) || turn_file_count >= 50 { debug!( - "Loaded session tail turns: session_id={} turn_count={} requested_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + "Loaded session tail turns: session_id={} turn_count={} requested_count={} turn_file_count={} missing_turn_file_count={} fast_path={} metadata_duration_ms={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} total_duration_ms={}", session_id, turns.len(), count, turn_file_count, + missing_turn_file_count, + fast_path, + metadata_duration.as_millis(), scan_duration.as_millis(), read_duration.as_millis(), + max_turn_read_duration_ms, total_duration.as_millis() ); } @@ -2696,48 +2331,27 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult<()> { - let turns_dir = self.turns_dir(workspace_path, session_id); - if !turns_dir.exists() { + if !self.turns_dir(workspace_path, session_id).exists() { return Ok(()); } - let mut entries = fs::read_dir(&turns_dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to read turns directory: {}", e)))?; - while let Some(entry) = entries - .next_entry() + self.session_layout(workspace_path) + .delete_indexed_turn_paths_from(session_id, turn_index) .await - .map_err(|e| BitFunError::io(format!("Failed to iterate turns directory: {}", e)))? - { - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { - continue; - }; - let Some(index_str) = stem.strip_prefix("turn-") else { - continue; - }; - let Ok(index) = index_str.parse::() else { - continue; - }; - if index >= turn_index { - fs::remove_file(&path).await.map_err(|e| { - BitFunError::io(format!("Failed to delete dialog turn file: {}", e)) - })?; - } - } + .map_err(|e| BitFunError::io(format!("Failed to delete dialog turn files: {}", e)))?; if let Some(mut metadata) = self .load_session_metadata(workspace_path, session_id) .await? { let turns = self.load_session_turns(workspace_path, session_id).await?; - metadata.turn_count = turns.len(); - metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); + let workspace_path_text = workspace_path.to_string_lossy(); + refresh_session_metadata_from_turns( + &mut metadata, + workspace_path_text.as_ref(), + &turns, + Self::system_time_to_unix_ms(SystemTime::now()), + ); self.save_session_metadata(workspace_path, &metadata) .await?; } @@ -2975,16 +2589,13 @@ impl PersistenceManager { .await? { let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; - metadata.turn_count = remaining_turns.len(); - metadata.message_count = remaining_turns - .iter() - .map(Self::estimate_turn_message_count) - .sum(); - metadata.tool_call_count = remaining_turns - .iter() - .map(DialogTurnData::count_tool_calls) - .sum(); - metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); + let workspace_path_text = workspace_path.to_string_lossy(); + refresh_session_metadata_from_turns( + &mut metadata, + workspace_path_text.as_ref(), + &remaining_turns, + Self::system_time_to_unix_ms(SystemTime::now()), + ); self.save_session_metadata(workspace_path, &metadata) .await?; } @@ -3019,16 +2630,13 @@ impl PersistenceManager { .await? { let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; - metadata.turn_count = remaining_turns.len(); - metadata.message_count = remaining_turns - .iter() - .map(Self::estimate_turn_message_count) - .sum(); - metadata.tool_call_count = remaining_turns - .iter() - .map(DialogTurnData::count_tool_calls) - .sum(); - metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); + let workspace_path_text = workspace_path.to_string_lossy(); + refresh_session_metadata_from_turns( + &mut metadata, + workspace_path_text.as_ref(), + &remaining_turns, + Self::system_time_to_unix_ms(SystemTime::now()), + ); self.save_session_metadata(workspace_path, &metadata) .await?; } @@ -3051,8 +2659,11 @@ impl PersistenceManager { #[cfg(test)] mod tests { - use super::{context_snapshot_payload_stats, PersistenceManager}; + use super::{context_snapshot_payload_stats, PersistenceManager, StoredDialogTurnFile}; use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; + use crate::agentic::skill_agent_snapshot::{ + AgentSnapshotEntry, SkillSnapshotEntry, TurnSkillAgentSnapshot, + }; use crate::infrastructure::PathManager; use crate::service::session::{ DialogTurnData, ModelRoundData, SessionMetadata, SessionRelationship, @@ -3232,6 +2843,107 @@ mod tests { assert_eq!(turn_indices, vec![3, 4]); assert_eq!(prompts, vec!["prompt 3", "prompt 4"]); + + let (_session, view_tail, total_turn_count) = manager + .load_session_with_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail view should load"); + let view_turn_indices = view_tail + .iter() + .map(|turn| turn.turn_index) + .collect::>(); + + assert_eq!(view_turn_indices, vec![3, 4]); + assert_eq!(total_turn_count, 5); + } + + #[tokio::test] + async fn load_session_tail_turns_uses_metadata_turn_count_as_normal_path_boundary() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "Tail turns boundary test".to_string(), + "agent".to_string(), + "model".to_string(), + ); + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + + for index in 0..5 { + let user_message = UserMessageData { + id: format!("user-{index}"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }; + let mut turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message, + ); + turn.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + + let orphan_user_message = UserMessageData { + id: "user-99".to_string(), + content: "orphan prompt".to_string(), + timestamp: 99, + metadata: None, + }; + let mut orphan_turn = DialogTurnData::new( + "turn-99".to_string(), + 99, + session_id.clone(), + orphan_user_message, + ); + orphan_turn.mark_completed(); + let orphan_file = StoredDialogTurnFile { + schema_version: super::SESSION_STORAGE_SCHEMA_VERSION, + turn: orphan_turn, + }; + let orphan_json = + serde_json::to_string_pretty(&orphan_file).expect("orphan turn should serialize"); + std::fs::write( + manager.turn_path(workspace.path(), &session_id, 99), + orphan_json, + ) + .expect("orphan turn should be written"); + + let tail = manager + .load_session_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail turns should load"); + + let turn_indices = tail.iter().map(|turn| turn.turn_index).collect::>(); + let prompts = tail + .iter() + .map(|turn| turn.user_message.content.as_str()) + .collect::>(); + + assert_eq!(turn_indices, vec![3, 4]); + assert_eq!(prompts, vec!["prompt 3", "prompt 4"]); + + let (_session, view_tail, total_turn_count) = manager + .load_session_with_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail view should load"); + let view_turn_indices = view_tail + .iter() + .map(|turn| turn.turn_index) + .collect::>(); + + assert_eq!(view_turn_indices, vec![3, 4]); + assert_eq!(total_turn_count, 5); } #[tokio::test] @@ -3300,6 +3012,8 @@ mod tests { parent_task_tool_id: None, subagent_session_id: None, status: None, + attempt_id: None, + attempt_index: None, } } @@ -3308,6 +3022,7 @@ mod tests { id: format!("round-{}", turn_id), turn_id: turn_id.to_string(), round_index: 0, + round_group_id: None, timestamp: 0, text_items, tool_items: Vec::new(), @@ -3834,4 +3549,61 @@ mod tests { assert!(runtime.plans_dir.exists()); assert!(runtime.layout_state_file.exists()); } + + #[tokio::test] + async fn skill_agent_snapshots_persist_and_truncate_with_context_snapshots() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let snapshot = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "/skills/a".to_string(), + }], + subagents: vec![AgentSnapshotEntry { + id: "agent-a".to_string(), + description: "desc-a".to_string(), + default_tools: vec!["Read".to_string()], + }], + }; + + manager + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 0, + &[Message::user("hi".to_string())], + ) + .await + .expect("context snapshot should save"); + manager + .save_turn_skill_agent_snapshot(workspace.path(), &session_id, 0, &snapshot) + .await + .expect("skill-agent snapshot should save"); + + let loaded = manager + .load_turn_skill_agent_snapshot(workspace.path(), &session_id, 0) + .await + .expect("skill-agent snapshot should load") + .expect("skill-agent snapshot should exist"); + assert_eq!(loaded, snapshot); + + manager + .delete_turn_context_snapshots_from(workspace.path(), &session_id, 0) + .await + .expect("snapshot deletion should succeed"); + + assert!(manager + .load_turn_skill_agent_snapshot(workspace.path(), &session_id, 0) + .await + .expect("skill-agent snapshot reload should succeed") + .is_none()); + assert!(manager + .load_turn_context_snapshot(workspace.path(), &session_id, 0) + .await + .expect("context snapshot reload should succeed") + .is_none()); + } } diff --git a/src/crates/assembly/core/src/agentic/persistence/mod.rs b/src/crates/assembly/core/src/agentic/persistence/mod.rs new file mode 100644 index 0000000000..90a533fc33 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/persistence/mod.rs @@ -0,0 +1,12 @@ +//! Persistence layer +//! +//! Responsible for persistent storage and loading of data + +pub mod manager; +pub mod session_branch; + +pub use bitfun_runtime_ports::SessionTurnLoadTiming; +pub use bitfun_services_core::session::{ + SessionBranchRequest, SessionBranchResult, SessionMetadataPage, +}; +pub use manager::PersistenceManager; diff --git a/src/crates/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs similarity index 76% rename from src/crates/core/src/agentic/persistence/session_branch.rs rename to src/crates/assembly/core/src/agentic/persistence/session_branch.rs index bf277af38f..ba9f40b511 100644 --- a/src/crates/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -1,76 +1,11 @@ use super::manager::PersistenceManager; use crate::agentic::core::{Session, SessionKind}; -use crate::service::session::{DialogTurnData, SessionStatus}; use crate::util::errors::{BitFunError, BitFunResult}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map as JsonMap, Value as JsonValue}; +use bitfun_services_core::session::{build_branched_session_metadata, BranchSessionMetadataFacts}; +pub use bitfun_services_core::session::{SessionBranchRequest, SessionBranchResult}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionBranchRequest { - pub source_session_id: String, - pub source_turn_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionBranchResult { - pub session_id: String, - pub session_name: String, - pub agent_type: String, -} - -fn estimate_turn_message_count(turn: &DialogTurnData) -> usize { - 1 + turn - .model_rounds - .iter() - .map(|round| round.text_items.len()) - .sum::() -} - -fn strip_child_session_metadata(value: Option<&JsonValue>) -> Option { - let Some(JsonValue::Object(existing)) = value else { - return None; - }; - - let mut next = existing.clone(); - for key in [ - "kind", - "parentSessionId", - "parentRequestId", - "parentDialogTurnId", - "parentTurnIndex", - ] { - next.remove(key); - } - Some(JsonValue::Object(next)) -} - -fn build_branch_custom_metadata( - source_metadata: Option<&JsonValue>, - source_session_id: &str, - source_turn_id: &str, - source_turn_index: usize, -) -> Option { - let mut base = match strip_child_session_metadata(source_metadata) { - Some(JsonValue::Object(map)) => map, - _ => JsonMap::new(), - }; - - base.insert( - "forkOrigin".to_string(), - serde_json::json!({ - "sessionId": source_session_id, - "turnId": source_turn_id, - "turnIndex": source_turn_index + 1, - }), - ); - - Some(JsonValue::Object(base)) -} - impl PersistenceManager { pub async fn branch_session( &self, @@ -160,6 +95,22 @@ impl PersistenceManager { ) .await?; } + if let Some(snapshot) = self + .load_turn_skill_agent_snapshot( + workspace_path, + &request.source_session_id, + source_turn.turn_index, + ) + .await? + { + self.save_turn_skill_agent_snapshot( + workspace_path, + &target_session_id, + new_index, + &snapshot, + ) + .await?; + } } for turn in &branched_turns { @@ -170,50 +121,36 @@ impl PersistenceManager { self.save_prompt_cache(workspace_path, &target_session_id, cache) .await?; } + if let Some(snapshot) = self + .load_skill_agent_baseline_override_snapshot( + workspace_path, + &request.source_session_id, + ) + .await? + { + self.save_skill_agent_baseline_override_snapshot( + workspace_path, + &target_session_id, + &snapshot, + ) + .await?; + } let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - let mut branched_metadata = source_metadata.clone(); - branched_metadata.session_id = target_session_id.clone(); - branched_metadata.session_name = target_session_name.clone(); - branched_metadata.agent_type = target_agent_type.clone(); - branched_metadata.created_by = None; - branched_metadata.session_kind = SessionKind::Standard; - branched_metadata.created_at = now_ms; - branched_metadata.last_active_at = now_ms; - branched_metadata.turn_count = branched_turns.len(); - branched_metadata.message_count = - branched_turns.iter().map(estimate_turn_message_count).sum(); - branched_metadata.tool_call_count = branched_turns - .iter() - .map(DialogTurnData::count_tool_calls) - .sum(); - branched_metadata.status = SessionStatus::Active; - branched_metadata.snapshot_session_id = None; - branched_metadata.tags = branched_metadata - .tags - .into_iter() - .filter(|tag| { - tag != "btw" - && tag != "review" - && tag != "deep_review" - && tag != "miniapp" - && tag != "subagent" - }) - .collect(); - branched_metadata.custom_metadata = build_branch_custom_metadata( - source_metadata.custom_metadata.as_ref(), - &request.source_session_id, - &request.source_turn_id, + let branched_metadata = build_branched_session_metadata(BranchSessionMetadataFacts { + source_metadata: &source_metadata, + target_session_id: target_session_id.clone(), + target_session_name: target_session_name.clone(), + target_agent_type: target_agent_type.clone(), + source_session_id: &request.source_session_id, + source_turn_id: &request.source_turn_id, source_turn_index, - ); - branched_metadata.relationship = None; - branched_metadata.todos = None; - branched_metadata.deep_review_run_manifest = None; - branched_metadata.unread_completion = None; - branched_metadata.needs_user_attention = None; + branched_turns: &branched_turns, + now_ms, + }); self.save_session_metadata(workspace_path, &branched_metadata) .await?; @@ -245,6 +182,7 @@ mod tests { CachedSystemPrompt, CachedUserContext, SessionPromptCache, SystemPromptCacheIdentity, UserContextCacheIdentity, }; + use crate::agentic::skill_agent_snapshot::{SkillSnapshotEntry, TurnSkillAgentSnapshot}; use crate::infrastructure::PathManager; use crate::service::session::{DialogTurnData, UserMessageData}; use std::path::{Path, PathBuf}; @@ -469,4 +407,63 @@ mod tests { }) ); } + + #[tokio::test] + async fn branch_session_copies_skill_agent_baseline_override_snapshot() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + + let mut source_session = Session::new( + "Source Title".to_string(), + "agentic".to_string(), + Default::default(), + ); + source_session.kind = SessionKind::Standard; + manager + .save_session(workspace.path(), &source_session) + .await + .expect("source session should save"); + + let turn_0 = build_turn(&source_session.session_id, "turn-0", 0, "first"); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("turn 0 should save"); + + let baseline_override = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "interactive-debug".to_string(), + description: "debug helper".to_string(), + location: "/skills/interactive-debug".to_string(), + }], + subagents: Vec::new(), + }; + manager + .save_skill_agent_baseline_override_snapshot( + workspace.path(), + &source_session.session_id, + &baseline_override, + ) + .await + .expect("baseline override should save"); + + let result = manager + .branch_session( + workspace.path(), + &SessionBranchRequest { + source_session_id: source_session.session_id.clone(), + source_turn_id: "turn-0".to_string(), + }, + ) + .await + .expect("branch should succeed"); + + let branched_override = manager + .load_skill_agent_baseline_override_snapshot(workspace.path(), &result.session_id) + .await + .expect("branched override should load") + .expect("branched override should exist"); + assert_eq!(branched_override, baseline_override); + } } diff --git a/src/crates/core/src/agentic/prompts/init_agents_md.md b/src/crates/assembly/core/src/agentic/prompts/init_agents_md.md similarity index 100% rename from src/crates/core/src/agentic/prompts/init_agents_md.md rename to src/crates/assembly/core/src/agentic/prompts/init_agents_md.md diff --git a/src/crates/assembly/core/src/agentic/remote_file_delivery.rs b/src/crates/assembly/core/src/agentic/remote_file_delivery.rs new file mode 100644 index 0000000000..61e3fabc8e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/remote_file_delivery.rs @@ -0,0 +1,80 @@ +use bitfun_runtime_ports::DialogTriggerSource; +use std::path::Path; + +pub const TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY: &str = "remote_file_delivery_channel"; + +pub fn needs_computer_links_for_source(source: DialogTriggerSource) -> bool { + matches!( + source, + DialogTriggerSource::RemoteRelay | DialogTriggerSource::Bot + ) +} + +pub fn remote_file_delivery_reminder() -> &'static str { + r#"The user is messaging through a remote mobile or bot channel. + +When referencing a plan, report, presentation, spreadsheet, document, image, or archive, add `computer://` before the file path so the user can click to download it, for example [report.md](computer://artifacts/report.md)."# +} + +pub fn workspace_relative_link(path: &Path, workspace_root: Option<&Path>) -> Option { + workspace_root + .and_then(|root| path.strip_prefix(root).ok()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) +} + +pub fn computer_link(path: &Path, workspace_root: Option<&Path>) -> String { + workspace_relative_link(path, workspace_root) + .map(|rel| format!("computer://{rel}")) + .unwrap_or_else(|| format!("computer://{}", path.to_string_lossy().replace('\\', "/"))) +} + +pub fn user_file_link( + path: &Path, + workspace_root: Option<&Path>, + use_computer_link: bool, +) -> String { + if use_computer_link { + computer_link(path, workspace_root) + } else { + workspace_relative_link(path, workspace_root) + .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/")) + } +} + +pub fn user_workspace_relative_file_link(relative_path: &str, use_computer_link: bool) -> String { + let normalized = relative_path.replace('\\', "/"); + if use_computer_link { + format!("computer://{normalized}") + } else { + normalized + } +} + +#[cfg(test)] +mod tests { + use super::{user_file_link, user_workspace_relative_file_link, workspace_relative_link}; + use std::path::Path; + + #[test] + fn desktop_links_prefer_workspace_relative_paths() { + let root = Path::new("/repo"); + let report = Path::new("/repo/artifacts/report.md"); + + assert_eq!( + workspace_relative_link(report, Some(root)).as_deref(), + Some("artifacts/report.md") + ); + assert_eq!( + user_file_link(report, Some(root), false), + "artifacts/report.md" + ); + } + + #[test] + fn remote_delivery_links_use_computer_scheme() { + assert_eq!( + user_workspace_relative_file_link(r".bitfun\sessions\s1\research\report.md", true), + "computer://.bitfun/sessions/s1/research/report.md" + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/round_preempt.rs b/src/crates/assembly/core/src/agentic/round_preempt.rs new file mode 100644 index 0000000000..02fd68b42c --- /dev/null +++ b/src/crates/assembly/core/src/agentic/round_preempt.rs @@ -0,0 +1,8 @@ +//! Compatibility re-exports for round-boundary injection state. + +pub use bitfun_agent_runtime::scheduler::{ + DialogRoundInjectionInterrupt, NoopDialogRoundInjectionSource, SessionRoundInjectionBuffer, +}; +pub use bitfun_runtime_ports::{ + DialogRoundInjectionSource, RoundInjection, RoundInjectionKind, RoundInjectionTarget, +}; diff --git a/src/crates/core/src/agentic/session/compression/compressor.rs b/src/crates/assembly/core/src/agentic/session/compression/compressor.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/compressor.rs rename to src/crates/assembly/core/src/agentic/session/compression/compressor.rs diff --git a/src/crates/core/src/agentic/session/compression/fallback/builder.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/builder.rs similarity index 97% rename from src/crates/core/src/agentic/session/compression/fallback/builder.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/builder.rs index 232569a1c3..b0e021f1a8 100644 --- a/src/crates/core/src/agentic/session/compression/fallback/builder.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/fallback/builder.rs @@ -162,6 +162,13 @@ fn extract_nested_compression_entries(message: &Message) -> Option text.clone(), MessageContent::Multimodal { text, .. } => text.clone(), diff --git a/src/crates/core/src/agentic/session/compression/fallback/mod.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/mod.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/fallback/mod.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/mod.rs diff --git a/src/crates/core/src/agentic/session/compression/fallback/payload.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/payload.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/fallback/payload.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/payload.rs diff --git a/src/crates/core/src/agentic/session/compression/fallback/render.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/render.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/fallback/render.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/render.rs diff --git a/src/crates/core/src/agentic/session/compression/fallback/sanitize.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/sanitize.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/fallback/sanitize.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/sanitize.rs diff --git a/src/crates/core/src/agentic/session/compression/fallback/tests.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs similarity index 93% rename from src/crates/core/src/agentic/session/compression/fallback/tests.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs index 57a956ed92..d67ffde4d8 100644 --- a/src/crates/core/src/agentic/session/compression/fallback/tests.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs @@ -4,8 +4,8 @@ use super::{ }; use crate::agentic::core::{ render_system_reminder, render_user_query, CompressedMessageRole, CompressionContract, - CompressionContractItem, CompressionEntry, CompressionPayload, Message, MessageSemanticKind, - ToolCall, ToolResult, + CompressionContractItem, CompressionEntry, CompressionPayload, InternalReminderKind, Message, + MessageSemanticKind, ToolCall, ToolResult, }; use serde_json::json; @@ -132,6 +132,23 @@ fn drops_system_reminder_only_user_messages_from_fallback_summary() { ); } +#[test] +fn drops_listing_diff_internal_reminders_from_fallback_summary() { + let summary_artifact = build_structured_compression_summary( + vec![vec![Message::internal_reminder( + InternalReminderKind::SkillListingDiff, + "# Skill Listing Update\n\nChanged", + )]], + &default_options(), + ); + + assert!(summary_artifact.payload.entries.is_empty()); + assert_eq!( + summary_artifact.summary_text, + "No detailed historical entries fit within the remaining context budget." + ); +} + #[test] fn groups_consecutive_assistant_messages_under_single_role_header() { let summary_artifact = build_structured_compression_summary( diff --git a/src/crates/core/src/agentic/session/compression/fallback/types.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/types.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/fallback/types.rs rename to src/crates/assembly/core/src/agentic/session/compression/fallback/types.rs diff --git a/src/crates/core/src/agentic/session/compression/mod.rs b/src/crates/assembly/core/src/agentic/session/compression/mod.rs similarity index 100% rename from src/crates/core/src/agentic/session/compression/mod.rs rename to src/crates/assembly/core/src/agentic/session/compression/mod.rs diff --git a/src/crates/core/src/agentic/session/context_store.rs b/src/crates/assembly/core/src/agentic/session/context_store.rs similarity index 100% rename from src/crates/core/src/agentic/session/context_store.rs rename to src/crates/assembly/core/src/agentic/session/context_store.rs diff --git a/src/crates/assembly/core/src/agentic/session/evidence_ledger.rs b/src/crates/assembly/core/src/agentic/session/evidence_ledger.rs new file mode 100644 index 0000000000..6cfd374421 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/evidence_ledger.rs @@ -0,0 +1,3 @@ +//! Compatibility facade for session evidence ledger state. + +pub use bitfun_agent_runtime::evidence_ledger::*; diff --git a/src/crates/assembly/core/src/agentic/session/file_read_state.rs b/src/crates/assembly/core/src/agentic/session/file_read_state.rs new file mode 100644 index 0000000000..a078d811ab --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/file_read_state.rs @@ -0,0 +1,3 @@ +//! Compatibility facade for session-scoped file read state. + +pub use bitfun_agent_runtime::file_read_state::{FileReadState, FileReadStateStore}; diff --git a/src/crates/core/src/agentic/session/mod.rs b/src/crates/assembly/core/src/agentic/session/mod.rs similarity index 54% rename from src/crates/core/src/agentic/session/mod.rs rename to src/crates/assembly/core/src/agentic/session/mod.rs index dd7e37b54c..11d036dc84 100644 --- a/src/crates/core/src/agentic/session/mod.rs +++ b/src/crates/assembly/core/src/agentic/session/mod.rs @@ -8,6 +8,8 @@ pub mod evidence_ledger; pub mod file_read_state; pub mod prompt_cache; pub mod session_manager; +pub mod session_store_port; +pub mod turn_skill_agent_snapshot_store; pub use compression::*; pub use context_store::*; @@ -15,3 +17,10 @@ pub use evidence_ledger::*; pub use file_read_state::*; pub use prompt_cache::*; pub use session_manager::*; +pub use session_store_port::*; +pub use turn_skill_agent_snapshot_store::*; + +pub use bitfun_runtime_ports::{ + SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, + SessionTurnLoadTiming, SessionViewRestoreRequest, SessionViewRestoreTiming, +}; diff --git a/src/crates/assembly/core/src/agentic/session/prompt_cache.rs b/src/crates/assembly/core/src/agentic/session/prompt_cache.rs new file mode 100644 index 0000000000..722af9f404 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/prompt_cache.rs @@ -0,0 +1,7 @@ +//! Prompt cache compatibility facade. +//! +//! `bitfun-agent-runtime` owns prompt-cache identities, policy, DTOs, and +//! in-memory runtime store. Core keeps this module for old import paths and +//! concrete session persistence wiring. + +pub use bitfun_agent_runtime::prompt_cache::*; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs similarity index 77% rename from src/crates/core/src/agentic/session/session_manager.rs rename to src/crates/assembly/core/src/agentic/session/session_manager.rs index a16c0fe2a2..8544841ec6 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -3,18 +3,21 @@ //! Responsible for session CRUD, lifecycle management, and resource association use crate::agentic::core::{ - new_turn_id, CompressionContract, CompressionState, Message, MessageSemanticKind, - ProcessingPhase, Session, SessionConfig, SessionKind, SessionState, SessionSummary, TurnStats, + new_turn_id, CompressionContract, CompressionState, InternalReminderKind, Message, + MessageSemanticKind, ProcessingPhase, Session, SessionConfig, SessionKind, SessionState, + SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; +use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::agentic::session::{ CachedSystemPrompt, CachedUserContext, EvidenceLedgerCheckpoint, EvidenceLedgerEvent, EvidenceLedgerEventStatus, EvidenceLedgerSummary, EvidenceLedgerTargetKind, FileReadState, FileReadStateStore, PromptCacheLookup, PromptCachePolicy, PromptCacheScope, SessionContextStore, SessionEvidenceLedger, SessionPromptCache, SessionPromptCacheStore, - SystemPromptCacheIdentity, UserContextCacheIdentity, + SystemPromptCacheIdentity, TurnSkillAgentSnapshotStore, UserContextCacheIdentity, }; +use crate::agentic::skill_agent_snapshot::TurnSkillAgentSnapshot; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::{ get_app_language_code, get_global_config_service, short_model_user_language_instruction, @@ -22,17 +25,26 @@ use crate::service::config::{ }; use crate::service::session::{ DialogTurnData, DialogTurnKind, ModelRoundData, SessionMetadata, SessionRelationship, - SessionRelationshipKind, TextItemData, TurnStatus, UserMessageData, + TextItemData, TurnStatus, UserMessageData, }; use crate::service::snapshot::ensure_snapshot_manager_for_workspace; use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::sanitize_plain_model_output; use crate::util::timing::elapsed_ms_u64; +pub use bitfun_runtime_ports::SessionViewRestoreTiming; +use bitfun_runtime_ports::{ + SessionStoragePathRequest, SessionStorePort, SessionViewRestoreRequest, +}; +use bitfun_services_core::session::{ + apply_session_lineage, collect_hidden_subagent_cascade as collect_hidden_subagent_cascade_ids, + merge_session_custom_metadata as merge_session_custom_metadata_value, + set_deep_review_run_manifest, set_session_relationship, +}; use dashmap::DashMap; use log::{debug, error, info, warn}; use serde_json::json; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -81,6 +93,13 @@ pub struct ResolvedSessionTitle { pub title: String, pub method: SessionTitleMethod, } + +// When a full skill/agent listing baseline is rebuilt at turn R, snapshots whose +// turn_index < R still contain now-redundant listing diff reminders. We do not +// eagerly rewrite all historical snapshots; instead restore/rollback sanitize those +// older snapshots lazily based on this persisted cutoff. +const LISTING_BASELINE_REBUILD_TURN_INDEX_METADATA_KEY: &str = "listingBaselineRebuildTurnIndex"; + /// Session manager pub struct SessionManager { /// Active sessions in memory @@ -96,6 +115,8 @@ pub struct SessionManager { /// Sub-components context_store: Arc, prompt_cache_store: Arc, + turn_skill_agent_snapshot_store: Arc, + skill_agent_baseline_override_snapshot_store: Arc>, file_read_state_store: Arc, evidence_ledger: Arc, persistence_manager: Arc, @@ -391,29 +412,9 @@ impl SessionManager { /// Resolve the effective storage path for a session's workspace. async fn effective_workspace_path_from_config(config: &SessionConfig) -> Option { - let workspace_path = config.workspace_path.as_ref()?; - let identity = - crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; - - if identity.hostname - == crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST - { - Some(PathBuf::from(identity.logical_workspace_path())) - } else if identity.hostname == "_unresolved" { - Some( - crate::service::remote_ssh::workspace_state::unresolved_remote_session_storage_dir( - identity.remote_connection_id.as_deref().unwrap_or_default(), - identity.logical_workspace_path(), - ), - ) - } else { - Some(identity.session_storage_path()) - } + CoreSessionStorePort::resolve_storage_path_for_config(config) + .await + .map(|resolution| resolution.effective_storage_path) } #[allow(dead_code)] @@ -715,6 +716,17 @@ impl SessionManager { self.prompt_cache_store.replace_cache(session_id, cache); } + async fn load_turn_skill_agent_snapshot_from_persistence( + &self, + workspace_path: &Path, + session_id: &str, + turn_index: usize, + ) -> BitFunResult> { + self.persistence_manager + .load_turn_skill_agent_snapshot(workspace_path, session_id, turn_index) + .await + } + async fn load_prompt_cache_from_persistence( &self, workspace_path: &Path, @@ -800,6 +812,8 @@ impl SessionManager { session_workspace_index: Arc::new(DashMap::new()), context_store, prompt_cache_store: Arc::new(SessionPromptCacheStore::new()), + turn_skill_agent_snapshot_store: Arc::new(TurnSkillAgentSnapshotStore::new()), + skill_agent_baseline_override_snapshot_store: Arc::new(DashMap::new()), file_read_state_store: Arc::new(FileReadStateStore::new()), evidence_ledger: Arc::new(SessionEvidenceLedger::new()), persistence_manager, @@ -987,6 +1001,9 @@ impl SessionManager { let session_workspace_index = self.session_workspace_index.clone(); let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); + let turn_skill_agent_snapshot_store = self.turn_skill_agent_snapshot_store.clone(); + let skill_agent_baseline_override_snapshot_store = + self.skill_agent_baseline_override_snapshot_store.clone(); let file_read_state_store = self.file_read_state_store.clone(); let evidence_ledger = self.evidence_ledger.clone(); let persistence_manager = self.persistence_manager.clone(); @@ -1008,6 +1025,8 @@ impl SessionManager { session_workspace_index, context_store, prompt_cache_store, + turn_skill_agent_snapshot_store, + skill_agent_baseline_override_snapshot_store, file_read_state_store, evidence_ledger, persistence_manager, @@ -1147,6 +1166,8 @@ impl SessionManager { // 2. Initialize the in-memory context cache. self.context_store.create_session(&session_id); + self.turn_skill_agent_snapshot_store + .create_session(&session_id); self.file_read_state_store.create_session(&session_id); // 3. Persist to local path (handles remote workspaces correctly) @@ -1262,1479 +1283,1969 @@ impl SessionManager { true } - pub async fn invalidate_prompt_cache( + pub async fn turn_skill_agent_snapshot( &self, session_id: &str, - scope: PromptCacheScope, - reason: &str, - ) { - self.ensure_prompt_cache_loaded(session_id).await; - let changed = self.prompt_cache_store.invalidate(session_id, scope); + turn_index: usize, + ) -> Option { + if let Some(snapshot) = self + .turn_skill_agent_snapshot_store + .get_snapshot(session_id, turn_index) + { + return Some(snapshot); + } - if changed { - debug!( - "Invalidated session prompt cache: session_id={}, scope={:?}, reason={}", - session_id, scope, reason - ); - self.persist_prompt_cache_best_effort(session_id, reason) - .await; + if !self.should_persist_session_id(session_id) { + return None; } - } - /// Synchronously reset session state to Idle if it is currently Processing - /// the expected turn. - /// - /// This is an in-memory-only operation intended for RAII-style cleanup in - /// spawn tasks. Because `Drop::drop` is synchronous we cannot do async - /// file I/O here, but that is acceptable: the in-memory state is the - /// source of truth at runtime, and `restore_session` already resets any - /// non-Idle persisted state to Idle on application restart. - pub fn reset_session_state_if_processing(&self, session_id: &str, expected_turn_id: &str) { - if let Some(mut session) = self.sessions.get_mut(session_id) { - if matches!( - &session.state, - SessionState::Processing { - current_turn_id, - .. - } if current_turn_id == expected_turn_id - ) { - debug!( - "RAII guard resetting stuck Processing state to Idle: session_id={}, turn_id={}", - session_id, expected_turn_id + let workspace_path = self.effective_session_workspace_path(session_id).await?; + match self + .load_turn_skill_agent_snapshot_from_persistence( + &workspace_path, + session_id, + turn_index, + ) + .await + { + Ok(Some(snapshot)) => { + self.turn_skill_agent_snapshot_store.set_snapshot( + session_id, + turn_index, + snapshot.clone(), ); - session.state = SessionState::Idle; - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); + Some(snapshot) + } + Ok(None) => None, + Err(error) => { + warn!( + "Failed to load turn skill-agent snapshot: session_id={}, turn_index={}, workspace_path={}, error={}", + session_id, + turn_index, + workspace_path.display(), + error + ); + None } } } - /// Update session state - pub async fn update_session_state( + pub async fn latest_turn_skill_agent_snapshot_at_or_before( &self, session_id: &str, - new_state: SessionState, - ) -> BitFunResult<()> { - let effective_path = self.effective_session_workspace_path(session_id).await; - - // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. - // Collect the data needed for persistence, then release the guard before doing I/O. - let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { - session.state = new_state.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); + turn_index: usize, + ) -> Option<(usize, TurnSkillAgentSnapshot)> { + let cached_snapshot = self + .turn_skill_agent_snapshot_store + .latest_snapshot_at_or_before(session_id, turn_index); + if let Some(snapshot) = cached_snapshot.as_ref() { + if snapshot.0 == turn_index || !self.should_persist_session_id(session_id) { + return cached_snapshot; + } + } - let persist = self.config.enable_persistence && Self::should_persist_session(&session); - persist - } else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - // RefMut guard released here -- DashMap shard lock is free. + if !self.should_persist_session_id(session_id) { + return cached_snapshot; + } - // Persist state changes outside the guard scope. - if should_persist { - if let Some(ref workspace_path) = effective_path { - self.persistence_manager - .save_session_state(workspace_path, session_id, &new_state) - .await?; + let workspace_path = self.effective_session_workspace_path(session_id).await?; + let scan_floor_exclusive = cached_snapshot.as_ref().map(|snapshot| snapshot.0); + for index in (0..=turn_index).rev() { + if scan_floor_exclusive.is_some_and(|floor| index <= floor) { + break; + } + match self + .load_turn_skill_agent_snapshot_from_persistence(&workspace_path, session_id, index) + .await + { + Ok(Some(snapshot)) => { + self.turn_skill_agent_snapshot_store.set_snapshot( + session_id, + index, + snapshot.clone(), + ); + return Some((index, snapshot)); + } + Ok(None) => {} + Err(error) => { + warn!( + "Failed to load turn skill-agent snapshot while scanning backwards: session_id={}, turn_index={}, workspace_path={}, error={}", + session_id, + index, + workspace_path.display(), + error + ); + } } } - debug!( - "Updated session state: session_id={}, state={:?}", - session_id, new_state - ); - - Ok(()) + cached_snapshot } - /// Update session state only when the session is still processing the - /// expected turn. Returns `true` when the state was updated. - pub async fn update_session_state_for_turn_if_processing( + pub async fn remember_turn_skill_agent_snapshot( &self, session_id: &str, - expected_turn_id: &str, - new_state: SessionState, - ) -> BitFunResult { - let effective_path = self.effective_session_workspace_path(session_id).await; + turn_index: usize, + snapshot: TurnSkillAgentSnapshot, + ) { + self.turn_skill_agent_snapshot_store + .set_snapshot(session_id, turn_index, snapshot.clone()); - let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { - let owns_processing_turn = matches!( - &session.state, - SessionState::Processing { - current_turn_id, - .. - } if current_turn_id == expected_turn_id + if !self.should_persist_session_id(session_id) { + return; + } + + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { + debug!( + "Skipping turn skill-agent snapshot persistence because workspace path is unavailable: session_id={}, turn_index={}", + session_id, turn_index ); + return; + }; - if !owns_processing_turn { - debug!( - "Skipped session state update for stale turn: session_id={}, expected_turn_id={}, current_state={:?}", - session_id, expected_turn_id, session.state - ); - return Ok(false); - } + if let Err(error) = self + .persistence_manager + .save_turn_skill_agent_snapshot(&workspace_path, session_id, turn_index, &snapshot) + .await + { + warn!( + "Failed to persist turn skill-agent snapshot: session_id={}, turn_index={}, workspace_path={}, error={}", + session_id, + turn_index, + workspace_path.display(), + error + ); + } + } - session.state = new_state.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); + pub async fn recover_first_turn_skill_agent_snapshot( + &self, + session_id: &str, + snapshot: TurnSkillAgentSnapshot, + ) { + self.turn_skill_agent_snapshot_store + .remove_from(session_id, 1); + self.turn_skill_agent_snapshot_store + .set_snapshot(session_id, 0, snapshot.clone()); - self.config.enable_persistence && Self::should_persist_session(&session) - } else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", + if !self.should_persist_session_id(session_id) { + return; + } + + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { + debug!( + "Skipping first-turn skill-agent baseline recovery persistence because workspace path is unavailable: session_id={}", session_id - ))); + ); + return; }; - if should_persist { - if let Some(ref workspace_path) = effective_path { - self.persistence_manager - .save_session_state(workspace_path, session_id, &new_state) - .await?; - } + if let Err(error) = self + .persistence_manager + .delete_turn_skill_agent_snapshots_from(&workspace_path, session_id, 1) + .await + { + warn!( + "Failed to prune turn skill-agent snapshots during baseline recovery: session_id={}, workspace_path={}, error={}", + session_id, + workspace_path.display(), + error + ); } - debug!( - "Updated session state for turn: session_id={}, turn_id={}, state={:?}", - session_id, expected_turn_id, new_state - ); - - Ok(true) + if let Err(error) = self + .persistence_manager + .save_turn_skill_agent_snapshot(&workspace_path, session_id, 0, &snapshot) + .await + { + warn!( + "Failed to persist recovered first-turn skill-agent snapshot: session_id={}, workspace_path={}, error={}", + session_id, + workspace_path.display(), + error + ); + } } - /// Update session title (in-memory + persistence) - pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { - let normalized_title = Self::normalize_session_title_input(title)?; - let workspace_path = self.effective_session_workspace_path(session_id).await; + pub async fn remember_skill_agent_baseline_override_snapshot( + &self, + session_id: &str, + snapshot: TurnSkillAgentSnapshot, + ) { + self.skill_agent_baseline_override_snapshot_store + .insert(session_id.to_string(), snapshot.clone()); + + if !self.should_persist_session_id(session_id) { + return; + } + + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { + debug!( + "Skipping listing reminder baseline override persistence because workspace path is unavailable: session_id={}", + session_id + ); + return; + }; + match self + .persistence_manager + .save_skill_agent_baseline_override_snapshot(&workspace_path, session_id, &snapshot) + .await { - let Some(mut session) = self.sessions.get_mut(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - session.session_name = normalized_title.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); + Err(error) => { + warn!( + "Failed to persist listing reminder baseline override snapshot: session_id={}, workspace_path={}, error={}", + session_id, + workspace_path.display(), + error + ); + } + Ok(()) => {} } + } - if self.should_persist_session_id(session_id) { - let Some(workspace_path) = workspace_path.as_ref() else { - return Err(BitFunError::Session(format!( - "Workspace path is unavailable for session {}", - session_id - ))); - }; - // Clone the session data out of the DashMap guard before awaiting I/O. - let session_snapshot = { - let Some(session) = self.sessions.get(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - session.clone() - }; - // Ref guard released -- DashMap shard lock is free. - self.persistence_manager - .save_session(workspace_path, &session_snapshot) - .await?; + pub async fn skill_agent_baseline_override_snapshot( + &self, + session_id: &str, + ) -> Option { + if let Some(snapshot) = self + .skill_agent_baseline_override_snapshot_store + .get(session_id) + .map(|value| value.clone()) + { + return Some(snapshot); } - info!( - "Session title updated: session_id={}, title={}", - session_id, normalized_title - ); + if !self.should_persist_session_id(session_id) { + return None; + } - Ok(()) + let workspace_path = self.effective_session_workspace_path(session_id).await?; + let snapshot = match self + .persistence_manager + .load_skill_agent_baseline_override_snapshot(&workspace_path, session_id) + .await + { + Ok(snapshot) => snapshot, + Err(error) => { + warn!( + "Failed to load listing reminder baseline override snapshot: session_id={}, workspace_path={}, error={}", + session_id, + workspace_path.display(), + error + ); + return None; + } + }; + let snapshot = snapshot?; + self.skill_agent_baseline_override_snapshot_store + .insert(session_id.to_string(), snapshot.clone()); + Some(snapshot) } - pub async fn update_session_title_if_current( + pub async fn seed_forked_skill_agent_listing_baselines( &self, - session_id: &str, - expected_current_title: &str, - title: &str, - ) -> BitFunResult { - let Some(session) = self.sessions.get(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); + parent_session_id: &str, + child_session_id: &str, + ) { + // Forked children need two different baselines at the same time: + // - the parent's turn-0 snapshot stays as the prompt/listing baseline so the child's + // first request can reuse the same full skill/agent listing prefix + // - the parent's latest snapshot becomes the child's own turn-0 snapshot so later child + // turns diff against the fork-time surface instead of diffing forever against the + // parent's original turn-0 baseline + let prompt_listing_baseline = self.turn_skill_agent_snapshot(parent_session_id, 0).await; + if let Some(snapshot) = prompt_listing_baseline.clone() { + self.remember_skill_agent_baseline_override_snapshot(child_session_id, snapshot) + .await; + } + + let latest_parent_snapshot = match self.get_turn_count(parent_session_id).checked_sub(1) { + Some(turn_index) => self + .latest_turn_skill_agent_snapshot_at_or_before(parent_session_id, turn_index) + .await + .map(|(_, snapshot)| snapshot), + None => None, }; - if session.session_name != expected_current_title { - debug!( - "Skipping auto-generated title because current title changed: session_id={}, expected_title={}, current_title={}", + if let Some(snapshot) = latest_parent_snapshot.or(prompt_listing_baseline) { + self.remember_turn_skill_agent_snapshot(child_session_id, 0, snapshot) + .await; + } + } + + pub async fn rebuild_skill_agent_listing_baseline_to_latest(&self, session_id: &str) -> bool { + let Some(turn_index) = self + .sessions + .get(session_id) + .and_then(|session| session.dialog_turn_ids.len().checked_sub(1)) + else { + return false; + }; + + let Some((_, latest_snapshot)) = self + .latest_turn_skill_agent_snapshot_at_or_before(session_id, turn_index) + .await + else { + return false; + }; + + if self + .skill_agent_baseline_override_snapshot(session_id) + .await + .is_some() + { + self.remember_skill_agent_baseline_override_snapshot( session_id, - expected_current_title, - session.session_name - ); - return Ok(false); + latest_snapshot.clone(), + ) + .await; } - drop(session); - self.update_session_title(session_id, title).await?; - Ok(true) + self.recover_first_turn_skill_agent_snapshot(session_id, latest_snapshot) + .await; + self.persist_listing_baseline_rebuild_turn_index_best_effort(session_id, turn_index) + .await; + + let _ = self + .remove_listing_diff_internal_reminders(session_id) + .await; + true } - /// Update session agent type (in-memory + persistence) - pub async fn update_session_agent_type( - &self, - session_id: &str, - agent_type: &str, - ) -> BitFunResult<()> { - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.agent_type = agent_type.to_string(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); + pub async fn remove_listing_diff_internal_reminders(&self, session_id: &str) -> bool { + let context_messages = self.context_store.get_context_messages(session_id); + if context_messages.is_empty() { + return false; } - if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_workspace_path(session_id).await; - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - // Ref guard released -- DashMap shard lock is free. - if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - } + let (filtered_messages, changed) = + Self::strip_listing_diff_internal_reminders(context_messages); + if !changed { + return false; } - debug!( - "Session agent type updated: session_id={}, agent_type={}", - session_id, agent_type - ); + self.context_store + .replace_context(session_id, filtered_messages); + self.persist_current_turn_context_snapshot_best_effort( + session_id, + "listing_diff_internal_reminders_removed", + ) + .await; + true + } - Ok(()) + fn strip_listing_diff_internal_reminders(messages: Vec) -> (Vec, bool) { + let original_len = messages.len(); + let filtered_messages = messages + .into_iter() + .filter(|message| { + !message + .internal_reminder_kind() + .is_some_and(InternalReminderKind::is_listing_diff) + }) + .collect::>(); + + let changed = filtered_messages.len() != original_len; + (filtered_messages, changed) } - /// Update the most recent scheduler-accepted user submission mode. - /// - /// This state is intentionally independent from rollback-sensitive history - /// semantics. Prompt-cache guards should read this instead of deriving from - /// surviving dialog turns. - pub async fn update_last_submitted_agent_type( + fn listing_baseline_rebuild_turn_index_from_custom_metadata( + custom_metadata: Option<&serde_json::Value>, + ) -> Option { + custom_metadata? + .get(LISTING_BASELINE_REBUILD_TURN_INDEX_METADATA_KEY)? + .as_u64()? + .try_into() + .ok() + } + + fn listing_baseline_rebuild_turn_index_from_metadata( + metadata: Option<&SessionMetadata>, + ) -> Option { + Self::listing_baseline_rebuild_turn_index_from_custom_metadata( + metadata.and_then(|metadata| metadata.custom_metadata.as_ref()), + ) + } + + async fn persist_context_snapshot_messages_best_effort( &self, + workspace_path: &Path, session_id: &str, - agent_type: &str, - ) -> BitFunResult<()> { - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.last_submitted_agent_type = Some(agent_type.to_string()); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); + turn_index: usize, + messages: &[Message], + reason: &str, + ) { + if !self.should_persist_session_id(session_id) { + return; } - if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_workspace_path(session_id).await; - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - } + if let Err(err) = self + .persistence_manager + .save_turn_context_snapshot(workspace_path, session_id, turn_index, messages) + .await + { + warn!( + "failed to persist explicit context snapshot: session_id={}, turn_index={}, reason={}, err={}", + session_id, turn_index, reason, err + ); + } + } + + async fn sanitize_listing_diff_context_snapshot_if_needed( + &self, + workspace_path: &Path, + session_id: &str, + turn_index: usize, + messages: Vec, + cutoff_turn_index: Option, + reason: &str, + ) -> Vec { + let Some(cutoff_turn_index) = cutoff_turn_index else { + return messages; + }; + // The rebuild performed at turn R already persisted snapshots on and after R against + // the new baseline. Only snapshots strictly before that rebuilt turn need diff-reminder + // cleanup, so the predicate is `< cutoff`, not `<= cutoff`. + if turn_index >= cutoff_turn_index { + return messages; + } + + let (sanitized_messages, changed) = Self::strip_listing_diff_internal_reminders(messages); + if !changed { + return sanitized_messages; } debug!( - "Session last submitted agent type updated: session_id={}, agent_type={}", - session_id, agent_type + "Sanitized listing diff reminders from pre-rebuild context snapshot: session_id={}, turn_index={}, cutoff_turn_index={}, reason={}", + session_id, turn_index, cutoff_turn_index, reason ); - - Ok(()) + self.persist_context_snapshot_messages_best_effort( + workspace_path, + session_id, + turn_index, + &sanitized_messages, + reason, + ) + .await; + sanitized_messages } - fn derive_last_user_dialog_agent_type_from_turns( - turns: &[DialogTurnData], - fallback_agent_type: Option<&str>, - ) -> Option { - // New turns persist their mode on the turn itself. For older persisted - // sessions that predate this field, fall back to the session default - // only when at least one surviving user dialog turn exists. - turns - .iter() - .rev() - .find(|turn| turn.kind == DialogTurnKind::UserDialog) - .and_then(|turn| { - turn.agent_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) - .or_else(|| { - if turns - .iter() - .any(|turn| turn.kind == DialogTurnKind::UserDialog) - { - fallback_agent_type - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - } else { - None - } - }) + async fn persist_listing_baseline_rebuild_turn_index_best_effort( + &self, + session_id: &str, + turn_index: usize, + ) { + if let Err(err) = self + .merge_session_custom_metadata( + session_id, + json!({ + LISTING_BASELINE_REBUILD_TURN_INDEX_METADATA_KEY: turn_index, + }), + ) + .await + { + warn!( + "failed to persist listing baseline rebuild turn index: session_id={}, turn_index={}, err={}", + session_id, turn_index, err + ); + } } - /// Update session model id (in-memory + persistence) - pub async fn update_session_model_id( + async fn truncate_listing_baseline_rebuild_turn_index_after_rollback( &self, + workspace_path: &Path, session_id: &str, - model_id: &str, + target_turn: usize, ) -> BitFunResult<()> { - let ai_config = Self::load_ai_config_for_model_resolution().await; - let mut resolved_context_window = None; + let metadata = self + .persistence_manager + .load_session_metadata(workspace_path, session_id) + .await?; + let Some(existing_cutoff) = + Self::listing_baseline_rebuild_turn_index_from_metadata(metadata.as_ref()) + else { + return Ok(()); + }; - // If the session was evicted from memory (idle > 1h), try to restore it - // using the workspace path recorded when it was first created/restored. - if !self.sessions.contains_key(session_id) && self.config.enable_persistence { - let workspace_path = self - .session_workspace_index - .get(session_id) - .map(|entry| entry.clone()); - if let Some(workspace_path) = workspace_path { + if existing_cutoff <= target_turn { + return Ok(()); + } + + // After rollback, the session branches again from `target_turn`. Keeping a cutoff newer + // than that branch point would cause future snapshots on the new branch to be mistaken + // for "pre-rebuild" history during the next restore, so clamp the cutoff down. + self.merge_session_custom_metadata( + session_id, + json!({ + LISTING_BASELINE_REBUILD_TURN_INDEX_METADATA_KEY: target_turn, + }), + ) + .await + } + + pub async fn invalidate_prompt_cache( + &self, + session_id: &str, + scope: PromptCacheScope, + reason: &str, + ) { + self.ensure_prompt_cache_loaded(session_id).await; + let changed = self.prompt_cache_store.invalidate(session_id, scope); + + if changed { + debug!( + "Invalidated session prompt cache: session_id={}, scope={:?}, reason={}", + session_id, scope, reason + ); + self.persist_prompt_cache_best_effort(session_id, reason) + .await; + } + } + + /// Synchronously reset session state to Idle if it is currently Processing + /// the expected turn. + /// + /// This is an in-memory-only operation intended for RAII-style cleanup in + /// spawn tasks. Because `Drop::drop` is synchronous we cannot do async + /// file I/O here, but that is acceptable: the in-memory state is the + /// source of truth at runtime, and `restore_session` already resets any + /// non-Idle persisted state to Idle on application restart. + pub fn reset_session_state_if_processing(&self, session_id: &str, expected_turn_id: &str) { + if let Some(mut session) = self.sessions.get_mut(session_id) { + if matches!( + &session.state, + SessionState::Processing { + current_turn_id, + .. + } if current_turn_id == expected_turn_id + ) { debug!( - "Session evicted from memory, restoring for model update: session_id={}", - session_id + "RAII guard resetting stuck Processing state to Idle: session_id={}, turn_id={}", + session_id, expected_turn_id ); - let _ = self.restore_session(&workspace_path, session_id).await; + session.state = SessionState::Idle; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); } } + } - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.config.model_id = Some(model_id.to_string()); - if let Some(ai_config) = ai_config.as_ref() { - resolved_context_window = - Self::sync_session_context_window_from_ai_config(&mut session, ai_config); - } + /// Update session state + pub async fn update_session_state( + &self, + session_id: &str, + new_state: SessionState, + ) -> BitFunResult<()> { + let effective_path = self.effective_session_workspace_path(session_id).await; + + // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. + // Collect the data needed for persistence, then release the guard before doing I/O. + let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { + session.state = new_state.clone(); session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); + + let persist = self.config.enable_persistence && Self::should_persist_session(&session); + persist } else { return Err(BitFunError::NotFound(format!( "Session not found: {}", session_id ))); - } + }; + // RefMut guard released here -- DashMap shard lock is free. - if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_workspace_path(session_id).await; - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - // Ref guard released -- DashMap shard lock is free. - if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + // Persist state changes outside the guard scope. + if should_persist { + if let Some(ref workspace_path) = effective_path { self.persistence_manager - .save_session(&workspace_path, &session) + .save_session_state(workspace_path, session_id, &new_state) .await?; } } debug!( - "Session model id updated: session_id={}, model_id={}, max_context_tokens={:?}", - session_id, model_id, resolved_context_window + "Updated session state: session_id={}, state={:?}", + session_id, new_state ); Ok(()) } - /// Update session activity time - pub fn touch_session(&self, session_id: &str) { - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.last_activity_at = SystemTime::now(); - } - } - - /// Delete session (cascade delete all resources) - pub async fn delete_session( + /// Update session state only when the session is still processing the + /// expected turn. Returns `true` when the state was updated. + pub async fn update_session_state_for_turn_if_processing( &self, - workspace_path: &Path, session_id: &str, - ) -> BitFunResult<()> { - let delete_started_at = Instant::now(); - debug!( - "Session deletion started: session_id={}, workspace_path={}, persistence_enabled={}", - session_id, - workspace_path.display(), - self.config.enable_persistence - ); + expected_turn_id: &str, + new_state: SessionState, + ) -> BitFunResult { + let effective_path = self.effective_session_workspace_path(session_id).await; - // 1. Clean up snapshot system resources (including physical snapshot files) - let snapshot_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=snapshot_cleanup", - session_id - ); - if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(workspace_path) { - let snapshot_service = snapshot_manager.get_snapshot_service(); - let snapshot_service = snapshot_service.read().await; - if let Err(e) = snapshot_service.accept_session(session_id).await { - warn!("Failed to cleanup snapshot system resources: {}", e); - } else { + let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { + let owns_processing_turn = matches!( + &session.state, + SessionState::Processing { + current_turn_id, + .. + } if current_turn_id == expected_turn_id + ); + + if !owns_processing_turn { debug!( - "Snapshot system resources cleaned up: session_id={}", - session_id + "Skipped session state update for stale turn: session_id={}, expected_turn_id={}, current_state={:?}", + session_id, expected_turn_id, session.state ); + return Ok(false); } - } - debug!( - "Session deletion stage completed: session_id={}, stage=snapshot_cleanup, duration_ms={}", - session_id, - elapsed_ms_u64(snapshot_stage_started_at) - ); - - let context_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=context_store_delete", - session_id - ); - self.context_store.delete_session(session_id); - self.prompt_cache_store.delete_session(session_id); - self.file_read_state_store.delete_session(session_id); - debug!( - "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", - session_id, - elapsed_ms_u64(context_stage_started_at) - ); - // 2. Delete persisted data - if self.config.enable_persistence { - let persistence_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=persistence_delete", - session_id - ); - self.persistence_manager - .delete_session(workspace_path, session_id) - .await?; - debug!( - "Session deletion stage completed: session_id={}, stage=persistence_delete, duration_ms={}", - session_id, - elapsed_ms_u64(persistence_stage_started_at) - ); - } + session.state = new_state.clone(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); - if let Some(cron) = crate::service::cron::get_global_cron_service() { - let cron_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=cron_cleanup", + self.config.enable_persistence && Self::should_persist_session(&session) + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", session_id - ); - match cron.delete_jobs_for_session(session_id).await { - Ok(removed) if removed > 0 => { - info!( - "Removed {} scheduled job(s) for deleted session_id={}", - removed, session_id - ); - } - Ok(_) => {} - Err(e) => { - warn!( - "Failed to remove scheduled jobs for deleted session_id={}: {}", - session_id, e - ); - } - } - debug!( - "Session deletion stage completed: session_id={}, stage=cron_cleanup, duration_ms={}", - session_id, - elapsed_ms_u64(cron_stage_started_at) - ); - } + ))); + }; - // 3. Clean up associated Terminal session - use crate::service::terminal::TerminalApi; - if let Ok(terminal_api) = TerminalApi::from_singleton() { - let binding = terminal_api.session_manager().binding(); - let terminal_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=terminal_binding_cleanup, has_binding={}", - session_id, - binding.has(session_id) - ); - if binding.has(session_id) { - if let Err(e) = binding.remove(session_id).await { - warn!("Failed to cleanup associated Terminal session: {}", e); - } else { - debug!( - "Associated Terminal session cleaned up: session_id={}", - session_id - ); - } + if should_persist { + if let Some(ref workspace_path) = effective_path { + self.persistence_manager + .save_session_state(workspace_path, session_id, &new_state) + .await?; } - debug!( - "Session deletion stage completed: session_id={}, stage=terminal_binding_cleanup, duration_ms={}", - session_id, - elapsed_ms_u64(terminal_stage_started_at) - ); } - // 4. Remove from memory - let memory_stage_started_at = Instant::now(); debug!( - "Session deletion stage starting: session_id={}, stage=in_memory_remove", - session_id + "Updated session state for turn: session_id={}, turn_id={}, state={:?}", + session_id, expected_turn_id, new_state ); - self.sessions.remove(session_id); - debug!( - "Session deletion stage completed: session_id={}, stage=in_memory_remove, duration_ms={}", - session_id, - elapsed_ms_u64(memory_stage_started_at) - ); - self.session_workspace_index.remove(session_id); + + Ok(true) + } + + /// Update session title (in-memory + persistence) + pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { + let normalized_title = Self::normalize_session_title_input(title)?; + let workspace_path = self.effective_session_workspace_path(session_id).await; + + { + let Some(mut session) = self.sessions.get_mut(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + session.session_name = normalized_title.clone(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } + + if self.should_persist_session_id(session_id) { + let Some(workspace_path) = workspace_path.as_ref() else { + return Err(BitFunError::Session(format!( + "Workspace path is unavailable for session {}", + session_id + ))); + }; + // Clone the session data out of the DashMap guard before awaiting I/O. + let session_snapshot = { + let Some(session) = self.sessions.get(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + session.clone() + }; + // Ref guard released -- DashMap shard lock is free. + self.persistence_manager + .save_session(workspace_path, &session_snapshot) + .await?; + } info!( - "Session deletion completed: session_id={}, workspace_path={}, duration_ms={}", - session_id, - workspace_path.display(), - elapsed_ms_u64(delete_started_at) + "Session title updated: session_id={}, title={}", + session_id, normalized_title ); Ok(()) } - /// Restore session (from persistent storage) - pub async fn restore_session( + pub async fn update_session_title_if_current( &self, - workspace_path: &Path, session_id: &str, - ) -> BitFunResult { - self.restore_session_internal(workspace_path, session_id, false) - .await - } + expected_current_title: &str, + title: &str, + ) -> BitFunResult { + let Some(session) = self.sessions.get(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; - pub async fn restore_internal_session( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - self.restore_session_internal(workspace_path, session_id, true) - .await - } + if session.session_name != expected_current_title { + debug!( + "Skipping auto-generated title because current title changed: session_id={}, expected_title={}, current_title={}", + session_id, + expected_current_title, + session.session_name + ); + return Ok(false); + } + drop(session); - async fn restore_session_internal( - &self, - workspace_path: &Path, - session_id: &str, - include_internal: bool, - ) -> BitFunResult { - let (session, _) = self - .restore_session_with_turns_internal(workspace_path, session_id, include_internal) - .await?; - Ok(session) + self.update_session_title(session_id, title).await?; + Ok(true) } - /// Restore the persisted session header and turns needed by the UI view - /// without loading runtime context snapshots or inserting the session into - /// the in-memory coordinator state. - pub async fn restore_session_view( + /// Update session agent type (in-memory + persistence) + pub async fn update_session_agent_type( &self, - workspace_path: &Path, session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - self.restore_session_view_internal(workspace_path, session_id, false, None) - .await - .map(|(session, turns, _)| (session, turns)) - } + agent_type: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.agent_type = agent_type.to_string(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } - pub async fn restore_internal_session_view( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - self.restore_session_view_internal(workspace_path, session_id, true, None) - .await - .map(|(session, turns, _)| (session, turns)) - } + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + // Ref guard released -- DashMap shard lock is free. + if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + } - pub async fn restore_session_view_tail( - &self, - workspace_path: &Path, - session_id: &str, - tail_turn_count: usize, - ) -> BitFunResult<(Session, Vec, usize)> { - self.restore_session_view_internal(workspace_path, session_id, false, Some(tail_turn_count)) - .await - } + debug!( + "Session agent type updated: session_id={}, agent_type={}", + session_id, agent_type + ); - pub async fn restore_internal_session_view_tail( - &self, - workspace_path: &Path, - session_id: &str, - tail_turn_count: usize, - ) -> BitFunResult<(Session, Vec, usize)> { - self.restore_session_view_internal(workspace_path, session_id, true, Some(tail_turn_count)) - .await + Ok(()) } - async fn restore_session_view_internal( + /// Update the most recent scheduler-accepted user submission mode. + /// + /// This state is intentionally independent from rollback-sensitive history + /// semantics. Prompt-cache guards should read this instead of deriving from + /// surviving dialog turns. + pub async fn update_last_submitted_agent_type( &self, - workspace_path: &Path, session_id: &str, - include_internal: bool, - tail_turn_count: Option, - ) -> BitFunResult<(Session, Vec, usize)> { - let restore_started_at = Instant::now(); - let storage_path_started_at = Instant::now(); - let session_storage_path = { - let ws = workspace_path.to_string_lossy().to_string(); - let tmp_config = SessionConfig { - workspace_path: Some(ws), - ..Default::default() - }; - Self::effective_workspace_path_from_config(&tmp_config) - .await - .unwrap_or_else(|| workspace_path.to_path_buf()) - }; - debug!( - "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", - session_id, - elapsed_ms_u64(storage_path_started_at) - ); - - let metadata_started_at = Instant::now(); - if self - .persistence_manager - .load_session_metadata(&session_storage_path, session_id) - .await? - .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) - { + agent_type: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.last_submitted_agent_type = Some(agent_type.to_string()); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { return Err(BitFunError::NotFound(format!( "Session not found: {}", session_id ))); } - debug!( - "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", - session_id, - elapsed_ms_u64(metadata_started_at) - ); - let session_started_at = Instant::now(); - let (mut session, persisted_turns, total_turn_count) = if let Some(tail_turn_count) = - tail_turn_count - { - self.persistence_manager - .load_session_with_tail_turns(&session_storage_path, session_id, tail_turn_count) - .await? - } else { - let (session, turns) = self - .persistence_manager - .load_session_with_turns(&session_storage_path, session_id) - .await?; - let total_turn_count = turns.len(); - (session, turns, total_turn_count) - }; + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + } + debug!( - "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, total_turn_count={}, tail_turn_count={:?}, duration_ms={}", - session_id, - persisted_turns.len(), - total_turn_count, - tail_turn_count, - elapsed_ms_u64(session_started_at) + "Session last submitted agent type updated: session_id={}, agent_type={}", + session_id, agent_type ); - if !matches!(session.state, SessionState::Idle) { - let old_state = session.state.clone(); - session.state = SessionState::Idle; - debug!( - "Resetting session state during view restore: session_id={}, state={:?} -> Idle", - session_id, old_state - ); - } + Ok(()) + } - let persisted_turn_ids: Vec = persisted_turns - .iter() - .map(|turn| turn.turn_id.clone()) - .collect(); - if session.dialog_turn_ids != persisted_turn_ids { - debug!( - "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", - session_id, - session.dialog_turn_ids.len(), - persisted_turn_ids.len() - ); - session.dialog_turn_ids = persisted_turn_ids; + fn derive_last_user_dialog_agent_type_from_turns( + turns: &[DialogTurnData], + fallback_agent_type: Option<&str>, + ) -> Option { + // New turns persist their mode on the turn itself. For older persisted + // sessions that predate this field, fall back to the session default + // only when at least one surviving user dialog turn exists. + turns + .iter() + .rev() + .find(|turn| turn.kind == DialogTurnKind::UserDialog) + .and_then(|turn| { + turn.agent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .or_else(|| { + if turns + .iter() + .any(|turn| turn.kind == DialogTurnKind::UserDialog) + { + fallback_agent_type + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + } else { + None + } + }) + } + + /// Update session model id (in-memory + persistence) + pub async fn update_session_model_id( + &self, + session_id: &str, + model_id: &str, + ) -> BitFunResult<()> { + let ai_config = Self::load_ai_config_for_model_resolution().await; + let mut resolved_context_window = None; + + // If the session was evicted from memory (idle > 1h), try to restore it + // using the workspace path recorded when it was first created/restored. + if !self.sessions.contains_key(session_id) && self.config.enable_persistence { + let workspace_path = self + .session_workspace_index + .get(session_id) + .map(|entry| entry.clone()); + if let Some(workspace_path) = workspace_path { + debug!( + "Session evicted from memory, restoring for model update: session_id={}", + session_id + ); + let _ = self.restore_session(&workspace_path, session_id).await; + } + } + + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.config.model_id = Some(model_id.to_string()); + if let Some(ai_config) = ai_config.as_ref() { + resolved_context_window = + Self::sync_session_context_window_from_ai_config(&mut session, ai_config); + } + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + // Ref guard released -- DashMap shard lock is free. + if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } } debug!( - "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", - session_id, - session.session_name, - persisted_turns.len(), - elapsed_ms_u64(restore_started_at) + "Session model id updated: session_id={}, model_id={}, max_context_tokens={:?}", + session_id, model_id, resolved_context_window ); - Ok((session, persisted_turns, total_turn_count)) + Ok(()) } - /// Restore session and return the persisted turns read during restore. - pub async fn restore_session_with_turns( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - self.restore_session_with_turns_internal(workspace_path, session_id, false) - .await + /// Sync session context window from AI config without requiring an explicit model_id. + /// + /// Subagent sessions created via `build_session_config_for_workspace` use + /// `SessionConfig::default()` which hardcodes `max_context_tokens: 128128`. + /// This method reloads the AI config and updates `max_context_tokens` to the + /// model's actual configured `context_window`, so subagents with large-context + /// models are not prematurely capped. + pub async fn refresh_session_context_window(&self, session_id: &str) -> BitFunResult<()> { + if let Some(ai_config) = Self::load_ai_config_for_model_resolution().await { + if let Some(mut session) = self.sessions.get_mut(session_id) { + let previous = session.config.max_context_tokens; + Self::sync_session_context_window_from_ai_config(&mut session, &ai_config); + let updated = session.config.max_context_tokens; + if updated != previous { + debug!( + "Refreshed session context window: session_id={}, previous={}, updated={}", + session_id, previous, updated + ); + } + } + } + Ok(()) } - pub async fn restore_internal_session_with_turns( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - self.restore_session_with_turns_internal(workspace_path, session_id, true) - .await + /// Update session activity time + pub fn touch_session(&self, session_id: &str) { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.last_activity_at = SystemTime::now(); + } } - async fn restore_session_with_turns_internal( + /// Delete session (cascade delete all resources) + pub async fn delete_session( &self, workspace_path: &Path, session_id: &str, - include_internal: bool, - ) -> BitFunResult<(Session, Vec)> { - let restore_started_at = Instant::now(); - // Check if session is already in memory - let session_already_in_memory = self.sessions.contains_key(session_id); - - let storage_path_started_at = Instant::now(); - let session_storage_path = { - let ws = workspace_path.to_string_lossy().to_string(); - let tmp_config = SessionConfig { - workspace_path: Some(ws), - ..Default::default() - }; - Self::effective_workspace_path_from_config(&tmp_config) - .await - .unwrap_or_else(|| workspace_path.to_path_buf()) - }; + ) -> BitFunResult<()> { + let delete_started_at = Instant::now(); debug!( - "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + "Session deletion started: session_id={}, workspace_path={}, persistence_enabled={}", session_id, - elapsed_ms_u64(storage_path_started_at) + workspace_path.display(), + self.config.enable_persistence ); - let metadata_started_at = Instant::now(); - if self - .persistence_manager - .load_session_metadata(&session_storage_path, session_id) - .await? - .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) - { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); + // 1. Clean up snapshot system resources (including physical snapshot files) + let snapshot_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=snapshot_cleanup", + session_id + ); + if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(workspace_path) { + let snapshot_service = snapshot_manager.get_snapshot_service(); + let snapshot_service = snapshot_service.read().await; + if let Err(e) = snapshot_service.accept_session(session_id).await { + warn!("Failed to cleanup snapshot system resources: {}", e); + } else { + debug!( + "Snapshot system resources cleaned up: session_id={}", + session_id + ); + } } debug!( - "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + "Session deletion stage completed: session_id={}, stage=snapshot_cleanup, duration_ms={}", session_id, - elapsed_ms_u64(metadata_started_at) + elapsed_ms_u64(snapshot_stage_started_at) ); - // 1. Load session and turns from storage in one pass - let session_started_at = Instant::now(); - let (mut session, persisted_turns) = self - .persistence_manager - .load_session_with_turns(&session_storage_path, session_id) - .await?; + let context_stage_started_at = Instant::now(); debug!( - "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + "Session deletion stage starting: session_id={}, stage=context_store_delete", + session_id + ); + self.context_store.delete_session(session_id); + self.prompt_cache_store.delete_session(session_id); + self.turn_skill_agent_snapshot_store + .delete_session(session_id); + self.skill_agent_baseline_override_snapshot_store + .remove(session_id); + self.file_read_state_store.delete_session(session_id); + debug!( + "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", session_id, - persisted_turns.len(), - elapsed_ms_u64(session_started_at) + elapsed_ms_u64(context_stage_started_at) ); - let ai_config_for_restore = Self::load_ai_config_for_model_resolution().await; - let mut should_persist_restored_session = false; - - // Lazy migration: if the persisted model_id is no longer usable - // (model deleted or disabled while the session was on disk), repoint - // it to "auto" before the session re-enters memory. The next request - // will pick a model via the normal auto/agent/default pipeline. - if let Some(persisted_model_id) = session.config.model_id.as_deref() { - let trimmed = persisted_model_id.trim(); - let needs_migration = if trimmed.is_empty() { - false - } else if let Some(ai_config) = ai_config_for_restore.as_ref() { - !Self::is_session_model_id_usable(ai_config, trimmed) - } else { - false - }; - - if needs_migration { - warn!( - "Session restore detected stale model_id; migrating to auto: session_id={}, previous_model_id={}", - session_id, trimmed - ); - let previous_model_id = trimmed.to_string(); - session.config.model_id = Some("auto".to_string()); - should_persist_restored_session = true; - - if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { - coordinator - .emit_session_model_auto_migrated( - session_id, - &previous_model_id, - "auto", - "model_unavailable_on_restore", - ) - .await; - } - } + // 2. Delete persisted data + if self.config.enable_persistence { + let persistence_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=persistence_delete", + session_id + ); + self.persistence_manager + .delete_session(workspace_path, session_id) + .await?; + debug!( + "Session deletion stage completed: session_id={}, stage=persistence_delete, duration_ms={}", + session_id, + elapsed_ms_u64(persistence_stage_started_at) + ); } - if let Some(ai_config) = ai_config_for_restore.as_ref() { - let previous_max_context_tokens = session.config.max_context_tokens; - if let Some(context_window) = - Self::sync_session_context_window_from_ai_config(&mut session, ai_config) - { - if context_window != previous_max_context_tokens { - should_persist_restored_session = true; - debug!( - "Session context window refreshed during restore: session_id={}, previous={}, resolved={}", - session_id, previous_max_context_tokens, context_window + if let Some(cron) = crate::service::cron::get_global_cron_service() { + let cron_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=cron_cleanup", + session_id + ); + match cron.delete_jobs_for_session(session_id).await { + Ok(removed) if removed > 0 => { + info!( + "Removed {} scheduled job(s) for deleted session_id={}", + removed, session_id + ); + } + Ok(_) => {} + Err(e) => { + warn!( + "Failed to remove scheduled jobs for deleted session_id={}: {}", + session_id, e ); } } - } - - // Reset session state to Idle - // After application restart, previous Processing state is invalid and must be reset - let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); - if previous_state_was_not_idle { - let old_state = session.state.clone(); - session.state = SessionState::Idle; debug!( - "Resetting session state during restore: session_id={}, state={:?} -> Idle", - session_id, old_state + "Session deletion stage completed: session_id={}, stage=cron_cleanup, duration_ms={}", + session_id, + elapsed_ms_u64(cron_stage_started_at) ); } - // 2. Restore runtime context with snapshot-first semantics. - // If the latest snapshot lags behind turn persistence, append the missing turn delta - // instead of truncating session history. - // - // This compensates for the fact that persistence is not transactional across - // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. - let persisted_turn_ids: Vec = persisted_turns - .iter() - .map(|turn| turn.turn_id.clone()) - .collect(); - session.last_user_dialog_agent_type = Self::derive_last_user_dialog_agent_type_from_turns( - &persisted_turns, - Some(session.agent_type.as_str()), - ); - let mut latest_turn_index: Option = None; - let context_snapshot_started_at = Instant::now(); - let mut messages = match self - .persistence_manager - .load_latest_turn_context_snapshot(&session_storage_path, session_id) - .await? - { - Some((turn_index, msgs)) => { - latest_turn_index = Some(turn_index); - msgs - } - None => Self::build_messages_from_turns(&persisted_turns), - }; - debug!( - "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", - session_id, - latest_turn_index, - messages.len(), - elapsed_ms_u64(context_snapshot_started_at) - ); - - if let Some(snapshot_turn_index) = latest_turn_index { - let delta_start = snapshot_turn_index.saturating_add(1); - if delta_start < persisted_turns.len() { - warn!( - "Context snapshot is behind persisted turns, rebuilding delta: session_id={}, snapshot_turn_index={}, persisted_turn_count={}", - session_id, - snapshot_turn_index, - persisted_turns.len() - ); - messages.extend(Self::build_messages_from_turns( - &persisted_turns[delta_start..], - )); + // 3. Clean up associated Terminal session + use crate::service::terminal::TerminalApi; + if let Ok(terminal_api) = TerminalApi::from_singleton() { + let binding = terminal_api.session_manager().binding(); + let terminal_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=terminal_binding_cleanup, has_binding={}", + session_id, + binding.has(session_id) + ); + if binding.has(session_id) { + if let Err(e) = binding.remove(session_id).await { + warn!("Failed to cleanup associated Terminal session: {}", e); + } else { + debug!( + "Associated Terminal session cleaned up: session_id={}", + session_id + ); + } } - }; - - if messages.is_empty() { debug!( - "Session {} has empty persisted messages (may be new session)", - session_id + "Session deletion stage completed: session_id={}, stage=terminal_binding_cleanup, duration_ms={}", + session_id, + elapsed_ms_u64(terminal_stage_started_at) ); } - // 3. Restore the in-memory context cache from the recovered messages. - // If session already exists, delete old one first then create (ensure clean state) - if session_already_in_memory { - self.context_store.delete_session(session_id); - self.prompt_cache_store.delete_session(session_id); - self.file_read_state_store.delete_session(session_id); - } - - let context_replace_started_at = Instant::now(); - self.context_store - .replace_context(session_id, messages.clone()); + // 4. Remove from memory + let memory_stage_started_at = Instant::now(); debug!( - "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", + "Session deletion stage starting: session_id={}, stage=in_memory_remove", + session_id + ); + self.sessions.remove(session_id); + debug!( + "Session deletion stage completed: session_id={}, stage=in_memory_remove, duration_ms={}", session_id, - messages.len(), - elapsed_ms_u64(context_replace_started_at) + elapsed_ms_u64(memory_stage_started_at) ); + self.session_workspace_index.remove(session_id); - let recoverable_turn_count = latest_turn_index - .map(|turn_index| turn_index + 1) - .unwrap_or(0) - .max(persisted_turns.len()); - - if session.dialog_turn_ids.len() < persisted_turns.len() { - warn!( - "Session metadata is behind persisted turns, rebuilding dialog_turn_ids: session_id={}, session_turn_count={}, persisted_turn_count={}", - session_id, - session.dialog_turn_ids.len(), - persisted_turns.len() - ); - session.dialog_turn_ids = persisted_turn_ids; - } else if session.dialog_turn_ids.len() > recoverable_turn_count { - warn!( - "Session metadata exceeds recoverable history, truncating: session_id={}, session_turn_count={}, recoverable_turn_count={}", - session_id, - session.dialog_turn_ids.len(), - recoverable_turn_count - ); - session.dialog_turn_ids.truncate(recoverable_turn_count); - } else if persisted_turns.len() == session.dialog_turn_ids.len() - && session.dialog_turn_ids != persisted_turn_ids - { - warn!( - "Session metadata turn ids diverge from persisted turns, normalizing order: session_id={}", - session_id - ); - session.dialog_turn_ids = persisted_turn_ids; - } - - if recoverable_turn_count == 0 && !session.dialog_turn_ids.is_empty() && messages.is_empty() - { - warn!( - "Session has no available context snapshot and messages are empty, clearing turns: session_id={}", - session_id - ); - session.dialog_turn_ids.clear(); - } - - let context_msg_count = self.context_store.get_context_messages(session_id).len(); - - debug!( - "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", + info!( + "Session deletion completed: session_id={}, workspace_path={}, duration_ms={}", session_id, - session.session_name, - messages.len(), - context_msg_count, - persisted_turns.len(), - elapsed_ms_u64(restore_started_at) + workspace_path.display(), + elapsed_ms_u64(delete_started_at) ); - // Do not infer unread completion from persisted runtime state during restore. - // Older IDE versions could leave sessions in non-idle states on disk; treating those - // as completed would surface misleading unread indicators after an upgrade. - // Unread completion is now written only by runtime completion/persist paths. + Ok(()) + } - if should_persist_restored_session && self.should_persist_session_id(session_id) { - self.persistence_manager - .save_session(&session_storage_path, &session) - .await?; - } + /// Restore session (from persistent storage) + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.restore_session_internal(workspace_path, session_id, false) + .await + } - // 4. Add to memory (will overwrite if already exists) - self.sessions - .insert(session_id.to_string(), session.clone()); - self.session_workspace_index - .insert(session_id.to_string(), session_storage_path.clone()); + pub async fn restore_internal_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.restore_session_internal(workspace_path, session_id, true) + .await + } - Ok((session, persisted_turns)) + async fn restore_session_internal( + &self, + workspace_path: &Path, + session_id: &str, + include_internal: bool, + ) -> BitFunResult { + let (session, _) = self + .restore_session_with_turns_internal(workspace_path, session_id, include_internal) + .await?; + Ok(session) } - /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) - pub async fn rollback_context_to_turn_start( + /// Restore the persisted session header and turns needed by the UI view + /// without loading runtime context snapshots or inserting the session into + /// the in-memory coordinator state. + pub async fn restore_session_view( &self, workspace_path: &Path, session_id: &str, - target_turn: usize, - ) -> BitFunResult<()> { - // Ensure session is in memory (restore from persistence if necessary) - if !self.sessions.contains_key(session_id) && self.config.enable_persistence { - let _ = self.restore_session(workspace_path, session_id).await; - } + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_view_timed(workspace_path, session_id) + .await + .map(|(session, turns, _)| (session, turns)) + } - // 1) Load target context (target_turn == 0 => empty context) - let messages = if target_turn == 0 { - Vec::new() - } else { - self.persistence_manager - .load_turn_context_snapshot(workspace_path, session_id, target_turn - 1) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!( - "turn context snapshot not found: session_id={} turn={}", - session_id, - target_turn - 1 - )) - })? - }; - - // 2) Restore the in-memory context cache. - self.context_store.replace_context(session_id, messages); - - let last_user_dialog_agent_type = if target_turn == 0 { - None - } else { - let surviving_turns = self - .persistence_manager - .load_session_turns(workspace_path, session_id) - .await?; - let kept_turns = surviving_turns - .into_iter() - .take(target_turn) - .collect::>(); - let fallback_agent_type = self - .sessions - .get(session_id) - .map(|session| session.agent_type.clone()); - Self::derive_last_user_dialog_agent_type_from_turns( - &kept_turns, - fallback_agent_type.as_deref(), - ) - }; - - // 3) Truncate session turn list & persist - // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. - let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { - if session.dialog_turn_ids.len() > target_turn { - session.dialog_turn_ids.truncate(target_turn); - } - session.last_user_dialog_agent_type = last_user_dialog_agent_type; - session.state = SessionState::Idle; - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - - let should_persist = - Self::should_persist_session(&session) && self.config.enable_persistence; - if should_persist { - Some(session.clone()) - } else { - None - } - } else { - None - }; - // RefMut guard released here -- DashMap shard lock is free. - - if let Some(session) = session_snapshot { - self.persistence_manager - .save_session(workspace_path, &session) - .await?; - } - - // 4) Delete persisted turns and snapshots from target_turn (inclusive) onwards. - // Runtime restore rebuilds history from persisted turn files, so removing only - // context snapshots would make rolled-back prompts reappear after reload. - if self.config.enable_persistence { - self.persistence_manager - .delete_dialog_turns_from(workspace_path, session_id, target_turn) - .await?; - self.persistence_manager - .delete_turn_context_snapshots_from(workspace_path, session_id, target_turn) - .await?; - } - - Ok(()) - } - - /// List all sessions - pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { - if self.config.enable_persistence { - self.persistence_manager.list_sessions(workspace_path).await - } else { - let summaries: Vec<_> = self - .sessions - .iter() - .map(|entry| { - let session = entry.value(); - SessionSummary { - session_id: session.session_id.clone(), - session_name: session.session_name.clone(), - agent_type: session.agent_type.clone(), - last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), - last_submitted_agent_type: session.last_submitted_agent_type.clone(), - created_by: session.created_by.clone(), - kind: session.kind, - turn_count: session.dialog_turn_ids.len(), - created_at: session.created_at, - last_activity_at: session.last_activity_at, - state: session.state.clone(), - } - }) - .filter(|summary| { - !matches!( - summary.kind, - SessionKind::Subagent | SessionKind::EphemeralChild - ) - }) - .collect(); - Ok(summaries) - } + pub async fn restore_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec, SessionViewRestoreTiming)> { + self.restore_session_view_internal(workspace_path, session_id, false, None) + .await + .map(|(session, turns, _, timing)| (session, turns, timing)) } - pub async fn load_session_metadata( + pub async fn restore_internal_session_view( &self, workspace_path: &Path, session_id: &str, - ) -> BitFunResult> { - self.persistence_manager - .load_session_metadata(workspace_path, session_id) + ) -> BitFunResult<(Session, Vec)> { + self.restore_internal_session_view_timed(workspace_path, session_id) .await + .map(|(session, turns, _)| (session, turns)) } - pub async fn save_session_metadata( + pub async fn restore_internal_session_view_timed( &self, workspace_path: &Path, - metadata: &SessionMetadata, - ) -> BitFunResult<()> { - self.persistence_manager - .save_session_metadata(workspace_path, metadata) + session_id: &str, + ) -> BitFunResult<(Session, Vec, SessionViewRestoreTiming)> { + self.restore_session_view_internal(workspace_path, session_id, true, None) .await + .map(|(session, turns, _, timing)| (session, turns, timing)) } - pub async fn merge_session_custom_metadata( + pub async fn restore_session_view_tail( &self, + workspace_path: &Path, session_id: &str, - patch: serde_json::Value, - ) -> BitFunResult<()> { - if !self.should_persist_session_id(session_id) { - return Ok(()); - } - - let workspace_path = self - .effective_session_workspace_path(session_id) + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + self.restore_session_view_tail_timed(workspace_path, session_id, tail_turn_count) .await - .ok_or_else(|| { - BitFunError::Validation(format!( - "Session workspace_path is missing: {}", - session_id - )) - })?; - - let mut metadata = match self - .persistence_manager - .load_session_metadata(&workspace_path, session_id) - .await? - { - Some(metadata) => metadata, - None => { - let session = self - .sessions - .get(session_id) - .map(|value| value.clone()) - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })?; - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - self.persistence_manager - .load_session_metadata(&workspace_path, session_id) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })? - } - }; - - metadata.custom_metadata = Some(match (metadata.custom_metadata.take(), patch) { - ( - Some(serde_json::Value::Object(mut existing)), - serde_json::Value::Object(patch_obj), - ) => { - for (key, value) in patch_obj { - existing.insert(key, value); - } - serde_json::Value::Object(existing) - } - (_, value) => value, - }); + .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) + } - self.persistence_manager - .save_session_metadata(&workspace_path, &metadata) + pub async fn restore_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + SessionViewRestoreTiming, + )> { + self.restore_session_view_internal(workspace_path, session_id, false, Some(tail_turn_count)) .await } - pub async fn merge_session_relationship( + pub async fn restore_internal_session_view_tail( &self, + workspace_path: &Path, session_id: &str, - relationship: SessionRelationship, - ) -> BitFunResult<()> { - if !self.should_persist_session_id(session_id) { - return Ok(()); - } - - let workspace_path = self - .effective_session_workspace_path(session_id) + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + self.restore_internal_session_view_tail_timed(workspace_path, session_id, tail_turn_count) .await - .ok_or_else(|| { - BitFunError::Validation(format!( - "Session workspace_path is missing: {}", - session_id - )) - })?; - - let mut metadata = match self - .persistence_manager - .load_session_metadata(&workspace_path, session_id) - .await? - { - Some(metadata) => metadata, - None => { - let session = self - .sessions - .get(session_id) - .map(|value| value.clone()) - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })?; - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - self.persistence_manager - .load_session_metadata(&workspace_path, session_id) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })? - } - }; + .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) + } - metadata.relationship = Some(relationship); - self.persistence_manager - .save_session_metadata(&workspace_path, &metadata) + pub async fn restore_internal_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + SessionViewRestoreTiming, + )> { + self.restore_session_view_internal(workspace_path, session_id, true, Some(tail_turn_count)) .await } - pub async fn persist_session_lineage( + async fn restore_session_view_internal( &self, + workspace_path: &Path, session_id: &str, - relationship: SessionRelationship, - ) -> BitFunResult<()> { - if !self.should_persist_session_id(session_id) { - return Ok(()); - } - - let workspace_path = self - .effective_session_workspace_path(session_id) + include_internal: bool, + tail_turn_count: Option, + ) -> BitFunResult<( + Session, + Vec, + usize, + SessionViewRestoreTiming, + )> { + let restore_request = SessionViewRestoreRequest { + workspace_path: workspace_path.to_path_buf(), + session_id: session_id.to_string(), + include_internal, + tail_turn_count, + }; + let restore_started_at = Instant::now(); + let storage_path_started_at = Instant::now(); + let session_storage_path = CoreSessionStorePort + .resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: restore_request.workspace_path.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) .await - .ok_or_else(|| { - BitFunError::Validation(format!( - "Session workspace_path is missing: {}", - session_id - )) - })?; + .map(|resolution| resolution.effective_storage_path) + .unwrap_or_else(|_| restore_request.workspace_path.clone()); + let resolve_storage_path_duration_ms = elapsed_ms_u64(storage_path_started_at); + debug!( + "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + restore_request.session_id, + resolve_storage_path_duration_ms + ); - let mut metadata = match self + let metadata_started_at = Instant::now(); + if self .persistence_manager - .load_session_metadata(&workspace_path, session_id) + .load_session_metadata(&session_storage_path, session_id) .await? + .is_some_and(|metadata| { + !restore_request.include_internal && metadata.should_hide_from_user_lists() + }) { - Some(metadata) => metadata, - None => { - let session = self - .sessions - .get(session_id) - .map(|value| value.clone()) - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })?; - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + restore_request.session_id + ))); + } + let visibility_metadata_duration_ms = elapsed_ms_u64(metadata_started_at); + debug!( + "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + restore_request.session_id, + visibility_metadata_duration_ms + ); + + let session_started_at = Instant::now(); + let (mut session, persisted_turns, total_turn_count, turn_load) = + if let Some(tail_turn_count) = restore_request.tail_turn_count { self.persistence_manager - .load_session_metadata(&workspace_path, session_id) + .load_session_with_tail_turns_timed( + &session_storage_path, + &restore_request.session_id, + tail_turn_count, + ) .await? - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })? - } - }; + } else { + let (session, turns, timing) = self + .persistence_manager + .load_session_with_turns_timed( + &session_storage_path, + &restore_request.session_id, + ) + .await?; + let total_turn_count = turns.len(); + (session, turns, total_turn_count, timing) + }; + let load_session_with_turns_duration_ms = elapsed_ms_u64(session_started_at); + debug!( + "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, total_turn_count={}, tail_turn_count={:?}, duration_ms={}", + session_id, + persisted_turns.len(), + total_turn_count, + restore_request.tail_turn_count, + load_session_with_turns_duration_ms + ); - metadata.relationship = Some(relationship); + if !matches!(session.state, SessionState::Idle) { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during view restore: session_id={}, state={:?} -> Idle", + session_id, old_state + ); + } - if let Some(serde_json::Value::Object(mut custom_metadata)) = - metadata.custom_metadata.take() - { - for key in [ - "kind", - "parentSessionId", - "parentRequestId", - "parentDialogTurnId", - "parentTurnIndex", - "parentToolCallId", - "subagentType", - ] { - custom_metadata.remove(key); - } - metadata.custom_metadata = - (!custom_metadata.is_empty()).then_some(serde_json::Value::Object(custom_metadata)); + let normalize_started_at = Instant::now(); + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + if session.dialog_turn_ids != persisted_turn_ids { + debug!( + "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + persisted_turn_ids.len() + ); + session.dialog_turn_ids = persisted_turn_ids; } + let normalize_turn_ids_duration_ms = elapsed_ms_u64(normalize_started_at); - self.persistence_manager - .save_session_metadata(&workspace_path, &metadata) + let total_duration_ms = elapsed_ms_u64(restore_started_at); + debug!( + "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", + session_id, + session.session_name, + persisted_turns.len(), + total_duration_ms + ); + + let timing = SessionViewRestoreTiming { + resolve_storage_path_duration_ms, + visibility_metadata_duration_ms, + load_session_with_turns_duration_ms, + normalize_turn_ids_duration_ms, + total_duration_ms, + turn_load, + }; + + Ok((session, persisted_turns, total_turn_count, timing)) + } + + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_with_turns_internal(workspace_path, session_id, false) .await } - pub async fn collect_hidden_subagent_cascade_for_parent_turns( + pub async fn restore_internal_session_with_turns( &self, workspace_path: &Path, - parent_session_id: &str, - parent_dialog_turn_ids: &HashSet, - ) -> BitFunResult> { - if parent_session_id.trim().is_empty() || parent_dialog_turn_ids.is_empty() { - return Ok(Vec::new()); + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_with_turns_internal(workspace_path, session_id, true) + .await + } + + async fn restore_session_with_turns_internal( + &self, + workspace_path: &Path, + session_id: &str, + include_internal: bool, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + // Check if session is already in memory + let session_already_in_memory = self.sessions.contains_key(session_id); + + let storage_path_started_at = Instant::now(); + let session_storage_path = { + let ws = workspace_path.to_string_lossy().to_string(); + let tmp_config = SessionConfig { + workspace_path: Some(ws), + ..Default::default() + }; + Self::effective_workspace_path_from_config(&tmp_config) + .await + .unwrap_or_else(|| workspace_path.to_path_buf()) + }; + debug!( + "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + + let metadata_started_at = Instant::now(); + let session_metadata = self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await?; + if session_metadata + .as_ref() + .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); } + let listing_baseline_rebuild_turn_index = + Self::listing_baseline_rebuild_turn_index_from_metadata(session_metadata.as_ref()); + debug!( + "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); - let metadata_list = self + // 1. Load session and turns from storage in one pass + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self .persistence_manager - .list_session_metadata_including_internal(workspace_path) + .load_session_with_turns(&session_storage_path, session_id) .await?; + debug!( + "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); - let mut child_session_ids_by_parent: HashMap> = HashMap::new(); - let mut root_session_ids = Vec::new(); + let ai_config_for_restore = Self::load_ai_config_for_model_resolution().await; + let mut should_persist_restored_session = false; - for metadata in metadata_list { - let Some((relationship_kind, relationship_parent_session_id, parent_dialog_turn_id)) = - extract_subagent_relationship(&metadata) - else { - continue; + // Lazy migration: if the persisted model_id is no longer usable + // (model deleted or disabled while the session was on disk), repoint + // it to "auto" before the session re-enters memory. The next request + // will pick a model via the normal auto/agent/default pipeline. + if let Some(persisted_model_id) = session.config.model_id.as_deref() { + let trimmed = persisted_model_id.trim(); + let needs_migration = if trimmed.is_empty() { + false + } else if let Some(ai_config) = ai_config_for_restore.as_ref() { + !Self::is_session_model_id_usable(ai_config, trimmed) + } else { + false }; - if relationship_kind != SessionRelationshipKind::Subagent { - continue; - } - - child_session_ids_by_parent - .entry(relationship_parent_session_id.clone()) - .or_default() - .push(metadata.session_id.clone()); + if needs_migration { + warn!( + "Session restore detected stale model_id; migrating to auto: session_id={}, previous_model_id={}", + session_id, trimmed + ); + let previous_model_id = trimmed.to_string(); + session.config.model_id = Some("auto".to_string()); + should_persist_restored_session = true; - if relationship_parent_session_id == parent_session_id - && parent_dialog_turn_ids.contains(&parent_dialog_turn_id) - { - root_session_ids.push(metadata.session_id); + if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { + coordinator + .emit_session_model_auto_migrated( + session_id, + &previous_model_id, + "auto", + "model_unavailable_on_restore", + ) + .await; + } } } - let mut visited = HashSet::new(); - let mut ordered_session_ids = Vec::new(); - - fn visit( - session_id: &str, - child_session_ids_by_parent: &HashMap>, - visited: &mut HashSet, - ordered_session_ids: &mut Vec, - ) { - if !visited.insert(session_id.to_string()) { - return; - } - - if let Some(child_session_ids) = child_session_ids_by_parent.get(session_id) { - for child_session_id in child_session_ids { - visit( - child_session_id, - child_session_ids_by_parent, - visited, - ordered_session_ids, + if let Some(ai_config) = ai_config_for_restore.as_ref() { + let previous_max_context_tokens = session.config.max_context_tokens; + if let Some(context_window) = + Self::sync_session_context_window_from_ai_config(&mut session, ai_config) + { + if context_window != previous_max_context_tokens { + should_persist_restored_session = true; + debug!( + "Session context window refreshed during restore: session_id={}, previous={}, resolved={}", + session_id, previous_max_context_tokens, context_window ); } } - - ordered_session_ids.push(session_id.to_string()); } - for root_session_id in root_session_ids { - visit( - &root_session_id, - &child_session_ids_by_parent, - &mut visited, - &mut ordered_session_ids, + // Reset session state to Idle + // After application restart, previous Processing state is invalid and must be reset + let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); + if previous_state_was_not_idle { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during restore: session_id={}, state={:?} -> Idle", + session_id, old_state ); } - Ok(ordered_session_ids) - } - - pub async fn set_session_deep_review_run_manifest( - &self, - session_id: &str, - deep_review_run_manifest: Option, - ) -> BitFunResult<()> { - if !self.should_persist_session_id(session_id) { - return Ok(()); - } - - let workspace_path = self - .effective_session_workspace_path(session_id) - .await - .ok_or_else(|| { - BitFunError::Validation(format!( - "Session workspace_path is missing: {}", - session_id - )) - })?; - - let mut metadata = match self + // 2. Restore runtime context with snapshot-first semantics. + // If the latest snapshot lags behind turn persistence, append the missing turn delta + // instead of truncating session history. + // + // This compensates for the fact that persistence is not transactional across + // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + session.last_user_dialog_agent_type = Self::derive_last_user_dialog_agent_type_from_turns( + &persisted_turns, + Some(session.agent_type.as_str()), + ); + let mut latest_turn_index: Option = None; + let context_snapshot_started_at = Instant::now(); + let mut messages = match self .persistence_manager - .load_session_metadata(&workspace_path, session_id) + .load_latest_turn_context_snapshot(&session_storage_path, session_id) .await? { - Some(metadata) => metadata, - None => { - let session = self - .sessions - .get(session_id) - .map(|value| value.clone()) - .ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", session_id)) - })?; - self.persistence_manager - .save_session(&workspace_path, &session) + Some((turn_index, msgs)) => { + latest_turn_index = Some(turn_index); + self.sanitize_listing_diff_context_snapshot_if_needed( + &session_storage_path, + session_id, + turn_index, + msgs, + listing_baseline_rebuild_turn_index, + "restore_pre_listing_baseline_rebuild_snapshot", + ) + .await + } + None => Self::build_messages_from_turns(&persisted_turns), + }; + debug!( + "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", + session_id, + latest_turn_index, + messages.len(), + elapsed_ms_u64(context_snapshot_started_at) + ); + + if let Some(snapshot_turn_index) = latest_turn_index { + let delta_start = snapshot_turn_index.saturating_add(1); + if delta_start < persisted_turns.len() { + warn!( + "Context snapshot is behind persisted turns, rebuilding delta: session_id={}, snapshot_turn_index={}, persisted_turn_count={}", + session_id, + snapshot_turn_index, + persisted_turns.len() + ); + messages.extend(Self::build_messages_from_turns( + &persisted_turns[delta_start..], + )); + } + }; + + if messages.is_empty() { + debug!( + "Session {} has empty persisted messages (may be new session)", + session_id + ); + } + + // 3. Restore the in-memory context cache from the recovered messages. + // If session already exists, delete old one first then create (ensure clean state) + if session_already_in_memory { + self.context_store.delete_session(session_id); + self.prompt_cache_store.delete_session(session_id); + self.turn_skill_agent_snapshot_store + .delete_session(session_id); + self.skill_agent_baseline_override_snapshot_store + .remove(session_id); + self.file_read_state_store.delete_session(session_id); + } + + let context_replace_started_at = Instant::now(); + self.context_store + .replace_context(session_id, messages.clone()); + debug!( + "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", + session_id, + messages.len(), + elapsed_ms_u64(context_replace_started_at) + ); + + let recoverable_turn_count = latest_turn_index + .map(|turn_index| turn_index + 1) + .unwrap_or(0) + .max(persisted_turns.len()); + + if session.dialog_turn_ids.len() < persisted_turns.len() { + warn!( + "Session metadata is behind persisted turns, rebuilding dialog_turn_ids: session_id={}, session_turn_count={}, persisted_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + persisted_turns.len() + ); + session.dialog_turn_ids = persisted_turn_ids; + } else if session.dialog_turn_ids.len() > recoverable_turn_count { + warn!( + "Session metadata exceeds recoverable history, truncating: session_id={}, session_turn_count={}, recoverable_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + recoverable_turn_count + ); + session.dialog_turn_ids.truncate(recoverable_turn_count); + } else if persisted_turns.len() == session.dialog_turn_ids.len() + && session.dialog_turn_ids != persisted_turn_ids + { + warn!( + "Session metadata turn ids diverge from persisted turns, normalizing order: session_id={}", + session_id + ); + session.dialog_turn_ids = persisted_turn_ids; + } + + if recoverable_turn_count == 0 && !session.dialog_turn_ids.is_empty() && messages.is_empty() + { + warn!( + "Session has no available context snapshot and messages are empty, clearing turns: session_id={}", + session_id + ); + session.dialog_turn_ids.clear(); + } + + let context_msg_count = self.context_store.get_context_messages(session_id).len(); + + debug!( + "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", + session_id, + session.session_name, + messages.len(), + context_msg_count, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) + ); + + // Do not infer unread completion from persisted runtime state during restore. + // Older IDE versions could leave sessions in non-idle states on disk; treating those + // as completed would surface misleading unread indicators after an upgrade. + // Unread completion is now written only by runtime completion/persist paths. + + if should_persist_restored_session && self.should_persist_session_id(session_id) { + self.persistence_manager + .save_session(&session_storage_path, &session) + .await?; + } + + // 4. Add to memory (will overwrite if already exists) + self.sessions + .insert(session_id.to_string(), session.clone()); + self.session_workspace_index + .insert(session_id.to_string(), session_storage_path.clone()); + + Ok((session, persisted_turns)) + } + + /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) + pub async fn rollback_context_to_turn_start( + &self, + workspace_path: &Path, + session_id: &str, + target_turn: usize, + ) -> BitFunResult<()> { + // Ensure session is in memory (restore from persistence if necessary) + if !self.sessions.contains_key(session_id) && self.config.enable_persistence { + let _ = self.restore_session(workspace_path, session_id).await; + } + + // Rollback may load a historical snapshot from before the latest rebuilt baseline. In + // that case we must strip all listing diff reminders before the snapshot re-enters + // runtime context, otherwise old diffs reappear after rollback/reopen. + let listing_baseline_rebuild_turn_index = if self.config.enable_persistence { + let metadata = self + .persistence_manager + .load_session_metadata(workspace_path, session_id) + .await?; + Self::listing_baseline_rebuild_turn_index_from_metadata(metadata.as_ref()) + } else { + None + }; + + // 1) Load target context (target_turn == 0 => empty context) + let messages = if target_turn == 0 { + Vec::new() + } else { + let messages = self + .persistence_manager + .load_turn_context_snapshot(workspace_path, session_id, target_turn - 1) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!( + "turn context snapshot not found: session_id={} turn={}", + session_id, + target_turn - 1 + )) + })?; + self.sanitize_listing_diff_context_snapshot_if_needed( + workspace_path, + session_id, + target_turn - 1, + messages, + listing_baseline_rebuild_turn_index, + "rollback_restore_pre_listing_baseline_rebuild_snapshot", + ) + .await + }; + + // 2) Restore the in-memory context cache. + self.context_store.replace_context(session_id, messages); + + let last_user_dialog_agent_type = if target_turn == 0 { + None + } else { + let surviving_turns = self + .persistence_manager + .load_session_turns(workspace_path, session_id) + .await?; + let kept_turns = surviving_turns + .into_iter() + .take(target_turn) + .collect::>(); + let fallback_agent_type = self + .sessions + .get(session_id) + .map(|session| session.agent_type.clone()); + Self::derive_last_user_dialog_agent_type_from_turns( + &kept_turns, + fallback_agent_type.as_deref(), + ) + }; + + // 3) Truncate session turn list & persist + // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. + let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { + if session.dialog_turn_ids.len() > target_turn { + session.dialog_turn_ids.truncate(target_turn); + } + session.last_user_dialog_agent_type = last_user_dialog_agent_type; + session.state = SessionState::Idle; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + + let should_persist = + Self::should_persist_session(&session) && self.config.enable_persistence; + if should_persist { + Some(session.clone()) + } else { + None + } + } else { + None + }; + // RefMut guard released here -- DashMap shard lock is free. + + if let Some(session) = session_snapshot { + self.persistence_manager + .save_session(workspace_path, &session) + .await?; + } + + // 4) Delete persisted turns and snapshots from target_turn (inclusive) onwards. + // Runtime restore rebuilds history from persisted turn files, so removing only + // context snapshots would make rolled-back prompts reappear after reload. + if self.config.enable_persistence { + self.persistence_manager + .delete_dialog_turns_from(workspace_path, session_id, target_turn) + .await?; + self.persistence_manager + .delete_turn_context_snapshots_from(workspace_path, session_id, target_turn) + .await?; + self.truncate_listing_baseline_rebuild_turn_index_after_rollback( + workspace_path, + session_id, + target_turn, + ) + .await?; + } + self.turn_skill_agent_snapshot_store + .remove_from(session_id, target_turn); + + Ok(()) + } + + /// List all sessions + pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { + if self.config.enable_persistence { + self.persistence_manager.list_sessions(workspace_path).await + } else { + let summaries: Vec<_> = self + .sessions + .iter() + .map(|entry| { + let session = entry.value(); + SessionSummary { + session_id: session.session_id.clone(), + session_name: session.session_name.clone(), + agent_type: session.agent_type.clone(), + last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), + last_submitted_agent_type: session.last_submitted_agent_type.clone(), + created_by: session.created_by.clone(), + kind: session.kind, + turn_count: session.dialog_turn_ids.len(), + created_at: session.created_at, + last_activity_at: session.last_activity_at, + state: session.state.clone(), + } + }) + .filter(|summary| { + !matches!( + summary.kind, + SessionKind::Subagent | SessionKind::EphemeralChild + ) + }) + .collect(); + Ok(summaries) + } + } + + pub async fn load_session_metadata( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult> { + self.persistence_manager + .load_session_metadata(workspace_path, session_id) + .await + } + + pub async fn save_session_metadata( + &self, + workspace_path: &Path, + metadata: &SessionMetadata, + ) -> BitFunResult<()> { + self.persistence_manager + .save_session_metadata(workspace_path, metadata) + .await + } + + async fn metadata_workspace_path_for_update(&self, session_id: &str) -> BitFunResult { + if !self.should_persist_session_id(session_id) { + return Err(BitFunError::Validation(format!( + "Session persistence is disabled: {}", + session_id + ))); + } + + self.effective_session_workspace_path(session_id) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + }) + } + + async fn load_or_persist_session_metadata( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + match self + .persistence_manager + .load_session_metadata(workspace_path, session_id) + .await? + { + Some(metadata) => Ok(metadata), + None => { + let session = self + .sessions + .get(session_id) + .map(|value| value.clone()) + .ok_or_else(|| { + BitFunError::NotFound(format!("Session not found: {}", session_id)) + })?; + self.persistence_manager + .save_session(workspace_path, &session) .await?; self.persistence_manager - .load_session_metadata(&workspace_path, session_id) + .load_session_metadata(workspace_path, session_id) .await? .ok_or_else(|| { BitFunError::NotFound(format!("Session not found: {}", session_id)) - })? + }) } - }; + } + } - metadata.deep_review_run_manifest = deep_review_run_manifest; + async fn update_session_metadata_at_workspace( + &self, + workspace_path: &Path, + session_id: &str, + update: impl FnOnce(&mut SessionMetadata), + ) -> BitFunResult<()> { + let mut metadata = self + .load_or_persist_session_metadata(workspace_path, session_id) + .await?; + update(&mut metadata); self.persistence_manager - .save_session_metadata(&workspace_path, &metadata) + .save_session_metadata(workspace_path, &metadata) + .await + } + + async fn update_persisted_session_metadata( + &self, + session_id: &str, + update: impl FnOnce(&mut SessionMetadata), + ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + return Ok(()); + } + + let workspace_path = self.metadata_workspace_path_for_update(session_id).await?; + self.update_session_metadata_at_workspace(&workspace_path, session_id, update) .await } + pub async fn merge_session_custom_metadata( + &self, + session_id: &str, + patch: serde_json::Value, + ) -> BitFunResult<()> { + self.update_persisted_session_metadata(session_id, |metadata| { + merge_session_custom_metadata_value(metadata, patch) + }) + .await + } + + pub async fn merge_session_relationship( + &self, + session_id: &str, + relationship: SessionRelationship, + ) -> BitFunResult<()> { + self.update_persisted_session_metadata(session_id, |metadata| { + set_session_relationship(metadata, relationship) + }) + .await + } + + pub async fn persist_session_lineage( + &self, + session_id: &str, + relationship: SessionRelationship, + ) -> BitFunResult<()> { + self.update_persisted_session_metadata(session_id, |metadata| { + apply_session_lineage(metadata, relationship) + }) + .await + } + + pub async fn collect_hidden_subagent_cascade_for_parent_turns( + &self, + workspace_path: &Path, + parent_session_id: &str, + parent_dialog_turn_ids: &HashSet, + ) -> BitFunResult> { + if parent_session_id.trim().is_empty() || parent_dialog_turn_ids.is_empty() { + return Ok(Vec::new()); + } + + let metadata_list = self + .persistence_manager + .list_session_metadata_including_internal(workspace_path) + .await?; + Ok(collect_hidden_subagent_cascade_ids( + metadata_list, + parent_session_id, + parent_dialog_turn_ids, + )) + } + + pub async fn set_session_deep_review_run_manifest( + &self, + session_id: &str, + deep_review_run_manifest: Option, + ) -> BitFunResult<()> { + self.update_persisted_session_metadata(session_id, |metadata| { + set_deep_review_run_manifest(metadata, deep_review_run_manifest) + }) + .await + } + // ============ Dialog Turn Management ============ #[allow(clippy::too_many_arguments)] @@ -2745,7 +3256,7 @@ impl SessionManager { agent_type: Option, user_input: String, turn_id: Option, - context_message: Option, + context_messages: Vec, processing_phase: ProcessingPhase, user_message_metadata: Option, ) -> BitFunResult { @@ -2777,7 +3288,7 @@ impl SessionManager { session.last_activity_at = SystemTime::now(); } - if let Some(message) = context_message { + for message in context_messages { self.context_store .add_message(session_id, message.with_turn_id(turn_id.clone())); } @@ -2851,7 +3362,7 @@ impl SessionManager { Some(agent_type), user_input, turn_id, - Some(user_message), + vec![user_message], ProcessingPhase::Starting, user_message_metadata, ) @@ -2862,6 +3373,49 @@ impl SessionManager { Ok(turn_id) } + pub async fn start_dialog_turn_with_prepended_messages( + &self, + session_id: &str, + agent_type: String, + user_input: String, + turn_id: Option, + image_contexts: Option>, + prepended_messages: Vec, + user_message_metadata: Option, + ) -> BitFunResult { + let user_message = + if let Some(images) = image_contexts.as_ref().filter(|v| !v.is_empty()).cloned() { + Message::user_multimodal(user_input.clone(), images) + .with_semantic_kind(MessageSemanticKind::ActualUserInput) + } else { + Message::user(user_input.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput) + }; + + let mut context_messages = prepended_messages; + context_messages.push(user_message); + + let turn_id = self + .start_persisted_turn( + session_id, + DialogTurnKind::UserDialog, + Some(agent_type), + user_input, + turn_id, + context_messages, + ProcessingPhase::Starting, + user_message_metadata, + ) + .await?; + + debug!( + "Starting dialog turn with prepended messages: turn_id={}", + turn_id + ); + + Ok(turn_id) + } + /// Start a new dialog turn when the model-visible user message has already /// been inserted into runtime context by the caller. /// @@ -2885,7 +3439,7 @@ impl SessionManager { Some(agent_type), user_input, turn_id, - None, + Vec::new(), ProcessingPhase::Starting, user_message_metadata, ) @@ -2914,7 +3468,7 @@ impl SessionManager { None, display_message, turn_id, - None, + Vec::new(), ProcessingPhase::Compacting, user_message_metadata, ) @@ -3077,6 +3631,7 @@ impl SessionManager { id: format!("{}-final-round", turn.turn_id), turn_id: turn.turn_id.clone(), round_index, + round_group_id: None, timestamp: completion_timestamp, text_items: vec![TextItemData { id: format!("{}-final-text", turn.turn_id), @@ -3089,6 +3644,8 @@ impl SessionManager { parent_task_tool_id: None, subagent_session_id: None, status: Some("completed".to_string()), + attempt_id: None, + attempt_index: None, }], tool_items: Vec::new(), thinking_items: Vec::new(), @@ -3391,151 +3948,6 @@ impl SessionManager { Ok(()) } - /// Persist a completed `/btw` side-question turn into an existing child session. - #[allow(clippy::too_many_arguments)] - pub async fn persist_btw_turn( - &self, - workspace_path: &Path, - child_session_id: &str, - request_id: &str, - question: &str, - full_text: &str, - parent_session_id: &str, - parent_dialog_turn_id: Option<&str>, - parent_turn_index: Option, - ) -> BitFunResult<()> { - let session = self.sessions.get(child_session_id).ok_or_else(|| { - BitFunError::NotFound(format!("Session not found: {}", child_session_id)) - })?; - let turn_id = format!("btw-turn-{}", request_id); - let turn_index = session - .dialog_turn_ids - .iter() - .position(|existing| existing == &turn_id) - .unwrap_or(session.dialog_turn_ids.len()); - - let user_message_id = format!("btw-user-{}", request_id); - let round_id = format!("btw-round-{}", request_id); - let text_id = format!("btw-text-{}", request_id); - let now = SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - - let mut turn = DialogTurnData::new( - turn_id.clone(), - turn_index, - child_session_id.to_string(), - UserMessageData { - id: user_message_id, - content: question.to_string(), - timestamp: now, - metadata: Some(json!({ - "kind": "btw", - "parentSessionId": parent_session_id, - "parentRequestId": request_id, - "parentDialogTurnId": parent_dialog_turn_id, - "parentTurnIndex": parent_turn_index, - })), - }, - ); - turn.timestamp = now; - turn.start_time = now; - turn.end_time = Some(now); - turn.duration_ms = Some(0); - turn.status = TurnStatus::Completed; - turn.model_rounds = vec![ModelRoundData { - id: round_id, - turn_id: turn_id.clone(), - round_index: 0, - timestamp: now, - text_items: vec![TextItemData { - id: text_id, - content: full_text.to_string(), - is_streaming: false, - timestamp: now, - is_markdown: true, - order_index: None, - is_subagent_item: None, - parent_task_tool_id: None, - subagent_session_id: None, - status: Some("completed".to_string()), - }], - tool_items: vec![], - thinking_items: vec![], - start_time: now, - end_time: Some(now), - duration_ms: Some(0), - provider_id: None, - model_id: None, - model_alias: None, - first_chunk_ms: None, - first_visible_output_ms: None, - stream_duration_ms: None, - attempt_count: None, - failure_category: None, - token_details: None, - status: "completed".to_string(), - }]; - - drop(session); - - // Persist the turn to disk - self.persistence_manager - .save_dialog_turn(workspace_path, &turn) - .await?; - - // Sync messages to the in-memory caches so subsequent turns can access context. - let user_message = Message::user(question.to_string()) - .with_turn_id(turn_id.clone()) - .with_semantic_kind(MessageSemanticKind::ActualUserInput); - let assistant_message = - Message::assistant(full_text.to_string()).with_turn_id(turn_id.clone()); - - // Add to the in-memory runtime context cache. - self.context_store - .add_message(child_session_id, user_message); - self.context_store - .add_message(child_session_id, assistant_message); - - // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. - let session_snapshot = if let Some(mut session) = self.sessions.get_mut(child_session_id) { - if !session - .dialog_turn_ids - .iter() - .any(|existing| existing == &turn_id) - { - session.dialog_turn_ids.push(turn_id); - } - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - - if self.config.enable_persistence && Self::should_persist_session(&session) { - Some(session.clone()) - } else { - None - } - } else { - None - }; - // RefMut guard released here -- DashMap shard lock is free. - - if let Some(session) = session_snapshot { - self.persistence_manager - .save_session(workspace_path, &session) - .await?; - } - - self.persist_context_snapshot_for_turn_best_effort( - child_session_id, - turn_index, - "btw_turn_persisted", - ) - .await; - - Ok(()) - } - // ============ Helper Methods ============ /// Get a best-effort message view for the session. @@ -3841,6 +4253,9 @@ impl SessionManager { let enable_persistence = self.config.enable_persistence; let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); + let turn_skill_agent_snapshot_store = self.turn_skill_agent_snapshot_store.clone(); + let skill_agent_baseline_override_snapshot_store = + self.skill_agent_baseline_override_snapshot_store.clone(); let file_read_state_store = self.file_read_state_store.clone(); tokio::spawn(async move { @@ -3899,6 +4314,8 @@ impl SessionManager { { context_store.delete_session(&candidate.session_id); prompt_cache_store.delete_session(&candidate.session_id); + turn_skill_agent_snapshot_store.delete_session(&candidate.session_id); + skill_agent_baseline_override_snapshot_store.remove(&candidate.session_id); file_read_state_store.delete_session(&candidate.session_id); } } @@ -3906,51 +4323,12 @@ impl SessionManager { }); debug!("Cleanup task started"); - } -} - -fn extract_subagent_relationship( - metadata: &SessionMetadata, -) -> Option<(SessionRelationshipKind, String, String)> { - let relationship = metadata.relationship.as_ref(); - let custom_metadata = metadata.custom_metadata.as_ref(); - - let relationship_kind = relationship - .and_then(|value| value.kind.clone()) - .or_else(|| { - custom_metadata - .and_then(|value| value.get("kind")) - .and_then(|value| value.as_str()) - .and_then(|value| match value { - "subagent" => Some(SessionRelationshipKind::Subagent), - _ => None, - }) - })?; - - let parent_session_id = relationship - .and_then(|value| value.parent_session_id.clone()) - .or_else(|| { - custom_metadata - .and_then(|value| value.get("parentSessionId")) - .and_then(|value| value.as_str()) - .map(str::to_string) - })?; - - let parent_dialog_turn_id = relationship - .and_then(|value| value.parent_dialog_turn_id.clone()) - .or_else(|| { - custom_metadata - .and_then(|value| value.get("parentDialogTurnId")) - .and_then(|value| value.as_str()) - .map(str::to_string) - })?; - - Some((relationship_kind, parent_session_id, parent_dialog_turn_id)) + } } #[cfg(test)] mod tests { - use super::{SessionManager, SessionManagerConfig}; + use super::{CoreSessionStorePort, SessionManager, SessionManagerConfig}; use crate::agentic::core::{ Message, MessageContent, MessageRole, ProcessingPhase, Session, SessionConfig, SessionState, }; @@ -3959,6 +4337,7 @@ mod tests { PromptCachePolicy, PromptCacheScope, SessionContextStore, SystemPromptCacheIdentity, UserContextCacheIdentity, }; + use crate::agentic::skill_agent_snapshot::{SkillSnapshotEntry, TurnSkillAgentSnapshot}; use crate::infrastructure::PathManager; use crate::service::config::types::{ AIConfig as ServiceAIConfig, AIModelConfig as ServiceAIModelConfig, @@ -4006,287 +4385,710 @@ mod tests { } } - fn test_manager(persistence_manager: Arc) -> SessionManager { - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - SessionManagerConfig { - max_active_sessions: 100, - session_idle_timeout: Duration::from_secs(3600), - auto_save_interval: Duration::from_secs(300), - enable_persistence: true, - prompt_cache_policy: PromptCachePolicy::default(), - }, - ) - } + fn test_manager(persistence_manager: Arc) -> SessionManager { + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: true, + prompt_cache_policy: PromptCachePolicy::default(), + }, + ) + } + + fn test_manager_with_config( + persistence_manager: Arc, + config: SessionManagerConfig, + ) -> SessionManager { + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + config, + ) + } + + fn in_memory_test_manager() -> SessionManager { + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + ) + } + + fn test_model(id: &str, context_window: u32) -> ServiceAIModelConfig { + ServiceAIModelConfig { + id: id.to_string(), + name: id.to_string(), + model_name: id.to_string(), + enabled: true, + context_window: Some(context_window), + ..Default::default() + } + } + + #[test] + fn sync_session_context_window_refreshes_stale_explicit_model_window() { + let mut ai_config = ServiceAIConfig::default(); + ai_config.models = vec![test_model("deepseek-v4-pro", 1_000_000)]; + + let mut session = Session::new_with_id( + "session-804".to_string(), + "DeepSeek session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("deepseek-v4-pro".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + } + + #[test] + fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { + let mut ai_config = ServiceAIConfig::default(); + ai_config.models = vec![ + test_model("primary-model", 512_000), + test_model("agent-model", 1_000_000), + ]; + ai_config.default_models.primary = Some("primary-model".to_string()); + ai_config + .agent_models + .insert("agentic".to_string(), "agent-model".to_string()); + + let mut session = Session::new_with_id( + "session-auto".to_string(), + "Auto session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("auto".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + + ai_config.agent_models.clear(); + session.config.max_context_tokens = 256_000; + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(512_000)); + assert_eq!(session.config.max_context_tokens, 512_000); + } + + #[tokio::test] + async fn auto_save_interval_waits_before_first_tick() { + let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); + let started = tokio::time::Instant::now(); + + ticker.tick().await; + + assert!(started.elapsed() >= Duration::from_millis(30)); + } + + #[tokio::test] + async fn auto_save_snapshot_collection_releases_session_map_guards() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session = manager + .create_session( + "Auto-save snapshot".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let snapshots = SessionManager::collect_auto_save_snapshots(&manager.sessions); + assert!(snapshots + .iter() + .any(|snapshot| snapshot.session_id == session.session_id)); + + match manager.sessions.try_get_mut(&session.session_id) { + TryResult::Present(_) => {} + TryResult::Absent => panic!("session should remain present"), + TryResult::Locked => panic!("snapshot collection should not retain session map guards"), + }; + } + + #[tokio::test] + async fn reset_session_state_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!( + session.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-2" + )); + } + + #[tokio::test] + async fn reset_session_state_if_processing_resets_the_matching_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!(session.state, SessionState::Idle)); + } + + #[tokio::test] + async fn update_session_state_for_turn_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + let updated = manager + .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + .await + .expect("conditional state update should not fail"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(!updated); + assert!(matches!( + session.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-2" + )); + } + + #[tokio::test] + async fn update_session_state_for_turn_if_processing_updates_matching_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + let updated = manager + .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + .await + .expect("conditional state update should not fail"); - fn test_manager_with_config( - persistence_manager: Arc, - config: SessionManagerConfig, - ) -> SessionManager { - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - config, - ) + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(updated); + assert!(matches!(session.state, SessionState::Idle)); } - fn in_memory_test_manager() -> SessionManager { + #[tokio::test] + async fn append_completed_local_command_turn_persists_without_model_context() { + let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - SessionManagerConfig { - max_active_sessions: 100, - session_idle_timeout: Duration::from_secs(3600), - auto_save_interval: Duration::from_secs(300), - enable_persistence: false, - prompt_cache_policy: PromptCachePolicy::default(), - }, - ) - } + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Usage session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); - fn test_model(id: &str, context_window: u32) -> ServiceAIModelConfig { - ServiceAIModelConfig { - id: id.to_string(), - name: id.to_string(), - model_name: id.to_string(), - enabled: true, - context_window: Some(context_window), - ..Default::default() - } - } + let turn = manager + .append_completed_local_command_turn( + &session.session_id, + "# Session Usage Report".to_string(), + Some("local-usage-1".to_string()), + Some(42), + Some(json!({ + "localCommandKind": "usage_report", + "modelVisible": false, + })), + ) + .await + .expect("local command turn should persist"); - #[test] - fn sync_session_context_window_refreshes_stale_explicit_model_window() { - let mut ai_config = ServiceAIConfig::default(); - ai_config.models = vec![test_model("deepseek-v4-pro", 1_000_000)]; + assert_eq!(turn.kind, DialogTurnKind::LocalCommand); + assert_eq!(turn.status, TurnStatus::Completed); - let mut session = Session::new_with_id( - "session-804".to_string(), - "DeepSeek session".to_string(), - "agentic".to_string(), - SessionConfig { - model_id: Some("deepseek-v4-pro".to_string()), - max_context_tokens: 256_000, - ..Default::default() - }, - ); + let active = manager + .get_session(&session.session_id) + .expect("session should remain active"); + assert_eq!(active.dialog_turn_ids, vec!["local-usage-1".to_string()]); + assert!(manager + .context_store + .get_context_messages(&session.session_id) + .is_empty()); - let resolved = - SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + let persisted_turns = persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load"); + assert_eq!(persisted_turns.len(), 1); + assert_eq!(persisted_turns[0].kind, DialogTurnKind::LocalCommand); + assert!(SessionManager::build_messages_from_turns(&persisted_turns).is_empty()); - assert_eq!(resolved, Some(1_000_000)); - assert_eq!(session.config.max_context_tokens, 1_000_000); + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 1); } - #[test] - fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { - let mut ai_config = ServiceAIConfig::default(); - ai_config.models = vec![ - test_model("primary-model", 512_000), - test_model("agent-model", 1_000_000), - ]; - ai_config.default_models.primary = Some("primary-model".to_string()); - ai_config - .agent_models - .insert("agentic".to_string(), "agent-model".to_string()); - + #[tokio::test] + async fn restore_session_resets_processing_state_without_marking_unread_completion() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let session_id = Uuid::new_v4().to_string(); let mut session = Session::new_with_id( - "session-auto".to_string(), - "Auto session".to_string(), - "agentic".to_string(), + session_id.clone(), + "Legacy processing session".to_string(), + "agent".to_string(), SessionConfig { - model_id: Some("auto".to_string()), - max_context_tokens: 256_000, + workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; - let resolved = - SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + persistence_manager + .save_session_state(workspace.path(), &session_id, &session.state) + .await + .expect("processing state should save"); - assert_eq!(resolved, Some(1_000_000)); - assert_eq!(session.config.max_context_tokens, 1_000_000); + let manager = test_manager(persistence_manager.clone()); + let restored = manager + .restore_session(workspace.path(), &session_id) + .await + .expect("session should restore"); + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); - ai_config.agent_models.clear(); - session.config.max_context_tokens = 256_000; + assert!(matches!(restored.state, SessionState::Idle)); + assert_eq!(metadata.unread_completion, None); + } - let resolved = - SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + #[tokio::test] + async fn ephemeral_child_session_is_kept_in_memory_without_persisting() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); - assert_eq!(resolved, Some(512_000)); - assert_eq!(session.config.max_context_tokens, 512_000); + let session = manager + .create_session_with_id_and_details( + Some(Uuid::new_v4().to_string()), + "Side thread".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + Some("session-parent".to_string()), + SessionKind::EphemeralChild, + ) + .await + .expect("ephemeral child session should create"); + + assert!(manager.get_session(&session.session_id).is_some()); + assert!(persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata lookup should succeed") + .is_none()); } #[tokio::test] - async fn auto_save_interval_waits_before_first_tick() { - let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); - let started = tokio::time::Instant::now(); + async fn persist_session_lineage_updates_structured_relationship_and_clears_legacy_projection() + { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); - ticker.tick().await; + let session = manager + .create_session_with_id_and_details( + Some(Uuid::new_v4().to_string()), + "Review child".to_string(), + "CodeReview".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + Some("session-parent".to_string()), + SessionKind::Standard, + ) + .await + .expect("session should create"); - assert!(started.elapsed() >= Duration::from_millis(30)); - } + manager + .merge_session_custom_metadata( + &session.session_id, + json!({ + "kind": "review", + "parentSessionId": "stale-parent", + "parentRequestId": "stale-request", + "parentDialogTurnId": "stale-turn", + "parentTurnIndex": 1, + "parentToolCallId": "stale-tool", + "subagentType": "stale-subagent", + "preservedKey": "preserved-value", + }), + ) + .await + .expect("legacy compatibility metadata should seed"); - #[tokio::test] - async fn auto_save_snapshot_collection_releases_session_map_guards() { - let workspace = TestWorkspace::new(); - let manager = in_memory_test_manager(); - let session = manager - .create_session( - "Auto-save snapshot".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() + manager + .persist_session_lineage( + &session.session_id, + SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-1".to_string()), + parent_request_id: Some("request-1".to_string()), + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: None, + subagent_type: None, }, ) .await - .expect("session should create"); + .expect("lineage should persist"); - let snapshots = SessionManager::collect_auto_save_snapshots(&manager.sessions); - assert!(snapshots - .iter() - .any(|snapshot| snapshot.session_id == session.session_id)); + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata lookup should succeed") + .expect("metadata should exist"); - match manager.sessions.try_get_mut(&session.session_id) { - TryResult::Present(_) => {} - TryResult::Absent => panic!("session should remain present"), - TryResult::Locked => panic!("snapshot collection should not retain session map guards"), - }; + assert_eq!( + metadata.relationship, + Some(SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-1".to_string()), + parent_request_id: Some("request-1".to_string()), + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: None, + subagent_type: None, + }) + ); + + let custom_metadata = metadata + .custom_metadata + .expect("non-lineage custom metadata should remain"); + assert_eq!(custom_metadata["preservedKey"], "preserved-value"); + assert!(custom_metadata.get("kind").is_none()); + assert!(custom_metadata.get("parentSessionId").is_none()); + assert!(custom_metadata.get("parentRequestId").is_none()); + assert!(custom_metadata.get("parentDialogTurnId").is_none()); + assert!(custom_metadata.get("parentTurnIndex").is_none()); + assert!(custom_metadata.get("parentToolCallId").is_none()); + assert!(custom_metadata.get("subagentType").is_none()); } #[tokio::test] - async fn reset_session_state_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), + async fn collect_hidden_subagent_cascade_for_parent_turns_returns_post_order_matches() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + let manager = test_manager(persistence_manager.clone()); - manager.reset_session_state_if_processing(&session_id, "turn-1"); + let mut matched_root = SessionMetadata::new( + "child-root".to_string(), + "Subagent: root".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + matched_root.session_kind = SessionKind::Subagent; + matched_root.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: Some("tool-1".to_string()), + subagent_type: Some("Explore".to_string()), + }); + persistence_manager + .save_session_metadata(workspace.path(), &matched_root) + .await + .expect("matched root should save"); - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!( - session.state, - SessionState::Processing { - ref current_turn_id, - .. - } if current_turn_id == "turn-2" - )); - } + let mut matched_grandchild = SessionMetadata::new( + "grandchild".to_string(), + "Subagent: grandchild".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + matched_grandchild.session_kind = SessionKind::Subagent; + matched_grandchild.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("child-root".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("child-turn".to_string()), + parent_turn_index: None, + parent_tool_call_id: Some("tool-child".to_string()), + subagent_type: Some("Explore".to_string()), + }); + persistence_manager + .save_session_metadata(workspace.path(), &matched_grandchild) + .await + .expect("grandchild should save"); - #[tokio::test] - async fn reset_session_state_if_processing_resets_the_matching_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), + let mut unmatched_root = SessionMetadata::new( + "child-other-turn".to_string(), + "Subagent: other turn".to_string(), + "Explore".to_string(), + "model".to_string(), ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + unmatched_root.session_kind = SessionKind::Subagent; + unmatched_root.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-1".to_string()), + parent_turn_index: Some(1), + parent_tool_call_id: Some("tool-2".to_string()), + subagent_type: Some("Explore".to_string()), + }); + persistence_manager + .save_session_metadata(workspace.path(), &unmatched_root) + .await + .expect("unmatched root should save"); - manager.reset_session_state_if_processing(&session_id, "turn-1"); + let mut visible_review_child = SessionMetadata::new( + "review-child".to_string(), + "Review child".to_string(), + "DeepReview".to_string(), + "model".to_string(), + ); + visible_review_child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: None, + subagent_type: None, + }); + persistence_manager + .save_session_metadata(workspace.path(), &visible_review_child) + .await + .expect("visible review child should save"); - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!(session.state, SessionState::Idle)); + let matched_turn_ids = HashSet::from(["turn-2".to_string()]); + let cascade = manager + .collect_hidden_subagent_cascade_for_parent_turns( + workspace.path(), + "parent-session", + &matched_turn_ids, + ) + .await + .expect("cascade lookup should succeed"); + + assert_eq!( + cascade, + vec!["grandchild".to_string(), "child-root".to_string()] + ); } #[tokio::test] - async fn update_session_state_for_turn_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, + async fn core_session_store_port_resolves_unresolved_remote_storage_path() { + use bitfun_runtime_ports::{ + SessionStorageKind, SessionStoragePathRequest, SessionStorePort, }; - manager.sessions.insert(session_id.clone(), session); - let updated = manager - .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + let port = CoreSessionStorePort; + let resolution = port + .resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: PathBuf::from("/remote/project"), + remote_connection_id: Some("conn-1".to_string()), + remote_ssh_host: None, + }) .await - .expect("conditional state update should not fail"); + .expect("storage path should resolve"); - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(!updated); - assert!(matches!( - session.state, - SessionState::Processing { - ref current_turn_id, - .. - } if current_turn_id == "turn-2" - )); + assert_eq!( + resolution.storage_kind, + SessionStorageKind::UnresolvedRemote + ); + assert!(resolution.is_remote_storage()); + assert_eq!(resolution.remote_connection_id.as_deref(), Some("conn-1")); + assert_ne!( + resolution.effective_storage_path, + PathBuf::from("/remote/project") + ); } #[tokio::test] - async fn update_session_state_for_turn_if_processing_updates_matching_turn() { - let manager = in_memory_test_manager(); + async fn restore_session_view_loads_turns_without_restoring_runtime_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); let session_id = Uuid::new_v4().to_string(); let mut session = Session::new_with_id( session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), + "Large history".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 0, + &[Message::user("snapshot prompt".to_string())], + ) + .await + .expect("context snapshot should save"); - let updated = manager - .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) .await - .expect("conditional state update should not fail"); + .expect("session view should restore"); - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(updated); - assert!(matches!(session.state, SessionState::Idle)); + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(turns.len(), 1); + assert!(manager.get_session(&session_id).is_none()); + assert!(manager + .context_store + .get_context_messages(&session_id) + .is_empty()); } #[tokio::test] - async fn append_completed_local_command_turn_persists_without_model_context() { + async fn start_dialog_turn_with_existing_context_persists_turn_and_snapshot() { let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), - ); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); let manager = test_manager(persistence_manager.clone()); let session = manager .create_session( - "Usage session".to_string(), - "agent".to_string(), + "Fork child".to_string(), + "agentic".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() @@ -4295,392 +5097,419 @@ mod tests { .await .expect("session should create"); - let turn = manager - .append_completed_local_command_turn( + let seeded_messages = vec![ + Message::user("fork reminder".to_string()), + Message::assistant("inherited context".to_string()), + ]; + manager + .replace_context_messages(&session.session_id, seeded_messages.clone()) + .await; + + let turn_id = manager + .start_dialog_turn_with_existing_context( &session.session_id, - "# Session Usage Report".to_string(), - Some("local-usage-1".to_string()), - Some(42), - Some(json!({ - "localCommandKind": "usage_report", - "modelVisible": false, - })), + "agentic".to_string(), + "delegate task".to_string(), + Some("subagent-turn-0".to_string()), + None, ) .await - .expect("local command turn should persist"); + .expect("turn should start"); - assert_eq!(turn.kind, DialogTurnKind::LocalCommand); - assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn_id, "subagent-turn-0"); + assert_eq!( + manager + .get_session(&session.session_id) + .expect("session should remain in memory") + .dialog_turn_ids, + vec!["subagent-turn-0".to_string()] + ); - let active = manager - .get_session(&session.session_id) - .expect("session should remain active"); - assert_eq!(active.dialog_turn_ids, vec!["local-usage-1".to_string()]); - assert!(manager - .context_store - .get_context_messages(&session.session_id) - .is_empty()); + let persisted_turn = persistence_manager + .load_dialog_turn(workspace.path(), &session.session_id, 0) + .await + .expect("turn load should succeed") + .expect("turn should exist"); + assert_eq!(persisted_turn.turn_id, "subagent-turn-0"); + assert_eq!(persisted_turn.user_message.content, "delegate task"); - let persisted_turns = persistence_manager - .load_session_turns(workspace.path(), &session.session_id) + let snapshot = persistence_manager + .load_turn_context_snapshot(workspace.path(), &session.session_id, 0) .await - .expect("turns should load"); - assert_eq!(persisted_turns.len(), 1); - assert_eq!(persisted_turns[0].kind, DialogTurnKind::LocalCommand); - assert!(SessionManager::build_messages_from_turns(&persisted_turns).is_empty()); + .expect("snapshot load should succeed") + .expect("snapshot should exist"); + assert_eq!(snapshot.len(), seeded_messages.len()); + assert!(matches!(snapshot[0].role, MessageRole::User)); + assert!(matches!(snapshot[1].role, MessageRole::Assistant)); + assert!(matches!( + &snapshot[0].content, + MessageContent::Text(text) if text == "fork reminder" + )); + assert!(matches!( + &snapshot[1].content, + MessageContent::Text(text) if text == "inherited context" + )); - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session.session_id) + let runtime_context = manager + .get_context_messages(&session.session_id) .await - .expect("metadata should load") - .expect("metadata should exist"); - assert_eq!(metadata.turn_count, 1); + .expect("runtime context should remain readable"); + assert_eq!(runtime_context.len(), seeded_messages.len()); } #[tokio::test] - async fn restore_session_resets_processing_state_without_marking_unread_completion() { + async fn restore_session_view_preserves_full_visible_tool_result_payload() { let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), ); + let manager = test_manager(persistence_manager.clone()); let session_id = Uuid::new_v4().to_string(); let mut session = Session::new_with_id( session_id.clone(), - "Legacy processing session".to_string(), + "History with tool output".to_string(), "agent".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; + session.dialog_turn_ids = vec!["turn-1".to_string()]; persistence_manager .save_session(workspace.path(), &session) .await .expect("session should save"); - persistence_manager - .save_session_state(workspace.path(), &session_id, &session.state) - .await - .expect("processing state should save"); - - let manager = test_manager(persistence_manager.clone()); - let restored = manager - .restore_session(workspace.path(), &session_id) - .await - .expect("session should restore"); - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session_id) - .await - .expect("metadata should load") - .expect("metadata should exist"); - - assert!(matches!(restored.state, SessionState::Idle)); - assert_eq!(metadata.unread_completion, None); - } - #[tokio::test] - async fn ephemeral_child_session_is_kept_in_memory_without_persisting() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + let visible_output = "complete visible output ".repeat(128); + let assistant_output = "assistant visible summary ".repeat(16); + let mut turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "show full output".to_string(), + timestamp: 1, + metadata: None, + }, ); - let manager = test_manager(persistence_manager.clone()); - - let session = manager - .create_session_with_id_and_details( - Some(Uuid::new_v4().to_string()), - "Side thread".to_string(), - "agentic".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() + turn.model_rounds.push(ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + round_group_id: None, + timestamp: 1, + text_items: vec![], + tool_items: vec![ToolItemData { + id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + tool_call: ToolCallData { + id: "call-1".to_string(), + input: json!({ "command": "printf output" }), }, - Some("session-parent".to_string()), - SessionKind::EphemeralChild, - ) + tool_result: Some(ToolResultData { + result: json!({ + "stdout": visible_output, + "nested": { + "stderr": "also visible", + }, + }), + success: true, + result_for_assistant: Some(assistant_output.clone()), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + attempt_id: None, + attempt_index: None, + subagent_model_id: None, + subagent_model_alias: None, + status: Some("completed".to_string()), + interruption_reason: None, + }], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) .await - .expect("ephemeral child session should create"); + .expect("turn should save"); - assert!(manager.get_session(&session.session_id).is_some()); - assert!(persistence_manager - .load_session_metadata(workspace.path(), &session.session_id) + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) .await - .expect("metadata lookup should succeed") - .is_none()); + .expect("session view should restore"); + + let restored_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should be preserved"); + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!( + restored_result.result["stdout"].as_str(), + Some(visible_output.as_str()) + ); + assert_eq!( + restored_result.result["nested"]["stderr"].as_str(), + Some("also visible") + ); + assert_eq!( + restored_result.result_for_assistant.as_deref(), + Some(assistant_output.as_str()) + ); + assert!(manager.get_session(&session_id).is_none()); } #[tokio::test] - async fn persist_session_lineage_updates_structured_relationship_and_clears_legacy_projection() - { + async fn rollback_context_deletes_persisted_turns_from_target() { let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let manager = test_manager(persistence_manager.clone()); - let session = manager - .create_session_with_id_and_details( - Some(Uuid::new_v4().to_string()), - "Review child".to_string(), - "CodeReview".to_string(), + .create_session( + "Rollback session".to_string(), + "agent".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, - Some("session-parent".to_string()), - SessionKind::Standard, ) .await .expect("session should create"); - manager - .merge_session_custom_metadata( - &session.session_id, - json!({ - "kind": "review", - "parentSessionId": "stale-parent", - "parentRequestId": "stale-request", - "parentDialogTurnId": "stale-turn", - "parentTurnIndex": 1, - "parentToolCallId": "stale-tool", - "subagentType": "stale-subagent", - "preservedKey": "preserved-value", - }), - ) - .await - .expect("legacy compatibility metadata should seed"); - - manager - .persist_session_lineage( - &session.session_id, - SessionRelationship { - kind: Some(SessionRelationshipKind::DeepReview), - parent_session_id: Some("parent-1".to_string()), - parent_request_id: Some("request-1".to_string()), - parent_dialog_turn_id: Some("turn-2".to_string()), - parent_turn_index: Some(2), - parent_tool_call_id: None, - subagent_type: None, + for index in 0..3 { + let mut turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, }, - ) - .await - .expect("lineage should persist"); - - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session.session_id) - .await - .expect("metadata lookup should succeed") - .expect("metadata should exist"); - - assert_eq!( - metadata.relationship, - Some(SessionRelationship { - kind: Some(SessionRelationshipKind::DeepReview), - parent_session_id: Some("parent-1".to_string()), - parent_request_id: Some("request-1".to_string()), - parent_dialog_turn_id: Some("turn-2".to_string()), - parent_turn_index: Some(2), - parent_tool_call_id: None, - subagent_type: None, - }) - ); - - let custom_metadata = metadata - .custom_metadata - .expect("non-lineage custom metadata should remain"); - assert_eq!(custom_metadata["preservedKey"], "preserved-value"); - assert!(custom_metadata.get("kind").is_none()); - assert!(custom_metadata.get("parentSessionId").is_none()); - assert!(custom_metadata.get("parentRequestId").is_none()); - assert!(custom_metadata.get("parentDialogTurnId").is_none()); - assert!(custom_metadata.get("parentTurnIndex").is_none()); - assert!(custom_metadata.get("parentToolCallId").is_none()); - assert!(custom_metadata.get("subagentType").is_none()); - } - - #[tokio::test] - async fn collect_hidden_subagent_cascade_for_parent_turns_returns_post_order_matches() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), - ); - let manager = test_manager(persistence_manager.clone()); - - let mut matched_root = SessionMetadata::new( - "child-root".to_string(), - "Subagent: root".to_string(), - "Explore".to_string(), - "model".to_string(), - ); - matched_root.session_kind = SessionKind::Subagent; - matched_root.relationship = Some(SessionRelationship { - kind: Some(SessionRelationshipKind::Subagent), - parent_session_id: Some("parent-session".to_string()), - parent_request_id: None, - parent_dialog_turn_id: Some("turn-2".to_string()), - parent_turn_index: Some(2), - parent_tool_call_id: Some("tool-1".to_string()), - subagent_type: Some("Explore".to_string()), - }); - persistence_manager - .save_session_metadata(workspace.path(), &matched_root) - .await - .expect("matched root should save"); - - let mut matched_grandchild = SessionMetadata::new( - "grandchild".to_string(), - "Subagent: grandchild".to_string(), - "Explore".to_string(), - "model".to_string(), - ); - matched_grandchild.session_kind = SessionKind::Subagent; - matched_grandchild.relationship = Some(SessionRelationship { - kind: Some(SessionRelationshipKind::Subagent), - parent_session_id: Some("child-root".to_string()), - parent_request_id: None, - parent_dialog_turn_id: Some("child-turn".to_string()), - parent_turn_index: None, - parent_tool_call_id: Some("tool-child".to_string()), - subagent_type: Some("Explore".to_string()), - }); - persistence_manager - .save_session_metadata(workspace.path(), &matched_grandchild) - .await - .expect("grandchild should save"); + ); + turn.agent_type = Some(if index == 0 { + "agentic".to_string() + } else { + "Plan".to_string() + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } - let mut unmatched_root = SessionMetadata::new( - "child-other-turn".to_string(), - "Subagent: other turn".to_string(), - "Explore".to_string(), - "model".to_string(), - ); - unmatched_root.session_kind = SessionKind::Subagent; - unmatched_root.relationship = Some(SessionRelationship { - kind: Some(SessionRelationshipKind::Subagent), - parent_session_id: Some("parent-session".to_string()), - parent_request_id: None, - parent_dialog_turn_id: Some("turn-1".to_string()), - parent_turn_index: Some(1), - parent_tool_call_id: Some("tool-2".to_string()), - subagent_type: Some("Explore".to_string()), - }); + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active"); + active.dialog_turn_ids = vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string(), + ]; + active.last_user_dialog_agent_type = Some("Plan".to_string()); + } persistence_manager - .save_session_metadata(workspace.path(), &unmatched_root) + .save_turn_context_snapshot( + workspace.path(), + &session.session_id, + 0, + &[crate::agentic::core::Message::user("prompt 0".to_string())], + ) .await - .expect("unmatched root should save"); - - let mut visible_review_child = SessionMetadata::new( - "review-child".to_string(), - "Review child".to_string(), - "DeepReview".to_string(), - "model".to_string(), - ); - visible_review_child.relationship = Some(SessionRelationship { - kind: Some(SessionRelationshipKind::DeepReview), - parent_session_id: Some("parent-session".to_string()), - parent_request_id: None, - parent_dialog_turn_id: Some("turn-2".to_string()), - parent_turn_index: Some(2), - parent_tool_call_id: None, - subagent_type: None, - }); + .expect("snapshot 0 should save"); persistence_manager - .save_session_metadata(workspace.path(), &visible_review_child) - .await - .expect("visible review child should save"); - - let matched_turn_ids = HashSet::from(["turn-2".to_string()]); - let cascade = manager - .collect_hidden_subagent_cascade_for_parent_turns( + .save_turn_context_snapshot( workspace.path(), - "parent-session", - &matched_turn_ids, + &session.session_id, + 1, + &[ + crate::agentic::core::Message::user("prompt 0".to_string()), + crate::agentic::core::Message::user("prompt 1".to_string()), + ], ) .await - .expect("cascade lookup should succeed"); + .expect("snapshot 1 should save"); + + manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) + .await + .expect("rollback should succeed"); + + let turns = persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].user_message.content, "prompt 0"); + assert_eq!(turns[0].agent_type.as_deref(), Some("agentic")); + assert!(persistence_manager + .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) + .await + .expect("snapshot load should succeed") + .is_none()); + manager.sessions.remove(&session.session_id); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("session should restore"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); assert_eq!( - cascade, - vec!["grandchild".to_string(), "child-root".to_string()] + restored.last_user_dialog_agent_type.as_deref(), + Some("agentic") + ); + assert_eq!( + manager + .context_store + .get_context_messages(&session.session_id) + .len(), + 1 ); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 1); } #[tokio::test] - async fn restore_session_view_loads_turns_without_restoring_runtime_context() { + async fn latest_skill_agent_snapshot_scans_persistence_beyond_stale_cache_hit() { + use crate::agentic::skill_agent_snapshot::{ + AgentSnapshotEntry, SkillSnapshotEntry, TurnSkillAgentSnapshot, + }; + let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let manager = test_manager(persistence_manager.clone()); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Large history".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ); - session.dialog_turn_ids = vec!["turn-1".to_string()]; - - persistence_manager - .save_session(workspace.path(), &session) + let session = manager + .create_session( + "Skill agent snapshot".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) .await - .expect("session should save"); - let turn = DialogTurnData::new( - "turn-1".to_string(), + .expect("session should create"); + + manager + .remember_turn_skill_agent_snapshot( + &session.session_id, + 0, + TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "/a".to_string(), + }], + subagents: vec![AgentSnapshotEntry { + id: "agent-a".to_string(), + description: "desc-a".to_string(), + default_tools: vec!["Read".to_string()], + }], + }, + ) + .await; + manager + .remember_turn_skill_agent_snapshot( + &session.session_id, + 1, + TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "/a".to_string(), + }], + subagents: vec![AgentSnapshotEntry { + id: "agent-b".to_string(), + description: "desc-b".to_string(), + default_tools: vec!["Read".to_string(), "Grep".to_string()], + }], + }, + ) + .await; + + manager + .turn_skill_agent_snapshot_store + .delete_session(&session.session_id); + manager + .turn_skill_agent_snapshot_store + .create_session(&session.session_id); + manager.turn_skill_agent_snapshot_store.set_snapshot( + &session.session_id, 0, - session_id.clone(), - UserMessageData { - id: "turn-1-user".to_string(), - content: "hello".to_string(), - timestamp: 1, - metadata: None, + TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "/a".to_string(), + }], + subagents: vec![AgentSnapshotEntry { + id: "agent-a".to_string(), + description: "desc-a".to_string(), + default_tools: vec!["Read".to_string()], + }], }, ); - persistence_manager - .save_dialog_turn(workspace.path(), &turn) - .await - .expect("turn should save"); - persistence_manager - .save_turn_context_snapshot( - workspace.path(), - &session_id, - 0, - &[Message::user("snapshot prompt".to_string())], - ) - .await - .expect("context snapshot should save"); - let (view_session, turns) = manager - .restore_session_view(workspace.path(), &session_id) + let latest = manager + .latest_turn_skill_agent_snapshot_at_or_before(&session.session_id, 1) .await - .expect("session view should restore"); + .expect("latest snapshot should exist"); - assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); - assert_eq!(turns.len(), 1); - assert!(manager.get_session(&session_id).is_none()); - assert!(manager - .context_store - .get_context_messages(&session_id) - .is_empty()); + assert_eq!(latest.0, 1); + assert_eq!(latest.1.subagents[0].id, "agent-b"); } #[tokio::test] - async fn start_dialog_turn_with_existing_context_persists_turn_and_snapshot() { + async fn rebuild_skill_agent_listing_baseline_to_latest_removes_listing_diff_reminders() { + use crate::agentic::core::{InternalReminderKind, Message, MessageSemanticKind}; + use crate::agentic::skill_agent_snapshot::{SkillSnapshotEntry, TurnSkillAgentSnapshot}; + let workspace = TestWorkspace::new(); - let persistence_manager = - Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); let manager = test_manager(persistence_manager.clone()); let session = manager .create_session( - "Fork child".to_string(), + "Listing baseline rebuild".to_string(), "agentic".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), @@ -4690,193 +5519,206 @@ mod tests { .await .expect("session should create"); - let seeded_messages = vec![ - Message::user("fork reminder".to_string()), - Message::assistant("inherited context".to_string()), - ]; + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active"); + active.dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; + } + + manager.context_store.replace_context( + &session.session_id, + vec![ + Message::internal_reminder( + InternalReminderKind::SkillListingDiff, + "# Skill Listing Update\n\nChanged", + ) + .with_turn_id("turn-1".to_string()), + Message::internal_reminder( + InternalReminderKind::AgentListingDiff, + "# Agent Listing Update\n\nChanged", + ) + .with_turn_id("turn-1".to_string()), + Message::user("real question".to_string()) + .with_turn_id("turn-1".to_string()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput), + ], + ); + manager - .replace_context_messages(&session.session_id, seeded_messages.clone()) + .remember_turn_skill_agent_snapshot( + &session.session_id, + 0, + TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "old-skill".to_string(), + description: "old".to_string(), + location: "/old".to_string(), + }], + ..Default::default() + }, + ) .await; - - let turn_id = manager - .start_dialog_turn_with_existing_context( + manager + .remember_turn_skill_agent_snapshot( &session.session_id, - "agentic".to_string(), - "delegate task".to_string(), - Some("subagent-turn-0".to_string()), - None, + 1, + TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "new-skill".to_string(), + description: "new".to_string(), + location: "/new".to_string(), + }], + ..Default::default() + }, ) - .await - .expect("turn should start"); + .await; - assert_eq!(turn_id, "subagent-turn-0"); - assert_eq!( + assert!( manager - .get_session(&session.session_id) - .expect("session should remain in memory") - .dialog_turn_ids, - vec!["subagent-turn-0".to_string()] + .rebuild_skill_agent_listing_baseline_to_latest(&session.session_id) + .await ); - let persisted_turn = persistence_manager - .load_dialog_turn(workspace.path(), &session.session_id, 0) - .await - .expect("turn load should succeed") - .expect("turn should exist"); - assert_eq!(persisted_turn.turn_id, "subagent-turn-0"); - assert_eq!(persisted_turn.user_message.content, "delegate task"); + let context_messages = manager + .context_store + .get_context_messages(&session.session_id); + assert_eq!(context_messages.len(), 1); + assert_eq!( + context_messages[0].metadata.semantic_kind, + Some(MessageSemanticKind::ActualUserInput) + ); - let snapshot = persistence_manager - .load_turn_context_snapshot(workspace.path(), &session.session_id, 0) + let baseline = manager + .turn_skill_agent_snapshot(&session.session_id, 0) .await - .expect("snapshot load should succeed") - .expect("snapshot should exist"); - assert_eq!(snapshot.len(), seeded_messages.len()); - assert!(matches!(snapshot[0].role, MessageRole::User)); - assert!(matches!(snapshot[1].role, MessageRole::Assistant)); - assert!(matches!( - &snapshot[0].content, - MessageContent::Text(text) if text == "fork reminder" - )); - assert!(matches!( - &snapshot[1].content, - MessageContent::Text(text) if text == "inherited context" - )); + .expect("baseline snapshot should exist"); + assert_eq!(baseline.skills[0].name, "new-skill"); + assert!(manager + .turn_skill_agent_snapshot(&session.session_id, 1) + .await + .is_none()); - let runtime_context = manager - .get_context_messages(&session.session_id) + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) .await - .expect("runtime context should remain readable"); - assert_eq!(runtime_context.len(), seeded_messages.len()); + .expect("metadata lookup should succeed") + .expect("metadata should exist"); + assert_eq!( + SessionManager::listing_baseline_rebuild_turn_index_from_metadata(Some(&metadata)), + Some(1) + ); } #[tokio::test] - async fn restore_session_view_preserves_full_visible_tool_result_payload() { + async fn restore_session_sanitizes_pre_cutoff_listing_diff_snapshot() { + use crate::agentic::core::{InternalReminderKind, Message, MessageSemanticKind}; + let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let manager = test_manager(persistence_manager.clone()); let session_id = Uuid::new_v4().to_string(); let mut session = Session::new_with_id( session_id.clone(), - "History with tool output".to_string(), - "agent".to_string(), + "Restore sanitize".to_string(), + "agentic".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, ); - session.dialog_turn_ids = vec!["turn-1".to_string()]; + session.dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; persistence_manager .save_session(workspace.path(), &session) .await .expect("session should save"); - let visible_output = "complete visible output ".repeat(128); - let assistant_output = "assistant visible summary ".repeat(16); - let mut turn = DialogTurnData::new( - "turn-1".to_string(), - 0, - session_id.clone(), - UserMessageData { - id: "turn-1-user".to_string(), - content: "show full output".to_string(), - timestamp: 1, - metadata: None, - }, - ); - turn.model_rounds.push(ModelRoundData { - id: "round-1".to_string(), - turn_id: "turn-1".to_string(), - round_index: 0, - timestamp: 1, - text_items: vec![], - tool_items: vec![ToolItemData { - id: "tool-1".to_string(), - tool_name: "Bash".to_string(), - tool_call: ToolCallData { - id: "call-1".to_string(), - input: json!({ "command": "printf output" }), + let mut metadata = persistence_manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata load should succeed") + .expect("metadata should exist"); + metadata.custom_metadata = Some(json!({ + super::LISTING_BASELINE_REBUILD_TURN_INDEX_METADATA_KEY: 2, + })); + persistence_manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + + for index in 0..=1 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, }, - tool_result: Some(ToolResultData { - result: json!({ - "stdout": visible_output, - "nested": { - "stderr": "also visible", - }, - }), - success: true, - result_for_assistant: Some(assistant_output.clone()), - error: None, - duration_ms: Some(1), - }), - ai_intent: None, - start_time: 1, - end_time: Some(2), - duration_ms: Some(1), - queue_wait_ms: None, - preflight_ms: None, - confirmation_wait_ms: None, - execution_ms: None, - order_index: None, - is_subagent_item: None, - parent_task_tool_id: None, - subagent_session_id: None, - subagent_model_id: None, - subagent_model_alias: None, - status: Some("completed".to_string()), - interruption_reason: None, - }], - thinking_items: vec![], - start_time: 1, - end_time: Some(2), - duration_ms: Some(1), - provider_id: None, - model_id: None, - model_alias: None, - first_chunk_ms: None, - first_visible_output_ms: None, - stream_duration_ms: None, - attempt_count: None, - failure_category: None, - token_details: None, - status: "completed".to_string(), - }); + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + persistence_manager - .save_dialog_turn(workspace.path(), &turn) + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 1, + &[ + Message::internal_reminder( + InternalReminderKind::SkillListingDiff, + "# Skill Listing Update\n\nChanged", + ) + .with_turn_id("turn-1".to_string()), + Message::user("prompt 1".to_string()) + .with_turn_id("turn-1".to_string()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput), + ], + ) .await - .expect("turn should save"); + .expect("snapshot should save"); - let (view_session, turns) = manager - .restore_session_view(workspace.path(), &session_id) + let restored = manager + .restore_session(workspace.path(), &session_id) .await - .expect("session view should restore"); + .expect("session should restore"); - let restored_result = turns[0].model_rounds[0].tool_items[0] - .tool_result - .as_ref() - .expect("tool result should be preserved"); - assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); assert_eq!( - restored_result.result["stdout"].as_str(), - Some(visible_output.as_str()) + restored.dialog_turn_ids, + vec!["turn-0".to_string(), "turn-1".to_string()] ); + let context_messages = manager.context_store.get_context_messages(&session_id); + assert_eq!(context_messages.len(), 1); assert_eq!( - restored_result.result["nested"]["stderr"].as_str(), - Some("also visible") + context_messages[0].metadata.semantic_kind, + Some(MessageSemanticKind::ActualUserInput) ); + + let sanitized_snapshot = persistence_manager + .load_turn_context_snapshot(workspace.path(), &session_id, 1) + .await + .expect("snapshot load should succeed") + .expect("snapshot should still exist"); + assert_eq!(sanitized_snapshot.len(), 1); assert_eq!( - restored_result.result_for_assistant.as_deref(), - Some(assistant_output.as_str()) + sanitized_snapshot[0].metadata.semantic_kind, + Some(MessageSemanticKind::ActualUserInput) ); - assert!(manager.get_session(&session_id).is_none()); } #[tokio::test] - async fn rollback_context_deletes_persisted_turns_from_target() { + async fn rollback_sanitizes_pre_cutoff_snapshot_and_truncates_cutoff() { + use crate::agentic::core::{InternalReminderKind, Message, MessageSemanticKind}; + let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), @@ -4884,8 +5726,8 @@ mod tests { let manager = test_manager(persistence_manager.clone()); let session = manager .create_session( - "Rollback session".to_string(), - "agent".to_string(), + "Rollback sanitize".to_string(), + "agentic".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() @@ -4894,8 +5736,8 @@ mod tests { .await .expect("session should create"); - for index in 0..3 { - let mut turn = DialogTurnData::new( + for index in 0..=2 { + let turn = DialogTurnData::new( format!("turn-{index}"), index, session.session_id.clone(), @@ -4906,11 +5748,6 @@ mod tests { metadata: None, }, ); - turn.agent_type = Some(if index == 0 { - "agentic".to_string() - } else { - "Plan".to_string() - }); persistence_manager .save_dialog_turn(workspace.path(), &turn) .await @@ -4927,14 +5764,33 @@ mod tests { "turn-1".to_string(), "turn-2".to_string(), ]; - active.last_user_dialog_agent_type = Some("Plan".to_string()); } + + manager + .merge_session_custom_metadata( + &session.session_id, + json!({ + super::LISTING_BASELINE_REBUILD_TURN_INDEX_METADATA_KEY: 2, + }), + ) + .await + .expect("cutoff metadata should save"); + persistence_manager .save_turn_context_snapshot( workspace.path(), &session.session_id, 0, - &[crate::agentic::core::Message::user("prompt 0".to_string())], + &[ + Message::internal_reminder( + InternalReminderKind::AgentListingDiff, + "# Agent Listing Update\n\nChanged", + ) + .with_turn_id("turn-0".to_string()), + Message::user("prompt 0".to_string()) + .with_turn_id("turn-0".to_string()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput), + ], ) .await .expect("snapshot 0 should save"); @@ -4944,8 +5800,8 @@ mod tests { &session.session_id, 1, &[ - crate::agentic::core::Message::user("prompt 0".to_string()), - crate::agentic::core::Message::user("prompt 1".to_string()), + Message::user("prompt 0".to_string()), + Message::user("prompt 1".to_string()), ], ) .await @@ -4956,43 +5812,35 @@ mod tests { .await .expect("rollback should succeed"); - let turns = persistence_manager - .load_session_turns(workspace.path(), &session.session_id) - .await - .expect("turns should load"); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].user_message.content, "prompt 0"); - assert_eq!(turns[0].agent_type.as_deref(), Some("agentic")); - assert!(persistence_manager - .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) - .await - .expect("snapshot load should succeed") - .is_none()); - - manager.sessions.remove(&session.session_id); - let restored = manager - .restore_session(workspace.path(), &session.session_id) - .await - .expect("session should restore"); - assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); + let context_messages = manager + .context_store + .get_context_messages(&session.session_id); + assert_eq!(context_messages.len(), 1); assert_eq!( - restored.last_user_dialog_agent_type.as_deref(), - Some("agentic") + context_messages[0].metadata.semantic_kind, + Some(MessageSemanticKind::ActualUserInput) ); + + let sanitized_snapshot = persistence_manager + .load_turn_context_snapshot(workspace.path(), &session.session_id, 0) + .await + .expect("snapshot 0 load should succeed") + .expect("snapshot 0 should still exist"); + assert_eq!(sanitized_snapshot.len(), 1); assert_eq!( - manager - .context_store - .get_context_messages(&session.session_id) - .len(), - 1 + sanitized_snapshot[0].metadata.semantic_kind, + Some(MessageSemanticKind::ActualUserInput) ); let metadata = persistence_manager .load_session_metadata(workspace.path(), &session.session_id) .await - .expect("metadata should load") + .expect("metadata load should succeed") .expect("metadata should exist"); - assert_eq!(metadata.turn_count, 1); + assert_eq!( + SessionManager::listing_baseline_rebuild_turn_index_from_metadata(Some(&metadata)), + Some(1) + ); } #[tokio::test] @@ -5250,6 +6098,163 @@ mod tests { ); } + #[tokio::test] + async fn skill_agent_baseline_override_snapshot_persists_across_session_restore() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Listing baseline".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should be created"); + let baseline = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "/skills/a".to_string(), + }], + ..Default::default() + }; + + manager + .remember_skill_agent_baseline_override_snapshot(&session.session_id, baseline.clone()) + .await; + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata load should succeed") + .expect("metadata should exist"); + assert_eq!(metadata.custom_metadata, None); + assert_eq!( + persistence_manager + .load_skill_agent_baseline_override_snapshot(workspace.path(), &session.session_id,) + .await + .expect("override snapshot load should succeed"), + Some(baseline.clone()) + ); + + let restored_manager = test_manager(persistence_manager); + restored_manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("session should restore"); + + assert_eq!( + restored_manager + .skill_agent_baseline_override_snapshot(&session.session_id) + .await, + Some(baseline) + ); + } + + #[tokio::test] + async fn seed_forked_skill_agent_listing_baselines_splits_prompt_and_diff_baselines() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager.clone()); + let parent = manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("parent session should create"); + let child = manager + .create_session( + "Child".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("child session should create"); + let prompt_baseline = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-parent-turn-0".to_string(), + description: "desc-0".to_string(), + location: "/skills/turn-0".to_string(), + }], + ..Default::default() + }; + let latest_baseline = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-parent-latest".to_string(), + description: "desc-latest".to_string(), + location: "/skills/latest".to_string(), + }], + ..Default::default() + }; + + manager + .remember_turn_skill_agent_snapshot(&parent.session_id, 0, prompt_baseline.clone()) + .await; + manager + .remember_turn_skill_agent_snapshot(&parent.session_id, 2, latest_baseline.clone()) + .await; + { + let mut parent_session = manager + .sessions + .get_mut(&parent.session_id) + .expect("parent session should remain in memory"); + parent_session.dialog_turn_ids = vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string(), + ]; + } + + manager + .seed_forked_skill_agent_listing_baselines(&parent.session_id, &child.session_id) + .await; + + assert_eq!( + manager + .skill_agent_baseline_override_snapshot(&child.session_id) + .await, + Some(prompt_baseline.clone()) + ); + assert_eq!( + manager + .turn_skill_agent_snapshot(&child.session_id, 0) + .await, + Some(latest_baseline.clone()) + ); + + let restored_manager = test_manager(persistence_manager); + restored_manager + .restore_session(workspace.path(), &child.session_id) + .await + .expect("child session should restore"); + assert_eq!( + restored_manager + .skill_agent_baseline_override_snapshot(&child.session_id) + .await, + Some(prompt_baseline) + ); + assert_eq!( + restored_manager + .turn_skill_agent_snapshot(&child.session_id, 0) + .await, + Some(latest_baseline) + ); + } + #[tokio::test] async fn prompt_cache_invalidation_removes_persisted_entries() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs new file mode 100644 index 0000000000..283154ddd7 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; + +use bitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, + SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, +}; + +use crate::agentic::core::SessionConfig; +use crate::service::remote_ssh::workspace_state::{ + resolve_workspace_session_identity, unresolved_remote_session_storage_dir, + LOCAL_WORKSPACE_SSH_HOST, +}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct CoreSessionStorePort; + +impl CoreSessionStorePort { + pub async fn resolve_storage_path_for_config( + config: &SessionConfig, + ) -> Option { + let workspace_path = config.workspace_path.as_ref()?; + let request = SessionStoragePathRequest { + workspace_path: PathBuf::from(workspace_path), + remote_connection_id: config.remote_connection_id.clone(), + remote_ssh_host: config.remote_ssh_host.clone(), + }; + Self::default() + .resolve_session_storage_path(request) + .await + .ok() + } +} + +impl RuntimeServicePort for CoreSessionStorePort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::SessionStore + } +} + +#[async_trait::async_trait] +impl SessionStorePort for CoreSessionStorePort { + async fn resolve_session_storage_path( + &self, + request: SessionStoragePathRequest, + ) -> PortResult { + let workspace_path = request.workspace_path.to_string_lossy().to_string(); + let identity = resolve_workspace_session_identity( + &workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .ok_or_else(|| { + PortError::new( + PortErrorKind::InvalidRequest, + "Session workspace_path is required", + ) + })?; + + let requested_workspace_path = request.workspace_path; + let (effective_storage_path, storage_kind, remote_ssh_host) = + if identity.hostname == LOCAL_WORKSPACE_SSH_HOST { + ( + PathBuf::from(identity.logical_workspace_path()), + SessionStorageKind::Local, + None, + ) + } else if identity.hostname == "_unresolved" { + ( + unresolved_remote_session_storage_dir( + identity.remote_connection_id.as_deref().unwrap_or_default(), + identity.logical_workspace_path(), + ), + SessionStorageKind::UnresolvedRemote, + None, + ) + } else { + ( + identity.session_storage_path(), + SessionStorageKind::Remote, + Some(identity.hostname.clone()), + ) + }; + + Ok(SessionStoragePathResolution::new( + requested_workspace_path, + effective_storage_path, + storage_kind, + identity.remote_connection_id, + remote_ssh_host, + )) + } +} diff --git a/src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs b/src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs new file mode 100644 index 0000000000..5342a5875f --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs @@ -0,0 +1,3 @@ +//! Compatibility facade for turn skill/agent snapshot state. + +pub use bitfun_agent_runtime::skill_agent_snapshot::TurnSkillAgentSnapshotStore; diff --git a/src/crates/core/src/agentic/side_question.rs b/src/crates/assembly/core/src/agentic/side_question.rs similarity index 91% rename from src/crates/core/src/agentic/side_question.rs rename to src/crates/assembly/core/src/agentic/side_question.rs index 1ac75636a8..8d29b27ea9 100644 --- a/src/crates/core/src/agentic/side_question.rs +++ b/src/crates/assembly/core/src/agentic/side_question.rs @@ -1,6 +1,6 @@ //! Shared `/btw` helpers and runtime-only request tracking. -use crate::agentic::core::PromptEnvelope; +use crate::agentic::core::{InternalReminderKind, Message}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -101,9 +101,12 @@ CRITICAL CONSTRAINTS: Simply answer the question with the information you have, and use tools only when needed."# } -pub fn build_btw_user_input(question: &str) -> String { - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder(btw_system_reminder()); - envelope.push_user_query(question.trim()); - envelope.render() +pub fn build_btw_user_input(question: &str) -> (String, Vec) { + ( + question.trim().to_string(), + vec![Message::internal_reminder( + InternalReminderKind::SideQuestion, + btw_system_reminder(), + )], + ) } diff --git a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs new file mode 100644 index 0000000000..05c3bec783 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs @@ -0,0 +1,221 @@ +use crate::agentic::agents::{ + get_agent_registry, PromptBuilder, SubagentListScope, SubagentQueryContext, + ToolListingSections, UserContextPolicy, +}; +use crate::agentic::tools::implementations::skills::{get_skill_registry, SkillInfo}; +use crate::agentic::tools::manifest_resolver::{resolve_tool_manifest, ResolvedToolManifest}; +use crate::agentic::tools::product_runtime::GetToolSpecTool; +use crate::agentic::tools::tool_context_runtime; +use crate::agentic::workspace::WorkspaceServices; +use crate::agentic::WorkspaceBinding; +pub use bitfun_agent_runtime::skill_agent_snapshot::{ + build_skill_agent_tool_listing_sections_from_snapshot, diff_skill_agent_snapshot, + render_full_agent_listing_body, render_full_skill_listing_body, AgentSnapshotEntry, + SkillAgentDiff, SkillSnapshotEntry, TurnSkillAgentSnapshot, TurnSkillAgentSnapshotStore, +}; + +#[derive(Debug, Clone)] +pub struct SkillAgentSnapshotResolution { + pub snapshot: TurnSkillAgentSnapshot, + pub tool_listing_sections: ToolListingSections, +} + +pub async fn resolve_skill_agent_snapshot( + agent_type: &str, + workspace: Option<&WorkspaceBinding>, + workspace_services: Option<&WorkspaceServices>, + enable_tools: bool, + context_vars: &std::collections::HashMap, +) -> SkillAgentSnapshotResolution { + if !enable_tools { + return SkillAgentSnapshotResolution { + snapshot: TurnSkillAgentSnapshot::default(), + tool_listing_sections: ToolListingSections::default(), + }; + } + + let agent_registry = get_agent_registry(); + if let Some(workspace) = workspace { + if !workspace.is_remote() { + agent_registry + .load_custom_subagents(workspace.root_path()) + .await; + } + } + + let tool_policy = agent_registry + .get_agent_tool_policy(agent_type, workspace.map(|binding| binding.root_path())) + .await; + + let tool_description_context = tool_context_runtime::build_tool_description_context( + agent_type, + workspace, + workspace_services, + true, + context_vars, + ); + let manifest = resolve_tool_manifest( + &tool_policy.allowed_tools, + &tool_policy.exposure_overrides, + &tool_description_context, + ) + .await; + + let snapshot = + build_skill_agent_snapshot(workspace, workspace_services, agent_type, &manifest).await; + let tool_listing_sections = build_tool_listing_sections(&manifest, &snapshot); + + SkillAgentSnapshotResolution { + snapshot, + tool_listing_sections, + } +} + +async fn build_skill_agent_snapshot( + workspace: Option<&WorkspaceBinding>, + workspace_services: Option<&WorkspaceServices>, + agent_type: &str, + manifest: &ResolvedToolManifest, +) -> TurnSkillAgentSnapshot { + let has_tool = |tool_name: &str| { + manifest + .tool_definitions + .iter() + .any(|definition| definition.name == tool_name) + }; + + let mut snapshot = TurnSkillAgentSnapshot::default(); + + if has_tool("Skill") { + snapshot.skills = load_skill_entries(workspace, workspace_services, Some(agent_type)).await; + } + + if has_tool("Task") { + snapshot.subagents = load_subagent_entries(workspace, Some(agent_type)).await; + } + + snapshot +} + +fn build_tool_listing_sections( + manifest: &ResolvedToolManifest, + snapshot: &TurnSkillAgentSnapshot, +) -> ToolListingSections { + let has_tool = |tool_name: &str| { + manifest + .tool_definitions + .iter() + .any(|definition| definition.name == tool_name) + }; + + ToolListingSections { + skill_listing: has_tool("Skill") + .then(|| render_full_skill_listing_body(&snapshot.skills)) + .filter(|body| !body.is_empty()), + agent_listing: has_tool("Task") + .then(|| render_full_agent_listing_body(&snapshot.subagents)) + .filter(|body| !body.is_empty()), + collapsed_tool_listing: if has_tool("GetToolSpec") { + GetToolSpecTool::build_collapsed_tools_context_section( + &manifest.collapsed_tool_summaries, + ) + } else { + None + }, + } +} + +async fn load_skill_entries( + workspace: Option<&WorkspaceBinding>, + workspace_services: Option<&WorkspaceServices>, + agent_type: Option<&str>, +) -> Vec { + let registry = get_skill_registry(); + let skills = match workspace { + Some(workspace) if workspace.is_remote() => { + if let Some(services) = workspace_services { + registry + .get_resolved_skills_for_remote_workspace( + services.fs.as_ref(), + &workspace.root_path_string(), + agent_type, + ) + .await + } else { + Vec::new() + } + } + Some(workspace) => { + registry + .get_resolved_skills_for_workspace(Some(workspace.root_path()), agent_type) + .await + } + None => { + registry + .get_resolved_skills_for_workspace(None, agent_type) + .await + } + }; + + skills + .into_iter() + .map(skill_snapshot_entry_from_skill_info) + .collect() +} + +fn skill_snapshot_entry_from_skill_info(skill: SkillInfo) -> SkillSnapshotEntry { + SkillSnapshotEntry { + name: skill.name, + description: skill.description, + location: skill.path, + } +} + +async fn load_subagent_entries( + workspace: Option<&WorkspaceBinding>, + agent_type: Option<&str>, +) -> Vec { + let registry = get_agent_registry(); + let workspace_root = workspace + .filter(|workspace| !workspace.is_remote()) + .map(|workspace| workspace.root_path()); + let agents = registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: agent_type, + workspace_root, + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + }) + .await; + + agents + .into_iter() + .map(|agent| AgentSnapshotEntry { + id: agent.id, + description: agent.description, + default_tools: agent.default_tools, + }) + .collect() +} + +pub async fn build_embedded_user_context_reminder( + workspace: Option<&WorkspaceBinding>, + workspace_id: Option<&str>, + session_id: &str, + user_context_policy: &UserContextPolicy, +) -> Option { + let workspace = workspace?; + let context = crate::agentic::agents::build_prompt_context_for_workspace( + workspace, + workspace_id, + session_id, + None, + None, + ToolListingSections::default(), + Default::default(), + ) + .await?; + PromptBuilder::new(context) + .build_user_context_reminder(user_context_policy) + .await +} diff --git a/src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs b/src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs new file mode 100644 index 0000000000..70f495aacb --- /dev/null +++ b/src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs @@ -0,0 +1,7 @@ +//! Generic subagent runtime primitives. +//! +//! This module is intentionally smaller than a scheduler. It should not import +//! Deep Review modules or define Deep Review product policy. + +#[allow(unused_imports)] +pub(crate) use bitfun_runtime_ports::{DelegationPolicy, SubagentContextMode}; diff --git a/src/crates/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs similarity index 95% rename from src/crates/core/src/agentic/system.rs rename to src/crates/assembly/core/src/agentic/system.rs index 1d5ab16773..b9a264ed9a 100644 --- a/src/crates/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -8,6 +8,7 @@ use log::info; use crate::agentic::coordination; use crate::agentic::events; use crate::agentic::execution; +use crate::agentic::goal_mode::ThreadGoalTokenSubscriber; use crate::agentic::persistence; use crate::agentic::session; use crate::agentic::tools; @@ -37,6 +38,10 @@ pub async fn init_agentic_system() -> Result { let token_usage_service = Arc::new(TokenUsageService::new(path_manager.clone()).await?); let token_usage_subscriber = Arc::new(TokenUsageSubscriber::new(token_usage_service.clone())); event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); + event_router.subscribe_internal( + "thread_goal_tokens".to_string(), + Arc::new(ThreadGoalTokenSubscriber), + ); let context_store = Arc::new(session::SessionContextStore::new()); let context_compressor = Arc::new(session::ContextCompressor::new(Default::default())); diff --git a/src/crates/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md similarity index 94% rename from src/crates/core/src/agentic/tools/agent-tool-exposure.md rename to src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index 2a44b5949d..7fc33d1c0c 100644 --- a/src/crates/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -16,7 +16,10 @@ Notes: | `Write` | Expanded | None | - | | `Edit` | Expanded | None | - | | `Delete` | Expanded | None | - | -| `Bash` | Expanded | None | - | +| `ExecCommand` | Expanded | None | - | +| `WriteStdin` | Expanded | None | - | +| `ExecControl` | Expanded | None | - | +| `GetTime` | Expanded | None | - | | `Task` | Expanded | None | - | | `Skill` | Expanded | None | - | | `AskUserQuestion` | Expanded | None | - | @@ -26,7 +29,6 @@ Notes: | `CreatePlan` | Collapsed | None | - | | `GetFileDiff` | Collapsed | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Expanded | | `Log` | Collapsed | None | - | -| `TerminalControl` | Collapsed | None | - | | `SessionControl` | Collapsed | None | - | | `SessionMessage` | Collapsed | None | - | | `SessionHistory` | Collapsed | None | - | diff --git a/src/crates/core/src/agentic/tools/browser_control/actions.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs similarity index 57% rename from src/crates/core/src/agentic/tools/browser_control/actions.rs rename to src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs index 5963ee21ce..05cfca10ea 100644 --- a/src/crates/core/src/agentic/tools/browser_control/actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs @@ -3,6 +3,7 @@ use super::cdp_client::{CdpClient, CdpEvent}; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::{json, Value}; +use std::collections::BTreeMap; use tokio::sync::broadcast; /// Result of waiting for a CDP `Page.lifecycleEvent`. @@ -76,6 +77,14 @@ impl<'a> BrowserActions<'a> { Self { client } } + pub async fn enable_observers(&self) -> BitFunResult { + let _ = self.client.send("Page.enable", None).await; + let _ = self.client.send("Runtime.enable", None).await; + let _ = self.client.send("Network.enable", None).await; + let _ = self.client.send("DOM.enable", None).await; + Ok(json!({ "success": true, "action": "enable_observers" })) + } + // ── Navigation ───────────────────────────────────────────────────── pub async fn navigate(&self, url: &str) -> BitFunResult { @@ -137,6 +146,23 @@ impl<'a> BrowserActions<'a> { Ok(body) } + pub async fn back(&self) -> BitFunResult { + self.evaluate("history.back(); undefined").await?; + Ok(json!({ "success": true, "action": "back" })) + } + + pub async fn forward(&self) -> BitFunResult { + self.evaluate("history.forward(); undefined").await?; + Ok(json!({ "success": true, "action": "forward" })) + } + + pub async fn reload(&self, ignore_cache: bool) -> BitFunResult { + self.client + .send("Page.reload", Some(json!({ "ignoreCache": ignore_cache }))) + .await?; + Ok(json!({ "success": true, "action": "reload", "ignore_cache": ignore_cache })) + } + pub async fn get_url(&self) -> BitFunResult { let result = self.evaluate("window.location.href").await?; Ok(result @@ -282,9 +308,86 @@ impl<'a> BrowserActions<'a> { } } } + Self::attach_snapshot_text(&mut parsed); Ok(parsed) } + fn attach_snapshot_text(parsed: &mut Value) { + let Some(elements) = parsed.get("elements").and_then(|v| v.as_array()) else { + return; + }; + let mut lines = Vec::::new(); + let mut refs = BTreeMap::::new(); + for element in elements { + let reference = element.get("ref").and_then(|v| v.as_str()).unwrap_or(""); + let tag = element + .get("tag") + .and_then(|v| v.as_str()) + .unwrap_or("element"); + let role = element.get("role").and_then(|v| v.as_str()).unwrap_or(""); + let text = element + .get("ariaLabel") + .or_else(|| element.get("placeholder")) + .or_else(|| element.get("text")) + .or_else(|| element.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let type_text = element.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let id = element.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let frame_path = element + .get("frame_path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let scope = element + .get("scope") + .and_then(|v| v.as_str()) + .unwrap_or("document"); + let mut label = if role.is_empty() { + tag.to_string() + } else { + role.to_string() + }; + if !type_text.is_empty() { + label.push(':'); + label.push_str(type_text); + } + let mut line = if reference.is_empty() { + format!("- {}", label) + } else { + format!("- {} [{}]", label, reference) + }; + if !text.is_empty() { + let clipped = if text.chars().count() > 80 { + format!("{}...", text.chars().take(77).collect::()) + } else { + text.to_string() + }; + line.push(' '); + line.push_str( + &serde_json::to_string(&clipped).unwrap_or_else(|_| "\"\"".to_string()), + ); + } + if !id.is_empty() { + line.push_str(&format!(" id={}", id)); + } + if scope != "document" || !frame_path.is_empty() { + line.push_str(&format!(" scope={}", scope)); + if !frame_path.is_empty() { + line.push_str(&format!(" frame={}", frame_path)); + } + } + lines.push(line); + if !reference.is_empty() { + refs.insert(reference.to_string(), element.clone()); + } + } + if let Some(obj) = parsed.as_object_mut() { + obj.insert("snapshot".to_string(), json!(lines.join("\n"))); + obj.insert("refs".to_string(), json!(refs)); + } + } + /// Resolve `backend_node_id` for every snapshot element by walking the /// DOM through CDP. Mutates `parsed["elements"][i]["backend_node_id"]` /// in place. Returns `Err` if the document tree could not be fetched. @@ -361,17 +464,37 @@ impl<'a> BrowserActions<'a> { /// genuinely empty. The lookup walks shadow roots / same-origin /// iframes, matching the rest of the browser action surface. pub async fn get_text(&self, selector: &str) -> BitFunResult> { + self.get_attribute(selector, "text") + .await + .map(|v| v.map(|v| v.as_str().unwrap_or("").to_string())) + } + + pub async fn get_attribute( + &self, + selector: &str, + attribute: &str, + ) -> BitFunResult> { let resolve = Self::resolve_element_js(selector); + let getter = match attribute { + "text" => "(el.textContent || '').trim().slice(0, 5000)".to_string(), + "value" => "('value' in el ? el.value : '')".to_string(), + "html" => "el.outerHTML".to_string(), + other => format!( + "el.getAttribute('{}')", + other.replace('\\', "\\\\").replace('\'', "\\'") + ), + }; let js = format!( r#"(function(){{ try {{ {resolve} - return JSON.stringify({{ found: true, text: (el.textContent || '').trim().slice(0, 5000) }}); + return JSON.stringify({{ found: true, value: {getter} }}); }} catch (e) {{ return JSON.stringify({{ found: false, error: String(e && e.message || e) }}); }} }})()"#, - resolve = resolve + resolve = resolve, + getter = getter, ); let result = self.evaluate(&js).await?; let raw = result @@ -385,13 +508,7 @@ impl<'a> BrowserActions<'a> { .and_then(|v| v.as_bool()) .unwrap_or(false) { - Ok(Some( - parsed - .get("text") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - )) + Ok(Some(parsed.get("value").cloned().unwrap_or(Value::Null))) } else { Ok(None) } @@ -401,20 +518,7 @@ impl<'a> BrowserActions<'a> { /// Click an element by CSS selector or by `@eN` ref. pub async fn click(&self, selector: &str) -> BitFunResult { - let js = Self::resolve_element_js(selector); - let center_js = format!( - r#"(function(){{ {} const rect = el.getBoundingClientRect(); return JSON.stringify({{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }}); }})()"#, - js - ); - let result = self.evaluate(¢er_js).await?; - let coords_str = result - .get("result") - .and_then(|r| r.get("value")) - .and_then(|v| v.as_str()) - .unwrap_or("{}"); - let coords: Value = serde_json::from_str(coords_str).unwrap_or(json!({})); - let x = coords.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let y = coords.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); + let (x, y) = self.element_center(selector).await?; self.client .send( @@ -445,6 +549,44 @@ impl<'a> BrowserActions<'a> { })) } + async fn element_center(&self, selector: &str) -> BitFunResult<(f64, f64)> { + let js = Self::resolve_element_js(selector); + let center_js = format!( + r#"(function(){{ {} el.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }}); const rect = el.getBoundingClientRect(); return JSON.stringify({{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }}); }})()"#, + js + ); + let result = self.evaluate(¢er_js).await?; + let coords_str = result + .get("result") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let coords: Value = serde_json::from_str(coords_str).unwrap_or(json!({})); + let x = coords.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = coords.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); + Ok((x, y)) + } + + pub async fn hover(&self, selector: &str) -> BitFunResult { + let (x, y) = self.element_center(selector).await?; + self.client + .send( + "Input.dispatchMouseEvent", + Some(json!({ + "type": "mouseMoved", + "x": x, "y": y, + "button": "none" + })), + ) + .await?; + Ok(json!({ + "success": true, + "action": "hover", + "selector": selector, + "coordinates": { "x": x, "y": y } + })) + } + /// Fill (clear + type) a text input identified by selector or `@eN` ref. pub async fn fill(&self, selector: &str, value: &str) -> BitFunResult { let js = Self::resolve_element_js(selector); @@ -473,6 +615,34 @@ impl<'a> BrowserActions<'a> { Ok(json!({ "success": true, "action": "type", "text": text })) } + pub async fn set_checked(&self, selector: &str, checked: bool) -> BitFunResult { + let js = Self::resolve_element_js(selector); + let script = format!( + r#"(function(){{ + {js} + el.checked = {checked}; + el.dispatchEvent(new Event('input', {{ bubbles: true }})); + el.dispatchEvent(new Event('change', {{ bubbles: true }})); + return JSON.stringify({{ success: true, checked: !!el.checked }}); + }})()"#, + js = js, + checked = if checked { "true" } else { "false" } + ); + let result = self.evaluate(&script).await?; + let text = result + .get("result") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let parsed: Value = serde_json::from_str(text).unwrap_or(json!({})); + Ok(json!({ + "success": parsed.get("success").and_then(|v| v.as_bool()).unwrap_or(true), + "action": if checked { "check" } else { "uncheck" }, + "selector": selector, + "checked": parsed.get("checked").cloned().unwrap_or(json!(checked)), + })) + } + /// Select a dropdown option by visible text. pub async fn select(&self, selector: &str, option_text: &str) -> BitFunResult { let js = format!( @@ -510,6 +680,18 @@ impl<'a> BrowserActions<'a> { })), ) .await?; + if key.chars().count() == 1 { + self.client + .send( + "Input.dispatchKeyEvent", + Some(json!({ + "type": "char", + "key": key, + "text": key, + })), + ) + .await?; + } self.client .send( "Input.dispatchKeyEvent", @@ -525,9 +707,11 @@ impl<'a> BrowserActions<'a> { /// Scroll the page. pub async fn scroll(&self, direction: &str, amount: Option) -> BitFunResult { let px = amount.unwrap_or(500); - let delta_y = match direction { - "up" => -px, - "down" => px, + let (delta_x, delta_y) = match direction { + "up" => (0, -px), + "down" => (0, px), + "left" => (-px, 0), + "right" => (px, 0), "top" => { self.evaluate("window.scrollTo(0, 0)").await?; return Ok(json!({ "success": true, "action": "scroll", "direction": "top" })); @@ -537,7 +721,7 @@ impl<'a> BrowserActions<'a> { .await?; return Ok(json!({ "success": true, "action": "scroll", "direction": "bottom" })); } - _ => px, + _ => (0, px), }; self.client .send( @@ -545,13 +729,53 @@ impl<'a> BrowserActions<'a> { Some(json!({ "type": "mouseWheel", "x": 400, "y": 300, - "deltaX": 0, "deltaY": delta_y, + "deltaX": delta_x, "deltaY": delta_y, })), ) .await?; Ok(json!({ "success": true, "action": "scroll", "direction": direction, "amount": px })) } + pub async fn auto_scroll( + &self, + direction: &str, + max_scrolls: u64, + delay_ms: u64, + ) -> BitFunResult { + let max_scrolls = max_scrolls.clamp(1, 200); + let delay_ms = delay_ms.clamp(0, 5_000); + let delta = if direction == "up" { + "-window.innerHeight" + } else { + "window.innerHeight" + }; + let boundary = if direction == "up" { + "window.scrollY === 0" + } else { + "window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2" + }; + let script = format!( + r#"(async () => {{ + let scrolls = 0; + while (scrolls < {max_scrolls}) {{ + const before = window.scrollY; + window.scrollBy(0, {delta}); + await new Promise(resolve => setTimeout(resolve, {delay_ms})); + scrolls++; + if ({boundary} || window.scrollY === before) break; + }} + return {{ scrolls, scrollY: window.scrollY, height: document.documentElement.scrollHeight }}; + }})()"# + ); + let result = self.evaluate(&script).await?; + Ok(json!({ + "success": true, + "action": "auto_scroll", + "direction": direction, + "result": result.get("result").and_then(|r| r.get("value")).cloned().unwrap_or(Value::Null), + })) + } + /// Wait for a duration or a condition. pub async fn wait( &self, @@ -631,20 +855,92 @@ impl<'a> BrowserActions<'a> { /// Take a screenshot of the current page, returns base64 JPEG data. pub async fn screenshot(&self) -> BitFunResult { + self.screenshot_with_options("jpeg", Some(80), true).await + } + + pub async fn screenshot_with_options( + &self, + format: &str, + quality: Option, + from_surface: bool, + ) -> BitFunResult { + self.screenshot_with_options_ext(format, quality, from_surface, false) + .await + } + + pub async fn screenshot_with_options_ext( + &self, + format: &str, + quality: Option, + from_surface: bool, + full_page: bool, + ) -> BitFunResult { + let normalized = if format.eq_ignore_ascii_case("png") { + "png" + } else { + "jpeg" + }; + + if full_page { + if let Ok(metrics) = self.client.send("Page.getLayoutMetrics", None).await { + let size = metrics + .get("cssContentSize") + .or_else(|| metrics.get("contentSize")); + if let Some(size) = size { + let width = size + .get("width") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + .ceil() as u64; + let height = size + .get("height") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + .ceil() as u64; + if width > 0 && height > 0 { + let _ = self + .client + .send( + "Emulation.setDeviceMetricsOverride", + Some(json!({ + "mobile": false, + "width": width, + "height": height, + "deviceScaleFactor": 1, + })), + ) + .await; + } + } + } + } + + let mut params = json!({ + "format": normalized, + "fromSurface": from_surface, + }); + if normalized == "jpeg" { + params["quality"] = json!(quality.unwrap_or(80).min(100)); + } let result = self .client - .send( - "Page.captureScreenshot", - Some(json!({ "format": "jpeg", "quality": 80 })), - ) + .send("Page.captureScreenshot", Some(params)) .await?; + if full_page { + let _ = self + .client + .send("Emulation.clearDeviceMetricsOverride", None) + .await; + } let data = result.get("data").and_then(|v| v.as_str()).unwrap_or(""); Ok(json!({ "success": true, "action": "screenshot", - "format": "jpeg", + "format": normalized, + "full_page": full_page, "data_length": data.len(), "base64_data": data, + "data_url": format!("data:image/{};base64,{}", normalized, data), })) } @@ -652,15 +948,237 @@ impl<'a> BrowserActions<'a> { /// Evaluate a JavaScript expression in the page context. pub async fn evaluate(&self, expression: &str) -> BitFunResult { + self.evaluate_with_options(expression, true, true).await + } + + pub async fn evaluate_with_options( + &self, + expression: &str, + await_promise: bool, + return_by_value: bool, + ) -> BitFunResult { + let mut last_error: Option = None; + for attempt in 0..2 { + let result = self + .client + .send( + "Runtime.evaluate", + Some(json!({ + "expression": expression, + "returnByValue": return_by_value, + "awaitPromise": await_promise, + })), + ) + .await; + match result { + Ok(value) => { + if let Some(details) = value.get("exceptionDetails") { + let message = details + .get("exception") + .and_then(|e| e.get("description")) + .and_then(|v| v.as_str()) + .or_else(|| details.get("text").and_then(|v| v.as_str())) + .unwrap_or("Runtime.evaluate failed"); + return Err(BitFunError::tool(format!("JS error: {}", message))); + } + return Ok(value); + } + Err(err) => { + let message = err.to_string(); + let retryable = message.contains("Inspected target navigated") + || message.contains("Target closed") + || message.contains("Cannot find context with specified id"); + last_error = Some(err); + if retryable && attempt == 0 { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + continue; + } + break; + } + } + } + Err(last_error.unwrap_or_else(|| BitFunError::tool("Runtime.evaluate failed".to_string()))) + } + + pub async fn get_cookies(&self, urls: Option>) -> BitFunResult { + let params = urls + .filter(|items| !items.is_empty()) + .map(|urls| json!({ "urls": urls })) + .unwrap_or_else(|| json!({})); + let result = self.client.send("Network.getCookies", Some(params)).await?; + Ok(json!({ + "success": true, + "action": "cookies", + "cookies": result.get("cookies").cloned().unwrap_or_else(|| json!([])), + })) + } + + pub async fn set_cookies(&self, cookies: &[Value]) -> BitFunResult { + let mut set = 0usize; + let mut errors = Vec::::new(); + for cookie in cookies { + match self + .client + .send("Network.setCookie", Some(cookie.clone())) + .await + { + Ok(result) + if result + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(true) => + { + set += 1; + } + Ok(result) => errors.push(json!({ "cookie": cookie, "result": result })), + Err(err) => errors.push(json!({ "cookie": cookie, "error": err.to_string() })), + } + } + Ok(json!({ + "success": errors.is_empty(), + "action": "set_cookies", + "set": set, + "errors": errors, + })) + } + + pub async fn set_file_input_files( + &self, + selector: Option<&str>, + files: &[String], + ) -> BitFunResult { + if files.is_empty() { + return Err(BitFunError::tool( + "set_file_input_files requires non-empty 'files'".to_string(), + )); + } + let query = selector.unwrap_or("input[type=\"file\"]"); + let css_selector = if query.starts_with("@e") { + format!(r#"[data-cdp-ref="{}"]"#, query) + } else { + query.to_string() + }; + let document = self.client.send("DOM.getDocument", None).await?; + let root_id = document + .get("root") + .and_then(|r| r.get("nodeId")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| BitFunError::tool("DOM.getDocument: missing root nodeId".to_string()))?; + let node = self + .client + .send( + "DOM.querySelector", + Some(json!({ "nodeId": root_id, "selector": css_selector })), + ) + .await?; + let node_id = node.get("nodeId").and_then(|v| v.as_i64()).unwrap_or(0); + if node_id == 0 { + return Err(BitFunError::tool(format!( + "No file input found for selector: {}", + query + ))); + } self.client .send( - "Runtime.evaluate", - Some(json!({ - "expression": expression, - "returnByValue": true, - })), + "DOM.setFileInputFiles", + Some(json!({ "nodeId": node_id, "files": files })), ) - .await + .await?; + Ok(json!({ + "success": true, + "action": "set_file_input_files", + "selector": query, + "count": files.len(), + })) + } + + pub async fn fetch( + &self, + url: &str, + method: &str, + headers: Value, + body: Option<&str>, + ) -> BitFunResult { + let script = format!( + r#"(async () => {{ + try {{ + const init = {{ + method: {method}, + credentials: 'include', + headers: {headers} + }}; + const body = {body}; + if (body !== null && init.method !== 'GET' && init.method !== 'HEAD') init.body = body; + const resp = await fetch({url}, init); + const contentType = resp.headers.get('content-type') || ''; + let responseBody; + if (contentType.includes('application/json') && resp.status !== 204) {{ + try {{ responseBody = await resp.json(); }} catch (_) {{ responseBody = await resp.text(); }} + }} else {{ + responseBody = await resp.text(); + }} + return JSON.stringify({{ + ok: resp.ok, + status: resp.status, + status_text: resp.statusText, + content_type: contentType, + url: resp.url, + body: responseBody + }}); + }} catch (e) {{ + return JSON.stringify({{ error: String(e && e.message || e) }}); + }} + }})()"#, + url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()), + method = serde_json::to_string(&method.to_uppercase()) + .unwrap_or_else(|_| "\"GET\"".to_string()), + headers = headers, + body = body + .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "null".to_string()), + ); + let result = self.evaluate(&script).await?; + let raw = result + .get("result") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let parsed: Value = serde_json::from_str(raw).unwrap_or(json!({})); + Ok(json!({ "success": parsed.get("error").is_none(), "action": "fetch", "result": parsed })) + } + + pub async fn read_article(&self) -> BitFunResult { + let script = r#" + (function() { + function textOf(node) { + return (node && node.textContent || '').replace(/\s+/g, ' ').trim(); + } + const article = document.querySelector('article') || document.querySelector('main') || document.body; + const title = document.querySelector('meta[property="og:title"]')?.content || document.title || ''; + const description = document.querySelector('meta[name="description"]')?.content || document.querySelector('meta[property="og:description"]')?.content || ''; + const siteName = document.querySelector('meta[property="og:site_name"]')?.content || location.hostname; + const publishedTime = document.querySelector('meta[property="article:published_time"]')?.content || document.querySelector('time[datetime]')?.getAttribute('datetime') || null; + const textContent = textOf(article); + const headings = Array.from(article.querySelectorAll('h1,h2,h3')).slice(0, 20).map(h => ({ level: h.tagName.toLowerCase(), text: textOf(h) })).filter(h => h.text); + return { + title, + description, + siteName, + publishedTime, + url: location.href, + length: textContent.length, + excerpt: textContent.slice(0, 500), + textContent, + headings, + }; + })() + "#; + let result = self.evaluate(script).await?; + Ok(json!({ + "success": true, + "action": "read_article", + "article": result.get("result").and_then(|r| r.get("value")).cloned().unwrap_or(Value::Null), + })) } // ── Close ────────────────────────────────────────────────────────── diff --git a/src/crates/core/src/agentic/tools/browser_control/browser_launcher.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs similarity index 100% rename from src/crates/core/src/agentic/tools/browser_control/browser_launcher.rs rename to src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs diff --git a/src/crates/core/src/agentic/tools/browser_control/cdp_client.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs similarity index 91% rename from src/crates/core/src/agentic/tools/browser_control/cdp_client.rs rename to src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs index e7d17ab328..f116cc8267 100644 --- a/src/crates/core/src/agentic/tools/browser_control/cdp_client.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs @@ -85,6 +85,28 @@ impl CdpClient { Ok(pages) } + /// Create a new page/tab on the given debug port. + pub async fn create_page(port: u16, url: Option<&str>) -> BitFunResult { + let endpoint = if let Some(url) = url { + let encoded = url.replace(' ', "%20"); + format!("http://127.0.0.1:{}/json/new?{}", port, encoded) + } else { + format!("http://127.0.0.1:{}/json/new", port) + }; + let resp = reqwest::Client::new() + .put(&endpoint) + .send() + .await + .map_err(|e| { + BitFunError::tool(format!("Cannot create CDP page on port {}: {}", port, e)) + })?; + let page: CdpPageInfo = resp + .json() + .await + .map_err(|e| BitFunError::tool(format!("Invalid CDP new page response: {}", e)))?; + Ok(page) + } + /// Connect to a specific page by its WebSocket debugger URL. pub async fn connect(ws_url: &str) -> BitFunResult { info!("CDP connecting to {}", ws_url); diff --git a/src/crates/core/src/agentic/tools/browser_control/mod.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs similarity index 93% rename from src/crates/core/src/agentic/tools/browser_control/mod.rs rename to src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs index ae49d36f39..50369bca13 100644 --- a/src/crates/core/src/agentic/tools/browser_control/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs @@ -13,4 +13,4 @@ pub mod session_registry; pub use actions::BrowserActions; pub use browser_launcher::BrowserLauncher; pub use cdp_client::CdpClient; -pub use session_registry::{BrowserSession, BrowserSessionRegistry}; +pub use session_registry::{BrowserSession, BrowserSessionRegistry, BrowserSessionState, DialogHandler}; diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/session_registry.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/session_registry.rs new file mode 100644 index 0000000000..a70f0a7a0e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/session_registry.rs @@ -0,0 +1,604 @@ +//! Browser session registry — addresses CDP pages by stable session id and +//! removes the previous "single global slot" footgun. +//! +//! ## Why +//! +//! The Phase-0 ControlHub kept exactly **one** `Option` in a +//! `OnceLock>`. Every `connect` / `switch_page` clobbered the +//! slot, and every concurrent action raced on it. A second user task that +//! switched to a different tab would silently steal the connection from +//! the first task and break its in-flight `wait` / lifecycle subscription. +//! +//! ## Model +//! +//! - Each connected page is a `BrowserSession` keyed by `session_id` (the +//! CDP page id, which is stable for the page's lifetime). +//! - The registry tracks an optional **default** session for backward +//! compatibility with callers that omit `session_id`. +//! - All sessions are reachable via `Arc` so concurrent actions +//! on the *same* page share one WebSocket while sessions on *different* +//! pages stay isolated. +//! +//! ## Lifecycle +//! +//! - `register(session_id, client)` inserts/replaces, spawns the event +//! listener task, and bumps the default. +//! - `set_default(session_id)` is called by `switch_page`. +//! - `get(session_id)` resolves a specific id or falls back to the default. +//! - `remove(session_id)` is called by `close` or when CDP disconnects. +//! +//! ## Event Recording +//! +//! Each session records network (500 cap), console (200 cap), errors (200 cap), +//! and optional CDP trace (1000 cap). Queries support filter, since, and limit. + +use crate::agentic::tools::browser_control::cdp_client::{CdpClient, CdpEvent}; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +const NETWORK_CAPACITY: usize = 500; +const CONSOLE_CAPACITY: usize = 200; +const ERROR_CAPACITY: usize = 200; +const TRACE_CAPACITY: usize = 1000; + +#[derive(Clone, Debug)] +pub struct DialogHandler { + pub accept: bool, + pub prompt_text: Option, +} + +#[derive(Default)] +pub struct BrowserSessionState { + next_seq: AtomicU64, + last_action_seq: AtomicU64, + trace_recording: AtomicBool, + trace_events: Mutex>, + network_events: Mutex>, + console_events: Mutex>, + js_errors: Mutex>, + dialog_handler: Mutex>, + active_frame_id: Mutex>, +} + +impl BrowserSessionState { + pub fn new() -> Self { + Self::default() + } + + fn next_seq(&self) -> u64 { + self.next_seq.fetch_add(1, Ordering::SeqCst) + 1 + } + + pub fn record_action(&self) -> u64 { + let seq = self.next_seq(); + self.last_action_seq.store(seq, Ordering::SeqCst); + seq + } + + fn since_threshold(&self, since: Option<&str>) -> Option { + match since { + Some("last_action") => Some(self.last_action_seq.load(Ordering::SeqCst)), + Some(raw) => raw.parse::().ok(), + None => None, + } + } + + async fn push_capped(queue: &Mutex>, value: Value, capacity: usize) { + let mut guard = queue.lock().await; + if guard.len() >= capacity { + guard.pop_front(); + } + guard.push_back(value); + } + + pub async fn record_event(&self, event: CdpEvent) { + let seq = self.next_seq(); + if self.trace_recording.load(Ordering::SeqCst) { + Self::push_capped( + &self.trace_events, + json!({ + "seq": seq, + "method": event.method, + "params": event.params, + "timestamp_ms": chrono::Utc::now().timestamp_millis(), + }), + TRACE_CAPACITY, + ) + .await; + } + match event.method.as_str() { + "Network.requestWillBeSent" => { + let request = event + .params + .get("request") + .cloned() + .unwrap_or_else(|| json!({})); + Self::push_capped( + &self.network_events, + json!({ + "seq": seq, + "event": "request", + "request_id": event.params.get("requestId"), + "loader_id": event.params.get("loaderId"), + "type": event.params.get("type"), + "timestamp": event.params.get("timestamp"), + "url": request.get("url"), + "method": request.get("method"), + "headers": request.get("headers"), + "post_data": request.get("postData"), + }), + NETWORK_CAPACITY, + ) + .await; + } + "Network.responseReceived" => { + let response = event + .params + .get("response") + .cloned() + .unwrap_or_else(|| json!({})); + Self::push_capped( + &self.network_events, + json!({ + "seq": seq, + "event": "response", + "request_id": event.params.get("requestId"), + "type": event.params.get("type"), + "timestamp": event.params.get("timestamp"), + "url": response.get("url"), + "status": response.get("status"), + "status_text": response.get("statusText"), + "mime_type": response.get("mimeType"), + "headers": response.get("headers"), + }), + NETWORK_CAPACITY, + ) + .await; + } + "Network.loadingFailed" => { + Self::push_capped( + &self.network_events, + json!({ + "seq": seq, + "event": "failed", + "request_id": event.params.get("requestId"), + "timestamp": event.params.get("timestamp"), + "error_text": event.params.get("errorText"), + "canceled": event.params.get("canceled"), + }), + NETWORK_CAPACITY, + ) + .await; + } + "Runtime.consoleAPICalled" => { + Self::push_capped( + &self.console_events, + json!({ + "seq": seq, + "type": event.params.get("type"), + "timestamp": event.params.get("timestamp"), + "args": event.params.get("args"), + "execution_context_id": event.params.get("executionContextId"), + "stack_trace": event.params.get("stackTrace"), + }), + CONSOLE_CAPACITY, + ) + .await; + } + "Runtime.exceptionThrown" => { + Self::push_capped( + &self.js_errors, + json!({ + "seq": seq, + "timestamp": event.params.get("timestamp"), + "exception_details": event.params.get("exceptionDetails"), + }), + ERROR_CAPACITY, + ) + .await; + } + _ => {} + } + } + + pub async fn query_network( + &self, + filter: Option<&str>, + method: Option<&str>, + status: Option<&str>, + since: Option<&str>, + limit: usize, + ) -> Vec { + let threshold = self.since_threshold(since); + Self::query(&self.network_events, filter, threshold, limit, |item| { + if let Some(method) = method { + let wanted = method.to_uppercase(); + let actual = item + .get("method") + .and_then(|v| v.as_str()) + .map(str::to_uppercase); + if actual.as_deref() != Some(wanted.as_str()) { + return false; + } + } + if let Some(status_filter) = status { + let status_value = item.get("status").and_then(|v| v.as_u64()); + let matched = match status_filter { + "4xx" => status_value + .map(|s| (400..500).contains(&s)) + .unwrap_or(false), + "5xx" => status_value + .map(|s| (500..600).contains(&s)) + .unwrap_or(false), + raw => raw + .parse::() + .ok() + .zip(status_value) + .map(|(wanted, actual)| wanted == actual) + .unwrap_or(false), + }; + if !matched { + return false; + } + } + true + }) + .await + } + + pub async fn query_network_requests( + &self, + filter: Option<&str>, + method: Option<&str>, + status: Option<&str>, + since: Option<&str>, + limit: usize, + ) -> Vec { + let events = self + .query_network(filter, method, status, since, usize::MAX) + .await; + let mut order = Vec::::new(); + let mut by_id = HashMap::::new(); + for event in events.into_iter().rev() { + let Some(request_id) = event + .get("request_id") + .and_then(|v| v.as_str()) + .map(str::to_string) + else { + continue; + }; + let entry = by_id.entry(request_id.clone()).or_insert_with(|| { + order.push(request_id.clone()); + json!({ "request_id": request_id }) + }); + if let Some(obj) = entry.as_object_mut() { + if let Some(seq) = event.get("seq") { + obj.insert("seq".to_string(), seq.clone()); + } + match event.get("event").and_then(|v| v.as_str()) { + Some("request") => { + for key in ["url", "method", "type", "timestamp", "headers", "post_data"] { + if let Some(value) = event.get(key) { + obj.insert(key.to_string(), value.clone()); + } + } + } + Some("response") => { + for key in ["url", "status", "status_text", "mime_type", "headers"] { + if let Some(value) = event.get(key) { + obj.insert(format!("response_{key}"), value.clone()); + } + } + } + Some("failed") => { + obj.insert("failed".to_string(), json!(true)); + if let Some(value) = event.get("error_text") { + obj.insert("failure_reason".to_string(), value.clone()); + } + } + _ => {} + } + } + } + order + .into_iter() + .filter_map(|id| by_id.remove(&id)) + .rev() + .take(limit.max(1)) + .collect() + } + + pub async fn query_console( + &self, + filter: Option<&str>, + since: Option<&str>, + limit: usize, + ) -> Vec { + let threshold = self.since_threshold(since); + Self::query(&self.console_events, filter, threshold, limit, |_| true).await + } + + pub async fn query_errors( + &self, + filter: Option<&str>, + since: Option<&str>, + limit: usize, + ) -> Vec { + let threshold = self.since_threshold(since); + Self::query(&self.js_errors, filter, threshold, limit, |_| true).await + } + + async fn query( + queue: &Mutex>, + filter: Option<&str>, + since: Option, + limit: usize, + extra_filter: F, + ) -> Vec + where + F: Fn(&Value) -> bool, + { + let needle = filter.map(str::to_lowercase); + let guard = queue.lock().await; + guard + .iter() + .rev() + .filter(|item| { + if let Some(threshold) = since { + if item.get("seq").and_then(|v| v.as_u64()).unwrap_or(0) <= threshold { + return false; + } + } + if !extra_filter(item) { + return false; + } + needle + .as_ref() + .map(|n| item.to_string().to_lowercase().contains(n)) + .unwrap_or(true) + }) + .take(limit.max(1)) + .cloned() + .collect() + } + + pub async fn clear_network(&self) { + self.network_events.lock().await.clear(); + } + + pub async fn clear_console(&self) { + self.console_events.lock().await.clear(); + } + + pub async fn clear_errors(&self) { + self.js_errors.lock().await.clear(); + } + + pub async fn trace_start(&self) -> Value { + self.trace_events.lock().await.clear(); + self.trace_recording.store(true, Ordering::SeqCst); + json!({ "recording": true, "event_count": 0 }) + } + + pub async fn trace_stop(&self, limit: usize) -> Value { + self.trace_recording.store(false, Ordering::SeqCst); + let events = Self::query(&self.trace_events, None, None, limit, |_| true).await; + json!({ + "recording": false, + "event_count": events.len(), + "events": events, + }) + } + + pub async fn trace_status(&self) -> Value { + let event_count = self.trace_events.lock().await.len(); + json!({ + "recording": self.trace_recording.load(Ordering::SeqCst), + "event_count": event_count, + }) + } + + pub async fn trace_clear(&self) -> Value { + self.trace_recording.store(false, Ordering::SeqCst); + self.trace_events.lock().await.clear(); + json!({ "recording": false, "event_count": 0, "cleared": true }) + } + + pub async fn arm_dialog(&self, handler: DialogHandler) { + *self.dialog_handler.lock().await = Some(handler); + } + + pub async fn take_dialog_handler(&self) -> Option { + self.dialog_handler.lock().await.take() + } + + pub async fn set_active_frame(&self, frame_id: Option) { + *self.active_frame_id.lock().await = frame_id; + } + + pub async fn active_frame(&self) -> Option { + self.active_frame_id.lock().await.clone() + } +} + +#[derive(Clone)] +pub struct BrowserSession { + pub session_id: String, + pub port: u16, + pub client: Arc, + pub state: Arc, +} + +impl std::fmt::Debug for BrowserSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BrowserSession") + .field("session_id", &self.session_id) + .field("port", &self.port) + .field("client", &"") + .finish() + } +} + +#[derive(Default)] +struct RegistryInner { + sessions: HashMap, + default_id: Option, +} + +#[derive(Default)] +pub struct BrowserSessionRegistry { + inner: RwLock, +} + +impl BrowserSessionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Insert or replace a session and mark it as the default. + /// Also spawns a background task that consumes CDP events for recording + /// and dialog auto-handling. + pub async fn register(&self, session: BrowserSession) { + let mut events = session.client.subscribe_events(); + let state = session.state.clone(); + let client = session.client.clone(); + tokio::spawn(async move { + loop { + match events.recv().await { + Ok(event) => { + if event.method == "Page.javascriptDialogOpening" { + if let Some(handler) = state.take_dialog_handler().await { + let mut params = json!({ "accept": handler.accept }); + if let Some(text) = handler.prompt_text { + params["promptText"] = json!(text); + } + let _ = client + .send("Page.handleJavaScriptDialog", Some(params)) + .await; + } + } + state.record_event(event).await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + let mut g = self.inner.write().await; + let id = session.session_id.clone(); + g.sessions.insert(id.clone(), session); + g.default_id = Some(id); + } + + /// Promote an existing session to the default. No-op if the id is unknown. + pub async fn set_default(&self, session_id: &str) -> BitFunResult<()> { + let mut g = self.inner.write().await; + if !g.sessions.contains_key(session_id) { + return Err(BitFunError::tool(format!( + "Browser session '{}' not registered.", + session_id + ))); + } + g.default_id = Some(session_id.to_string()); + Ok(()) + } + + /// Resolve a session id (or the current default) to a session. + /// + /// Also prunes entries whose underlying CDP WebSocket reader task has + /// terminated (the user closed the tab outside of our control). Without + /// the prune, the next `send` call would block until its 30-second + /// internal timeout — confusing the model with a `TIMEOUT` error code + /// that hides the real `WRONG_TAB` failure mode. + pub async fn get(&self, session_id: Option<&str>) -> BitFunResult { + // First pass: read-only resolve. + let resolved = { + let g = self.inner.read().await; + let id = match session_id { + Some(s) => s.to_string(), + None => g.default_id.clone().ok_or_else(|| { + BitFunError::tool( + "No browser session registered. Use action 'connect' first.".to_string(), + ) + })?, + }; + g.sessions.get(&id).cloned().map(|s| (id, s)) + }; + + let (id, session) = resolved.ok_or_else(|| { + BitFunError::tool( + "Browser session is not connected. Use action 'connect' or 'switch_page'." + .to_string(), + ) + })?; + + if !session.client.is_connected() { + // Best-effort eviction. Acquire the write lock only when we + // actually need to mutate the map. + let mut g = self.inner.write().await; + g.sessions.remove(&id); + if g.default_id.as_deref() == Some(id.as_str()) { + g.default_id = None; + } + return Err(BitFunError::tool(format!( + "Browser session '{}' is no longer connected (the tab was likely closed). Call 'connect' or 'switch_page' to attach a new one.", + id + ))); + } + + Ok(session) + } + + /// Remove a session. If it was the default, the default is cleared (the + /// next `connect` / `switch_page` will install a new default). + pub async fn remove(&self, session_id: &str) { + let mut g = self.inner.write().await; + g.sessions.remove(session_id); + if g.default_id.as_deref() == Some(session_id) { + g.default_id = None; + } + } + + /// Snapshot of registered session ids — used by `list_sessions` actions. + pub async fn list(&self) -> Vec { + let g = self.inner.read().await; + let mut ids: Vec = g.sessions.keys().cloned().collect(); + ids.sort(); + ids + } + + /// Current default session id, if any. + pub async fn default_id(&self) -> Option { + let g = self.inner.read().await; + g.default_id.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // We can't construct a real `CdpClient` without a live browser, so + // the tests below exercise only the bookkeeping paths that don't + // require an actual session (empty get, unknown id ⇒ set_default). + // Session-aware behavior is exercised by integration tests in the + // browser_control e2e suite. + + #[tokio::test] + async fn empty_registry_errors_on_get() { + let r = BrowserSessionRegistry::new(); + let err = r.get(None).await.unwrap_err(); + assert!(err.to_string().contains("No browser session")); + } + + #[tokio::test] + async fn unknown_id_cannot_become_default() { + let r = BrowserSessionRegistry::new(); + let err = r.set_default("missing").await.unwrap_err(); + assert!(err.to_string().contains("not registered")); + } +} \ No newline at end of file diff --git a/src/crates/core/src/agentic/tools/computer_use_capability.rs b/src/crates/assembly/core/src/agentic/tools/computer_use_capability.rs similarity index 100% rename from src/crates/core/src/agentic/tools/computer_use_capability.rs rename to src/crates/assembly/core/src/agentic/tools/computer_use_capability.rs diff --git a/src/crates/core/src/agentic/tools/computer_use_host.rs b/src/crates/assembly/core/src/agentic/tools/computer_use_host.rs similarity index 100% rename from src/crates/core/src/agentic/tools/computer_use_host.rs rename to src/crates/assembly/core/src/agentic/tools/computer_use_host.rs diff --git a/src/crates/core/src/agentic/tools/computer_use_optimizer.rs b/src/crates/assembly/core/src/agentic/tools/computer_use_optimizer.rs similarity index 100% rename from src/crates/core/src/agentic/tools/computer_use_optimizer.rs rename to src/crates/assembly/core/src/agentic/tools/computer_use_optimizer.rs diff --git a/src/crates/core/src/agentic/tools/computer_use_verification.rs b/src/crates/assembly/core/src/agentic/tools/computer_use_verification.rs similarity index 100% rename from src/crates/core/src/agentic/tools/computer_use_verification.rs rename to src/crates/assembly/core/src/agentic/tools/computer_use_verification.rs diff --git a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs similarity index 99% rename from src/crates/core/src/agentic/tools/file_read_state_runtime.rs rename to src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs index 05199967d3..0c3fa1ad79 100644 --- a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs @@ -373,9 +373,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } diff --git a/src/crates/core/src/agentic/tools/file_tool_guidance.rs b/src/crates/assembly/core/src/agentic/tools/file_tool_guidance.rs similarity index 61% rename from src/crates/core/src/agentic/tools/file_tool_guidance.rs rename to src/crates/assembly/core/src/agentic/tools/file_tool_guidance.rs index de63f952dd..a2c5f57488 100644 --- a/src/crates/core/src/agentic/tools/file_tool_guidance.rs +++ b/src/crates/assembly/core/src/agentic/tools/file_tool_guidance.rs @@ -1,4 +1,4 @@ -//! Compatibility re-exports for file Write/Edit guardrail guidance markers. +//! Shared guidance markers for file Write/Edit guardrail messages. pub use bitfun_agent_tools::{ file_tool_guidance_message, is_file_tool_guidance_message, FILE_TOOL_GUIDANCE_PREFIX, diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/assembly/core/src/agentic/tools/framework.rs similarity index 100% rename from src/crates/core/src/agentic/tools/framework.rs rename to src/crates/assembly/core/src/agentic/tools/framework.rs diff --git a/src/crates/core/src/agentic/tools/image_context.rs b/src/crates/assembly/core/src/agentic/tools/image_context.rs similarity index 100% rename from src/crates/core/src/agentic/tools/image_context.rs rename to src/crates/assembly/core/src/agentic/tools/image_context.rs diff --git a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs similarity index 62% rename from src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs index acb6a72ba8..0874c87e8b 100644 --- a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -3,8 +3,11 @@ //! Allows AI to ask questions to users during execution and wait for answers use async_trait::async_trait; +use bitfun_agent_runtime::user_questions::{ + ask_user_question_available_for_acp_transport, build_answered_user_question_result, + build_cancelled_user_question_result, validate_ask_user_question_input, AskUserQuestionInput, +}; use log::{debug, warn}; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; @@ -13,29 +16,6 @@ use crate::agentic::tools::user_input_manager::get_user_input_manager; use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent}; use crate::util::errors::BitFunResult; -/// Question option -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QuestionOption { - pub label: String, - pub description: String, -} - -/// Question definition -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Question { - pub question: String, - pub header: String, - pub options: Vec, - #[serde(rename = "multiSelect")] - pub multi_select: bool, -} - -/// Tool input parameters - supports multiple questions -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AskUserQuestionInput { - pub questions: Vec, -} - /// AskUserQuestion tool pub struct AskUserQuestionTool; @@ -50,110 +30,10 @@ impl AskUserQuestionTool { Self } - fn is_acp_context(context: Option<&ToolUseContext>) -> bool { - context - .and_then(|ctx| ctx.custom_data.get("acp_transport")) - .is_some_and(|value| value == "true" || value == &json!(true)) - } - - /// Validate question format (supports multiple questions) - fn validate_input(input: &AskUserQuestionInput) -> Result<(), String> { - // Validate question count - if input.questions.is_empty() { - return Err("At least one question is required".to_string()); - } - if input.questions.len() > 4 { - return Err("Maximum 4 questions allowed".to_string()); - } - - // Validate each question - for (q_idx, question) in input.questions.iter().enumerate() { - let q_num = q_idx + 1; - - // Validate question text - if question.question.trim().is_empty() { - return Err(format!("Question {} text is required", q_num)); - } - - // Validate header - if question.header.trim().is_empty() { - return Err(format!("Question {} header is required", q_num)); - } - if question.header.chars().count() > 20 { - return Err(format!( - "Question {} header must be less than 20 characters", - q_num - )); - } - - // Validate options - if question.options.len() < 2 || question.options.len() > 10 { - return Err(format!("Question {} must have 2-10 options", q_num)); - } - - for (opt_idx, opt) in question.options.iter().enumerate() { - if opt.label.trim().is_empty() { - return Err(format!( - "Question {} option {} label is required", - q_num, - opt_idx + 1 - )); - } - if opt.description.trim().is_empty() { - return Err(format!( - "Question {} option {} description is required", - q_num, - opt_idx + 1 - )); - } - } - } - - Ok(()) - } - - /// Format result for AI (supports multiple questions) - fn format_result_for_assistant(questions: &[Question], answers: &Value) -> String { - // Try flat structure first (frontend sends {"0": "...", "1": [...]}), - // then fall back to nested {"answers": {...}} for backward compatibility - let answers_obj = answers - .as_object() - .or_else(|| answers.get("answers").and_then(|v| v.as_object())); - - if let Some(answers_map) = answers_obj { - let mut result_lines = vec!["User has answered your questions:".to_string()]; - - for (idx, question) in questions.iter().enumerate() { - let idx_str = idx.to_string(); - let answer_text = if let Some(answer_value) = answers_map.get(&idx_str) { - if let Some(arr) = answer_value.as_array() { - // Multi-select: join answers - arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join(", ") - } else if let Some(s) = answer_value.as_str() { - // Single-select - s.to_string() - } else { - "N/A".to_string() - } - } else { - "N/A".to_string() - }; - - result_lines.push(format!( - "- {} ({}): \"{}\"", - question.question, question.header, answer_text - )); - } - - result_lines - .push("\nYou can now continue with the user's answers in mind.".to_string()); - result_lines.join("\n") - } else { - "User has answered your questions (no valid answers received).".to_string() - } + fn is_available_for_tool_context(context: Option<&ToolUseContext>) -> bool { + ask_user_question_available_for_acp_transport( + context.and_then(|ctx| ctx.custom_data.get("acp_transport")), + ) } /// Generate tool ID @@ -255,14 +135,14 @@ Usage notes: }, "multiSelect": { "type": "boolean", - "description": "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive." + "default": false, + "description": "Optional. Defaults to false. Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive." } }, "required": [ "question", "header", - "options", - "multiSelect" + "options" ], "additionalProperties": false }, @@ -287,7 +167,7 @@ Usage notes: } async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { - !Self::is_acp_context(context) + Self::is_available_for_tool_context(context) } async fn call_impl( @@ -305,7 +185,7 @@ Usage notes: })?; // 2. Validate question format - if let Err(error) = Self::validate_input(&tool_input) { + if let Err(error) = validate_ask_user_question_input(&tool_input) { return Err(crate::util::errors::BitFunError::Validation(error)); } @@ -352,39 +232,20 @@ Usage notes: "AskUserQuestion tool received user response, tool_id: {}", tool_id ); - let result_text = - Self::format_result_for_assistant(&tool_input.questions, &response.answers); - - // Build question summary for return data - let questions_summary: Vec = tool_input - .questions - .iter() - .map(|q| { - json!({ - "question": q.question, - "header": q.header - }) - }) - .collect(); + let result = build_answered_user_question_result(&tool_input, response.answers); Ok(vec![ToolResult::Result { - data: json!({ - "questions": questions_summary, - "answers": response.answers, - "status": "answered" - }), - result_for_assistant: Some(result_text), + data: result.data, + result_for_assistant: Some(result.result_for_assistant), image_attachments: None, }]) } Err(_) => { warn!("AskUserQuestion tool channel closed, tool_id: {}", tool_id); + let result = build_cancelled_user_question_result(&tool_input); Ok(vec![ToolResult::Result { - data: json!({ - "questions_count": tool_input.questions.len(), - "status": "cancelled" - }), - result_for_assistant: Some("User input request was cancelled.".to_string()), + data: result.data, + result_for_assistant: Some(result.result_for_assistant), image_attachments: None, }]) } @@ -408,9 +269,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data, computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } @@ -434,4 +294,20 @@ mod tests { assert!(tool.is_available_in_context(Some(&context)).await); } + + #[test] + fn ask_user_question_schema_defaults_multi_select_to_false() { + let schema = AskUserQuestionTool::new().input_schema(); + let question_schema = &schema["properties"]["questions"]["items"]; + + assert_eq!( + question_schema["properties"]["multiSelect"]["default"], + false + ); + assert!(!question_schema["required"] + .as_array() + .expect("required array") + .iter() + .any(|value| value == "multiSelect")); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs similarity index 71% rename from src/crates/core/src/agentic/tools/implementations/bash_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs index 54a4373c52..1b5ddbd72e 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs @@ -1,4 +1,3 @@ -use crate::agentic::coordination::scheduler::get_global_scheduler; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -9,10 +8,12 @@ use crate::infrastructure::events::event_system::BackendEvent::{ }; use crate::infrastructure::events::{emit_global_event, BackendEvent}; use crate::service::config::global::get_global_config_service; +use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::event::{ToolExecutionProgressInfo, ToolTerminalReadyInfo}; use async_trait::async_trait; +use bitfun_runtime_ports::AgentBackgroundResultRequest; use futures::StreamExt; use log::{debug, error, info}; use serde_json::{json, Value}; @@ -25,124 +26,15 @@ use terminal_core::{ TerminalBindingOptions, TerminalSessionBinding, }; use tokio::io::AsyncWriteExt; -use tool_runtime::util::ansi_cleaner::strip_ansi; - -// Inline rendering budget for Bash's own result formatter. When the shared -// oversized tool-result pipeline can persist the result, it stores the raw -// `output` field instead of this rendered/truncated fallback. -const MAX_OUTPUT_LENGTH: usize = 30000; -const INTERRUPT_OUTPUT_DRAIN_MS: u64 = 500; - -const BANNED_COMMANDS: &[&str] = &[ - "alias", - // "curl", - // "curlie", - // "wget", - // "axel", - // "aria2c", - // "nc", - // "telnet", - // "lynx", - // "w3m", - // "links", - // "httpie", - // "xh", - // "http-prompt", - // "chrome", - // "firefox", - // "safari", -]; - -/// Detect a known-broken pattern: `osascript ... keystroke ""`. AppleScript's `keystroke` sends raw key codes, NOT Unicode -/// strings — typing CJK / emoji / non-Latin text via `keystroke` produces -/// garbage like "AAA…" because the receiving app sees the wrong key codes. -/// The correct path is `ControlHub domain:"desktop" action:"paste"` (which -/// uses the system clipboard). -fn detect_osascript_keystroke_non_ascii(cmd: &str) -> Option { - if !cmd.contains("osascript") { - return None; - } - // Walk every `keystroke "..."` literal and check for non-ASCII inside. - let bytes = cmd.as_bytes(); - let needle = b"keystroke"; - let mut i = 0usize; - while i + needle.len() < bytes.len() { - if &bytes[i..i + needle.len()] == needle { - // Find the next quoted string after `keystroke`. - let mut j = i + needle.len(); - while j < bytes.len() && bytes[j] != b'"' { - j += 1; - } - if j >= bytes.len() { - break; - } - let start = j + 1; - let mut end = start; - while end < bytes.len() && bytes[end] != b'"' { - end += 1; - } - if end > bytes.len() { - break; - } - let literal = &cmd[start..end.min(cmd.len())]; - if !literal.is_ascii() { - return Some(literal.to_string()); - } - i = end + 1; - } else { - i += 1; - } - } - None -} - -/// Detect `osascript` driving a chat / IM application. The model loves to -/// reach for AppleScript here, but `tell process "" to keystroke …` is -/// brittle (no CJK), opaque (no return value to verify), and almost always -/// loses to `system.open_app + desktop.paste` or the `im_send_message` -/// playbook. Returns the matched app name when detected. -fn detect_osascript_im_app(cmd: &str) -> Option<&'static str> { - if !cmd.contains("osascript") { - return None; - } - const IM_APPS: &[&str] = &[ - "WeChat", "微信", "iMessage", "Messages", "Slack", "Lark", "飞书", "Telegram", "DingTalk", - "钉钉", "QQ", "Discord", "Teams", "Whatsapp", "WhatsApp", - ]; - let cmd_lc = cmd.to_lowercase(); - for app in IM_APPS { - let app_lc = app.to_lowercase(); - if cmd.contains(app) || cmd_lc.contains(&app_lc) { - return Some(*app); - } - } - None -} - -fn truncate_output_preserving_tail(s: &str, max_chars: usize) -> String { - let chars: Vec = s.chars().collect(); - if chars.len() <= max_chars { - return s.to_string(); - } - - let tail_bias = max_chars.saturating_mul(4) / 5; - let separator = "\n... [truncated, middle omitted, tail preserved] ...\n"; - let separator_len = separator.chars().count(); - - if separator_len >= max_chars { - return chars[chars.len() - max_chars..].iter().collect(); - } - - let content_budget = max_chars - separator_len; - let tail_len = tail_bias.min(content_budget); - let head_len = content_budget.saturating_sub(tail_len); - - let head: String = chars[..head_len].iter().collect(); - let tail: String = chars[chars.len() - tail_len..].iter().collect(); - - format!("{head}{separator}{tail}") -} +use tool_runtime::shell::{ + banned_shell_command, bash_noninteractive_env, command_for_working_directory, + detect_osascript_im_app, detect_osascript_keystroke_non_ascii, + format_background_command_delivery_text, format_background_command_display_text, + format_background_command_error_display_text, format_background_command_error_text, + render_local_shell_result, render_remote_shell_result, BackgroundCommandDeliveryTextRequest, + BackgroundCommandErrorTextRequest, BackgroundCommandStatusFacts, LocalShellResultRenderRequest, + RemoteShellResultRenderRequest, BASH_INTERRUPT_OUTPUT_DRAIN_MS, BASH_RESULT_MAX_OUTPUT_LENGTH, +}; /// Result of shell resolution for bash tool struct ResolvedShell { @@ -152,6 +44,55 @@ struct ResolvedShell { display_name: String, } +fn json_object_metadata(value: Value) -> serde_json::Map { + match value { + Value::Object(map) => map, + _ => serde_json::Map::new(), + } +} + +async fn deliver_background_bash_result( + parent_session_id: String, + parent_agent_type: String, + parent_workspace_path: Option, + delivery_text: String, + display_text: String, + metadata: serde_json::Map, + terminal_session_id: String, + failure_context: &'static str, +) { + let runtime = match CoreServiceAgentRuntime::global_agent_runtime_with_lifecycle_delivery() { + Ok(runtime) => runtime, + Err(error) => { + error!( + "Agent runtime lifecycle delivery is not available; background Bash {} dropped: session_id={}, terminal_session_id={}, error={}", + failure_context, parent_session_id, terminal_session_id, error + ); + return; + } + }; + + if let Err(error) = runtime + .deliver_background_result(AgentBackgroundResultRequest { + session_id: parent_session_id.clone(), + agent_type: parent_agent_type, + workspace_path: parent_workspace_path, + content: delivery_text, + display_content: Some(display_text), + metadata, + }) + .await + { + error!( + "Failed to deliver background Bash {}: session_id={}, terminal_session_id={}, error={}", + failure_context, + parent_session_id, + terminal_session_id, + CoreServiceAgentRuntime::runtime_error_message(error) + ); + } +} + /// Bash tool pub struct BashTool; @@ -166,18 +107,6 @@ impl BashTool { Self } - fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) - } - - fn command_for_working_directory(command: &str, working_directory: Option<&str>) -> String { - working_directory - .map(str::trim) - .filter(|dir| !dir.is_empty()) - .map(|dir| format!("cd {} && {}", Self::sh_quote(dir), command)) - .unwrap_or_else(|| command.to_string()) - } - fn resolve_working_directory( input: &Value, context: &ToolUseContext, @@ -214,17 +143,7 @@ impl BashTool { /// Build environment variables that suppress interactive behaviors /// (pagers, editors, prompts) so agent-driven commands never block. pub fn noninteractive_env() -> std::collections::HashMap { - let mut env = std::collections::HashMap::new(); - env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); - // Disable git pager globally (prevents `less`/`more` from blocking) - env.insert("GIT_PAGER".to_string(), "cat".to_string()); - // Disable generic pager for other tools (man, etc.) - env.insert("PAGER".to_string(), "cat".to_string()); - // Prevent git from prompting for credentials or SSH passphrases - env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()); - // Ensure git never opens an interactive editor (e.g. for commit messages) - env.insert("GIT_EDITOR".to_string(), "true".to_string()); - env + bash_noninteractive_env() } /// Resolve shell configuration for bash tool. @@ -269,151 +188,6 @@ impl BashTool { } } - fn render_result( - &self, - terminal_session_id: &str, - working_directory: &str, - output_text: &str, - interrupted: bool, - timed_out: bool, - exit_code: i32, - shell_state: Option<&str>, - ) -> String { - let mut result_string = String::new(); - - // Exit code - result_string.push_str(&format!("{}", exit_code)); - if !working_directory.is_empty() { - result_string.push_str(&format!( - "{}", - working_directory - )); - } - - // Main output content - if !output_text.is_empty() { - let cleaned_output = strip_ansi(output_text); - let output_len = cleaned_output.chars().count(); - if output_len > MAX_OUTPUT_LENGTH { - let truncated = truncate_output_preserving_tail(&cleaned_output, MAX_OUTPUT_LENGTH); - result_string.push_str(&format!( - "{}", - truncated - )); - } else { - result_string.push_str(&format!("{}", cleaned_output)); - } - } - - // Post-command terminal state: shows what the shell displayed after the - // command finished (e.g., prompt, continuation prompt, or other state). - // This gives AI the full picture of the terminal context. - if let Some(state) = shell_state { - let cleaned_state = strip_ansi(state); - result_string.push_str(&format!("{}", cleaned_state)); - } - - // Interruption notice - if timed_out { - result_string.push_str( - "Command timed out before completion. Partial output, if any, is included above.", - ); - } else if interrupted { - result_string.push_str( - "Command was canceled by the user. ASK THE USER what they would like to do next." - ); - } - - // Terminal session ID - result_string.push_str(&format!( - "{}", - terminal_session_id - )); - - result_string - } - - fn render_output_block_with_limit( - tag: &str, - output_text: &str, - max_chars: usize, - ) -> Option { - if output_text.is_empty() { - return None; - } - - let cleaned_output = strip_ansi(output_text); - let output_len = cleaned_output.chars().count(); - if max_chars == 0 { - Some(format!( - "<{tag} truncated=\"true\">... [truncated, no budget remaining] ..." - )) - } else if output_len > max_chars { - let truncated = truncate_output_preserving_tail(&cleaned_output, max_chars); - Some(format!("<{tag} truncated=\"true\">{}", truncated)) - } else { - Some(format!("<{tag}>{}", cleaned_output)) - } - } - - fn remote_stream_budgets(stdout: &str, stderr: &str) -> (usize, usize) { - let stdout_len = strip_ansi(stdout).chars().count(); - let stderr_len = strip_ansi(stderr).chars().count(); - - if stderr_len >= MAX_OUTPUT_LENGTH { - return (0, MAX_OUTPUT_LENGTH); - } - - let stderr_budget = stderr_len; - let stdout_budget = MAX_OUTPUT_LENGTH.saturating_sub(stderr_budget); - (stdout_budget.min(stdout_len), stderr_budget) - } - - fn render_remote_result( - &self, - working_directory: &str, - stdout: &str, - stderr: &str, - interrupted: bool, - timed_out: bool, - exit_code: i32, - ) -> String { - let mut result_string = String::new(); - result_string.push_str("true"); - result_string.push_str(&format!("{}", exit_code)); - if !working_directory.is_empty() { - result_string.push_str(&format!( - "{}", - working_directory - )); - } - - let (stdout_budget, stderr_budget) = Self::remote_stream_budgets(stdout, stderr); - - if let Some(stdout_block) = - Self::render_output_block_with_limit("stdout", stdout, stdout_budget) - { - result_string.push_str(&stdout_block); - } - if let Some(stderr_block) = - Self::render_output_block_with_limit("stderr", stderr, stderr_budget) - { - result_string.push_str(&stderr_block); - } - - if timed_out { - result_string.push_str( - "Command timed out before completion. Partial stdout/stderr, if any, is included above.", - ); - } else if interrupted { - result_string.push_str( - "Command was canceled before completion. ASK THE USER what they would like to do next.", - ); - } - - result_string - } - fn emit_terminal_ready_event(tool_use_id: &str, terminal_session_id: &str) { let event = ToolTerminalReady(ToolTerminalReadyInfo { tool_use_id: tool_use_id.to_string(), @@ -432,8 +206,7 @@ impl BashTool { fn cancellation_requested(context: &ToolUseContext) -> bool { context - .cancellation_token - .as_ref() + .cancellation_token() .is_some_and(|token| token.is_cancelled()) } @@ -454,77 +227,6 @@ impl BashTool { ) .unwrap_or_else(|_| output_file_path.display().to_string()) } - - fn format_background_command_delivery_text( - command: &str, - terminal_session_id: &str, - working_directory: &str, - exit_code: Option, - timed_out: bool, - interrupted: bool, - output_file_reference: &str, - output_persist_error: Option<&str>, - ) -> String { - let (status, summary) = if timed_out { - ("timeout", "Background Bash command timed out.") - } else if interrupted { - ("interrupted", "Background Bash command was interrupted.") - } else if exit_code == Some(0) { - ( - "completed", - "Background Bash command completed successfully.", - ) - } else { - ( - "failed", - "Background Bash command completed with a non-zero exit code.", - ) - }; - let exit_code_attr = exit_code - .map(|code| format!(" exit_code=\"{}\"", code)) - .unwrap_or_default(); - let persistence_line = output_persist_error.map_or_else( - || format!("Full output was saved to: {}", output_file_reference), - |error| { - format!( - "Output persistence encountered an error while writing {}: {}", - output_file_reference, error - ) - }, - ); - - format!( - "{summary}\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\n" - ) - } - - fn format_background_command_error_text( - command: &str, - terminal_session_id: &str, - working_directory: &str, - output_file_reference: &str, - error: &str, - output_persist_error: Option<&str>, - ) -> String { - let persistence_line = output_persist_error.map_or_else( - || { - format!( - "Any captured output was saved to: {}", - output_file_reference - ) - }, - |persist_error| { - format!( - "Output persistence encountered an error while writing {}: {}", - output_file_reference, persist_error - ) - }, - ); - - format!( - "Background Bash command failed before producing a final completion result.\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\nError: {error}\n" - ) - } } #[async_trait] @@ -552,10 +254,10 @@ Before executing the command, please follow these steps: 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") - Examples of proper quoting: - - cd "/Users/name/My Documents" (correct) - - cd /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) + - cd "My Documents" (correct) + - cd My Documents (incorrect - will fail) + - python "scripts/with spaces/script.py" (correct) + - python scripts/with spaces/script.py (incorrect - will fail) - After ensuring proper quoting, execute the command. - Capture the output of the command. @@ -564,15 +266,15 @@ Usage notes: - DO NOT use multiline commands or HEREDOC syntax (e.g., <` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID. - - The output may include the command echo and/or the shell prompt (e.g., `PS C:\path>`). Do not treat these as part of the command's actual result. + - The output may include the command echo and/or the shell prompt prefix (for example, a printed `PS` or `$` prompt line). Do not treat these as part of the command's actual result. - Avoid interactive commands that may block waiting for user input or open a pager/editor. Prefer non-interactive variants and explicit flags. For example, use `git --no-pager diff` instead of `git diff`, and avoid commands that prompt for confirmation unless the User explicitly asks for them. - - Prefer specialized tools for workspace file operations: Glob for file discovery, Grep for content search, Read for reading, Edit for modifying, Write for creating, and Delete for deletion. Use Bash for commands that genuinely need a shell, such as build/test/package CLIs, process control, scripts, and environment checks. Never use shell output only to communicate with the user. + - Prefer specialized tools for workspace file operations: Glob for file discovery, Grep for content search, Read for reading, Edit for modifying, Write for creating, and Delete for deletion. Prefer the Git tool (after loading it with GetToolSpec when collapsed) for Git subcommands such as status, diff, log, add, commit, branch, checkout, pull, and push. Use Bash for commands that genuinely need a shell, such as build/test/package CLIs, process control, scripts, and environment checks. Never use shell output only to communicate with the user. - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel. + - If the commands are independent and can run in parallel, make multiple tool calls in a single message. For Git inspection, prefer parallel Git tool calls such as `{{"operation":"status"}}` and `{{"operation":"diff","args":"--stat"}}` instead of Bash. - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) @@ -665,20 +367,16 @@ Usage notes: .unwrap_or(false); if let Some(cmd) = command { - let parts: Vec<&str> = cmd.split_whitespace().collect(); - if let Some(base_cmd) = parts.first() { - // Check if command is banned - if BANNED_COMMANDS.contains(&base_cmd.to_lowercase().as_str()) { - return ValidationResult { - result: false, - message: Some(format!( - "Command '{}' is not allowed for security reasons", - base_cmd - )), - error_code: Some(403), - meta: None, - }; - } + if let Some(base_cmd) = banned_shell_command(cmd) { + return ValidationResult { + result: false, + message: Some(format!( + "Command '{}' is not allowed for security reasons", + base_cmd + )), + error_code: Some(403), + meta: None, + }; } // Reject `osascript ... keystroke ""` — fundamentally @@ -883,10 +581,8 @@ Usage notes: "Executing command on remote workspace via SSH: {}", command_str ); - let remote_command = Self::command_for_working_directory( - command_str, - requested_working_directory.as_deref(), - ); + let remote_command = + command_for_working_directory(command_str, requested_working_directory.as_deref()); let timeout_ms = input .get("timeout_ms") @@ -898,7 +594,7 @@ Usage notes: &remote_command, WorkspaceCommandOptions { timeout_ms: Some(timeout_ms), - cancellation_token: context.cancellation_token.clone(), + cancellation_token: context.cancellation_token().cloned(), }, ) .await @@ -914,14 +610,14 @@ Usage notes: .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); let working_directory = requested_working_directory.unwrap_or(working_directory); - let result_for_assistant = self.render_remote_result( - &working_directory, - &exec_result.stdout, - &exec_result.stderr, - exec_result.interrupted, - exec_result.timed_out, - exec_result.exit_code, - ); + let result_for_assistant = render_remote_shell_result(RemoteShellResultRenderRequest { + working_directory: &working_directory, + stdout: &exec_result.stdout, + stderr: &exec_result.stderr, + interrupted: exec_result.interrupted, + timed_out: exec_result.timed_out, + exit_code: exec_result.exit_code, + }); let result = ToolResult::Result { data: json!({ @@ -1046,10 +742,8 @@ Usage notes: .as_ref() .cloned() .unwrap_or_else(|| primary_cwd.clone()); - let command_to_execute = Self::command_for_working_directory( - command_str, - requested_working_directory.as_deref(), - ); + let command_to_execute = + command_for_working_directory(command_str, requested_working_directory.as_deref()); // --- Foreground execution --- @@ -1113,13 +807,13 @@ Usage notes: }; // Check cancellation request - if let Some(token) = &context.cancellation_token { + if let Some(token) = context.cancellation_token() { if token.is_cancelled() && !was_interrupted { debug!("Bash tool received cancellation request, sending interrupt signal, tool_id: {}", tool_use_id); was_interrupted = true; interrupt_drain_deadline = Some( tokio::time::Instant::now() - + Duration::from_millis(INTERRUPT_OUTPUT_DRAIN_MS), + + Duration::from_millis(BASH_INTERRUPT_OUTPUT_DRAIN_MS), ); let _ = terminal_api @@ -1258,15 +952,15 @@ Usage notes: "terminal_session_id": primary_session_id, }); - let result_for_assistant = self.render_result( - &primary_session_id, - &execution_working_directory, - &accumulated_output, - was_interrupted, + let result_for_assistant = render_local_shell_result(LocalShellResultRenderRequest { + terminal_session_id: &primary_session_id, + working_directory: &execution_working_directory, + output_text: &accumulated_output, + interrupted: was_interrupted, timed_out, - final_exit_code.unwrap_or(-1), - final_shell_state.as_deref(), - ); + exit_code: final_exit_code.unwrap_or(-1), + shell_state: final_shell_state.as_deref(), + }); Ok(vec![ToolResult::Result { data: result_data, @@ -1524,16 +1218,27 @@ impl BashTool { let timed_out = completion_reason == CommandCompletionReason::TimedOut; let interrupted = !timed_out && matches!(exit_code, Some(130) | Some(-1073741510)); - let delivery_text = Self::format_background_command_delivery_text( - &command, - &terminal_session_id, - &working_directory, + let status = BackgroundCommandStatusFacts { exit_code, timed_out, interrupted, - &output_file_reference_for_task, - output_persist_error.as_deref(), + }; + let delivery_text = format_background_command_delivery_text( + BackgroundCommandDeliveryTextRequest { + command: &command, + terminal_session_id: &terminal_session_id, + working_directory: &working_directory, + status, + output_file_reference: &output_file_reference_for_task, + output_persist_error: output_persist_error.as_deref(), + }, ); + let display_text = + format_background_command_display_text(BackgroundCommandStatusFacts { + exit_code, + timed_out, + interrupted, + }); let metadata = json!({ "kind": "background_result", "sourceKind": "bash_command", @@ -1545,41 +1250,32 @@ impl BashTool { "outputFile": output_file_reference_for_task.clone(), }); - if let Some(scheduler) = get_global_scheduler() { - if let Err(error) = scheduler - .deliver_background_result( - parent_session_id.clone(), - parent_agent_type.clone(), - parent_workspace_path.clone(), - delivery_text, - None, - Some(metadata), - ) - .await - { - error!( - "Failed to deliver background Bash result: session_id={}, terminal_session_id={}, error={}", - parent_session_id, terminal_session_id, error - ); - } - } else { - error!( - "Scheduler not initialized; background Bash result dropped: session_id={}, terminal_session_id={}", - parent_session_id, terminal_session_id - ); - } + deliver_background_bash_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + display_text, + json_object_metadata(metadata), + terminal_session_id.clone(), + "result", + ) + .await; delivery_sent = true; break; } CommandStreamEvent::Error { message } => { - let delivery_text = Self::format_background_command_error_text( - &command, - &terminal_session_id, - &working_directory, - &output_file_reference_for_task, - &message, - output_persist_error.as_deref(), + let delivery_text = format_background_command_error_text( + BackgroundCommandErrorTextRequest { + command: &command, + terminal_session_id: &terminal_session_id, + working_directory: &working_directory, + output_file_reference: &output_file_reference_for_task, + error: &message, + output_persist_error: output_persist_error.as_deref(), + }, ); + let display_text = format_background_command_error_display_text(); let metadata = json!({ "kind": "background_result", "sourceKind": "bash_command", @@ -1592,29 +1288,17 @@ impl BashTool { "error": message.clone(), }); - if let Some(scheduler) = get_global_scheduler() { - if let Err(error) = scheduler - .deliver_background_result( - parent_session_id.clone(), - parent_agent_type.clone(), - parent_workspace_path.clone(), - delivery_text, - None, - Some(metadata), - ) - .await - { - error!( - "Failed to deliver background Bash error result: session_id={}, terminal_session_id={}, error={}", - parent_session_id, terminal_session_id, error - ); - } - } else { - error!( - "Scheduler not initialized; background Bash error result dropped: session_id={}, terminal_session_id={}", - parent_session_id, terminal_session_id - ); - } + deliver_background_bash_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + display_text, + json_object_metadata(metadata), + terminal_session_id.clone(), + "error result", + ) + .await; delivery_sent = true; break; } @@ -1622,14 +1306,16 @@ impl BashTool { } if !saw_completion && !delivery_sent { - let delivery_text = Self::format_background_command_error_text( - &command, - &terminal_session_id, - &working_directory, - &output_file_reference_for_task, - "Background Bash command stream ended without a completion event.", - output_persist_error.as_deref(), - ); + let delivery_text = + format_background_command_error_text(BackgroundCommandErrorTextRequest { + command: &command, + terminal_session_id: &terminal_session_id, + working_directory: &working_directory, + output_file_reference: &output_file_reference_for_task, + error: "Background Bash command stream ended without a completion event.", + output_persist_error: output_persist_error.as_deref(), + }); + let display_text = format_background_command_error_display_text(); let metadata = json!({ "kind": "background_result", "sourceKind": "bash_command", @@ -1642,29 +1328,17 @@ impl BashTool { "error": "stream_ended_without_completion", }); - if let Some(scheduler) = get_global_scheduler() { - if let Err(error) = scheduler - .deliver_background_result( - parent_session_id.clone(), - parent_agent_type.clone(), - parent_workspace_path.clone(), - delivery_text, - None, - Some(metadata), - ) - .await - { - error!( - "Failed to deliver background Bash terminal stream-end result: session_id={}, terminal_session_id={}, error={}", - parent_session_id, terminal_session_id, error - ); - } - } else { - error!( - "Scheduler not initialized; background Bash stream-end result dropped: session_id={}, terminal_session_id={}", - parent_session_id, terminal_session_id - ); - } + deliver_background_bash_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + display_text, + json_object_metadata(metadata), + terminal_session_id.clone(), + "stream-end result", + ) + .await; } }); @@ -1714,7 +1388,7 @@ mod tests { fn truncate_output_preserving_tail_keeps_end_of_output() { let input = "BEGIN-".to_string() + &"x".repeat(120) + "-IMPORTANT-END"; - let truncated = truncate_output_preserving_tail(&input, 80); + let truncated = tool_runtime::shell::truncate_output_preserving_tail(&input, 80); assert!(truncated.contains("tail preserved")); assert!(truncated.ends_with("IMPORTANT-END")); @@ -1767,12 +1441,19 @@ mod tests { #[test] fn render_result_marks_truncated_output_and_keeps_tail() { - let tool = BashTool::new(); - let long_output = - "prefix\n".to_string() + &"y".repeat(MAX_OUTPUT_LENGTH + 100) + "\nfinal-error"; - - let rendered = - tool.render_result("session-1", "/repo", &long_output, false, false, 1, None); + let long_output = "prefix\n".to_string() + + &"y".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH + 100) + + "\nfinal-error"; + + let rendered = render_local_shell_result(LocalShellResultRenderRequest { + terminal_session_id: "session-1", + working_directory: "/repo", + output_text: &long_output, + interrupted: false, + timed_out: false, + exit_code: 1, + shell_state: None, + }); assert!(rendered.contains("")); assert!(rendered.contains("tail preserved")); @@ -1782,10 +1463,14 @@ mod tests { #[test] fn render_remote_result_keeps_stdout_and_stderr_separate() { - let tool = BashTool::new(); - - let rendered = - tool.render_remote_result("/repo", "stdout text", "stderr text", false, false, 2); + let rendered = render_remote_shell_result(RemoteShellResultRenderRequest { + working_directory: "/repo", + stdout: "stdout text", + stderr: "stderr text", + interrupted: false, + timed_out: false, + exit_code: 2, + }); assert!(rendered.contains("true")); assert!(rendered.contains("2")); @@ -1796,14 +1481,21 @@ mod tests { #[test] fn render_remote_result_uses_shared_budget_with_stderr_priority() { - let tool = BashTool::new(); - let long_stdout = - "prefix\n".to_string() + &"x".repeat(MAX_OUTPUT_LENGTH + 100) + "\nstdout-tail"; - let long_stderr = - "prefix\n".to_string() + &"z".repeat(MAX_OUTPUT_LENGTH / 2) + "\nstderr-tail"; - - let rendered = - tool.render_remote_result("/repo", &long_stdout, &long_stderr, false, false, 1); + let long_stdout = "prefix\n".to_string() + + &"x".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH + 100) + + "\nstdout-tail"; + let long_stderr = "prefix\n".to_string() + + &"z".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH / 2) + + "\nstderr-tail"; + + let rendered = render_remote_shell_result(RemoteShellResultRenderRequest { + working_directory: "/repo", + stdout: &long_stdout, + stderr: &long_stderr, + interrupted: false, + timed_out: false, + exit_code: 1, + }); assert!(rendered.contains("")); assert!(rendered.contains("stdout-tail")); @@ -1813,12 +1505,18 @@ mod tests { #[test] fn render_remote_result_gives_all_budget_to_oversized_stderr() { - let tool = BashTool::new(); - let long_stderr = - "prefix\n".to_string() + &"z".repeat(MAX_OUTPUT_LENGTH + 100) + "\nremote-final-error"; - - let rendered = - tool.render_remote_result("/repo", "stdout text", &long_stderr, false, false, 1); + let long_stderr = "prefix\n".to_string() + + &"z".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH + 100) + + "\nremote-final-error"; + + let rendered = render_remote_shell_result(RemoteShellResultRenderRequest { + working_directory: "/repo", + stdout: "stdout text", + stderr: &long_stderr, + interrupted: false, + timed_out: false, + exit_code: 1, + }); assert!(rendered.contains("")); assert!(rendered.contains("no budget remaining")); @@ -1838,33 +1536,30 @@ mod tests { #[test] fn command_is_prefixed_with_quoted_working_directory_when_requested() { - let command = BashTool::command_for_working_directory( - "pnpm install", - Some("/Users/example/My Project"), - ); + let command = + command_for_working_directory("pnpm install", Some("/Users/example/My Project")); assert_eq!(command, "cd '/Users/example/My Project' && pnpm install"); } #[test] fn command_prefix_escapes_single_quotes_in_working_directory() { - let command = BashTool::command_for_working_directory("pwd", Some("/tmp/it's fine")); + let command = command_for_working_directory("pwd", Some("/tmp/it's fine")); assert_eq!(command, "cd '/tmp/it'\\''s fine' && pwd"); } #[test] fn command_result_includes_working_directory_for_model() { - let tool = BashTool::new(); - let rendered = tool.render_result( - "session-1", - "/private/tmp", - "ERR_PNPM_NO_PKG_MANIFEST No package.json found in /private/tmp", - false, - false, - 1, - None, - ); + let rendered = render_local_shell_result(LocalShellResultRenderRequest { + terminal_session_id: "session-1", + working_directory: "/private/tmp", + output_text: "ERR_PNPM_NO_PKG_MANIFEST No package.json found in /private/tmp", + interrupted: false, + timed_out: false, + exit_code: 1, + shell_state: None, + }); assert!(rendered.contains("1")); assert!(rendered.contains("/private/tmp")); @@ -1873,16 +1568,19 @@ mod tests { #[test] fn background_delivery_text_points_to_saved_output_file() { - let rendered = BashTool::format_background_command_delivery_text( - "pnpm test", - "bg-session-1", - "/repo", - Some(0), - false, - false, - "/runtime/sessions/session/tool-results/bash_123.txt", - None, - ); + let rendered = + format_background_command_delivery_text(BackgroundCommandDeliveryTextRequest { + command: "pnpm test", + terminal_session_id: "bg-session-1", + working_directory: "/repo", + status: BackgroundCommandStatusFacts { + exit_code: Some(0), + timed_out: false, + interrupted: false, + }, + output_file_reference: "/runtime/sessions/session/tool-results/bash_123.txt", + output_persist_error: None, + }); assert!(rendered.contains("Background Bash command completed successfully.")); assert!(rendered.contains("status=\"completed\"")); @@ -1891,4 +1589,44 @@ mod tests { "Full output was saved to: /runtime/sessions/session/tool-results/bash_123.txt" )); } + + #[test] + fn background_display_text_is_concise() { + assert_eq!( + format_background_command_display_text(BackgroundCommandStatusFacts { + exit_code: Some(0), + timed_out: false, + interrupted: false, + }), + "Background Bash command completed successfully." + ); + assert_eq!( + format_background_command_display_text(BackgroundCommandStatusFacts { + exit_code: Some(1), + timed_out: false, + interrupted: false, + }), + "Background Bash command completed with a non-zero exit code." + ); + assert_eq!( + format_background_command_display_text(BackgroundCommandStatusFacts { + exit_code: None, + timed_out: true, + interrupted: false, + }), + "Background Bash command timed out." + ); + assert_eq!( + format_background_command_display_text(BackgroundCommandStatusFacts { + exit_code: Some(130), + timed_out: false, + interrupted: true, + }), + "Background Bash command was interrupted." + ); + assert_eq!( + format_background_command_error_display_text(), + "Background Bash command failed before producing a final completion result." + ); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/calendar_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/calendar_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/calendar_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/calendar_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs similarity index 86% rename from src/crates/core/src/agentic/tools/implementations/code_review_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs index 1a94f8fb2a..0018db7ee6 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs @@ -4,7 +4,7 @@ use crate::agentic::coordination::get_global_coordinator; use crate::agentic::core::CompressionContract; -use crate::agentic::deep_review::report::{self as deep_review_report, DeepReviewCacheUpdate}; +use crate::agentic::deep_review::report as deep_review_report; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::service::config::get_app_language_code; use crate::service::i18n::code_review_copy_for_language; @@ -455,18 +455,6 @@ impl CodeReviewTool { deep_review_report::log_deep_review_runtime_diagnostics(dialog_turn_id); } - fn deep_review_cache_from_completed_reviewers( - input: &Value, - run_manifest: Option<&Value>, - existing_cache: Option<&Value>, - ) -> Option { - deep_review_report::deep_review_cache_from_completed_reviewers( - input, - run_manifest, - existing_cache, - ) - } - async fn persist_deep_review_cache( context: &ToolUseContext, cache_value: Value, @@ -679,33 +667,17 @@ impl Tool for CodeReviewTool { context.dialog_turn_id.as_deref(), ); Self::log_deep_review_runtime_diagnostics(context.dialog_turn_id.as_deref()); - if let Some(cache_update) = Self::deep_review_cache_from_completed_reviewers( - &filled_input, - run_manifest.as_ref(), - existing_cache.as_ref(), - ) { - if cache_update.hit_count > 0 { - deep_review_report::push_reliability_signal_if_missing( - &mut filled_input, - json!({ - "kind": "cache_hit", - "severity": "info", - "count": cache_update.hit_count, - "source": "runtime" - }), - ); - } - if cache_update.miss_count > 0 { - deep_review_report::push_reliability_signal_if_missing( - &mut filled_input, - json!({ - "kind": "cache_miss", - "severity": "info", - "count": cache_update.miss_count, - "source": "runtime" - }), - ); - } + if let Some(cache_update) = + deep_review_report::deep_review_cache_from_completed_reviewers( + &filled_input, + run_manifest.as_ref(), + existing_cache.as_ref(), + ) + { + deep_review_report::fill_deep_review_cache_update_signals( + &mut filled_input, + &cache_update, + ); if let Err(error) = Self::persist_deep_review_cache(context, cache_update.value).await { @@ -743,9 +715,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } @@ -1447,191 +1418,4 @@ mod tests { Some(&CompressionContract::default()) )); } - - #[test] - fn deep_review_incremental_cache_stores_completed_reviewers_by_packet_id() { - use crate::agentic::deep_review_policy::DeepReviewIncrementalCache; - - let manifest = json!({ - "incrementalReviewCache": { - "fingerprint": "fp-review-v2" - }, - "workPackets": [ - { - "packetId": "reviewer:ReviewSecurity:group-1-of-1", - "phase": "reviewer", - "subagentId": "ReviewSecurity", - "displayName": "Security Reviewer" - }, - { - "packetId": "reviewer:ReviewPerformance:group-1-of-1", - "phase": "reviewer", - "subagentId": "ReviewPerformance", - "displayName": "Performance Reviewer" - } - ] - }); - let mut input = json!({ - "summary": { - "overall_assessment": "Review completed", - "risk_level": "medium", - "recommended_action": "request_changes" - }, - "issues": [], - "positive_points": [], - "reviewers": [ - { - "name": "Security Reviewer", - "specialty": "security", - "status": "completed", - "summary": "Found one high-risk issue." - }, - { - "name": "Performance Reviewer", - "specialty": "performance", - "status": "partial_timeout", - "summary": "Timed out before completion.", - "partial_output": "Large render path was still being checked." - } - ] - }); - - CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); - let cache_update = CodeReviewTool::deep_review_cache_from_completed_reviewers( - &input, - Some(&manifest), - None, - ) - .expect("completed reviewer should produce cache value"); - let cache = DeepReviewIncrementalCache::from_value(&cache_update.value); - - assert_eq!(cache.fingerprint(), "fp-review-v2"); - assert_eq!(cache_update.hit_count, 0); - assert_eq!(cache_update.miss_count, 1); - assert!(cache - .get_packet("reviewer:ReviewSecurity:group-1-of-1") - .is_some_and(|output| output.contains("Found one high-risk issue."))); - assert_eq!( - cache.get_packet("reviewer:ReviewPerformance:group-1-of-1"), - None - ); - } - - #[test] - fn deep_review_incremental_cache_replaces_stale_existing_cache() { - use crate::agentic::deep_review_policy::DeepReviewIncrementalCache; - - let manifest = json!({ - "incrementalReviewCache": { - "fingerprint": "fp-new" - }, - "workPackets": [ - { - "packetId": "reviewer:ReviewSecurity", - "phase": "reviewer", - "subagentId": "ReviewSecurity", - "displayName": "Security Reviewer" - } - ] - }); - let mut stale_cache = DeepReviewIncrementalCache::new("fp-old"); - stale_cache.store_packet("reviewer:ReviewSecurity", "stale output"); - let mut input = json!({ - "summary": { - "overall_assessment": "Review completed", - "risk_level": "low", - "recommended_action": "approve" - }, - "issues": [], - "positive_points": [], - "reviewers": [ - { - "name": "Security Reviewer", - "specialty": "security", - "status": "completed", - "summary": "Fresh security output." - } - ] - }); - - CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); - let cache_update = CodeReviewTool::deep_review_cache_from_completed_reviewers( - &input, - Some(&manifest), - Some(&stale_cache.to_value()), - ) - .expect("completed reviewer should replace stale cache"); - let cache = DeepReviewIncrementalCache::from_value(&cache_update.value); - - assert_eq!(cache.fingerprint(), "fp-new"); - assert_eq!(cache_update.hit_count, 0); - assert_eq!(cache_update.miss_count, 1); - assert!(cache - .get_packet("reviewer:ReviewSecurity") - .is_some_and(|output| output.contains("Fresh security output."))); - assert!(!cache - .get_packet("reviewer:ReviewSecurity") - .is_some_and(|output| output.contains("stale output"))); - } - - #[test] - fn deep_review_incremental_cache_counts_existing_packet_hits() { - use crate::agentic::deep_review_policy::DeepReviewIncrementalCache; - - let manifest = json!({ - "incrementalReviewCache": { - "fingerprint": "fp-existing" - }, - "workPackets": [ - { - "packetId": "reviewer:ReviewSecurity", - "phase": "reviewer", - "subagentId": "ReviewSecurity", - "displayName": "Security Reviewer" - }, - { - "packetId": "reviewer:ReviewPerformance", - "phase": "reviewer", - "subagentId": "ReviewPerformance", - "displayName": "Performance Reviewer" - } - ] - }); - let mut existing_cache = DeepReviewIncrementalCache::new("fp-existing"); - existing_cache.store_packet("reviewer:ReviewSecurity", "cached security output"); - let mut input = json!({ - "summary": { - "overall_assessment": "Review completed", - "risk_level": "medium", - "recommended_action": "request_changes" - }, - "issues": [], - "positive_points": [], - "reviewers": [ - { - "name": "Security Reviewer", - "specialty": "security", - "status": "completed", - "summary": "Reused security output." - }, - { - "name": "Performance Reviewer", - "specialty": "performance", - "status": "completed", - "summary": "Fresh performance output." - } - ] - }); - - CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); - let cache_update = CodeReviewTool::deep_review_cache_from_completed_reviewers( - &input, - Some(&manifest), - Some(&existing_cache.to_value()), - ) - .expect("completed reviewers should update cache"); - - assert_eq!(cache_update.hit_count, 1); - assert_eq!(cache_update.miss_count, 1); - } } diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_actions.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_input.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_input.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_input.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_input.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_locate.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_locate.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_locate.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_locate.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_result.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_result.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/computer_use_result.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_result.rs diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs similarity index 99% rename from src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index 128cc7366a..9d913c48bb 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -451,7 +451,7 @@ The **primary model cannot consume images** in tool results — **do not** use * "macos" => "On this host use command/option/control/shift in key_chord (not Win/Linux names). **System clipboard (prefer over type_text when pasting):** command+a select all, command+c copy, command+x cut, command+v paste — combine with focus/selection shortcuts as needed.", "windows" => "On this host use meta (Windows key), alt, control, shift in key_chord. **System clipboard:** control+a/c/x/v for select all, copy, cut, paste.", "linux" => "On this host use control, alt, shift, and meta/super as appropriate for the desktop. **System clipboard:** typically control+a/c/x/v (match the app and DE).", - _ => "Match key_chord modifiers to the host OS in the system prompt Environment Information. Prefer standard clipboard chords (select all, copy, cut, paste) before long type_text.", + _ => "Match key_chord modifiers to the host OS in Runtime Context. Prefer standard clipboard chords (select all, copy, cut, paste) before long type_text.", } } diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub/errors.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub/errors.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/control_hub/errors.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/control_hub/errors.rs diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub/mod.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/control_hub/mod.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/control_hub/mod.rs diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub/result.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub/result.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/control_hub/result.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/control_hub/result.rs diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs similarity index 72% rename from src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index e457a8dd9e..7e5911012f 100644 --- a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -14,7 +14,7 @@ use crate::agentic::tools::browser_control::browser_launcher::{ }; use crate::agentic::tools::browser_control::cdp_client::CdpClient; use crate::agentic::tools::browser_control::session_registry::{ - BrowserSession, BrowserSessionRegistry, + BrowserSession, BrowserSessionRegistry, BrowserSessionState, DialogHandler, }; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, @@ -96,10 +96,10 @@ Use this tool via `{ domain, action, params }` for browser automation, terminal ## Domains ### domain: "browser" (DOM/CDP browser control) -- Two browser modes: - * `connect { mode: "headless" }` — attach to a headless test browser on the test port for project Web UI testing that does not depend on user login state. - * `connect { mode: "default" }` (default) — attach to the user's default browser via CDP for flows that require login state, cookies, extensions, or the user's real profile. -- Actions: connect, navigate, snapshot, click, fill, type, select, press_key, scroll, wait, get_text, get_url, get_title, screenshot, evaluate, close, list_pages, tab_query, switch_page, list_sessions. +- Browser modes: + * `connect { mode: "default" }` (default) — start or attach the stable managed browser profile with CDP enabled. + * `connect { mode: "headless" }` — start or attach the stable managed headless browser profile for project Web UI testing that does not depend on user login state. +- Actions: connect, tab_new, navigate, back, forward, reload, snapshot, click, hover, fill, type, check, uncheck, select, press_key, scroll, auto_scroll, wait, get, get_text, get_url, get_title, get_html, screenshot, evaluate, fetch, cookies, set_cookies, set_file_input_files, cdp, network, console, errors, trace, dialog, frame, frame_main, read_article, close, list_pages, tab_query, switch_page, list_sessions. - Workflow: connect -> navigate -> snapshot (returns @e1, @e2 ... refs) -> click/fill using refs. - Take a fresh snapshot after any DOM mutation; stale refs return `error.code = STALE_REF`. @@ -384,6 +384,32 @@ Branch on `ok` and `error.code`, not on English messages. } } + fn is_allowed_browser_cdp_method(method: &str) -> bool { + matches!( + method, + "Accessibility.getFullAXTree" + | "DOM.getDocument" + | "DOM.getBoxModel" + | "DOM.getContentQuads" + | "DOM.querySelector" + | "DOM.querySelectorAll" + | "DOM.scrollIntoViewIfNeeded" + | "DOM.setFileInputFiles" + | "DOMSnapshot.captureSnapshot" + | "Input.dispatchMouseEvent" + | "Input.dispatchKeyEvent" + | "Input.insertText" + | "Network.getCookies" + | "Network.getResponseBody" + | "Network.setCookie" + | "Page.getLayoutMetrics" + | "Page.captureScreenshot" + | "Runtime.enable" + | "Emulation.setDeviceMetricsOverride" + | "Emulation.clearDeviceMetricsOverride" + ) + } + async fn handle_browser(&self, action: &str, params: &Value) -> BitFunResult> { let port = params .get("port") @@ -532,9 +558,17 @@ Branch on `ok` and `error.code`, not on English messages. session_id: page.id.clone(), port, client: Arc::new(client), + state: Arc::new(BrowserSessionState::new()), }; browser_sessions().register(session.clone()).await; + // Enable CDP observers so network/console/error events + // start recording immediately for later query via + // browser.network / browser.console / browser.errors. + let _ = BrowserActions::new(session.client.as_ref()) + .enable_observers() + .await; + // If the model targeted a specific tab AND wants it // foregrounded (default), bring it to front the same // way switch_page does. Failure here is non-fatal — @@ -700,6 +734,45 @@ Branch on `ok` and `error.code`, not on English messages. )]) } + "tab_new" => { + let url = params.get("url").and_then(|v| v.as_str()); + let activate = params + .get("activate") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let page = CdpClient::create_page(port, url).await?; + let ws_url = page + .web_socket_debugger_url + .as_ref() + .ok_or_else(|| { + BitFunError::tool("New tab has no WebSocket URL".to_string()) + })?; + let client = CdpClient::connect(ws_url).await?; + let session = BrowserSession { + session_id: page.id.clone(), + port, + client: Arc::new(client), + state: Arc::new(BrowserSessionState::new()), + }; + browser_sessions().register(session.clone()).await; + let _ = BrowserActions::new(session.client.as_ref()) + .enable_observers() + .await; + if activate { + let _ = session.client.send("Page.bringToFront", None).await; + } + Ok(vec![ToolResult::ok( + json!({ + "success": true, + "session_id": session.session_id, + "page_url": page.url, + "page_title": page.title, + "activated": activate, + }), + Some(format!("New tab opened: {} (session {})", page.title, session.session_id)), + )]) + } + "switch_page" => { let page_id = params .get("page_id") @@ -736,8 +809,12 @@ Branch on `ok` and `error.code`, not on English messages. session_id: page.id.clone(), port, client: Arc::new(client), + state: Arc::new(BrowserSessionState::new()), }; registry.register(session.clone()).await; + let _ = BrowserActions::new(session.client.as_ref()) + .enable_observers() + .await; session }; @@ -782,17 +859,200 @@ Branch on `ok` and `error.code`, not on English messages. )]) } - "list_sessions" => { - let registry = browser_sessions(); - let ids = registry.list().await; - let default = registry.default_id().await; - Ok(vec![ToolResult::ok( - json!({ - "sessions": ids, - "default_session_id": default, - }), - Some(format!("{} session(s) tracked", ids.len())), - )]) + "list_sessions" | "network" | "network_requests" + | "console" | "errors" | "trace" => { + match action { + "list_sessions" => { + let registry = browser_sessions(); + let ids = registry.list().await; + let default = registry.default_id().await; + Ok(vec![ToolResult::ok( + json!({ + "sessions": ids, + "default_session_id": default, + }), + Some(format!("{} session(s) tracked", ids.len())), + )]) + } + "network" | "network_requests" => { + let session = browser_sessions() + .get(session_id_param.as_deref()) + .await?; + let state = &session.state; + let sub = params + .get("sub_command") + .and_then(|v| v.as_str()); + match sub { + Some("clear") => { + state.clear_network().await; + Ok(vec![ToolResult::ok( + json!({ "success": true, "cleared": true }), + Some("Network events cleared".to_string()), + )]) + } + Some("summary") => { + let total = state + .query_network(None, None, None, None, usize::MAX) + .await + .len(); + let requests = state + .query_network_requests(None, None, None, None, 50) + .await; + Ok(vec![ToolResult::ok( + json!({ + "total_events": total, + "requests": requests, + }), + Some(format!("Network summary: {} total events", total)), + )]) + } + _ => { + let filter = + params.get("filter").and_then(|v| v.as_str()); + let method = + params.get("method").and_then(|v| v.as_str()); + let status = + params.get("status").and_then(|v| v.as_str()); + let since = + params.get("since").and_then(|v| v.as_str()); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) as usize; + let events = if sub == Some("requests") { + state + .query_network_requests( + filter, method, status, since, limit, + ) + .await + } else { + state + .query_network( + filter, method, status, since, limit, + ) + .await + }; + Ok(vec![ToolResult::ok( + json!({ "events": events, "count": events.len() }), + Some(format!("{} network event(s)", events.len())), + )]) + } + } + } + "console" => { + let session = browser_sessions() + .get(session_id_param.as_deref()) + .await?; + let state = &session.state; + let sub = params + .get("sub_command") + .and_then(|v| v.as_str()); + if sub == Some("clear") { + state.clear_console().await; + return Ok(vec![ToolResult::ok( + json!({ "success": true, "cleared": true }), + Some("Console events cleared".to_string()), + )]); + } + let filter = + params.get("filter").and_then(|v| v.as_str()); + let since = + params.get("since").and_then(|v| v.as_str()); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) as usize; + let events = state.query_console(filter, since, limit).await; + Ok(vec![ToolResult::ok( + json!({ "events": events, "count": events.len() }), + Some(format!("{} console event(s)", events.len())), + )]) + } + "errors" => { + let session = browser_sessions() + .get(session_id_param.as_deref()) + .await?; + let state = &session.state; + let sub = params + .get("sub_command") + .and_then(|v| v.as_str()); + if sub == Some("clear") { + state.clear_errors().await; + return Ok(vec![ToolResult::ok( + json!({ "success": true, "cleared": true }), + Some("JS error events cleared".to_string()), + )]); + } + let filter = + params.get("filter").and_then(|v| v.as_str()); + let since = + params.get("since").and_then(|v| v.as_str()); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) as usize; + let events = state.query_errors(filter, since, limit).await; + Ok(vec![ToolResult::ok( + json!({ "events": events, "count": events.len() }), + Some(format!("{} JS error event(s)", events.len())), + )]) + } + "trace" => { + let session = browser_sessions() + .get(session_id_param.as_deref()) + .await?; + let state = &session.state; + let sub = params + .get("sub_command") + .and_then(|v| v.as_str()); + match sub { + Some("start") => { + let result = state.trace_start().await; + Ok(vec![ToolResult::ok( + result, + Some("CDP trace recording started".to_string()), + )]) + } + Some("stop") => { + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(200) as usize; + let result = state.trace_stop(limit).await; + Ok(vec![ToolResult::ok( + result, + Some("CDP trace recording stopped".to_string()), + )]) + } + Some("status") => { + let result = state.trace_status().await; + Ok(vec![ToolResult::ok( + result, + Some("CDP trace status".to_string()), + )]) + } + Some("clear") => { + let result = state.trace_clear().await; + Ok(vec![ToolResult::ok( + result, + Some("CDP trace cleared".to_string()), + )]) + } + _ => { + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(100) as usize; + let result = state.trace_stop(limit).await; + Ok(vec![ToolResult::ok( + result, + Some("CDP trace events".to_string()), + )]) + } + } + } + _ => unreachable!(), + } } _ => { @@ -845,6 +1105,7 @@ Branch on `ok` and `error.code`, not on English messages. "fill" => { let selector = params .get("selector") + .or_else(|| params.get("ref")) .and_then(|v| v.as_str()) .ok_or_else(|| { BitFunError::tool("fill requires 'selector'".to_string()) @@ -995,7 +1256,26 @@ Branch on `ok` and `error.code`, not on English messages. )]) } "screenshot" => { - let result = actions.screenshot().await?; + let format = params + .get("format") + .and_then(|v| v.as_str()) + .unwrap_or("jpeg"); + let quality = params + .get("quality") + .and_then(|v| v.as_u64()) + .map(|q| q as u8); + let from_surface = params + .get("from_surface") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let full_page = params + .get("full_page") + .or_else(|| params.get("fullPage")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let result = actions + .screenshot_with_options_ext(format, quality, from_surface, full_page) + .await?; let data_len = result .get("data_length") .and_then(|v| v.as_u64()) @@ -1012,6 +1292,16 @@ Branch on `ok` and `error.code`, not on English messages. .ok_or_else(|| { BitFunError::tool("evaluate requires 'expression'".to_string()) })?; + let await_promise = params + .get("await_promise") + .or_else(|| params.get("awaitPromise")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let return_by_value = params + .get("return_by_value") + .or_else(|| params.get("returnByValue")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); // Bound the size of the returned value so a runaway // `JSON.stringify(document)` can't blow up the model // context window. Default 16 KiB; clamp to [1 KiB, 256 KiB]. @@ -1020,7 +1310,9 @@ Branch on `ok` and `error.code`, not on English messages. .and_then(|v| v.as_u64()) .unwrap_or(16 * 1024) .clamp(1024, 256 * 1024) as usize; - let mut result = actions.evaluate(expression).await?; + let mut result = actions + .evaluate_with_options(expression, await_promise, return_by_value) + .await?; let mut truncated = false; if let Some(value) = result.pointer_mut("/result/value") { let serialized = value.to_string(); @@ -1041,6 +1333,291 @@ Branch on `ok` and `error.code`, not on English messages. .unwrap_or_else(|| result.to_string()); Ok(vec![ToolResult::ok(result, Some(display))]) } + "back" => { + let result = actions.back().await?; + Ok(vec![ToolResult::ok(result, Some("Navigated back".to_string()))]) + } + "forward" => { + let result = actions.forward().await?; + Ok(vec![ToolResult::ok(result, Some("Navigated forward".to_string()))]) + } + "reload" | "refresh" => { + let ignore_cache = params + .get("ignore_cache") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let result = actions.reload(ignore_cache).await?; + Ok(vec![ToolResult::ok(result, Some("Page reloaded".to_string()))]) + } + "hover" => { + let selector = params + .get("selector") + .or_else(|| params.get("ref")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BitFunError::tool("hover requires 'selector'".to_string()) + })?; + let result = actions.hover(selector).await?; + Ok(vec![ToolResult::ok(result, Some(format!("Hovered {}", selector)))]) + } + "check" | "uncheck" => { + let selector = params + .get("selector") + .or_else(|| params.get("ref")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BitFunError::tool("check/uncheck requires 'selector'".to_string()) + })?; + let result = actions.set_checked(selector, action == "check").await?; + Ok(vec![ToolResult::ok(result, Some(format!("Set checked on {}", selector)))]) + } + "get" => { + let selector = params + .get("selector") + .or_else(|| params.get("ref")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BitFunError::tool("get requires 'selector'".to_string()) + })?; + let attribute = params + .get("attribute") + .and_then(|v| v.as_str()) + .unwrap_or("text"); + match actions.get_attribute(selector, attribute).await? { + Some(value) => { + let display = value.to_string(); + Ok(vec![ToolResult::ok( + json!({ "value": value, "found": true, "selector": selector, "attribute": attribute }), + Some(display), + )]) + } + None => Ok(err_response( + "browser", + "get", + ControlHubError::new( + ErrorCode::NotFound, + format!("No element matched selector '{}'", selector), + ) + .with_hint("Take a fresh snapshot and verify the @ref / CSS selector"), + )), + } + } + "get_html" | "content" => { + let selector = params + .get("selector") + .and_then(|v| v.as_str()); + let result = if let Some(sel) = selector { + actions.get_attribute(sel, "html").await? + } else { + actions.get_attribute("html", "html").await? // will fallback to document + }; + match result { + Some(value) => { + let html = value.as_str().unwrap_or("").to_string(); + Ok(vec![ToolResult::ok( + json!({ "html": html, "found": true }), + Some(format!("HTML: {} chars", html.len())), + )]) + } + None => { + // Fallback: evaluate document.documentElement.outerHTML + let result = actions.evaluate("document.documentElement.outerHTML").await?; + let html = result + .get("result") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + Ok(vec![ToolResult::ok( + json!({ "html": html, "found": true }), + Some(format!("HTML: {} chars", html.len())), + )]) + } + } + } + "auto_scroll" => { + let direction = params + .get("direction") + .and_then(|v| v.as_str()) + .unwrap_or("down"); + let max_scrolls = params + .get("max_scrolls") + .or_else(|| params.get("maxScrolls")) + .and_then(|v| v.as_u64()) + .unwrap_or(20); + let delay_ms = params + .get("delay_ms") + .or_else(|| params.get("delayMs")) + .and_then(|v| v.as_u64()) + .unwrap_or(800); + let result = actions.auto_scroll(direction, max_scrolls, delay_ms).await?; + Ok(vec![ToolResult::ok(result, Some(format!("Auto-scrolled {}", direction)))]) + } + "fetch" => { + let url = params + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BitFunError::tool("fetch requires 'url'".to_string()) + })?; + let method = params + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("GET"); + let headers = params + .get("headers") + .cloned() + .unwrap_or(json!({})); + let body = params + .get("body") + .and_then(|v| v.as_str()); + let result = actions.fetch(url, method, headers, body).await?; + Ok(vec![ToolResult::ok(result, Some(format!("Fetched {}", url)))]) + } + "cookies" | "get_cookies" => { + let urls = params + .get("urls") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); + let result = actions.get_cookies(urls).await?; + let cookies = result + .get("cookies") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + Ok(vec![ToolResult::ok( + result, + Some(format!("{} cookie(s)", cookies)), + )]) + } + "set_cookies" => { + let cookies = params + .get("cookies") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + BitFunError::tool("set_cookies requires 'cookies' array".to_string()) + })?; + let result = actions.set_cookies(cookies).await?; + let set = result.get("set").and_then(|v| v.as_u64()).unwrap_or(0); + Ok(vec![ToolResult::ok( + result, + Some(format!("{} cookie(s) set", set)), + )]) + } + "set_file_input_files" | "file_upload" => { + let selector = params + .get("selector") + .and_then(|v| v.as_str()); + let files: Vec = params + .get("files") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + BitFunError::tool("set_file_input_files requires 'files' array".to_string()) + })? + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + let result = actions.set_file_input_files(selector, &files).await?; + Ok(vec![ToolResult::ok(result, Some("Files set on input".to_string()))]) + } + "cdp" => { + let method = params + .get("method") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BitFunError::tool("cdp requires 'method'".to_string()) + })?; + if !Self::is_allowed_browser_cdp_method(method) { + return Ok(err_response( + "browser", + "cdp", + ControlHubError::new( + ErrorCode::InvalidParams, + format!("CDP method '{}' is not in the allowlist", method), + ) + .with_hint("Only safe DOM/Input/Page/Network/Runtime/Emulation methods are allowed for sandbox protection"), + )); + } + let cdp_params = params.get("params").cloned(); + let result = session.client.send(method, cdp_params).await?; + Ok(vec![ToolResult::ok( + json!({ "success": true, "method": method, "result": result }), + Some(format!("CDP {} executed", method)), + )]) + } + "dialog" => { + let response = params + .get("response") + .and_then(|v| v.as_str()) + .unwrap_or("accept"); + let accept = response != "dismiss"; + let prompt_text = params + .get("prompt_text") + .and_then(|v| v.as_str()) + .map(str::to_string); + session.state.arm_dialog(DialogHandler { accept, prompt_text }).await; + let _ = session.client.send("Page.enable", None).await; + Ok(vec![ToolResult::ok( + json!({ "success": true, "dialog_armed": true, "accept": accept }), + Some("Dialog handler armed".to_string()), + )]) + } + "read_article" => { + if let Some(url) = params.get("url").and_then(|v| v.as_str()) { + actions.navigate(url).await?; + } + let result = actions.read_article().await?; + let article = result.get("article").cloned().unwrap_or(Value::Null); + let excerpt = article + .get("excerpt") + .and_then(|v| v.as_str()) + .unwrap_or(""); + Ok(vec![ToolResult::ok( + result, + Some(format!("Article: {}", excerpt)), + )]) + } + "frame" => { + let selector = params + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BitFunError::tool("frame requires 'selector'".to_string()) + })?; + let script = format!( + r#"(function(){{ + const el = document.querySelector('{}'); + if (!el) return JSON.stringify({{ found: false }}); + return JSON.stringify({{ found: true, selector: '{}', name: el.name || '', url: el.src || '' }}); + }})()"#, + selector.replace('\'', "\\'"), + selector.replace('\'', "\\'"), + ); + let result = actions.evaluate(&script).await?; + let raw = result.get("result").and_then(|r| r.get("value")).and_then(|v| v.as_str()).unwrap_or("{}"); + let parsed: Value = serde_json::from_str(raw).unwrap_or(json!({})); + if !parsed.get("found").and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(err_response( + "browser", + "frame", + ControlHubError::new( + ErrorCode::NotFound, + format!("iframe not found: {}", selector), + ), + )); + } + session.state.set_active_frame(Some(selector.to_string())).await; + Ok(vec![ToolResult::ok( + json!({ "frame": parsed }), + Some("Frame context noted".to_string()), + )]) + } + "frame_main" => { + session.state.set_active_frame(None).await; + Ok(vec![ToolResult::ok( + json!({ "frame": "main" }), + Some("Frame context reset".to_string()), + )]) + } "close" => { let result = actions.close_page().await?; // After a close, drop the session so subsequent calls @@ -1049,7 +1626,7 @@ Branch on `ok` and `error.code`, not on English messages. Ok(vec![ToolResult::ok(result, Some("Page closed".to_string()))]) } other => Err(BitFunError::tool(format!( - "Unknown browser action: '{}'. Valid: connect, navigate, snapshot, click, fill, type, select, press_key, scroll, wait, get_text, get_url, get_title, screenshot, evaluate, close, list_pages, switch_page, list_sessions", + "Unknown browser action: '{}'. Valid: connect, tab_new, navigate, back, forward, reload, snapshot, click, hover, fill, type, check, uncheck, select, press_key, scroll, auto_scroll, wait, get, get_text, get_url, get_title, get_html, screenshot, evaluate, fetch, cookies, set_cookies, set_file_input_files, cdp, network, console, errors, trace, dialog, frame, frame_main, read_article, close, list_pages, tab_query, switch_page, list_sessions", other ))), } @@ -1480,9 +2057,8 @@ mod control_hub_tests { unlocked_collapsed_tools: Vec::new(), custom_data: std::collections::HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } @@ -1709,8 +2285,8 @@ mod control_hub_tests { async fn description_documents_two_browser_modes() { let desc = ControlHubTool::new().description().await.unwrap(); assert!( - desc.contains("Two browser modes"), - "description must describe the two browser control modes" + desc.contains("Browser modes"), + "description must describe the browser control modes" ); assert!( desc.contains("mode: \"headless\"") && desc.contains("mode: \"default\""), diff --git a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs similarity index 94% rename from src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs index 3335d27a41..d544813a66 100644 --- a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs @@ -2,6 +2,9 @@ //! //! Used to create and store plan files during the planning phase +use crate::agentic::remote_file_delivery::{ + computer_link as build_computer_link, user_file_link, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, +}; use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -215,17 +218,14 @@ Additional guidelines: vec![] }; - // Prefer workspace-relative computer:// links, but fall back to an - // absolute computer:// path when plans live outside the workspace tree. - let computer_link = context - .workspace_root() - .and_then(|root| { - std::path::Path::new(&plan_file_path_str) - .strip_prefix(root) - .ok() - .map(|rel| format!("computer://{}", rel.to_string_lossy().replace('\\', "/"))) - }) - .unwrap_or_else(|| format!("computer://{}", plan_file_path_str.replace('\\', "/"))); + let use_computer_link = context + .custom_data + .get(TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let plan_path = std::path::Path::new(&plan_file_path_str); + let computer_link = build_computer_link(plan_path, context.workspace_root()); + let user_link = user_file_link(plan_path, context.workspace_root(), use_computer_link); let plan_reference = context.build_runtime_artifact_reference(&format!("plans/{}", plan_file_name))?; @@ -236,13 +236,14 @@ Clickable link for user: [{}]({}) Your next reply MUST show the clickable link and then end the conversation turn. Do not continue with more planning details or additional questions.", plan_reference, plan_file_name, - computer_link, + user_link, ); let result = json!({ "success": true, "plan_file_path": plan_reference, "computer_link": computer_link.clone(), + "user_link": user_link.clone(), "plan_file_name": plan_file_name, "name": name, "overview": overview, diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs similarity index 94% rename from src/crates/core/src/agentic/tools/implementations/cron_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index bae17671c8..d16e758597 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -6,13 +6,16 @@ use crate::agentic::tools::framework::{ use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service::{ cron::{ - CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronSchedule, - UpdateCronJobRequest, + CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronJobTarget, + CronJobTargetKind, CronSchedule, CronWorkspaceRef, UpdateCronJobRequest, }, get_global_cron_service, }; +use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +use bitfun_agent_runtime::runtime::AgentRuntime; +use bitfun_runtime_ports::{AgentSessionListRequest, AgentSessionWorkspaceRequest}; use chrono::{DateTime, Local, SecondsFormat, TimeZone}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -117,16 +120,34 @@ impl CronTool { self.resolve_workspace(workspace.to_string_lossy().as_ref(), Some(context)) } + fn agent_runtime_if_available(&self) -> BitFunResult> { + let Some(coordinator) = get_global_coordinator() else { + return Ok(None); + }; + CoreServiceAgentRuntime::agent_runtime(coordinator) + .map(Some) + .map_err(BitFunError::tool) + } + + fn require_agent_runtime(&self) -> BitFunResult { + self.agent_runtime_if_available()? + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string())) + } + async fn resolve_effective_workspace_for_session( &self, session_id: &str, context: &ToolUseContext, ) -> BitFunResult { - if let Some(coordinator) = get_global_coordinator() { - if let Some(resolved) = coordinator - .resolve_session_workspace_path(session_id) + if let Some(runtime) = self.agent_runtime_if_available()? { + if let Some(resolved) = runtime + .resolve_session_workspace_path(AgentSessionWorkspaceRequest { + session_id: session_id.to_string(), + }) .await - .map(|path| path.to_string_lossy().to_string()) + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })? { return Ok(resolved); } @@ -162,9 +183,15 @@ impl CronTool { } async fn ensure_session_exists(&self, workspace: &str, session_id: &str) -> BitFunResult<()> { - let coordinator = get_global_coordinator() - .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; - let sessions = coordinator.list_sessions(Path::new(workspace)).await?; + let runtime = self.require_agent_runtime()?; + let sessions = runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace.to_string(), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; if sessions .iter() .any(|session| session.session_id == session_id) @@ -544,8 +571,8 @@ impl TryFrom<&CronJob> for CronToolJobOutput { schedule: CronToolScheduleOutput::try_from(&job.schedule)?, payload: job.payload.text.clone(), enabled: job.enabled, - session_id: job.session_id.clone(), - workspace_path: job.workspace_path.clone(), + session_id: job.session_id().unwrap_or_default().to_string(), + workspace_path: job.workspace().workspace_path.clone(), created_at_ms: job.created_at_ms, config_updated_at_ms: job.config_updated_at_ms, updated_at_ms: job.updated_at_ms, @@ -975,7 +1002,13 @@ Patch schema for "update": .resolve_effective_workspace_for_session(&session_id, context) .await?; let mut jobs = cron_service - .list_jobs_filtered(Some(&workspace), Some(&session_id)) + .list_jobs_filtered( + Some(&workspace), + None, + None, + Some(&session_id), + Some(CronJobTargetKind::Session), + ) .await; jobs.sort_by(|left, right| { left.created_at_ms @@ -1021,14 +1054,24 @@ Patch schema for "update": schedule: job.schedule.to_service_schedule("job.schedule")?, payload: Self::into_service_payload(job.payload), enabled: job.enabled.unwrap_or(true), - session_id: session_id.clone(), - workspace_path: workspace.clone(), + target: CronJobTarget::Session { + session_id: session_id.clone(), + workspace: CronWorkspaceRef { + workspace_id: None, + workspace_path: workspace.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }, + }, }) .await?; let serialized_job = Self::serialize_job(&created)?; let result_for_assistant = format!( "Created scheduled job '{}' ({}) for session '{}' in workspace '{}'.", - created.name, created.id, created.session_id, created.workspace_path + created.name, + created.id, + created.session_id().unwrap_or(""), + created.workspace().workspace_path ); Ok(vec![ToolResult::Result { @@ -1074,8 +1117,7 @@ Patch schema for "update": .transpose()?, payload: patch.payload.map(Self::into_service_payload), enabled: patch.enabled, - session_id: None, - workspace_path: None, + target: None, }, ) .await?; @@ -1167,9 +1209,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs similarity index 88% rename from src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs index d6f22b5f26..e7e2f65db8 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -5,10 +5,12 @@ use crate::agentic::tools::workspace_paths::is_bitfun_runtime_uri; use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use log::debug; use serde_json::{json, Value}; use std::path::Path; -use tokio::fs; +use tool_runtime::fs::{ + build_remote_delete_command, delete_local_path, inspect_local_delete_target, + DeleteLocalPathRequest, +}; /// File deletion tool - provides safe file/directory deletion functionality /// @@ -205,7 +207,7 @@ Important notes: } if !resolved.uses_remote_workspace_backend() { - let local_path = Path::new(&resolved.resolved_path); + let local_path = Path::new(&resolved.resolved_path).to_path_buf(); if !local_path.exists() { return ValidationResult { result: false, @@ -221,10 +223,13 @@ Important notes: .and_then(|v| v.as_bool()) .unwrap_or(false); - let is_empty = match fs::read_dir(local_path).await { - Ok(mut entries) => entries.next_entry().await.ok().flatten().is_none(), - Err(_) => false, - }; + let is_empty = + tokio::task::spawn_blocking(move || inspect_local_delete_target(&local_path)) + .await + .ok() + .and_then(Result::ok) + .map(|target| target.is_empty) + .unwrap_or(false); if !is_empty && !recursive { return ValidationResult { @@ -312,11 +317,7 @@ Important notes: BitFunError::tool("Workspace shell not available for remote Delete".to_string()) })?; - let rm_cmd = if recursive { - format!("rm -rf '{}'", resolved.resolved_path.replace('\'', "'\\''")) - } else { - format!("rm -f '{}'", resolved.resolved_path.replace('\'', "'\\''")) - }; + let rm_cmd = build_remote_delete_command(&resolved.resolved_path, recursive); let (_stdout, stderr, exit_code) = ws_shell .exec(&rm_cmd, Some(15_000)) @@ -345,36 +346,21 @@ Important notes: }]); } - let path = Path::new(&resolved.resolved_path); - let is_directory = path.is_dir(); - - debug!( - "DeleteFile tool deleting {}: {}", - if is_directory { "directory" } else { "file" }, - resolved.logical_path - ); - - if is_directory { - if recursive { - fs::remove_dir_all(path) - .await - .map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?; - } else { - fs::remove_dir(path) - .await - .map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?; - } - } else { - fs::remove_file(path) - .await - .map_err(|e| BitFunError::tool(format!("Failed to delete file: {}", e)))?; - } + let delete_request = DeleteLocalPathRequest { + logical_path: resolved.logical_path.clone(), + resolved_path: Path::new(&resolved.resolved_path).to_path_buf(), + recursive, + }; + let outcome = tokio::task::spawn_blocking(move || delete_local_path(delete_request)) + .await + .map_err(|error| BitFunError::tool(format!("Delete task failed: {}", error)))? + .map_err(BitFunError::tool)?; let result_data = json!({ "success": true, - "path": resolved.logical_path, - "is_directory": is_directory, - "recursive": recursive + "path": outcome.logical_path, + "is_directory": outcome.is_directory, + "recursive": outcome.recursive }); let result_text = self.render_result_for_assistant(&result_data); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/background_command_output.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/background_command_output.rs new file mode 100644 index 0000000000..950f3816dc --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/background_command_output.rs @@ -0,0 +1,3 @@ +//! Compatibility facade for background exec-command output capture. + +pub use tool_runtime::background_command_output::*; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs new file mode 100644 index 0000000000..311bbdffee --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -0,0 +1,1183 @@ +use super::background_command_output::{ + background_command_output_capture, BackgroundCommandOutputStatus, + StartBackgroundCommandOutputCapture, +}; +use super::env_snapshot::{remote_env_snapshot_for, RemoteEnvSnapshot}; +use super::local_shell::{resolve_local_exec_shell, ResolvedLocalExecShell}; +use super::progress::ExecOutputProgressBridge; +use super::rendering::render_exec_response_for_assistant_with_notes; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::infrastructure::events::event_system::{ + get_global_event_system, BackendEvent::BackgroundCommandLifecycle, +}; +use crate::service::remote_ssh::{ + get_global_remote_exec_process_manager, get_remote_workspace_manager, RemoteExecCommandRequest, + RemoteExecProcessLifecycleEvent, RemoteExecProcessLifecycleStatus, RemoteExecSessionCompletion, + RemoteExecSessionCompletionSource, RemoteExecSessionCompletionStatus, SSHCommandOptions, + SSHConnectionManager, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::types::event::BackgroundCommandLifecycleInfo; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use terminal_core::{ + get_global_exec_process_manager, ExecProcessLifecycleEvent, ExecProcessLifecycleStatus, + LocalExecCommandRequest, LocalExecSessionCompletion, LocalExecSessionCompletionSource, + LocalExecSessionCompletionStatus, ShellType, +}; +use tokio::sync::mpsc; + +const REMOTE_SHELL_PROBE_TIMEOUT_MS: u64 = 3_000; +const REMOTE_NON_TTY_INTERRUPT_GRACE_SECONDS: u64 = 2; +const DEFAULT_TOOL_YIELD_TIME_MS: u64 = 30_000; +const POWERSHELL_UTF8_OUTPUT_PREFIX: &str = + "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n"; + +#[derive(Debug, Clone)] +struct RemoteShell { + path: String, + shell_type: ShellType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExecCommandShellPromptInfo { + pub display_name: String, + pub shell_type: String, + pub path: String, + pub invocation: String, +} + +pub struct ExecCommandTool; + +impl Default for ExecCommandTool { + fn default() -> Self { + Self::new() + } +} + +impl ExecCommandTool { + pub fn new() -> Self { + Self + } + + pub(crate) async fn local_shell_prompt_info() -> ExecCommandShellPromptInfo { + let shell = resolve_local_exec_shell().await; + ExecCommandShellPromptInfo { + display_name: shell.display_name, + shell_type: shell.shell_type.to_string(), + path: shell.path.to_string_lossy().to_string(), + invocation: Self::shell_invocation_for_model(&shell.path, &shell.shell_type), + } + } + + fn command_env() -> HashMap { + HashMap::from([ + ("NO_COLOR".to_string(), "1".to_string()), + ("TERM".to_string(), "dumb".to_string()), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "C.UTF-8".to_string()), + ("COLORTERM".to_string(), String::new()), + ("CLICOLOR".to_string(), "0".to_string()), + ("PAGER".to_string(), "cat".to_string()), + ("GIT_PAGER".to_string(), "cat".to_string()), + ("GH_PAGER".to_string(), "cat".to_string()), + ("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()), + ("GIT_EDITOR".to_string(), "true".to_string()), + ("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()), + ]) + } + + fn resolve_workdir(input: &Value, context: &ToolUseContext) -> BitFunResult { + let raw = input + .get("workdir") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|workdir| !workdir.is_empty()) + .map(str::to_string) + .or_else(|| { + context.workspace.as_ref().map(|workspace| { + workspace + .session_identity + .logical_workspace_path() + .to_string() + }) + }) + .ok_or_else(|| { + BitFunError::tool("workspace root is required for ExecCommand".to_string()) + })?; + + let path = PathBuf::from(&raw); + if !path.is_absolute() { + return Err(BitFunError::tool( + "workdir must be an absolute path for ExecCommand".to_string(), + )); + } + if !path.is_dir() { + return Err(BitFunError::tool(format!( + "workdir does not exist or is not a directory: {}", + path.display() + ))); + } + Ok(path) + } + + async fn resolve_remote_workdir( + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult { + let raw = input + .get("workdir") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|workdir| !workdir.is_empty()) + .map(str::to_string) + .or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }) + .ok_or_else(|| { + BitFunError::tool("workspace root is required for ExecCommand".to_string()) + })?; + + if !raw.starts_with('/') { + return Err(BitFunError::tool( + "workdir must be an absolute remote path for ExecCommand".to_string(), + )); + } + + let resolved = context.resolve_workspace_tool_path(&raw)?; + let fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("remote workspace filesystem is required for ExecCommand".to_string()) + })?; + let is_dir = fs.is_dir(&resolved).await.map_err(|error| { + BitFunError::tool(format!( + "failed to check remote workdir '{}': {}", + resolved, error + )) + })?; + if !is_dir { + return Err(BitFunError::tool(format!( + "remote workdir does not exist or is not a directory: {}", + resolved + ))); + } + Ok(resolved) + } + + fn argv_for_shell(path: &Path, shell_type: &ShellType, cmd: &str) -> Vec { + let shell = path.to_string_lossy().to_string(); + match shell_type { + ShellType::Bash + | ShellType::Zsh + | ShellType::Fish + | ShellType::Sh + | ShellType::Ksh + | ShellType::Csh + | ShellType::Custom(_) => vec![shell, "-lc".to_string(), cmd.to_string()], + ShellType::PowerShell | ShellType::PowerShellCore => { + vec![ + shell, + "-Command".to_string(), + Self::powershell_command_with_utf8_output(cmd), + ] + } + ShellType::Cmd => vec![shell, "/c".to_string(), cmd.to_string()], + } + } + + fn powershell_command_with_utf8_output(cmd: &str) -> String { + let trimmed = cmd.trim_start(); + if trimmed.starts_with(POWERSHELL_UTF8_OUTPUT_PREFIX) { + cmd.to_string() + } else { + format!("{POWERSHELL_UTF8_OUTPUT_PREFIX}{cmd}") + } + } + + async fn resolve_remote_shell( + ssh_manager: &SSHConnectionManager, + connection_id: &str, + ) -> RemoteShell { + let probe_command = concat!( + "printf '%s\\n' \"${SHELL:-}\"; ", + "getent passwd \"$(id -un)\" 2>/dev/null | cut -d: -f7; ", + "command -v bash 2>/dev/null; ", + "command -v zsh 2>/dev/null; ", + "command -v sh 2>/dev/null" + ); + let result = ssh_manager + .execute_command_with_options( + connection_id, + probe_command, + SSHCommandOptions { + timeout_ms: Some(REMOTE_SHELL_PROBE_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await; + + if let Ok(result) = result { + if !result.timed_out && !result.interrupted && result.exit_code == 0 { + if let Some(shell) = parse_remote_shell_probe_output(&result.stdout) { + return shell; + } + } + } + + RemoteShell { + path: "/bin/bash".to_string(), + shell_type: ShellType::Bash, + } + } + + fn remote_login_shell_command( + workdir: &str, + cmd: &str, + shell: &RemoteShell, + env_snapshot: Option<&RemoteEnvSnapshot>, + ) -> String { + let env_words = remote_command_env_words(Self::merged_remote_env(env_snapshot)); + let shell_args = remote_shell_login_args().join(" "); + + format!( + "cd {} && env {} {} {} {}", + shell_escape(workdir), + env_words, + shell_escape(&shell.path), + shell_args, + shell_escape(cmd) + ) + } + + fn remote_non_tty_control_wrapper(cmd: &str, shell_path: &str) -> String { + let escaped_shell = shell_escape(shell_path); + let escaped_cmd = shell_escape(cmd); + format!( + r#"__bitfun_shell={escaped_shell} +__bitfun_cmd={escaped_cmd} +if command -v setsid >/dev/null 2>&1; then + setsid "$__bitfun_shell" -lc "$__bitfun_cmd" & +else + "$__bitfun_shell" -lc "$__bitfun_cmd" & +fi +__bitfun_child=$! +__bitfun_pgid=$__bitfun_child +__bitfun_stop() {{ + __bitfun_signal=${{1:-INT}} + __bitfun_exit=${{2:-130}} + __bitfun_grace=${{3:-{REMOTE_NON_TTY_INTERRUPT_GRACE_SECONDS}}} + trap - INT TERM + kill -"$__bitfun_signal" "-$__bitfun_pgid" 2>/dev/null || kill -"$__bitfun_signal" "$__bitfun_child" 2>/dev/null || true + if [ "$__bitfun_grace" -gt 0 ]; then + sleep "$__bitfun_grace" + fi + kill -KILL "-$__bitfun_pgid" 2>/dev/null || kill -KILL "$__bitfun_child" 2>/dev/null || true + wait "$__bitfun_child" 2>/dev/null || true + exit "$__bitfun_exit" +}} +trap '__bitfun_stop INT 130 {REMOTE_NON_TTY_INTERRUPT_GRACE_SECONDS}' INT +trap '__bitfun_stop KILL 137 0' TERM +wait "$__bitfun_child" +__bitfun_status=$? +trap - INT TERM +exit "$__bitfun_status""# + ) + } + + fn merged_remote_env(env_snapshot: Option<&RemoteEnvSnapshot>) -> HashMap { + let mut env = env_snapshot + .map(|snapshot| snapshot.env.clone()) + .unwrap_or_default(); + env.extend(Self::command_env()); + env + } + + fn remote_shell_metadata( + workdir: &str, + shell: &RemoteShell, + env_snapshot_applied: bool, + ) -> Value { + json!({ + "name": shell.shell_type.name(), + "type": shell.shell_type.to_string(), + "path": shell.path, + "invocation": format!( + "`cd {} && env ... {} {} `", + shell_escape(workdir), + shell_escape(&shell.path), + remote_shell_login_args().join(" ") + ), + "remote_env_snapshot_applied": env_snapshot_applied, + }) + } + + fn shell_invocation_for_model(path: &Path, shell_type: &ShellType) -> String { + let shell = path.to_string_lossy(); + match shell_type { + ShellType::Bash + | ShellType::Zsh + | ShellType::Fish + | ShellType::Sh + | ShellType::Ksh + | ShellType::Csh + | ShellType::Custom(_) => format!("`{shell} -lc `"), + ShellType::PowerShell | ShellType::PowerShellCore => { + format!("`{shell} -Command `") + } + ShellType::Cmd => format!("`{shell} /c `"), + } + } + + fn shell_metadata_value(shell: &ResolvedLocalExecShell) -> Value { + json!({ + "name": shell.display_name, + "type": shell.shell_type.to_string(), + "path": shell.path.to_string_lossy(), + "invocation": Self::shell_invocation_for_model(&shell.path, &shell.shell_type), + }) + } + + fn response_for_assistant(data: &Value) -> String { + let mut status_lines = Vec::new(); + let mut note_lines = Vec::new(); + let completion = data.get("completion"); + let completion_source = completion + .and_then(|value| value.get("source")) + .and_then(Value::as_str); + let completion_status = completion + .and_then(|value| value.get("status")) + .and_then(Value::as_str); + if completion_source == Some("out_of_band_control") { + match completion_status { + Some("interrupted") => { + status_lines.push("Process was interrupted externally.".to_string()) + } + Some("killed") => { + status_lines.push("Process was terminated externally.".to_string()) + } + Some(status) => { + status_lines.push(format!("Process ended externally with status {status}.")) + } + None => status_lines.push("Process ended externally.".to_string()), + } + if let Some(exit_code) = data.get("exit_code").and_then(Value::as_i64) { + status_lines.push(format!("Process exited with code {exit_code}.")); + } + } else if let Some(exit_code) = data.get("exit_code").and_then(Value::as_i64) { + status_lines.push(format!("Process exited with code {exit_code}.")); + } else if let Some(session_id) = data.get("session_id").and_then(Value::as_i64) { + status_lines.push(format!( + "Process is still running. session_id: {session_id}" + )); + } + if data.get("tty").and_then(Value::as_bool) == Some(false) + && data + .get("output") + .and_then(Value::as_str) + .map(str::is_empty) + .unwrap_or(true) + { + note_lines.push( + "No output was produced. In non-TTY mode, programs may block-buffer pipe output; use unbuffered flags/env vars or TTY mode if progressive output matters." + .to_string(), + ); + } + render_exec_response_for_assistant_with_notes(data, status_lines, note_lines, 3) + } + + fn local_completion_value(completion: LocalExecSessionCompletion) -> Value { + json!({ + "status": match completion.status { + LocalExecSessionCompletionStatus::Exited => "exited", + LocalExecSessionCompletionStatus::Interrupted => "interrupted", + LocalExecSessionCompletionStatus::Killed => "killed", + LocalExecSessionCompletionStatus::Pruned => "pruned", + }, + "source": match completion.source { + LocalExecSessionCompletionSource::Process => "process", + LocalExecSessionCompletionSource::OutOfBandControl => "out_of_band_control", + }, + }) + } + + fn remote_completion_value(completion: RemoteExecSessionCompletion) -> Value { + json!({ + "status": match completion.status { + RemoteExecSessionCompletionStatus::Exited => "exited", + RemoteExecSessionCompletionStatus::Interrupted => "interrupted", + RemoteExecSessionCompletionStatus::Killed => "killed", + RemoteExecSessionCompletionStatus::Pruned => "pruned", + }, + "source": match completion.source { + RemoteExecSessionCompletionSource::Process => "process", + RemoteExecSessionCompletionSource::OutOfBandControl => "out_of_band_control", + }, + }) + } + + fn local_background_output_status_for_completion( + completion: Option, + ) -> BackgroundCommandOutputStatus { + match completion.map(|completion| completion.status) { + Some(LocalExecSessionCompletionStatus::Interrupted) => { + BackgroundCommandOutputStatus::Interrupted + } + Some(LocalExecSessionCompletionStatus::Killed) => BackgroundCommandOutputStatus::Killed, + Some(LocalExecSessionCompletionStatus::Pruned) => BackgroundCommandOutputStatus::Pruned, + Some(LocalExecSessionCompletionStatus::Exited) | None => { + BackgroundCommandOutputStatus::Exited + } + } + } + + fn remote_background_output_status_for_completion( + completion: Option, + ) -> BackgroundCommandOutputStatus { + match completion.map(|completion| completion.status) { + Some(RemoteExecSessionCompletionStatus::Interrupted) => { + BackgroundCommandOutputStatus::Interrupted + } + Some(RemoteExecSessionCompletionStatus::Killed) => { + BackgroundCommandOutputStatus::Killed + } + Some(RemoteExecSessionCompletionStatus::Pruned) => { + BackgroundCommandOutputStatus::Pruned + } + Some(RemoteExecSessionCompletionStatus::Exited) | None => { + BackgroundCommandOutputStatus::Exited + } + } + } + + fn local_lifecycle_status(status: ExecProcessLifecycleStatus) -> &'static str { + match status { + ExecProcessLifecycleStatus::Running => "running", + ExecProcessLifecycleStatus::Exited => "exited", + ExecProcessLifecycleStatus::Interrupted => "interrupted", + ExecProcessLifecycleStatus::Killed => "killed", + ExecProcessLifecycleStatus::Pruned => "pruned", + } + } + + fn local_background_output_status( + status: ExecProcessLifecycleStatus, + ) -> BackgroundCommandOutputStatus { + match status { + ExecProcessLifecycleStatus::Running => BackgroundCommandOutputStatus::Running, + ExecProcessLifecycleStatus::Exited => BackgroundCommandOutputStatus::Exited, + ExecProcessLifecycleStatus::Interrupted => BackgroundCommandOutputStatus::Interrupted, + ExecProcessLifecycleStatus::Killed => BackgroundCommandOutputStatus::Killed, + ExecProcessLifecycleStatus::Pruned => BackgroundCommandOutputStatus::Pruned, + } + } + + fn remote_lifecycle_status(status: RemoteExecProcessLifecycleStatus) -> &'static str { + match status { + RemoteExecProcessLifecycleStatus::Running => "running", + RemoteExecProcessLifecycleStatus::Exited => "exited", + RemoteExecProcessLifecycleStatus::Interrupted => "interrupted", + RemoteExecProcessLifecycleStatus::Killed => "killed", + RemoteExecProcessLifecycleStatus::Pruned => "pruned", + } + } + + fn remote_background_output_status( + status: RemoteExecProcessLifecycleStatus, + ) -> BackgroundCommandOutputStatus { + match status { + RemoteExecProcessLifecycleStatus::Running => BackgroundCommandOutputStatus::Running, + RemoteExecProcessLifecycleStatus::Exited => BackgroundCommandOutputStatus::Exited, + RemoteExecProcessLifecycleStatus::Interrupted => { + BackgroundCommandOutputStatus::Interrupted + } + RemoteExecProcessLifecycleStatus::Killed => BackgroundCommandOutputStatus::Killed, + RemoteExecProcessLifecycleStatus::Pruned => BackgroundCommandOutputStatus::Pruned, + } + } + + fn now_unix_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn start_local_lifecycle_bridge( + context: &ToolUseContext, + _tool_name: &str, + ) -> Option> { + let capture_id = context.tool_call_id.clone()?; + let agent_session_id = context.session_id.clone(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + let event_system = get_global_event_system(); + let output_capture = background_command_output_capture(); + while let Some(event) = rx.recv().await { + let capture_status = Self::local_background_output_status(event.status); + if let Some(metadata) = output_capture + .update_lifecycle( + &capture_id, + event.session_id, + capture_status, + event.exit_code, + ) + .await + { + let timestamp = Self::now_unix_seconds(); + let _ = event_system + .emit(BackgroundCommandLifecycle(BackgroundCommandLifecycleInfo { + agent_session_id: metadata + .agent_session_id + .or(agent_session_id.clone()), + exec_session_id: event.session_id, + command: metadata.command, + workdir: metadata.workdir, + remote: false, + tty: metadata.tty, + status: Self::local_lifecycle_status(event.status).to_string(), + exit_code: event.exit_code, + started_at: metadata.started_at, + ended_at: metadata.ended_at, + timestamp, + })) + .await; + } + } + }); + Some(tx) + } + + fn start_remote_lifecycle_bridge( + context: &ToolUseContext, + _tool_name: &str, + ) -> Option> { + let capture_id = context.tool_call_id.clone()?; + let agent_session_id = context.session_id.clone(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + let event_system = get_global_event_system(); + let output_capture = background_command_output_capture(); + while let Some(event) = rx.recv().await { + let capture_status = Self::remote_background_output_status(event.status); + if let Some(metadata) = output_capture + .update_lifecycle( + &capture_id, + event.session_id, + capture_status, + event.exit_code, + ) + .await + { + let timestamp = Self::now_unix_seconds(); + let _ = event_system + .emit(BackgroundCommandLifecycle(BackgroundCommandLifecycleInfo { + agent_session_id: metadata + .agent_session_id + .or(agent_session_id.clone()), + exec_session_id: event.session_id, + command: metadata.command, + workdir: metadata.workdir, + remote: true, + tty: metadata.tty, + status: Self::remote_lifecycle_status(event.status).to_string(), + exit_code: event.exit_code, + started_at: metadata.started_at, + ended_at: metadata.ended_at, + timestamp, + })) + .await; + } + } + }); + Some(tx) + } + + async fn call_remote_pipe( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let cmd = input + .get("cmd") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::tool("cmd is required for ExecCommand".to_string()))?; + let tty = input.get("tty").and_then(Value::as_bool).unwrap_or(false); + + let workdir = Self::resolve_remote_workdir(input, context).await?; + let connection_id = context + .workspace + .as_ref() + .and_then(|workspace| workspace.connection_id()) + .ok_or_else(|| { + BitFunError::tool("remote connection id is required for ExecCommand".to_string()) + })? + .to_string(); + let ssh_manager = get_remote_workspace_manager() + .ok_or_else(|| { + BitFunError::tool( + "remote workspace manager is not initialized for ExecCommand".to_string(), + ) + })? + .get_ssh_manager() + .await + .ok_or_else(|| { + BitFunError::tool( + "remote SSH manager is not initialized for ExecCommand".to_string(), + ) + })?; + let yield_time_ms = input + .get("yield_time_ms") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_TOOL_YIELD_TIME_MS); + let shell = Self::resolve_remote_shell(&ssh_manager, &connection_id).await; + let env_snapshot = remote_env_snapshot_for( + ssh_manager.clone(), + &connection_id, + &shell.path, + &shell.shell_type, + ) + .await; + let command_body = if tty { + cmd.to_string() + } else { + Self::remote_non_tty_control_wrapper(cmd, &shell.path) + }; + let command = Self::remote_login_shell_command( + &workdir, + &command_body, + &shell, + env_snapshot.as_ref(), + ); + let output_capture_tx = if let Some(capture_id) = context.tool_call_id.as_ref() { + Some( + background_command_output_capture() + .start_capture(StartBackgroundCommandOutputCapture { + capture_id: capture_id.clone(), + agent_session_id: context.session_id.clone(), + command: cmd.to_string(), + workdir: Some(workdir.clone()), + remote: true, + tty, + }) + .await, + ) + } else { + None + }; + + let request = RemoteExecCommandRequest { + ssh_manager, + connection_id, + command, + tty, + yield_time_ms: Some(yield_time_ms), + max_output_chars: None, + lifecycle_tx: Self::start_remote_lifecycle_bridge(context, self.name()), + output_capture_tx, + }; + let progress_bridge = ExecOutputProgressBridge::start(context, self.name()); + let response_result = if let Some(bridge) = progress_bridge.as_ref() { + get_global_remote_exec_process_manager() + .exec_command_streaming(request, bridge.sender()) + .await + } else { + get_global_remote_exec_process_manager() + .exec_command(request) + .await + }; + if let Some(bridge) = progress_bridge { + bridge.finish().await; + } + let response = match response_result { + Ok(response) => response, + Err(error) => { + if let Some(capture_id) = context.tool_call_id.as_ref() { + background_command_output_capture() + .finish(capture_id, BackgroundCommandOutputStatus::Failed, None) + .await; + } + return Err(BitFunError::tool(format!("ExecCommand failed: {error}"))); + } + }; + if let Some(capture_id) = context.tool_call_id.as_ref() { + if let Some(session_id) = response.session_id { + background_command_output_capture() + .set_session_id(capture_id, Some(session_id)) + .await; + } + if response.session_id.is_none() { + background_command_output_capture() + .finish( + capture_id, + Self::remote_background_output_status_for_completion(response.completion), + response.exit_code, + ) + .await; + } + } + + let data = json!({ + "chunk_id": response.chunk_id, + "wall_time_seconds": response.wall_time_seconds, + "output": response.output, + "session_id": response.session_id, + "exit_code": response.exit_code, + "original_output_chars": response.original_output_chars, + "completion": response.completion.map(Self::remote_completion_value), + "workdir": workdir.clone(), + "tty": tty, + "remote": true, + "shell": Self::remote_shell_metadata(&workdir, &shell, env_snapshot.is_some()), + }); + let result_for_assistant = Self::response_for_assistant(&data); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +fn parse_remote_shell_probe_output(stdout: &str) -> Option { + stdout + .lines() + .map(str::trim) + .find(|line| is_plausible_remote_shell_path(line)) + .map(|path| RemoteShell { + path: path.to_string(), + shell_type: ShellType::from_executable(path), + }) +} + +fn is_plausible_remote_shell_path(path: &str) -> bool { + path.starts_with('/') + && !path.contains('\0') + && path.chars().all(|ch| !ch.is_control() || ch == '\t') +} + +fn remote_command_env_words(env: HashMap) -> String { + let mut env: Vec<_> = env.into_iter().collect(); + env.sort_by(|(left, _), (right, _)| left.cmp(right)); + env.into_iter() + .map(|(key, value)| shell_escape(&format!("{key}={value}"))) + .collect::>() + .join(" ") +} + +fn remote_shell_login_args() -> &'static [&'static str] { + &["-lc"] +} + +#[async_trait] +impl Tool for ExecCommandTool { + fn name(&self) -> &str { + "ExecCommand" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Runs a shell command in a separate process. + +TTY modes: +- `tty=false` (Default): + - runs without a PTY. Local commands use pipe-backed stdio; remote commands use a non-PTY SSH exec channel. + - No interactive stdin is attached, so input-waiting programs may see EOF instead of a prompt. + - programs may block-buffer pipe output, so output may appear only after the process exits. Use unbuffered flags/env vars such as `python -u` or `PYTHONUNBUFFERED=1` or TTY mode when progressive output matters. +- `tty=true`: allocates a PTY and gives the command terminal semantics. Use it for commands that need interactive stdin or terminal behavior. + +Waiting and continuation: +- yield_time_ms waits for output until the process exits or the deadline is reached. It does not stop the process. +- If the process is still running after `yield_time_ms`, the result includes a numeric session_id. +- Use WriteStdin to poll for more output or send input to tty=true sessions, and ExecControl to interrupt or kill it. + +Output: +- Output is only what was produced during this tool call's wait window. +- In non-TTY mode, stdout and stderr ordering is not guaranteed; use tty=true or redirect stderr with 2>&1 when terminal ordering matters."# + .to_string()) + } + + async fn description_with_context( + &self, + _context: Option<&ToolUseContext>, + ) -> BitFunResult { + self.description().await + } + + fn short_description(&self) -> String { + "Run a command in a fresh process.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "cmd": { + "type": "string", + "description": "Shell command to execute." + }, + "workdir": { + "type": "string", + "description": "Optional absolute working directory path. Defaults to the workspace root." + }, + "tty": { + "type": "boolean", + "description": "Set true only for commands that need interactive stdin. Defaults to false." + }, + "yield_time_ms": { + "type": "number", + "description": "How long to wait for output before yielding. Defaults to 30000 ms." + } + }, + "required": ["cmd"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + true + } + + fn manages_own_execution_timeout(&self) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let cmd = input.get("cmd").and_then(Value::as_str).unwrap_or_default(); + if cmd.trim().is_empty() { + return ValidationResult { + result: false, + message: Some("cmd is required for ExecCommand".to_string()), + error_code: Some(400), + meta: None, + }; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if context.is_remote() { + return self.call_remote_pipe(input, context).await; + } + + let cmd = input + .get("cmd") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::tool("cmd is required for ExecCommand".to_string()))?; + let workdir = Self::resolve_workdir(input, context)?; + let tty = input.get("tty").and_then(Value::as_bool).unwrap_or(false); + let shell = resolve_local_exec_shell().await; + let yield_time_ms = input + .get("yield_time_ms") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_TOOL_YIELD_TIME_MS); + let output_capture_tx = if let Some(capture_id) = context.tool_call_id.as_ref() { + Some( + background_command_output_capture() + .start_capture(StartBackgroundCommandOutputCapture { + capture_id: capture_id.clone(), + agent_session_id: context.session_id.clone(), + command: cmd.to_string(), + workdir: Some(workdir.to_string_lossy().to_string()), + remote: false, + tty, + }) + .await, + ) + } else { + None + }; + + let request = LocalExecCommandRequest { + argv: Self::argv_for_shell(&shell.path, &shell.shell_type, cmd), + cwd: workdir.clone(), + env: Self::command_env(), + tty, + yield_time_ms: Some(yield_time_ms), + max_output_chars: None, + lifecycle_tx: Self::start_local_lifecycle_bridge(context, self.name()), + output_capture_tx, + }; + let progress_bridge = ExecOutputProgressBridge::start(context, self.name()); + let response_result = if let Some(bridge) = progress_bridge.as_ref() { + get_global_exec_process_manager() + .exec_command_streaming(request, bridge.sender()) + .await + } else { + get_global_exec_process_manager() + .exec_command(request) + .await + }; + if let Some(bridge) = progress_bridge { + bridge.finish().await; + } + let response = match response_result { + Ok(response) => response, + Err(error) => { + if let Some(capture_id) = context.tool_call_id.as_ref() { + background_command_output_capture() + .finish(capture_id, BackgroundCommandOutputStatus::Failed, None) + .await; + } + return Err(BitFunError::tool(format!("ExecCommand failed: {error}"))); + } + }; + if let Some(capture_id) = context.tool_call_id.as_ref() { + if let Some(session_id) = response.session_id { + background_command_output_capture() + .set_session_id(capture_id, Some(session_id)) + .await; + } + if response.session_id.is_none() { + background_command_output_capture() + .finish( + capture_id, + Self::local_background_output_status_for_completion(response.completion), + response.exit_code, + ) + .await; + } + } + + let data = json!({ + "chunk_id": response.chunk_id, + "wall_time_seconds": response.wall_time_seconds, + "output": response.output, + "session_id": response.session_id, + "exit_code": response.exit_code, + "original_output_chars": response.original_output_chars, + "completion": response.completion.map(Self::local_completion_value), + "workdir": workdir.to_string_lossy(), + "tty": tty, + "shell": Self::shell_metadata_value(&shell), + }); + let result_for_assistant = Self::response_for_assistant(&data); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::super::env_snapshot::RemoteEnvSnapshot; + use super::{parse_remote_shell_probe_output, RemoteShell}; + use super::{ExecCommandTool, POWERSHELL_UTF8_OUTPUT_PREFIX}; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::workspace::WorkspaceBinding; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use serde_json::json; + use std::collections::HashMap; + use std::path::Path; + use std::path::PathBuf; + use terminal_core::ShellType; + + #[test] + fn powershell_commands_force_utf8_output() { + let argv = ExecCommandTool::argv_for_shell( + Path::new("pwsh"), + &ShellType::PowerShellCore, + "Get-Content README.md", + ); + + assert_eq!(argv[1], "-Command"); + assert!(argv[2].starts_with(POWERSHELL_UTF8_OUTPUT_PREFIX)); + assert!(argv[2].contains("Get-Content README.md")); + } + + #[test] + fn powershell_utf8_output_prefix_is_not_duplicated() { + let script = format!("{POWERSHELL_UTF8_OUTPUT_PREFIX}Write-Output ok"); + let argv = + ExecCommandTool::argv_for_shell(Path::new("pwsh"), &ShellType::PowerShellCore, &script); + + assert_eq!(argv[2], script); + } + + #[test] + fn response_notes_empty_non_tty_output_may_be_buffered() { + let data = json!({ + "wall_time_seconds": 30.0, + "output": "", + "session_id": 42, + "exit_code": null, + "tty": false, + }); + + let assistant = ExecCommandTool::response_for_assistant(&data); + + assert!(assistant.contains("")); + assert!(assistant.contains("block-buffer pipe output")); + } + + #[test] + fn response_does_not_note_empty_tty_output() { + let data = json!({ + "wall_time_seconds": 30.0, + "output": "", + "session_id": 42, + "exit_code": null, + "tty": true, + }); + + let assistant = ExecCommandTool::response_for_assistant(&data); + + assert!(!assistant.contains("")); + } + + #[test] + fn remote_login_shell_command_wraps_workdir_env_shell_and_user_command() { + let shell = RemoteShell { + path: "/bin/bash".to_string(), + shell_type: ShellType::Bash, + }; + let command = ExecCommandTool::remote_login_shell_command( + "/home/me/project", + "printf 'hi'", + &shell, + None, + ); + + assert!(command.starts_with("cd '/home/me/project' && env ")); + assert!(command.contains("'BITFUN_NONINTERACTIVE=1'")); + assert!(command.ends_with(" '/bin/bash' -lc 'printf '\\''hi'\\'''")); + } + + #[test] + fn remote_login_shell_command_injects_snapshot_before_tool_env() { + let shell = RemoteShell { + path: "/bin/bash".to_string(), + shell_type: ShellType::Bash, + }; + let snapshot = RemoteEnvSnapshot { + env: HashMap::from([ + ("PATH".to_string(), "/home/me/.nvm/bin:/usr/bin".to_string()), + ("TERM".to_string(), "xterm-256color".to_string()), + ]), + }; + let command = ExecCommandTool::remote_login_shell_command( + "/home/me/project", + "node --version", + &shell, + Some(&snapshot), + ); + + assert!(command.contains("'PATH=/home/me/.nvm/bin:/usr/bin'")); + assert!(command.contains("'TERM=dumb'")); + assert!(!command.contains("'TERM=xterm-256color'")); + } + + #[test] + fn remote_login_shell_command_uses_snapshot_without_interactive_startup() { + let shell = RemoteShell { + path: "/bin/bash".to_string(), + shell_type: ShellType::Bash, + }; + let snapshot = RemoteEnvSnapshot { + env: HashMap::from([("PATH".to_string(), "/home/me/.nvm/bin:/usr/bin".to_string())]), + }; + let command = ExecCommandTool::remote_login_shell_command( + "/home/me/project", + "node --version", + &shell, + Some(&snapshot), + ); + + assert!(command.contains("'PATH=/home/me/.nvm/bin:/usr/bin'")); + assert!(command.ends_with(" '/bin/bash' -lc 'node --version'")); + assert!(!command.contains(" -lic ")); + } + + #[test] + fn remote_non_tty_control_wrapper_cleans_process_group_after_interrupt_grace() { + let wrapper = + ExecCommandTool::remote_non_tty_control_wrapper("python3 -c 'print(1)'", "/bin/bash"); + + assert!(wrapper.contains("setsid \"$__bitfun_shell\" -lc \"$__bitfun_cmd\" &")); + assert!(wrapper.contains("trap '__bitfun_stop INT 130 2' INT")); + assert!(wrapper.contains("trap '__bitfun_stop KILL 137 0' TERM")); + assert!(wrapper.contains("__bitfun_grace=${3:-2}")); + assert!(wrapper.contains("sleep \"$__bitfun_grace\"")); + assert!(wrapper.contains("kill -KILL \"-$__bitfun_pgid\"")); + assert!(wrapper.contains("__bitfun_cmd='python3 -c '\\''print(1)'\\'''")); + } + + #[test] + fn remote_shell_probe_prefers_first_plausible_shell_path() { + let shell = parse_remote_shell_probe_output("\n/bin/zsh\n/usr/bin/bash\n") + .expect("shell should parse"); + + assert_eq!(shell.path, "/bin/zsh"); + assert_eq!(shell.shell_type, ShellType::Zsh); + } + + #[test] + fn remote_shell_login_args_use_login_without_interactive_startup() { + assert_eq!(super::remote_shell_login_args(), &["-lc"]); + } + + #[tokio::test] + async fn description_with_context_stays_stable_for_local_and_remote_workspaces() { + let tool = ExecCommandTool::new(); + let base = tool.description().await.expect("description should build"); + let session_identity = + workspace_session_identity("/home/me/project", Some("conn-1"), Some("remote-host")) + .expect("remote session identity should build"); + let remote_context = ToolUseContext { + tool_call_id: None, + agent_type: Some("agentic".to_string()), + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + None, + PathBuf::from("/home/me/project"), + "conn-1".to_string(), + "Remote Host".to_string(), + session_identity, + )), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + }; + + assert_eq!( + base, + tool.description_with_context(Some(&remote_context)) + .await + .expect("contextual description should build") + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs new file mode 100644 index 0000000000..53f04ee081 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs @@ -0,0 +1,563 @@ +use super::rendering::render_exec_response_for_assistant; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::service::remote_ssh::{ + get_global_remote_exec_process_manager, RemoteExecControlAction, RemoteExecControlOrigin, + RemoteExecControlRequest, RemoteExecError, RemoteExecSessionCompletion, + RemoteExecSessionCompletionSource, RemoteExecSessionCompletionStatus, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use terminal_core::{ + get_global_exec_process_manager, LocalExecControlAction, LocalExecControlOrigin, + LocalExecControlRequest, LocalExecSessionCompletion, LocalExecSessionCompletionSource, + LocalExecSessionCompletionStatus, TerminalError, +}; + +// ExecControl termination semantics by execution surface: +// +// Local workspace: +// - tty=true: interrupt writes Ctrl+C to the PTY; kill uses the PTY child killer. +// - tty=false, Windows: interrupt and kill both terminate the process tree via +// taskkill /T /F, with child.kill() as fallback. This is intentionally +// forceful because Windows pipe-mode Ctrl+C delivery is not reliable. +// - tty=false, Unix: the pipe child starts in its own session/process group. +// interrupt sends SIGINT to that group, waits a short grace window, then +// sends SIGKILL to clean up descendants; kill sends SIGKILL to the group. +// +// Remote SSH workspace: +// - tty=true: interrupt writes Ctrl+C to the remote PTY; kill sends an SSH +// SIGKILL request and closes the channel after a short drain. +// - tty=false, Unix/POSIX SSH host: ExecCommand wraps the user command in a +// remote process-group owner. interrupt asks the wrapper to send SIGINT, +// wait its grace window, then SIGKILL the remote process group. kill sends a +// catchable TERM to the wrapper, which immediately SIGKILLs the remote group. +// - Remote Windows SSH hosts are not part of the current ExecCommand contract; +// remote workspaces assume POSIX paths and shells. +pub struct ExecControlTool; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecCommandControlAction { + Interrupt, + Kill, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecCommandControlOrigin { + ModelTool, + OutOfBand, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecCommandCompletionStatus { + Exited, + Interrupted, + Killed, + Pruned, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecCommandCompletionSource { + Process, + OutOfBandControl, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExecCommandCompletion { + pub status: ExecCommandCompletionStatus, + pub source: ExecCommandCompletionSource, +} + +#[derive(Debug, Clone)] +pub struct ExecCommandControlRequest { + pub session_id: i32, + pub action: ExecCommandControlAction, + pub origin: ExecCommandControlOrigin, + pub remote: bool, + pub yield_time_ms: Option, +} + +#[derive(Debug, Clone)] +pub struct ExecCommandControlResponse { + pub chunk_id: String, + pub wall_time_seconds: f64, + pub output: String, + pub session_id: Option, + pub exit_code: Option, + pub original_output_chars: usize, + pub action: ExecCommandControlAction, + pub remote: bool, + pub completion: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ExecCommandControlError { + #[error("session not found: {0}")] + SessionNotFound(i32), + + #[error(transparent)] + Tool(#[from] BitFunError), +} + +pub async fn control_exec_command_session( + request: ExecCommandControlRequest, +) -> Result { + if request.remote { + let response = get_global_remote_exec_process_manager() + .control_session(RemoteExecControlRequest { + session_id: request.session_id, + action: ExecControlTool::remote_action(request.action), + origin: ExecControlTool::remote_origin(request.origin), + yield_time_ms: request.yield_time_ms, + max_output_chars: None, + }) + .await + .map_err(|error| match error { + RemoteExecError::SessionNotFound(session_id) => { + ExecCommandControlError::SessionNotFound(session_id) + } + error => ExecCommandControlError::Tool(BitFunError::tool(format!( + "ExecControl failed: {error}" + ))), + })?; + + return Ok(ExecCommandControlResponse { + chunk_id: response.chunk_id, + wall_time_seconds: response.wall_time_seconds, + output: response.output, + session_id: response.session_id, + exit_code: response.exit_code, + original_output_chars: response.original_output_chars, + action: request.action, + remote: true, + completion: response.completion.map(ExecControlTool::remote_completion), + }); + } + + let response = get_global_exec_process_manager() + .control_session(LocalExecControlRequest { + session_id: request.session_id, + action: ExecControlTool::local_action(request.action), + origin: ExecControlTool::local_origin(request.origin), + yield_time_ms: request.yield_time_ms, + max_output_chars: None, + }) + .await + .map_err(|error| match error { + TerminalError::SessionNotFound(_) => { + ExecCommandControlError::SessionNotFound(request.session_id) + } + error => ExecCommandControlError::Tool(BitFunError::tool(format!( + "ExecControl failed: {error}" + ))), + })?; + + Ok(ExecCommandControlResponse { + chunk_id: response.chunk_id, + wall_time_seconds: response.wall_time_seconds, + output: response.output, + session_id: response.session_id, + exit_code: response.exit_code, + original_output_chars: response.original_output_chars, + action: request.action, + remote: false, + completion: response.completion.map(ExecControlTool::local_completion), + }) +} + +impl Default for ExecControlTool { + fn default() -> Self { + Self::new() + } +} + +impl ExecControlTool { + pub fn new() -> Self { + Self + } + + fn session_id_from_input(input: &Value) -> Option { + input.get("session_id").and_then(|value| { + value + .as_i64() + .and_then(|id| i32::try_from(id).ok()) + .or_else(|| value.as_u64().and_then(|id| i32::try_from(id).ok())) + }) + } + + fn action_from_input(input: &Value) -> Option { + match input.get("action").and_then(Value::as_str)?.trim() { + "interrupt" => Some(ExecCommandControlAction::Interrupt), + "kill" => Some(ExecCommandControlAction::Kill), + _ => None, + } + } + + fn response_for_assistant(data: &Value, action: ExecCommandControlAction) -> String { + let mut status_lines = Vec::new(); + match action { + ExecCommandControlAction::Interrupt => { + status_lines.push("Sent interrupt to process.".to_string()) + } + ExecCommandControlAction::Kill => { + status_lines.push("Sent kill to process.".to_string()) + } + } + if let Some(exit_code) = data.get("exit_code").and_then(Value::as_i64) { + status_lines.push(format!("Process exited with code {exit_code}.")); + } else if let Some(session_id) = data.get("session_id").and_then(Value::as_i64) { + status_lines.push(format!( + "Process is still running. session_id: {session_id}" + )); + } + render_exec_response_for_assistant(data, status_lines, 4) + } + + fn session_not_found_result( + session_id: i32, + action: ExecCommandControlAction, + remote: bool, + ) -> Vec { + let action_name = match action { + ExecCommandControlAction::Interrupt => "interrupt", + ExecCommandControlAction::Kill => "kill", + }; + let message = format!( + "No {action_name} was sent because ExecCommand session {session_id} was not found. It may have already exited, been collected, or been pruned." + ); + let mut data = json!({ + "status": "session_not_found", + "message": message, + "requested_session_id": session_id, + "session_id": null, + "exit_code": null, + "output": "", + "original_output_chars": 0, + "action": action_name, + }); + if remote { + data["remote"] = json!(true); + } + + vec![ToolResult::Result { + data, + result_for_assistant: Some(message), + image_attachments: None, + }] + } + + fn local_action(action: ExecCommandControlAction) -> LocalExecControlAction { + match action { + ExecCommandControlAction::Interrupt => LocalExecControlAction::Interrupt, + ExecCommandControlAction::Kill => LocalExecControlAction::Kill, + } + } + + fn local_origin(origin: ExecCommandControlOrigin) -> LocalExecControlOrigin { + match origin { + ExecCommandControlOrigin::ModelTool => LocalExecControlOrigin::ModelTool, + ExecCommandControlOrigin::OutOfBand => LocalExecControlOrigin::OutOfBand, + } + } + + fn remote_action(action: ExecCommandControlAction) -> RemoteExecControlAction { + match action { + ExecCommandControlAction::Interrupt => RemoteExecControlAction::Interrupt, + ExecCommandControlAction::Kill => RemoteExecControlAction::Kill, + } + } + + fn remote_origin(origin: ExecCommandControlOrigin) -> RemoteExecControlOrigin { + match origin { + ExecCommandControlOrigin::ModelTool => RemoteExecControlOrigin::ModelTool, + ExecCommandControlOrigin::OutOfBand => RemoteExecControlOrigin::OutOfBand, + } + } + + fn local_completion(completion: LocalExecSessionCompletion) -> ExecCommandCompletion { + ExecCommandCompletion { + status: match completion.status { + LocalExecSessionCompletionStatus::Exited => ExecCommandCompletionStatus::Exited, + LocalExecSessionCompletionStatus::Interrupted => { + ExecCommandCompletionStatus::Interrupted + } + LocalExecSessionCompletionStatus::Killed => ExecCommandCompletionStatus::Killed, + LocalExecSessionCompletionStatus::Pruned => ExecCommandCompletionStatus::Pruned, + }, + source: match completion.source { + LocalExecSessionCompletionSource::Process => ExecCommandCompletionSource::Process, + LocalExecSessionCompletionSource::OutOfBandControl => { + ExecCommandCompletionSource::OutOfBandControl + } + }, + } + } + + fn remote_completion(completion: RemoteExecSessionCompletion) -> ExecCommandCompletion { + ExecCommandCompletion { + status: match completion.status { + RemoteExecSessionCompletionStatus::Exited => ExecCommandCompletionStatus::Exited, + RemoteExecSessionCompletionStatus::Interrupted => { + ExecCommandCompletionStatus::Interrupted + } + RemoteExecSessionCompletionStatus::Killed => ExecCommandCompletionStatus::Killed, + RemoteExecSessionCompletionStatus::Pruned => ExecCommandCompletionStatus::Pruned, + }, + source: match completion.source { + RemoteExecSessionCompletionSource::Process => ExecCommandCompletionSource::Process, + RemoteExecSessionCompletionSource::OutOfBandControl => { + ExecCommandCompletionSource::OutOfBandControl + } + }, + } + } + + async fn call_remote_pipe(&self, input: &Value) -> BitFunResult> { + let session_id = Self::session_id_from_input(input).ok_or_else(|| { + BitFunError::tool("session_id is required for ExecControl".to_string()) + })?; + let action = Self::action_from_input(input).ok_or_else(|| { + BitFunError::tool("action must be either 'interrupt' or 'kill'".to_string()) + })?; + let yield_time_ms = input.get("yield_time_ms").and_then(Value::as_u64); + let response = match control_exec_command_session(ExecCommandControlRequest { + session_id, + action, + origin: ExecCommandControlOrigin::ModelTool, + remote: true, + yield_time_ms, + }) + .await + { + Ok(response) => response, + Err(ExecCommandControlError::SessionNotFound(session_id)) => { + return Ok(Self::session_not_found_result(session_id, action, true)); + } + Err(ExecCommandControlError::Tool(error)) => return Err(error), + }; + + let action_name = match action { + ExecCommandControlAction::Interrupt => "interrupt", + ExecCommandControlAction::Kill => "kill", + }; + let data = json!({ + "chunk_id": response.chunk_id, + "wall_time_seconds": response.wall_time_seconds, + "output": response.output, + "session_id": response.session_id, + "exit_code": response.exit_code, + "original_output_chars": response.original_output_chars, + "action": action_name, + "remote": response.remote, + }); + let result_for_assistant = Self::response_for_assistant(&data, action); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +#[async_trait] +impl Tool for ExecControlTool { + fn name(&self) -> &str { + "ExecControl" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Interrupts or kills a running ExecCommand session. + +Pass the session_id returned by ExecCommand. +Use action="interrupt" when a command should stop gracefully, like pressing Ctrl+C. Use action="kill" when the process must be terminated. +After the action, yield_time_ms waits for output or exit status. +Output is only what was produced during this tool call's wait window."# + .to_string()) + } + + fn short_description(&self) -> String { + "Interrupt or kill a running ExecCommand session.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "session_id": { + "type": "number", + "description": "session_id returned by ExecCommand." + }, + "action": { + "type": "string", + "enum": ["interrupt", "kill"], + "description": "Use interrupt to stop gracefully; use kill to force termination." + }, + "yield_time_ms": { + "type": "number", + "description": "How long to wait for output after the control action before yielding. Defaults to 10000 ms." + } + }, + "required": ["session_id", "action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + true + } + + fn manages_own_execution_timeout(&self) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if Self::session_id_from_input(input).is_none() { + return ValidationResult { + result: false, + message: Some("session_id is required for ExecControl".to_string()), + error_code: Some(400), + meta: None, + }; + } + if Self::action_from_input(input).is_none() { + return ValidationResult { + result: false, + message: Some("action must be either 'interrupt' or 'kill'".to_string()), + error_code: Some(400), + meta: None, + }; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if context.is_remote() { + return self.call_remote_pipe(input).await; + } + + let session_id = Self::session_id_from_input(input).ok_or_else(|| { + BitFunError::tool("session_id is required for ExecControl".to_string()) + })?; + let action = Self::action_from_input(input).ok_or_else(|| { + BitFunError::tool("action must be either 'interrupt' or 'kill'".to_string()) + })?; + let yield_time_ms = input.get("yield_time_ms").and_then(Value::as_u64); + let response = match control_exec_command_session(ExecCommandControlRequest { + session_id, + action, + origin: ExecCommandControlOrigin::ModelTool, + remote: false, + yield_time_ms, + }) + .await + { + Ok(response) => response, + Err(ExecCommandControlError::SessionNotFound(session_id)) => { + return Ok(Self::session_not_found_result(session_id, action, false)); + } + Err(ExecCommandControlError::Tool(error)) => return Err(error), + }; + + let action_name = match action { + ExecCommandControlAction::Interrupt => "interrupt", + ExecCommandControlAction::Kill => "kill", + }; + let data = json!({ + "chunk_id": response.chunk_id, + "wall_time_seconds": response.wall_time_seconds, + "output": response.output, + "session_id": response.session_id, + "exit_code": response.exit_code, + "original_output_chars": response.original_output_chars, + "action": action_name, + }); + let result_for_assistant = Self::response_for_assistant(&data, action); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::{ + control_exec_command_session, ExecCommandControlAction, ExecCommandControlError, + ExecCommandControlOrigin, ExecCommandControlRequest, ExecControlTool, + }; + use crate::agentic::tools::framework::ToolResult; + + #[test] + fn session_not_found_result_uses_plain_assistant_message() { + let results = ExecControlTool::session_not_found_result( + 456, + ExecCommandControlAction::Interrupt, + false, + ); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected result"); + }; + + assert_eq!( + data.get("status").and_then(|value| value.as_str()), + Some("session_not_found") + ); + assert_eq!( + data.get("requested_session_id") + .and_then(|value| value.as_i64()), + Some(456) + ); + let assistant = result_for_assistant.as_deref().expect("assistant text"); + assert!(assistant.contains("No interrupt was sent")); + assert!(assistant.contains("ExecCommand session 456 was not found")); + assert!(!assistant.contains("")); + assert!(!assistant.contains("")); + } + + #[tokio::test] + async fn control_exec_command_session_returns_structured_session_not_found() { + let error = control_exec_command_session(ExecCommandControlRequest { + session_id: 987_654, + action: ExecCommandControlAction::Kill, + origin: ExecCommandControlOrigin::ModelTool, + remote: false, + yield_time_ms: Some(0), + }) + .await + .expect_err("missing session should be structured"); + + assert!(matches!( + error, + ExecCommandControlError::SessionNotFound(987_654) + )); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/env_snapshot.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/env_snapshot.rs new file mode 100644 index 0000000000..6c44e5550c --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/env_snapshot.rs @@ -0,0 +1,246 @@ +use crate::service::remote_ssh::{ + get_global_remote_exec_process_manager, RemoteExecCommandRequest, RemoteExecControlAction, + RemoteExecControlOrigin, RemoteExecControlRequest, SSHConnectionManager, +}; +use std::collections::HashMap; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; +use terminal_core::ShellType; +use tokio::sync::Mutex; + +const ENV_SNAPSHOT_BEGIN: &str = "__BITFUN_REMOTE_ENV_SNAPSHOT_BEGIN__"; +const ENV_SNAPSHOT_END: &str = "__BITFUN_REMOTE_ENV_SNAPSHOT_END__"; +const ENV_SNAPSHOT_TIMEOUT_MS: u64 = 3_000; +const ENV_SNAPSHOT_MAX_OUTPUT_CHARS: usize = 128 * 1024; +const ENV_SNAPSHOT_TTL: Duration = Duration::from_secs(10 * 60); + +static REMOTE_ENV_SNAPSHOT_CACHE: OnceLock>> = + OnceLock::new(); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteEnvSnapshot { + pub(super) env: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RemoteEnvSnapshotKey { + connection_id: String, + shell_path: String, + shell_type: String, +} + +#[derive(Debug, Clone)] +struct CachedSnapshot { + captured_at: Instant, + snapshot: RemoteEnvSnapshot, +} + +pub(super) async fn remote_env_snapshot_for( + ssh_manager: SSHConnectionManager, + connection_id: &str, + shell_path: &str, + shell_type: &ShellType, +) -> Option { + let key = RemoteEnvSnapshotKey { + connection_id: connection_id.to_string(), + shell_path: shell_path.to_string(), + shell_type: shell_type.to_string(), + }; + + if let Some(snapshot) = cached_snapshot(&key).await { + return Some(snapshot); + } + + let snapshot = + match capture_remote_env_snapshot(ssh_manager, connection_id, shell_path, shell_type).await + { + Ok(snapshot) => snapshot, + Err(_) => return None, + }; + cache_snapshot(key, snapshot.clone()).await; + Some(snapshot) +} + +async fn cached_snapshot(key: &RemoteEnvSnapshotKey) -> Option { + let cache = REMOTE_ENV_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let guard = cache.lock().await; + guard + .get(key) + .filter(|entry| entry.captured_at.elapsed() <= ENV_SNAPSHOT_TTL) + .map(|entry| entry.snapshot.clone()) +} + +async fn cache_snapshot(key: RemoteEnvSnapshotKey, snapshot: RemoteEnvSnapshot) { + let cache = REMOTE_ENV_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + cache.lock().await.insert( + key, + CachedSnapshot { + captured_at: Instant::now(), + snapshot, + }, + ); +} + +async fn capture_remote_env_snapshot( + ssh_manager: SSHConnectionManager, + connection_id: &str, + shell_path: &str, + shell_type: &ShellType, +) -> anyhow::Result { + let command = remote_env_snapshot_command(shell_path, shell_type); + let manager = get_global_remote_exec_process_manager(); + let response = manager + .exec_command(RemoteExecCommandRequest { + ssh_manager, + connection_id: connection_id.to_string(), + command, + tty: true, + yield_time_ms: Some(ENV_SNAPSHOT_TIMEOUT_MS), + max_output_chars: Some(ENV_SNAPSHOT_MAX_OUTPUT_CHARS), + lifecycle_tx: None, + output_capture_tx: None, + }) + .await?; + + if let Some(session_id) = response.session_id { + let _ = manager + .control_session(RemoteExecControlRequest { + session_id, + action: RemoteExecControlAction::Kill, + origin: RemoteExecControlOrigin::ModelTool, + yield_time_ms: Some(500), + max_output_chars: Some(2_000), + }) + .await; + anyhow::bail!("remote environment snapshot command did not exit before timeout"); + } + + if response.exit_code.is_some_and(|exit_code| exit_code != 0) { + anyhow::bail!( + "remote environment snapshot command exited with {:?}", + response.exit_code + ); + } + + parse_remote_env_snapshot_output(&response.output) + .ok_or_else(|| anyhow::anyhow!("remote environment snapshot markers were not found")) +} + +fn remote_env_snapshot_command(shell_path: &str, shell_type: &ShellType) -> String { + let script = format!( + "printf '%s\\n' {begin}; env; printf '%s\\n' {end}", + begin = shell_escape(ENV_SNAPSHOT_BEGIN), + end = shell_escape(ENV_SNAPSHOT_END) + ); + format!( + "{} {} {}", + shell_escape(shell_path), + remote_env_snapshot_shell_args(shell_type).join(" "), + shell_escape(&script) + ) +} + +fn remote_env_snapshot_shell_args(shell_type: &ShellType) -> &'static [&'static str] { + match shell_type { + ShellType::Bash | ShellType::Zsh => &["-lic"], + _ => &["-lc"], + } +} + +pub(super) fn parse_remote_env_snapshot_output(output: &str) -> Option { + let mut env = HashMap::new(); + let mut inside = false; + let mut saw_end = false; + + for raw_line in output.lines() { + let line = raw_line.trim_end_matches('\r'); + if !inside { + if line == ENV_SNAPSHOT_BEGIN { + inside = true; + } + continue; + } + + if line == ENV_SNAPSHOT_END { + saw_end = true; + break; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if should_import_env_var(key, value) { + env.insert(key.to_string(), value.to_string()); + } + } + + (inside && saw_end).then_some(RemoteEnvSnapshot { env }) +} + +fn should_import_env_var(key: &str, value: &str) -> bool { + is_valid_env_var_name(key) && !is_volatile_env_var(key) && !value.contains('\0') +} + +fn is_valid_env_var_name(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_volatile_env_var(key: &str) -> bool { + matches!( + key, + "_" | "PWD" | "OLDPWD" | "SHLVL" | "TERM" | "COLUMNS" | "LINES" + ) +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::{ + parse_remote_env_snapshot_output, remote_env_snapshot_command, + remote_env_snapshot_shell_args, + }; + use terminal_core::ShellType; + + #[test] + fn parses_env_snapshot_between_markers() { + let snapshot = parse_remote_env_snapshot_output( + "noise\r\n__BITFUN_REMOTE_ENV_SNAPSHOT_BEGIN__\r\nPATH=/home/me/.nvm/bin:/usr/bin\r\nNVM_DIR=/home/me/.nvm\r\nPWD=/tmp\r\nBAD-NAME=value\r\n__BITFUN_REMOTE_ENV_SNAPSHOT_END__\r\nmore noise", + ) + .expect("snapshot should parse"); + + assert_eq!( + snapshot.env.get("PATH").map(String::as_str), + Some("/home/me/.nvm/bin:/usr/bin") + ); + assert_eq!( + snapshot.env.get("NVM_DIR").map(String::as_str), + Some("/home/me/.nvm") + ); + assert!(!snapshot.env.contains_key("PWD")); + assert!(!snapshot.env.contains_key("BAD-NAME")); + } + + #[test] + fn snapshot_command_uses_interactive_login_shell_for_bash() { + let command = remote_env_snapshot_command("/bin/bash", &ShellType::Bash); + + assert!(command.starts_with("'/bin/bash' -lic ")); + assert!(command.contains("__BITFUN_REMOTE_ENV_SNAPSHOT_BEGIN__")); + assert!(command.contains("__BITFUN_REMOTE_ENV_SNAPSHOT_END__")); + } + + #[test] + fn snapshot_shell_args_are_interactive_only_for_known_interactive_shells() { + assert_eq!(remote_env_snapshot_shell_args(&ShellType::Bash), &["-lic"]); + assert_eq!(remote_env_snapshot_shell_args(&ShellType::Zsh), &["-lic"]); + assert_eq!(remote_env_snapshot_shell_args(&ShellType::Sh), &["-lc"]); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/input.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/input.rs new file mode 100644 index 0000000000..072b204938 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/input.rs @@ -0,0 +1,34 @@ +use crate::service::remote_ssh::{get_global_remote_exec_process_manager, RemoteSendStdinRequest}; +use crate::util::errors::{BitFunError, BitFunResult}; +use terminal_core::{get_global_exec_process_manager, LocalSendStdinRequest}; + +#[derive(Debug, Clone)] +pub struct ExecCommandInputRequest { + pub session_id: i32, + pub chars: String, + pub append_enter: bool, + pub remote: bool, +} + +pub async fn send_exec_command_input(request: ExecCommandInputRequest) -> BitFunResult<()> { + if request.remote { + get_global_remote_exec_process_manager() + .send_stdin(RemoteSendStdinRequest { + session_id: request.session_id, + chars: request.chars, + append_enter: request.append_enter, + }) + .await + .map_err(|error| BitFunError::tool(format!("ExecCommand input failed: {error}")))?; + return Ok(()); + } + + get_global_exec_process_manager() + .send_stdin(LocalSendStdinRequest { + session_id: request.session_id, + chars: request.chars, + append_enter: request.append_enter, + }) + .await + .map_err(|error| BitFunError::tool(format!("ExecCommand input failed: {error}"))) +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/local_shell.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/local_shell.rs new file mode 100644 index 0000000000..d2764da61e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/local_shell.rs @@ -0,0 +1,330 @@ +use crate::service::config::get_global_config_service; +use std::path::PathBuf; +use terminal_core::{ShellDetector, ShellType}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedLocalExecShell { + pub display_name: String, + pub path: PathBuf, + pub shell_type: ShellType, +} + +impl ResolvedLocalExecShell { + fn new(display_name: String, path: PathBuf, shell_type: ShellType) -> Self { + Self { + display_name, + path, + shell_type, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfiguredShellPreference { + PowerShellCore, + PowerShell, + Bash, + Cmd, + Zsh, + Fish, + Sh, + Ksh, + Csh, + Unsupported, +} + +pub(crate) async fn resolve_local_exec_shell() -> ResolvedLocalExecShell { + let configured = configured_shell_preference().await; + let detected_shells: Vec<_> = ShellDetector::detect_available_shells() + .into_iter() + .map(|shell| ResolvedLocalExecShell::new(shell.display_name, shell.path, shell.shell_type)) + .collect(); + let system_default = { + let shell = ShellDetector::get_default_shell(); + ResolvedLocalExecShell::new(shell.display_name, shell.path, shell.shell_type) + }; + + if cfg!(windows) { + select_windows_local_exec_shell(configured, &detected_shells, &system_default) + } else { + select_non_windows_local_exec_shell(configured, &detected_shells, &system_default) + } +} + +async fn configured_shell_preference() -> Option { + let config_service = get_global_config_service().await.ok()?; + let shell = config_service + .get_config::(Some("terminal.default_shell")) + .await + .ok()?; + parse_configured_shell_preference(&shell) +} + +fn parse_configured_shell_preference(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed + .rsplit(['\\', '/']) + .next() + .unwrap_or(trimmed) + .to_ascii_lowercase(); + let normalized = normalized.trim_end_matches(".exe"); + + Some(match normalized { + "powershellcore" | "pwsh" => ConfiguredShellPreference::PowerShellCore, + "powershell" | "windowspowershell" => ConfiguredShellPreference::PowerShell, + "bash" | "gitbash" => ConfiguredShellPreference::Bash, + "cmd" | "commandprompt" => ConfiguredShellPreference::Cmd, + "zsh" => ConfiguredShellPreference::Zsh, + "fish" => ConfiguredShellPreference::Fish, + "sh" => ConfiguredShellPreference::Sh, + "ksh" => ConfiguredShellPreference::Ksh, + "csh" | "tcsh" => ConfiguredShellPreference::Csh, + _ => ConfiguredShellPreference::Unsupported, + }) +} + +fn select_non_windows_local_exec_shell( + configured: Option, + detected_shells: &[ResolvedLocalExecShell], + system_default: &ResolvedLocalExecShell, +) -> ResolvedLocalExecShell { + configured + .and_then(|preference| shell_type_for_supported_preference(preference)) + .and_then(|shell_type| find_detected_shell(detected_shells, shell_type)) + .unwrap_or_else(|| system_default.clone()) +} + +fn select_windows_local_exec_shell( + configured: Option, + detected_shells: &[ResolvedLocalExecShell], + system_default: &ResolvedLocalExecShell, +) -> ResolvedLocalExecShell { + // ExecCommand deliberately narrows Windows shells to the variants whose + // one-shot command behavior we explicitly support well. + let pwsh = || find_detected_shell(detected_shells, ShellType::PowerShellCore); + let powershell = || find_detected_shell(detected_shells, ShellType::PowerShell); + let bash = || find_detected_shell(detected_shells, ShellType::Bash); + + match configured { + Some(ConfiguredShellPreference::PowerShellCore) => pwsh() + .or_else(powershell) + .or_else(bash) + .unwrap_or_else(|| system_default.clone()), + Some(ConfiguredShellPreference::PowerShell) => powershell() + .or_else(pwsh) + .or_else(bash) + .unwrap_or_else(|| system_default.clone()), + Some(ConfiguredShellPreference::Bash) => bash() + .or_else(powershell) + .or_else(pwsh) + .unwrap_or_else(|| system_default.clone()), + Some(ConfiguredShellPreference::Cmd) => pwsh() + .or_else(powershell) + .or_else(bash) + .unwrap_or_else(|| system_default.clone()), + Some( + ConfiguredShellPreference::Zsh + | ConfiguredShellPreference::Fish + | ConfiguredShellPreference::Sh + | ConfiguredShellPreference::Ksh + | ConfiguredShellPreference::Csh + | ConfiguredShellPreference::Unsupported, + ) => powershell() + .or_else(pwsh) + .or_else(bash) + .unwrap_or_else(|| system_default.clone()), + None => pwsh() + .or_else(powershell) + .or_else(bash) + .unwrap_or_else(|| system_default.clone()), + } +} + +fn shell_type_for_supported_preference(preference: ConfiguredShellPreference) -> Option { + Some(match preference { + ConfiguredShellPreference::PowerShellCore => ShellType::PowerShellCore, + ConfiguredShellPreference::PowerShell => ShellType::PowerShell, + ConfiguredShellPreference::Bash => ShellType::Bash, + ConfiguredShellPreference::Cmd => ShellType::Cmd, + ConfiguredShellPreference::Zsh => ShellType::Zsh, + ConfiguredShellPreference::Fish => ShellType::Fish, + ConfiguredShellPreference::Sh => ShellType::Sh, + ConfiguredShellPreference::Ksh => ShellType::Ksh, + ConfiguredShellPreference::Csh => ShellType::Csh, + ConfiguredShellPreference::Unsupported => return None, + }) +} + +fn find_detected_shell( + detected_shells: &[ResolvedLocalExecShell], + shell_type: ShellType, +) -> Option { + detected_shells + .iter() + .find(|shell| shell.shell_type == shell_type) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::{ + parse_configured_shell_preference, select_non_windows_local_exec_shell, + select_windows_local_exec_shell, ConfiguredShellPreference, ResolvedLocalExecShell, + }; + use std::path::PathBuf; + use terminal_core::ShellType; + + fn shell(name: &str, path: &str, shell_type: ShellType) -> ResolvedLocalExecShell { + ResolvedLocalExecShell { + display_name: name.to_string(), + path: PathBuf::from(path), + shell_type, + } + } + + #[test] + fn parses_configured_shell_values_from_enum_names_and_paths() { + assert_eq!( + parse_configured_shell_preference("PowerShellCore"), + Some(ConfiguredShellPreference::PowerShellCore) + ); + assert_eq!( + parse_configured_shell_preference("C:\\Program Files\\PowerShell\\7\\pwsh.exe"), + Some(ConfiguredShellPreference::PowerShellCore) + ); + assert_eq!( + parse_configured_shell_preference("Cmd"), + Some(ConfiguredShellPreference::Cmd) + ); + assert_eq!( + parse_configured_shell_preference("/usr/bin/bash"), + Some(ConfiguredShellPreference::Bash) + ); + assert_eq!(parse_configured_shell_preference(""), None); + } + + #[test] + fn windows_cmd_prefers_pwsh_then_powershell() { + let detected = vec![ + shell( + "Windows PowerShell", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ShellType::PowerShell, + ), + shell( + "PowerShell 7", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + ShellType::PowerShellCore, + ), + shell( + "Git Bash", + "C:\\Program Files\\Git\\bin\\bash.exe", + ShellType::Bash, + ), + ]; + let resolved = select_windows_local_exec_shell( + Some(ConfiguredShellPreference::Cmd), + &detected, + &detected[0], + ); + + assert_eq!(resolved.shell_type, ShellType::PowerShellCore); + assert_eq!( + resolved.path, + PathBuf::from("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + ); + } + + #[test] + fn windows_pwsh_falls_back_to_powershell_when_pwsh_is_missing() { + let detected = vec![shell( + "Windows PowerShell", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ShellType::PowerShell, + )]; + let resolved = select_windows_local_exec_shell( + Some(ConfiguredShellPreference::PowerShellCore), + &detected, + &detected[0], + ); + + assert_eq!(resolved.shell_type, ShellType::PowerShell); + } + + #[test] + fn windows_bash_uses_detected_git_bash_path() { + let detected = vec![ + shell( + "PowerShell 7", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + ShellType::PowerShellCore, + ), + shell("Git Bash", "D:\\Tools\\Git\\bin\\bash.exe", ShellType::Bash), + ]; + let resolved = select_windows_local_exec_shell( + Some(ConfiguredShellPreference::Bash), + &detected, + &detected[0], + ); + + assert_eq!(resolved.shell_type, ShellType::Bash); + assert_eq!( + resolved.path, + PathBuf::from("D:\\Tools\\Git\\bin\\bash.exe") + ); + } + + #[test] + fn windows_unsupported_shell_falls_back_to_powershell() { + let detected = vec![ + shell("Git Bash", "D:\\Tools\\Git\\bin\\bash.exe", ShellType::Bash), + shell( + "Windows PowerShell", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ShellType::PowerShell, + ), + ]; + let resolved = select_windows_local_exec_shell( + Some(ConfiguredShellPreference::Fish), + &detected, + &detected[0], + ); + + assert_eq!(resolved.shell_type, ShellType::PowerShell); + } + + #[test] + fn windows_auto_prefers_pwsh_then_powershell_then_bash() { + let detected = vec![ + shell("Git Bash", "D:\\Tools\\Git\\bin\\bash.exe", ShellType::Bash), + shell( + "Windows PowerShell", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ShellType::PowerShell, + ), + ]; + let resolved = select_windows_local_exec_shell(None, &detected, &detected[0]); + + assert_eq!(resolved.shell_type, ShellType::PowerShell); + } + + #[test] + fn non_windows_uses_configured_detected_shell_when_available() { + let detected = vec![ + shell("Bash", "/bin/bash", ShellType::Bash), + shell("Zsh", "/bin/zsh", ShellType::Zsh), + ]; + let resolved = select_non_windows_local_exec_shell( + Some(ConfiguredShellPreference::Zsh), + &detected, + &detected[0], + ); + + assert_eq!(resolved.shell_type, ShellType::Zsh); + assert_eq!(resolved.path, PathBuf::from("/bin/zsh")); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/mod.rs new file mode 100644 index 0000000000..036e2ad601 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/mod.rs @@ -0,0 +1,26 @@ +mod background_command_output; +mod command; +mod control; +mod env_snapshot; +mod input; +mod local_shell; +mod progress; +mod rendering; +mod stdin; + +pub use background_command_output::{ + background_command_output_capture, BackgroundCommandOutputMetadata, + BackgroundCommandOutputStatus, ListBackgroundCommandOutputRequest, + ListBackgroundCommandOutputResponse, ReadBackgroundCommandOutputRequest, + ReadBackgroundCommandOutputResponse, StartBackgroundCommandOutputCapture, + BACKGROUND_COMMAND_OUTPUT_CAPTURE_LIMIT_BYTES, +}; +pub use command::ExecCommandTool; +pub use control::{ + control_exec_command_session, ExecCommandCompletion, ExecCommandCompletionSource, + ExecCommandCompletionStatus, ExecCommandControlAction, ExecCommandControlError, + ExecCommandControlOrigin, ExecCommandControlRequest, ExecCommandControlResponse, + ExecControlTool, +}; +pub use input::{send_exec_command_input, ExecCommandInputRequest}; +pub use stdin::WriteStdinTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/progress.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/progress.rs new file mode 100644 index 0000000000..671e7308bc --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/progress.rs @@ -0,0 +1,92 @@ +use crate::agentic::tools::framework::ToolUseContext; +use crate::infrastructure::events::event_system::{ + get_global_event_system, BackendEvent::ToolExecutionProgress, +}; +use crate::util::types::event::ToolExecutionProgressInfo; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +const PROGRESS_FLUSH_INTERVAL_MS: u64 = 60; +const PROGRESS_FLUSH_MAX_CHARS: usize = 16 * 1024; +const PROGRESS_CHANNEL_CAPACITY: usize = 256; + +pub(super) struct ExecOutputProgressBridge { + tx: mpsc::Sender, + task: JoinHandle<()>, +} + +impl ExecOutputProgressBridge { + pub(super) fn start(context: &ToolUseContext, tool_name: &str) -> Option { + let tool_use_id = context.tool_call_id.clone()?; + let (tx, mut rx) = mpsc::channel::(PROGRESS_CHANNEL_CAPACITY); + let tool_name = tool_name.to_string(); + let task = tokio::spawn(async move { + let event_system = get_global_event_system(); + let mut pending = String::new(); + + loop { + match tokio::time::timeout( + Duration::from_millis(PROGRESS_FLUSH_INTERVAL_MS), + rx.recv(), + ) + .await + { + Ok(Some(chunk)) => { + if chunk.is_empty() { + continue; + } + pending.push_str(&chunk); + if pending.chars().count() >= PROGRESS_FLUSH_MAX_CHARS { + emit_progress(&event_system, &tool_use_id, &tool_name, &mut pending) + .await; + } + } + Ok(None) => { + emit_progress(&event_system, &tool_use_id, &tool_name, &mut pending).await; + break; + } + Err(_) => { + emit_progress(&event_system, &tool_use_id, &tool_name, &mut pending).await; + } + } + } + }); + + Some(Self { tx, task }) + } + + pub(super) fn sender(&self) -> mpsc::Sender { + self.tx.clone() + } + + pub(super) async fn finish(self) { + drop(self.tx); + let _ = tokio::time::timeout(Duration::from_millis(500), self.task).await; + } +} + +async fn emit_progress( + event_system: &std::sync::Arc, + tool_use_id: &str, + tool_name: &str, + pending: &mut String, +) { + if pending.is_empty() { + return; + } + + let progress_message = std::mem::take(pending); + let progress_event = ToolExecutionProgress(ToolExecutionProgressInfo { + tool_use_id: tool_use_id.to_string(), + tool_name: tool_name.to_string(), + progress_message, + percentage: None, + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }); + + let _ = event_system.emit(progress_event).await; +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/rendering.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/rendering.rs new file mode 100644 index 0000000000..7fda652ebe --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/rendering.rs @@ -0,0 +1,89 @@ +use serde_json::Value; + +pub(super) fn render_exec_response_for_assistant( + data: &Value, + status_lines: Vec, + wall_time_precision: usize, +) -> String { + render_exec_response_for_assistant_with_notes( + data, + status_lines, + Vec::new(), + wall_time_precision, + ) +} + +pub(super) fn render_exec_response_for_assistant_with_notes( + data: &Value, + status_lines: Vec, + note_lines: Vec, + wall_time_precision: usize, +) -> String { + let output = data.get("output").and_then(Value::as_str).unwrap_or(""); + let status = if status_lines.is_empty() { + "Process status unavailable.".to_string() + } else { + status_lines.join("\n") + }; + let wall_time = format!( + "{:.precision$} seconds", + data.get("wall_time_seconds") + .and_then(Value::as_f64) + .unwrap_or_default(), + precision = wall_time_precision, + ); + let note_section = if note_lines.is_empty() { + String::new() + } else { + format!("\n{}\n\n", note_lines.join("\n")) + }; + + format!( + "\n{status}\n\n\n{wall_time}\n\n{note_section}\n{output}\n" + ) +} + +#[cfg(test)] +mod tests { + use super::render_exec_response_for_assistant; + use serde_json::json; + + #[test] + fn renders_exec_response_with_xmlish_sections() { + let data = json!({ + "wall_time_seconds": 0.0068, + "output": "sh: 1: node: not found\r\n", + }); + + let rendered = render_exec_response_for_assistant( + &data, + vec!["Process exited with code 127.".to_string()], + 3, + ); + + assert_eq!( + rendered, + "\nProcess exited with code 127.\n\n\n0.007 seconds\n\n\nsh: 1: node: not found\r\n\n" + ); + } + + #[test] + fn renders_exec_response_with_note_section() { + let data = json!({ + "wall_time_seconds": 30.0, + "output": "", + }); + + let rendered = super::render_exec_response_for_assistant_with_notes( + &data, + vec!["Process is still running. session_id: 42".to_string()], + vec!["No output was produced during this wait window.".to_string()], + 3, + ); + + assert_eq!( + rendered, + "\nProcess is still running. session_id: 42\n\n\n30.000 seconds\n\n\nNo output was produced during this wait window.\n\n\n\n" + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs new file mode 100644 index 0000000000..e91c41158f --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs @@ -0,0 +1,378 @@ +use super::progress::ExecOutputProgressBridge; +use super::rendering::render_exec_response_for_assistant; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::service::remote_ssh::{ + get_global_remote_exec_process_manager, RemoteExecError, RemoteExecSessionCompletion, + RemoteExecSessionCompletionSource, RemoteExecSessionCompletionStatus, RemoteWriteStdinRequest, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use terminal_core::{ + get_global_exec_process_manager, LocalExecSessionCompletion, LocalExecSessionCompletionSource, + LocalExecSessionCompletionStatus, LocalWriteStdinRequest, TerminalError, +}; + +const DEFAULT_TOOL_YIELD_TIME_MS: u64 = 30_000; + +pub struct WriteStdinTool; + +impl Default for WriteStdinTool { + fn default() -> Self { + Self::new() + } +} + +impl WriteStdinTool { + pub fn new() -> Self { + Self + } + + pub(crate) fn session_id_from_input(input: &Value) -> Option { + input.get("session_id").and_then(|value| { + value + .as_i64() + .and_then(|id| i32::try_from(id).ok()) + .or_else(|| value.as_u64().and_then(|id| i32::try_from(id).ok())) + }) + } + + fn response_for_assistant(data: &Value) -> String { + let mut status_lines = Vec::new(); + let completion = data.get("completion"); + let completion_source = completion + .and_then(|value| value.get("source")) + .and_then(Value::as_str); + let completion_status = completion + .and_then(|value| value.get("status")) + .and_then(Value::as_str); + if completion_source == Some("out_of_band_control") { + match completion_status { + Some("interrupted") => { + status_lines.push("Process was interrupted externally.".to_string()) + } + Some("killed") => { + status_lines.push("Process was terminated externally.".to_string()) + } + Some(status) => { + status_lines.push(format!("Process ended externally with status {status}.")) + } + None => status_lines.push("Process ended externally.".to_string()), + } + if let Some(exit_code) = data.get("exit_code").and_then(Value::as_i64) { + status_lines.push(format!("Process exited with code {exit_code}.")); + } + } else if let Some(exit_code) = data.get("exit_code").and_then(Value::as_i64) { + status_lines.push(format!("Process exited with code {exit_code}.")); + } else if let Some(session_id) = data.get("session_id").and_then(Value::as_i64) { + status_lines.push(format!( + "Process is still running. session_id: {session_id}" + )); + } + render_exec_response_for_assistant(data, status_lines, 4) + } + + fn session_not_found_result(session_id: i32, remote: bool) -> Vec { + let message = format!( + "ExecCommand session {session_id} was not found. It may have already exited, been collected, or been pruned." + ); + let mut data = json!({ + "status": "session_not_found", + "message": message, + "requested_session_id": session_id, + "session_id": null, + "exit_code": null, + "output": "", + "original_output_chars": 0, + }); + if remote { + data["remote"] = json!(true); + } + + vec![ToolResult::Result { + data, + result_for_assistant: Some(message), + image_attachments: None, + }] + } + + fn local_completion_value(completion: LocalExecSessionCompletion) -> Value { + json!({ + "status": match completion.status { + LocalExecSessionCompletionStatus::Exited => "exited", + LocalExecSessionCompletionStatus::Interrupted => "interrupted", + LocalExecSessionCompletionStatus::Killed => "killed", + LocalExecSessionCompletionStatus::Pruned => "pruned", + }, + "source": match completion.source { + LocalExecSessionCompletionSource::Process => "process", + LocalExecSessionCompletionSource::OutOfBandControl => "out_of_band_control", + }, + }) + } + + fn remote_completion_value(completion: RemoteExecSessionCompletion) -> Value { + json!({ + "status": match completion.status { + RemoteExecSessionCompletionStatus::Exited => "exited", + RemoteExecSessionCompletionStatus::Interrupted => "interrupted", + RemoteExecSessionCompletionStatus::Killed => "killed", + RemoteExecSessionCompletionStatus::Pruned => "pruned", + }, + "source": match completion.source { + RemoteExecSessionCompletionSource::Process => "process", + RemoteExecSessionCompletionSource::OutOfBandControl => "out_of_band_control", + }, + }) + } + + async fn call_remote_pipe( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let session_id = Self::session_id_from_input(input).ok_or_else(|| { + BitFunError::tool("session_id is required for WriteStdin".to_string()) + })?; + let chars = input + .get("chars") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let append_enter = input + .get("append_enter") + .and_then(Value::as_bool) + .unwrap_or(false); + let yield_time_ms = input + .get("yield_time_ms") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_TOOL_YIELD_TIME_MS); + let request = RemoteWriteStdinRequest { + session_id, + chars, + append_enter, + yield_time_ms: Some(yield_time_ms), + max_output_chars: None, + }; + let progress_bridge = ExecOutputProgressBridge::start(context, self.name()); + let response_result = if let Some(bridge) = progress_bridge.as_ref() { + get_global_remote_exec_process_manager() + .write_stdin_streaming(request, bridge.sender()) + .await + } else { + get_global_remote_exec_process_manager() + .write_stdin(request) + .await + }; + if let Some(bridge) = progress_bridge { + bridge.finish().await; + } + let response = match response_result { + Ok(response) => response, + Err(RemoteExecError::SessionNotFound(session_id)) => { + return Ok(Self::session_not_found_result(session_id, true)); + } + Err(error) => return Err(BitFunError::tool(format!("WriteStdin failed: {error}"))), + }; + + let data = json!({ + "chunk_id": response.chunk_id, + "wall_time_seconds": response.wall_time_seconds, + "output": response.output, + "session_id": response.session_id, + "exit_code": response.exit_code, + "original_output_chars": response.original_output_chars, + "completion": response.completion.map(Self::remote_completion_value), + "remote": true, + }); + let result_for_assistant = Self::response_for_assistant(&data); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +#[async_trait] +impl Tool for WriteStdinTool { + fn name(&self) -> &str { + "WriteStdin" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Writes stdin to, or polls, a running ExecCommand session. + +Pass the session_id returned by ExecCommand. Leave chars empty or omit it to poll for new output. +chars is sent only to sessions started with tty=true. For tty=false sessions, this tool only polls. +Use append_enter=true to submit a line after chars. Use this for line-oriented interactive prompts instead of trying to encode \\r or \\n manually. +Output is only what was produced during this tool call's wait window."# + .to_string()) + } + + fn short_description(&self) -> String { + "Write to or poll a running ExecCommand session.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "session_id": { + "type": "number", + "description": "session_id returned by ExecCommand while a process is still running." + }, + "chars": { + "type": "string", + "description": "Characters to write to stdin. Empty or omitted means poll for new output." + }, + "append_enter": { + "type": "boolean", + "description": "When true, append an Enter key after chars." + }, + "yield_time_ms": { + "type": "number", + "description": "How long to wait for output before yielding. Defaults to 30000 ms." + } + }, + "required": ["session_id"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn manages_own_execution_timeout(&self) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if Self::session_id_from_input(input).is_none() { + return ValidationResult { + result: false, + message: Some("session_id is required for WriteStdin".to_string()), + error_code: Some(400), + meta: None, + }; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if context.is_remote() { + return self.call_remote_pipe(input, context).await; + } + + let session_id = Self::session_id_from_input(input).ok_or_else(|| { + BitFunError::tool("session_id is required for WriteStdin".to_string()) + })?; + let chars = input + .get("chars") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let append_enter = input + .get("append_enter") + .and_then(Value::as_bool) + .unwrap_or(false); + let yield_time_ms = input + .get("yield_time_ms") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_TOOL_YIELD_TIME_MS); + let request = LocalWriteStdinRequest { + session_id, + chars, + append_enter, + yield_time_ms: Some(yield_time_ms), + max_output_chars: None, + }; + let progress_bridge = ExecOutputProgressBridge::start(context, self.name()); + let response_result = if let Some(bridge) = progress_bridge.as_ref() { + get_global_exec_process_manager() + .write_stdin_streaming(request, bridge.sender()) + .await + } else { + get_global_exec_process_manager().write_stdin(request).await + }; + if let Some(bridge) = progress_bridge { + bridge.finish().await; + } + let response = match response_result { + Ok(response) => response, + Err(TerminalError::SessionNotFound(_)) => { + return Ok(Self::session_not_found_result(session_id, false)); + } + Err(error) => return Err(BitFunError::tool(format!("WriteStdin failed: {error}"))), + }; + + let data = json!({ + "chunk_id": response.chunk_id, + "wall_time_seconds": response.wall_time_seconds, + "output": response.output, + "session_id": response.session_id, + "exit_code": response.exit_code, + "original_output_chars": response.original_output_chars, + "completion": response.completion.map(Self::local_completion_value), + }); + let result_for_assistant = Self::response_for_assistant(&data); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::WriteStdinTool; + use crate::agentic::tools::framework::ToolResult; + + #[test] + fn session_not_found_result_uses_plain_assistant_message() { + let results = WriteStdinTool::session_not_found_result(123, false); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected result"); + }; + + assert_eq!( + data.get("status").and_then(|value| value.as_str()), + Some("session_not_found") + ); + assert_eq!( + data.get("requested_session_id") + .and_then(|value| value.as_i64()), + Some(123) + ); + let assistant = result_for_assistant.as_deref().expect("assistant text"); + assert!(assistant.contains("ExecCommand session 123 was not found")); + assert!(!assistant.contains("")); + assert!(!assistant.contains("")); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs similarity index 95% rename from src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs index af955f1557..26f7556e54 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -13,7 +13,9 @@ use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; use std::path::Path; -use tool_runtime::fs::edit_file::apply_edit_to_content; +use tool_runtime::fs::edit_file::{ + apply_edit_to_content, edit_local_file_with_content, EditLocalFileWithContentRequest, +}; pub struct FileEditTool; @@ -374,7 +376,7 @@ impl Tool for FileEditTool { return Ok(vec![result]); } - // Local: read → edit in memory → write back so failures can include current file context. + // Local: core keeps freshness/checkpoint, tool-runtime owns edit application and write-back. let content = std::fs::read_to_string(&resolved.resolved_path).map_err(|e| { BitFunError::tool(format!( "Failed to read file {}: {}", @@ -382,23 +384,21 @@ impl Tool for FileEditTool { )) })?; Self::assert_atomic_edit_freshness(context, &resolved, &content)?; - let edit_result = apply_edit_to_content(&content, old_string, new_string, replace_all) - .map_err(|error| { - if Self::is_edit_content_guardrail_error(&error) { - BitFunError::tool(file_tool_guidance_message(error)) - } else { - BitFunError::tool(error) - } - })?; - - std::fs::write(&resolved.resolved_path, edit_result.new_content.as_bytes()).map_err( - |e| { - BitFunError::tool(format!( - "Failed to write file {}: {}", - resolved.logical_path, e - )) - }, - )?; + let edit_result = edit_local_file_with_content(EditLocalFileWithContentRequest { + logical_path: resolved.logical_path.clone(), + resolved_path: Path::new(&resolved.resolved_path).to_path_buf(), + current_content: content, + old_string: old_string.to_string(), + new_string: new_string.to_string(), + replace_all, + }) + .map_err(|error| { + if Self::is_edit_content_guardrail_error(&error) { + BitFunError::tool(file_tool_guidance_message(error)) + } else { + BitFunError::tool(error) + } + })?; let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; update_file_read_state_after_mutation( diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs similarity index 78% rename from src/crates/core/src/agentic/tools/implementations/file_read_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs index 05501800f3..142fd044c8 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs @@ -12,7 +12,7 @@ use log::{debug, warn}; use serde_json::{json, Value}; use std::path::Path; use std::time::Instant; -use tool_runtime::fs::read_file::read_file; +use tool_runtime::fs::read_file::{build_remote_read_command, parse_remote_read_output, read_file}; pub struct FileReadTool { default_max_lines_to_read: usize, @@ -57,28 +57,18 @@ impl FileReadTool { limit: usize, context: &ToolUseContext, ) -> BitFunResult { - const TOTAL_LINES_MARKER: &str = "__BITFUN_TOTAL_LINES__="; - const HIT_TOTAL_CHAR_LIMIT_MARKER: &str = "__BITFUN_HIT_TOTAL_CHAR_LIMIT__="; - - let end_line = start_line - .checked_add(limit.saturating_sub(1)) - .ok_or_else(|| BitFunError::tool("Requested line range is too large".to_string()))?; - let ws_shell = context.ws_shell().ok_or_else(|| { BitFunError::tool("Remote workspace shell is unavailable".to_string()) })?; - let escaped_path = shell_escape(resolved_path); - let command = format!( - "if [ ! -f {path} ]; then exit 3; fi; awk -v start={start} -v end={end} -v max={max} -v budget={budget} 'BEGIN {{ total = 0; used = 0; hit = 0; }} {{ total = NR; if (!hit && NR >= start && NR <= end) {{ line = $0; if (length(line) > max) {{ line = substr(line, 1, max) \" [truncated]\"; }} rendered = sprintf(\"%6d\\t%s\", NR, line); extra = (used > 0 ? 1 : 0); next_used = used + extra + length(rendered); if (next_used > budget) {{ hit = 1; next; }} print rendered; used = next_used; }} }} END {{ printf(\"{marker}%d\\n\", total) > \"/dev/stderr\"; printf(\"{hit_marker}%d\\n\", hit) > \"/dev/stderr\"; }}' {path}", - path = escaped_path, - start = start_line, - end = end_line, - max = self.max_line_chars, - budget = self.max_total_chars, - marker = TOTAL_LINES_MARKER, - hit_marker = HIT_TOTAL_CHAR_LIMIT_MARKER, - ); + let command = build_remote_read_command( + resolved_path, + start_line, + limit, + self.max_line_chars, + self.max_total_chars, + ) + .map_err(BitFunError::tool)?; let remote_read_started_at = Instant::now(); debug!( @@ -115,92 +105,23 @@ impl FileReadTool { elapsed_ms_u64(remote_read_started_at) ); - let mut total_lines = None; - let mut hit_total_char_limit = false; - let mut stderr_messages = Vec::new(); - for line in stderr.lines() { - if let Some(rest) = line.strip_prefix(TOTAL_LINES_MARKER) { - total_lines = rest.trim().parse::().ok(); - } else if let Some(rest) = line.strip_prefix(HIT_TOTAL_CHAR_LIMIT_MARKER) { - hit_total_char_limit = rest.trim() == "1"; - } else if !line.trim().is_empty() { - stderr_messages.push(line.to_string()); - } - } - - if status != 0 { - let message = if status == 3 { - format!("File not found or not a regular file: {}", resolved_path) - } else if !stderr_messages.is_empty() { - stderr_messages.join("\n") - } else { - format!( - "Failed to read file: remote command exited with status {}", - status - ) - }; - return Err(BitFunError::tool(message)); - } - - let total_lines = total_lines.ok_or_else(|| { - BitFunError::tool( - "Failed to read file: remote command did not return line-count markers".to_string(), - ) - })?; - - if total_lines == 0 { - return Ok(tool_runtime::fs::read_file::ReadFileResult { - start_line: 0, - end_line: 0, - total_lines: 0, - content: String::new(), - hit_total_char_limit, - }); - } - - if start_line > total_lines { - return Err(BitFunError::tool(format!( - "`start_line` {} is larger than the number of lines in the file: {}", - start_line, total_lines - ))); - } - - let content = stdout.trim_end_matches('\n').to_string(); - let lines_read = if content.is_empty() { - 0 - } else { - content.lines().count() - }; - let end_line = if lines_read == 0 { - start_line - } else { - (start_line + lines_read).saturating_sub(1) - }; + let result = parse_remote_read_output(&stdout, &stderr, status, resolved_path, start_line) + .map_err(BitFunError::tool)?; debug!( "Remote file read parsed successfully: path={}, start_line={}, end_line={}, total_lines={}, hit_total_char_limit={}, duration_ms={}", resolved_path, - start_line, - end_line, - total_lines, - hit_total_char_limit, + result.start_line, + result.end_line, + result.total_lines, + result.hit_total_char_limit, elapsed_ms_u64(remote_read_started_at) ); - Ok(tool_runtime::fs::read_file::ReadFileResult { - start_line, - end_line, - total_lines, - content, - hit_total_char_limit, - }) + Ok(result) } } -fn shell_escape(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - #[async_trait] impl Tool for FileReadTool { fn name(&self) -> &str { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs new file mode 100644 index 0000000000..8e08344b67 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs @@ -0,0 +1,749 @@ +use crate::agentic::tools::file_read_state_runtime::{ + assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, get_stored_file_read_state, + local_file_modification_time_ms, read_current_file_content, read_state_tracking_enabled, + update_file_read_state_after_mutation, validate_existing_file_read_before_write, + FILE_UNEXPECTEDLY_MODIFIED_ERROR, +}; +use crate::agentic::tools::file_tool_guidance::{ + file_tool_guidance_message, is_file_tool_guidance_message, +}; +use crate::agentic::tools::framework::{ + Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::agentic::tools::ToolPathOperation; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::path::Path; +use tokio::fs; +use tool_runtime::fs::{write_local_file, WriteLocalFileMode, WriteLocalFileRequest}; + +pub struct FileWriteTool; + +const LARGE_WRITE_SOFT_LINE_LIMIT: usize = 200; +const LARGE_WRITE_SOFT_BYTE_LIMIT: usize = 20 * 1024; + +impl Default for FileWriteTool { + fn default() -> Self { + Self::new() + } +} + +impl FileWriteTool { + pub fn new() -> Self { + Self + } + + fn parse_mode_value(mode: Option<&str>) -> Result { + match mode.unwrap_or("w") { + "w" => Ok(WriteLocalFileMode::Write), + "a" => Ok(WriteLocalFileMode::Append), + other => Err(format!( + "mode must be either 'w' (overwrite) or 'a' (append), got '{}'", + other + )), + } + } + + fn mode_label(mode: WriteLocalFileMode) -> &'static str { + match mode { + WriteLocalFileMode::Write => "w", + WriteLocalFileMode::Append => "a", + } + } + + fn format_write_freshness_guidance(logical_path: &str, error: String) -> String { + if error == FILE_UNEXPECTEDLY_MODIFIED_ERROR || error.contains("unexpectedly modified") { + format!( + "The file {} changed since it was last read. Use Read again, then retry Write.", + logical_path + ) + } else if error.contains("modified since read") { + format!( + "The file {} changed after it was last read. Use Read again, then retry Write.", + logical_path + ) + } else { + error + } + } + + async fn file_exists(context: &ToolUseContext, resolved: &ToolPathResolution) -> bool { + if resolved.uses_remote_workspace_backend() { + if let Some(ws_fs) = context.ws_fs() { + ws_fs.exists(&resolved.resolved_path).await.unwrap_or(false) + } else { + false + } + } else { + Path::new(&resolved.resolved_path).exists() + } + } + + async fn existing_file_matches_content( + context: &ToolUseContext, + resolved: &ToolPathResolution, + content: &str, + ) -> Option { + let existing = if resolved.uses_remote_workspace_backend() { + context + .ws_fs()? + .read_file(&resolved.resolved_path) + .await + .ok()? + } else { + fs::read(&resolved.resolved_path).await.ok()? + }; + + Some(existing == content.as_bytes()) + } + + async fn existing_file_write_freshness_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if !Self::file_exists(context, resolved).await { + return None; + } + if !read_state_tracking_enabled(context) { + return None; + } + + let current_content = match read_current_file_content(context, resolved).await { + Ok(content) => content, + Err(error) => return Some(error.to_string()), + }; + let read_state = get_stored_file_read_state(context, resolved); + let current_mtime_ms = if resolved.uses_remote_workspace_backend() { + None + } else { + Some(local_file_modification_time_ms(Path::new( + &resolved.resolved_path, + ))) + }; + + assert_file_not_unexpectedly_modified( + read_state.as_ref(), + ¤t_content, + current_mtime_ms, + ) + .err() + .map(|error| Self::format_write_freshness_guidance(&resolved.logical_path, error)) + } + + async fn assert_atomic_write_freshness_if_exists( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> BitFunResult<()> { + if let Some(error) = Self::existing_file_write_freshness_error(context, resolved).await { + return Err(BitFunError::tool(file_tool_guidance_message(error))); + } + + Ok(()) + } + + async fn write_guardrail_preflight_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if !Self::file_exists(context, resolved).await { + return None; + } + + if let Some(message) = validate_existing_file_read_before_write(context, resolved).await { + return Some(file_tool_guidance_message(message)); + } + + Self::existing_file_write_freshness_error(context, resolved) + .await + .map(file_tool_guidance_message) + } + + pub(crate) async fn preflight_write_error( + context: &ToolUseContext, + file_path: &str, + ) -> Option { + let resolved = match context.resolve_tool_path(file_path) { + Ok(resolved) => resolved, + Err(err) => return Some(err.to_string()), + }; + + if let Err(err) = context.enforce_path_operation(ToolPathOperation::Write, &resolved) { + return Some(err.to_string()); + } + + Self::write_guardrail_preflight_error(context, &resolved).await + } + + fn write_success_result( + logical_path: &str, + mode: WriteLocalFileMode, + bytes_written: usize, + lines_written: usize, + status: &str, + assistant_message: String, + ) -> ToolResult { + ToolResult::Result { + data: json!({ + "file_path": logical_path, + "mode": Self::mode_label(mode), + "bytes_written": bytes_written, + "lines_written": lines_written, + "success": true, + "status": status, + "message": assistant_message, + }), + result_for_assistant: Some(assistant_message), + image_attachments: None, + } + } + + fn input_schema() -> Value { + json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to write (must be absolute, not relative)" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + }, + "mode": { + "type": "string", + "enum": ["w", "a"], + "description": "Write mode: 'w' overwrites the file (default), 'a' appends to the file and creates it if missing" + } + }, + "required": ["file_path", "content"], + "additionalProperties": false + }) + } + + fn description() -> String { + r#"Writes a file to the local filesystem. + +Usage: +- Always output `file_path` first when calling this tool. Example: `{"file_path": "src/main.rs", "content": "fn main() {}"}`. +- This tool defaults to `mode=\"w\"`, which overwrites the existing file if there is one at the provided path. +- Use `mode=\"a\"` to append content; it also creates the file if it does not exist. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- Prefer the Edit tool for modifying existing files — it only sends the diff. Only use this tool to create new files or for complete rewrites. +- NEVER create documentation files (*.md) or README files unless explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked."# + .to_string() + } +} + +#[cfg(test)] +mod tests { + use super::FileWriteTool; + use crate::agentic::tools::file_tool_guidance::{ + file_tool_guidance_message, is_file_tool_guidance_message, FILE_TOOL_GUIDANCE_PREFIX, + }; + use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + + fn local_context(root: PathBuf) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[test] + fn guidance_prefix_helpers_round_trip() { + let message = file_tool_guidance_message("Use Read first."); + assert!(is_file_tool_guidance_message(&message)); + assert_eq!( + message.strip_prefix(FILE_TOOL_GUIDANCE_PREFIX).unwrap(), + "Use Read first." + ); + } + + #[tokio::test] + async fn preflight_write_error_allows_new_file_target() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + + let error = + FileWriteTool::preflight_write_error(&local_context(root.clone()), "new.txt").await; + + let _ = std::fs::remove_dir_all(&root); + + assert!(error.is_none()); + } + + #[tokio::test] + async fn preflight_write_error_allows_existing_file_without_read_state_tracking() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "already here").expect("create existing file"); + + let error = + FileWriteTool::preflight_write_error(&local_context(root.clone()), "existing.md").await; + + let _ = std::fs::remove_dir_all(&root); + + assert!(error.is_none()); + } + + #[tokio::test] + async fn call_impl_treats_identical_existing_content_as_success() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "same content").expect("create existing file"); + + let tool = FileWriteTool::new(); + let results = tool + .call( + &json!({ "file_path": "existing.md", "content": "same content" }), + &local_context(root.clone()), + ) + .await + .expect("identical retry should be idempotent"); + + let _ = std::fs::remove_dir_all(&root); + + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected result"); + }; + assert_eq!(data["success"], true); + assert_eq!(data["bytes_written"], 0); + assert_eq!(data["lines_written"], 0); + assert_eq!(data["status"], "already_exists_same_content"); + assert!(result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("identical content")); + } + + #[tokio::test] + async fn call_impl_overwrites_different_existing_content() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "old content").expect("create existing file"); + + let tool = FileWriteTool::new(); + let results = tool + .call( + &json!({ "file_path": "existing.md", "content": "new content" }), + &local_context(root.clone()), + ) + .await + .expect("write should overwrite existing files"); + + let written = std::fs::read_to_string(root.join("existing.md")).expect("read file"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(written, "new content"); + + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected result"); + }; + assert_eq!(data["status"], "overwritten"); + assert_eq!(data["bytes_written"], "new content".len()); + assert_eq!(data["lines_written"], 1); + } + + #[tokio::test] + async fn call_impl_appends_when_mode_is_append() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "old").expect("create existing file"); + + let tool = FileWriteTool::new(); + let results = tool + .call( + &json!({ "file_path": "existing.md", "content": "\nnew", "mode": "a" }), + &local_context(root.clone()), + ) + .await + .expect("append should succeed"); + + let written = std::fs::read_to_string(root.join("existing.md")).expect("read file"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(written, "old\nnew"); + + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected result"); + }; + assert_eq!(data["status"], "appended"); + assert_eq!(data["mode"], "a"); + assert_eq!(data["bytes_written"], "\nnew".len()); + } + + #[tokio::test] + async fn schema_requires_file_path_and_content() { + let tool = FileWriteTool::new(); + + let schema = tool.input_schema_for_model().await; + + assert_eq!( + schema["required"], + serde_json::json!(["file_path", "content"]) + ); + assert!(schema["properties"].get("content").is_some()); + assert_eq!( + schema["properties"]["mode"]["enum"], + serde_json::json!(["w", "a"]) + ); + } + + #[tokio::test] + async fn validate_input_requires_content() { + let tool = FileWriteTool::new(); + + let validation = tool + .validate_input(&json!({ "file_path": "new.txt" }), None) + .await; + + assert!(!validation.result); + assert_eq!(validation.message.as_deref(), Some("content is required")); + } + + #[tokio::test] + async fn validate_input_rejects_invalid_mode() { + let tool = FileWriteTool::new(); + + let validation = tool + .validate_input( + &json!({ "file_path": "new.txt", "content": "hello", "mode": "x" }), + None, + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("mode must be either 'w' (overwrite) or 'a' (append), got 'x'") + ); + } +} + +#[async_trait] +impl Tool for FileWriteTool { + fn name(&self) -> &str { + "Write" + } + + async fn description(&self) -> BitFunResult { + Ok(FileWriteTool::description()) + } + + fn short_description(&self) -> String { + "Write or overwrite a file.".to_string() + } + + async fn description_with_context( + &self, + _context: Option<&ToolUseContext>, + ) -> BitFunResult { + Ok(FileWriteTool::description()) + } + + fn input_schema(&self) -> Value { + FileWriteTool::input_schema() + } + + async fn input_schema_for_model(&self) -> Value { + FileWriteTool::input_schema() + } + + async fn input_schema_for_model_with_context( + &self, + _context: Option<&ToolUseContext>, + ) -> Value { + FileWriteTool::input_schema() + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let file_path = match input.get("file_path").and_then(|v| v.as_str()) { + Some(path) if !path.is_empty() => path, + _ => { + return ValidationResult { + result: false, + message: Some("file_path is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + }; + + if input.get("content").is_none() { + return ValidationResult { + result: false, + message: Some("content is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if let Err(message) = + Self::parse_mode_value(input.get("mode").and_then(|value| value.as_str())) + { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } + + let large_write_warning = + input + .get("content") + .and_then(|v| v.as_str()) + .and_then(|content| { + let line_count = content.lines().count(); + let byte_count = content.len(); + if line_count > LARGE_WRITE_SOFT_LINE_LIMIT + || byte_count > LARGE_WRITE_SOFT_BYTE_LIMIT + { + Some((line_count, byte_count)) + } else { + None + } + }); + + if let Some(ctx) = context { + if let Some(message) = Self::preflight_write_error(ctx, file_path).await { + let is_guidance = is_file_tool_guidance_message(&message); + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: is_guidance.then(|| json!({ "failure_kind": "guidance" })), + }; + } + } + + if let Some((line_count, byte_count)) = large_write_warning { + return ValidationResult { + result: true, + message: Some(format!( + "Large Write payload: {} lines, {} bytes. This is allowed when necessary, but prefer a staged approach: for existing files use Read + focused Edit calls; for large new files write a stable scaffold first, then add sections in follow-up edits unless a complete initial body is required.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_write": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_WRITE_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_WRITE_SOFT_BYTE_LIMIT + })), + }; + } + + ValidationResult::default() + } + + fn render_tool_use_message(&self, input: &Value, options: &ToolRenderOptions) -> String { + let mode = Self::parse_mode_value(input.get("mode").and_then(|v| v.as_str())) + .unwrap_or(WriteLocalFileMode::Write); + if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) { + if options.verbose { + let content_len = input + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.len()) + .unwrap_or(0); + match mode { + WriteLocalFileMode::Write => { + format!("Writing {} characters to {}", content_len, file_path) + } + WriteLocalFileMode::Append => { + format!("Appending {} characters to {}", content_len, file_path) + } + } + } else { + match mode { + WriteLocalFileMode::Write => format!("Write {}", file_path), + WriteLocalFileMode::Append => format!("Append {}", file_path), + } + } + } else { + "Writing file".to_string() + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; + + let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; + context + .record_light_checkpoint( + "Write", + &resolved.logical_path, + vec![resolved.logical_path.clone()], + ) + .await; + + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; + let content = content.to_string(); + let mode = Self::parse_mode_value(input.get("mode").and_then(|v| v.as_str())) + .map_err(BitFunError::tool)?; + + let file_already_exists = Self::file_exists(context, &resolved).await; + if mode == WriteLocalFileMode::Write + && file_already_exists + && Self::existing_file_matches_content(context, &resolved, &content).await == Some(true) + { + let result = Self::write_success_result( + &resolved.logical_path, + mode, + 0, + 0, + "already_exists_same_content", + format!( + "Write skipped because {} already exists with identical content.", + resolved.logical_path + ), + ); + return Ok(vec![result]); + } + + Self::assert_atomic_write_freshness_if_exists(context, &resolved).await?; + let final_content = match (mode, file_already_exists) { + (WriteLocalFileMode::Append, true) => { + let mut existing = read_current_file_content(context, &resolved).await?; + existing.push_str(&content); + existing + } + _ => content.clone(), + }; + + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Remote workspace file system is unavailable".to_string()) + })?; + ws_fs + .write_file(&resolved.resolved_path, final_content.as_bytes()) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation(context, &resolved, &final_content, timestamp_ms); + + let (status, assistant_message) = match (mode, file_already_exists) { + (WriteLocalFileMode::Write, true) => ( + "overwritten", + format!( + "Successfully overwrote {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ), + (WriteLocalFileMode::Write, false) => ( + "created", + format!( + "Successfully created {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ), + (WriteLocalFileMode::Append, true) => ( + "appended", + format!( + "Successfully appended to {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ), + (WriteLocalFileMode::Append, false) => ( + "created", + format!( + "Successfully created {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ), + }; + + let result = Self::write_success_result( + &resolved.logical_path, + mode, + content.len(), + if content.is_empty() { + 0 + } else { + content.lines().count().max(1) + }, + status, + assistant_message, + ); + return Ok(vec![result]); + } + + let write_request = WriteLocalFileRequest { + logical_path: resolved.logical_path.clone(), + resolved_path: Path::new(&resolved.resolved_path).to_path_buf(), + content: content.clone(), + mode, + }; + let outcome = tokio::task::spawn_blocking(move || write_local_file(write_request)) + .await + .map_err(|error| BitFunError::tool(format!("Write task failed: {}", error)))? + .map_err(BitFunError::tool)?; + + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation(context, &resolved, &final_content, timestamp_ms); + + let result = Self::write_success_result( + &resolved.logical_path, + mode, + outcome.bytes_written, + outcome.lines_written, + outcome.status.as_str(), + outcome.assistant_message, + ); + + Ok(vec![result]) + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs similarity index 99% rename from src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs index 3841398585..2b1e22e84f 100644 --- a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -305,7 +305,7 @@ Input rules: 14. Do not use `parent.postMessage(...)` or custom `onclick` protocols for file opening when `data-file-path` can be attached directly to the clickable element. 15. CRITICAL for codebase maps, repo overviews, and architecture diagrams: NEVER guess or invent paths. Every clickable `data-file-path` MUST point to a REAL file that exists in the workspace. 16. For clickable file navigation, add `data-file-path` on the clickable element itself, and add `data-line` for the exact definition or anchor line whenever the node represents code. -17. `data-file-path` may be workspace-relative such as `src/crates/core/src/lib.rs`, or absolute when already verified, but it MUST resolve to a file, not a directory. +17. `data-file-path` may be workspace-relative such as `src/crates/assembly/core/src/lib.rs`, or absolute when already verified, but it MUST resolve to a file, not a directory. 18. Do NOT attach `data-file-path` to abstract grouping nodes such as "Core", "Frontend", "Agent System", or module containers unless that node intentionally opens one specific real file. 19. For codebase architecture diagrams, prefer one clickable node per concrete file. If a node represents a broader concept, package, or directory, leave it non-clickable instead of pointing it at a folder. 20. Workflow for architecture widgets: first verify candidate files with Glob or LS, then use Read with line numbers when needed, and only then emit clickable nodes with verified file paths and lines. diff --git a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs new file mode 100644 index 0000000000..2c371beaf7 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs @@ -0,0 +1,167 @@ +//! GetTime tool implementation. + +use crate::agentic::tools::framework::{Tool, ToolRenderOptions, ToolResult, ToolUseContext}; +use crate::util::errors::BitFunResult; +use async_trait::async_trait; +use chrono::{Datelike, Local, SecondsFormat, Utc}; +use serde_json::{json, Value}; + +/// GetTime tool - returns current local and UTC time facts. +pub struct GetTimeTool; + +impl Default for GetTimeTool { + fn default() -> Self { + Self::new() + } +} + +impl GetTimeTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for GetTimeTool { + fn name(&self) -> &str { + "GetTime" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Return the current time, weekday, and Unix timestamp. + +Use this tool when you need reliable current date/time facts for planning, timestamping, report names, or date-sensitive reasoning. It returns local time, UTC time, weekday, and Unix timestamps in seconds and milliseconds. + +This tool takes no parameters."# + .to_string()) + } + + fn short_description(&self) -> String { + "Return the current time, weekday, and Unix timestamp.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn render_tool_use_message(&self, _input: &Value, _options: &ToolRenderOptions) -> String { + "Get current time".to_string() + } + + fn render_tool_result_message(&self, output: &Value) -> String { + output + .get("local_time") + .and_then(Value::as_str) + .map(|local_time| format!("Current time: {local_time}")) + .unwrap_or_else(|| "Current time returned".to_string()) + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let now = Local::now(); + let utc = now.with_timezone(&Utc); + let local_time = now.to_rfc3339_opts(SecondsFormat::Secs, false); + let utc_time = utc.to_rfc3339_opts(SecondsFormat::Secs, true); + let weekday = now.format("%A").to_string(); + let unix_timestamp_seconds = now.timestamp(); + let unix_timestamp_millis = now.timestamp_millis(); + let timezone_offset = now.format("%:z").to_string(); + + let data = json!({ + "success": true, + "local_time": local_time, + "utc_time": utc_time, + "date": now.format("%Y-%m-%d").to_string(), + "time": now.format("%H:%M:%S").to_string(), + "weekday": weekday, + "weekday_number_from_monday": now.weekday().number_from_monday(), + "unix_timestamp_seconds": unix_timestamp_seconds, + "unix_timestamp_millis": unix_timestamp_millis, + "timestamp": unix_timestamp_seconds, + "timezone_offset": timezone_offset, + }); + let result_for_assistant = format!( + "Current local time: {} ({}); Unix timestamp: {}.", + data["local_time"].as_str().unwrap_or_default(), + data["weekday"].as_str().unwrap_or_default(), + unix_timestamp_seconds + ); + + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::GetTimeTool; + use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use std::collections::HashMap; + + fn test_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[tokio::test] + async fn get_time_returns_current_time_facts() { + let tool = GetTimeTool::new(); + let result = tool + .call_impl(&serde_json::json!({}), &test_context()) + .await + .expect("GetTime should return time facts"); + + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &result[0] + else { + panic!("GetTime should return a structured result"); + }; + + assert_eq!(data["success"], true); + assert!(data["local_time"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(data["utc_time"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(data["weekday"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(data["unix_timestamp_seconds"].as_i64().is_some()); + assert!(data["unix_timestamp_millis"].as_i64().is_some()); + assert!(result_for_assistant + .as_ref() + .is_some_and(|value| value.contains("Current local time"))); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs similarity index 83% rename from src/crates/core/src/agentic/tools/implementations/git_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs index ee305639cf..536fad9e43 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs @@ -13,7 +13,7 @@ use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use log::debug; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; // --------------------------------------------------------------------------- // Constants for git diff argument parsing @@ -98,6 +98,168 @@ impl GitTool { Self } + fn strip_command_wrapping(raw: &str) -> &str { + let trimmed = raw.trim(); + let Some(stripped) = trimmed + .strip_prefix("```") + .and_then(|value| value.strip_suffix("```")) + else { + return trimmed.trim_matches('`').trim(); + }; + + let stripped = stripped.trim(); + if let Some((first_line, rest)) = stripped.split_once('\n') { + if first_line + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return rest.trim(); + } + } + + stripped + } + + /// Parse shell-style git text such as `git status` or `git diff --staged`. + fn parse_git_command_text(text: &str) -> Option { + let trimmed = Self::strip_command_wrapping(text); + let command = trimmed + .strip_prefix("git ") + .map(str::trim) + .unwrap_or(trimmed); + let mut parts = command.splitn(2, char::is_whitespace); + let operation = parts.next()?.trim(); + if operation.is_empty() + || !operation + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return None; + } + + let args = parts.next().map(str::trim).filter(|args| !args.is_empty()); + let mut value = json!({ "operation": operation }); + if let Some(args) = args { + value["args"] = json!(args); + } + Some(value) + } + + fn split_leading_operation(args: &str) -> Option<(String, String)> { + let args = args + .trim() + .strip_prefix("git ") + .map(str::trim) + .unwrap_or(args.trim()); + let mut parts = args.splitn(2, char::is_whitespace); + let operation = parts.next()?.trim(); + if !ALLOWED_OPERATIONS.contains(&operation) { + return None; + } + + let rest = parts.next().unwrap_or("").trim().to_string(); + Some((operation.to_string(), rest)) + } + + fn infer_operation_from_flag_args(args: &str) -> Option<&'static str> { + let tokens: Vec<&str> = args.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + + let has_log_flag = tokens.iter().any(|token| { + matches!( + *token, + "--since" + | "--until" + | "--oneline" + | "--grep" + | "--author" + | "--decorate" + | "--walk-reflogs" + ) || token.starts_with("--since=") + || token.starts_with("--until=") + }); + if has_log_flag { + return Some("log"); + } + + let has_diff_flag = tokens.iter().any(|token| { + matches!( + *token, + "--staged" | "--cached" | "--stat" | "--numstat" | "--name-only" | "--name-status" + ) + }); + if has_diff_flag { + return Some("diff"); + } + + None + } + + fn preserve_git_input_metadata(parsed: &mut Value, source: &Map) { + let Some(parsed_obj) = parsed.as_object_mut() else { + return; + }; + for key in ["working_directory", "timeout"] { + if let Some(value) = source.get(key) { + parsed_obj + .entry(key.to_string()) + .or_insert_with(|| value.clone()); + } + } + } + + /// Coerce common malformed Git tool inputs into `{ operation, args? }`. + pub(crate) fn normalize_git_input(input: Value) -> Value { + if let Some(text) = input.as_str() { + return Self::parse_git_command_text(text).unwrap_or(input); + } + + let Some(source) = input.as_object() else { + return input; + }; + + if source + .get("operation") + .and_then(|value| value.as_str()) + .is_some_and(|operation| !operation.is_empty()) + { + return input; + } + + for key in ["command", "cmd"] { + if let Some(text) = source.get(key).and_then(|value| value.as_str()) { + if let Some(mut parsed) = Self::parse_git_command_text(text) { + Self::preserve_git_input_metadata(&mut parsed, source); + return parsed; + } + } + } + + if let Some(args) = source.get("args").and_then(|value| value.as_str()) { + if let Some((operation, rest)) = Self::split_leading_operation(args) { + let mut parsed = json!({ "operation": operation }); + if !rest.is_empty() { + parsed["args"] = json!(rest); + } + Self::preserve_git_input_metadata(&mut parsed, source); + return parsed; + } + + if let Some(operation) = Self::infer_operation_from_flag_args(args) { + let mut parsed = json!({ + "operation": operation, + "args": args.trim(), + }); + Self::preserve_git_input_metadata(&mut parsed, source); + return parsed; + } + } + + input + } + /// Check if operation is dangerous fn is_dangerous_operation(operation: &str, args: &str) -> bool { let full_cmd = format!("{} {}", operation, args); @@ -349,9 +511,26 @@ impl GitTool { let args_str = args.unwrap_or(""); // Parse parameters - let mut max_count = 10; + let mut max_count = 50; let oneline = args_str.contains("--oneline"); let stat = args_str.contains("--stat"); + let mut since: Option = None; + let mut until: Option = None; + + // Parse --since= and --until= + for prefix in &["--since=", "--until="] { + if let Some(pos) = args_str.find(prefix) { + let val = args_str[pos + prefix.len()..] + .split_whitespace() + .next() + .map(|s| s.trim_matches('"').trim_matches('\'').to_string()); + if *prefix == "--since=" { + since = val; + } else { + until = val; + } + } + } // Parse -n or -number if let Some(pos) = args_str.find("-n") { @@ -377,6 +556,8 @@ impl GitTool { let params = GitLogParams { max_count: Some(max_count), stat: Some(stat), + since, + until, ..Default::default() }; @@ -791,13 +972,14 @@ If this tool was collapsed earlier in the conversation, only call it after `GetT {"operation": "switch", "args": "main"} ``` -## Important: `args` Field Rules +## Important: Input Shape -- The `operation` field already specifies the Git subcommand (e.g. `diff`, `log`, `add`). -- The `args` field must contain **only additional arguments** for that subcommand. -- **Do NOT include the subcommand name itself in `args`.** For example, use `{"operation": "diff", "args": "HEAD~2..HEAD --stat"}` — NOT `{"operation": "diff", "args": "diff HEAD~2..HEAD --stat"}`. -- A raw shell command string is invalid. Use `{"operation": "status"}` instead of `"git status"`. -- An object with only `args` and no `operation` is invalid, even if `args` contains flags such as `--since` or `--oneline`. Retry with the explicit operation, for example `{"operation": "log", "args": "--since=\"2026-05-02\" --oneline"}`. +- **Preferred format:** always send a JSON object with top-level `operation` plus optional `args`. +- `operation` is the bare Git subcommand (`status`, `diff`, `log`, `add`, `commit`, ...). +- `args` contains only flags, refs, paths, or commit-message text for that subcommand. +- **Do NOT repeat the subcommand in `args`.** Example: `{"operation": "diff", "args": "HEAD~2..HEAD --stat"}` — not `{"operation": "diff", "args": "diff HEAD~2..HEAD --stat"}`. +- Prefer this tool over Bash for Git subcommands when `Git` is available. Bash is still fine for shell pipelines, hooks, or commands that combine Git with other tools. +- Common shell-style mistakes (`"git status"`, `{"command": "git status"}`, or `{"args": "log --oneline -10"}`) are auto-normalized when possible, but the canonical `{operation, args?}` shape above is more reliable. ## Safety Notes @@ -847,12 +1029,12 @@ When creating commits, use this format for the commit message: "properties": { "operation": { "type": "string", - "description": "Required Git subcommand to perform. Use the bare subcommand only, such as \"status\", \"diff\", \"log\", \"add\", or \"commit\". Do not send a raw command like \"git status\". Do not omit this field, and do not place the subcommand in args.", + "description": "Git subcommand to run. Use the bare subcommand only, such as \"status\", \"diff\", \"log\", \"add\", or \"commit\". Do not prefix with \"git\" and do not put the subcommand in args.", "enum": ALLOWED_OPERATIONS }, "args": { "type": "string", - "description": "Only additional arguments for the selected operation: flags, refs, commit messages, or file paths. Examples: \"--staged\", \"--oneline -10\", \"-m \\\"message\\\"\", or \"-- src/file.rs\". Do not include \"git\" or repeat the operation/subcommand here." + "description": "Optional extra arguments for the selected operation: flags, refs, commit messages, or file paths. Examples: \"--staged\", \"--oneline -10\", \"-m \\\"message\\\"\", or \"-- src/file.rs\". Do not include \"git\" or repeat the operation/subcommand here." }, "working_directory": { "type": "string", @@ -915,6 +1097,8 @@ When creating commits, use this format for the commit message: input: &Value, _context: Option<&ToolUseContext>, ) -> ValidationResult { + let input = &Self::normalize_git_input(input.clone()); + // Validate operation parameter let operation = match input.get("operation").and_then(|v| v.as_str()) { Some(op) => op, @@ -922,7 +1106,7 @@ When creating commits, use this format for the commit message: return ValidationResult { result: false, message: Some( - "operation is required. Provide an explicit top-level operation such as {\"operation\":\"status\"}; do not send a raw git command string or an args-only object." + "Could not determine Git operation. Send {\"operation\":\"status\"} (preferred) or a repairable shell-style payload such as {\"command\":\"git status\"} or {\"args\":\"log --oneline -10\"}." .to_string(), ), error_code: Some(400), @@ -1044,6 +1228,8 @@ When creating commits, use this format for the commit message: input: &Value, context: &ToolUseContext, ) -> BitFunResult> { + let input = &Self::normalize_git_input(input.clone()); + let operation = input .get("operation") .and_then(|v| v.as_str()) @@ -1173,7 +1359,7 @@ mod tests { assert!(schema["properties"]["operation"]["description"] .as_str() .unwrap() - .contains("Do not omit this")); + .contains("Do not prefix with \"git\"")); assert!(schema["properties"]["args"]["description"] .as_str() .unwrap() @@ -1182,12 +1368,50 @@ mod tests { let validation = tool .validate_input(&json!({"args": "--since=\"2026-05-02\" --oneline"}), None) .await; - assert!(!validation.result); - assert!(validation - .message - .as_deref() - .unwrap_or_default() - .contains("operation is required")); + assert!(validation.result); + + let validation = tool + .validate_input(&json!({"args": "log --oneline -10"}), None) + .await; + assert!(validation.result); + + let validation = tool + .validate_input(&json!({"command": "git status"}), None) + .await; + assert!(validation.result); + + let validation = tool.validate_input(&json!("git diff --staged"), None).await; + assert!(validation.result); + + let validation = tool.validate_input(&json!({"args": "--stat"}), None).await; + assert!(validation.result); + } + + #[test] + fn normalize_git_input_repairs_common_malformed_payloads() { + assert_eq!( + GitTool::normalize_git_input(json!("git status")), + json!({"operation": "status"}) + ); + assert_eq!( + GitTool::normalize_git_input(json!({"command": "git diff --staged"})), + json!({"operation": "diff", "args": "--staged"}) + ); + assert_eq!( + GitTool::normalize_git_input(json!({"args": "log --oneline -10"})), + json!({"operation": "log", "args": "--oneline -10"}) + ); + assert_eq!( + GitTool::normalize_git_input(json!({"args": "--since=\"2026-05-02\" --oneline"})), + json!({ + "operation": "log", + "args": "--since=\"2026-05-02\" --oneline" + }) + ); + assert_eq!( + GitTool::normalize_git_input(json!({"operation": "status"})), + json!({"operation": "status"}) + ); } #[test] diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs similarity index 53% rename from src/crates/core/src/agentic/tools/implementations/glob_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 052439f1b8..903a3a5922 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -4,393 +4,14 @@ use crate::service::search::{ workspace_search_feature_enabled, workspace_search_runtime_available, GlobSearchRequest, }; use crate::util::errors::{BitFunError, BitFunResult}; -use crate::util::process_manager; use async_trait::async_trait; -use globset::{GlobBuilder, GlobMatcher}; -use ignore::WalkBuilder; use log::{info, warn}; use serde_json::{json, Value}; -use std::cmp::Ordering; -use std::collections::BinaryHeap; -use std::path::{Component, Path, PathBuf}; - -fn extract_glob_base_directory(pattern: &str) -> (String, String) { - let glob_start = pattern.find(['*', '?', '[', '{']); - - match glob_start { - Some(index) => { - let static_prefix = &pattern[..index]; - let last_separator = static_prefix - .char_indices() - .rev() - .find(|(_, ch)| *ch == '/' || *ch == '\\') - .map(|(idx, _)| idx); - - if let Some(separator_index) = last_separator { - ( - static_prefix[..separator_index].to_string(), - pattern[separator_index + 1..].to_string(), - ) - } else { - (String::new(), pattern.to_string()) - } - } - None => { - let trimmed = pattern.trim_end_matches(['/', '\\']); - let literal_path = Path::new(trimmed); - let base_dir = literal_path - .parent() - .filter(|parent| !parent.as_os_str().is_empty() && *parent != Path::new(".")) - .map(|parent| parent.to_string_lossy().to_string()) - .unwrap_or_default(); - let file_name = literal_path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| trimmed.to_string()); - - let relative_pattern = if pattern.ends_with('/') || pattern.ends_with('\\') { - format!("{}/", file_name) - } else { - file_name - }; - - (base_dir, relative_pattern) - } - } -} - -fn normalize_path(path: &Path) -> String { - dunce::simplified(path).to_string_lossy().replace('\\', "/") -} - -fn shell_escape(value: &str) -> String { - value.replace('\'', "'\\''") -} - -#[derive(Debug, Clone, Eq, PartialEq)] -struct GlobCandidate { - depth: usize, - path: String, -} - -impl Ord for GlobCandidate { - fn cmp(&self, other: &Self) -> Ordering { - self.depth - .cmp(&other.depth) - .then_with(|| self.path.cmp(&other.path)) - } -} - -impl PartialOrd for GlobCandidate { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -fn is_safe_relative_subpath(path: &Path) -> bool { - !path.is_absolute() - && path - .components() - .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) -} - -fn derive_walk_root(search_path_abs: &Path, pattern: &str) -> (PathBuf, String) { - let (base_dir, relative_pattern) = extract_glob_base_directory(pattern); - let base_path = Path::new(&base_dir); - - if base_dir.is_empty() || !is_safe_relative_subpath(base_path) { - return (search_path_abs.to_path_buf(), pattern.to_string()); - } - - let walk_root = search_path_abs.join(base_path); - if walk_root.starts_with(search_path_abs) { - (walk_root, relative_pattern) - } else { - (search_path_abs.to_path_buf(), pattern.to_string()) - } -} - -fn resolve_glob_config(pattern: &str) -> (bool, bool) { - let is_whitelisted = pattern.starts_with(".bitfun") - || pattern.contains("/.bitfun") - || pattern.contains("\\.bitfun"); - - let apply_gitignore = !is_whitelisted; - let ignore_hidden_files = !is_whitelisted; - (apply_gitignore, ignore_hidden_files) -} - -fn build_rg_args( - relative_pattern: &str, - apply_gitignore: bool, - ignore_hidden_files: bool, -) -> Vec { - let mut args = vec![ - "--files".to_string(), - "--glob".to_string(), - relative_pattern.to_string(), - "--sort".to_string(), - "path".to_string(), - ]; - - if !apply_gitignore { - args.push("--no-ignore".to_string()); - } - - if !ignore_hidden_files { - args.push("--hidden".to_string()); - } - - args -} - -fn build_fallback_matcher(relative_pattern: &str) -> Result { - GlobBuilder::new(relative_pattern) - .literal_separator(true) - .build() - .map_err(|err| err.to_string()) - .map(|glob| glob.compile_matcher()) -} - -fn match_relative_path(matcher: &GlobMatcher, relative_path: &str, is_dir: bool) -> bool { - if is_dir { - matcher.is_match(relative_path) || matcher.is_match(format!("{}/", relative_path)) - } else { - matcher.is_match(relative_path) - } -} - -fn collect_with_walk_fallback( - walk_root: &Path, - relative_pattern: &str, - apply_gitignore: bool, - ignore_hidden_files: bool, - limit: usize, -) -> Result, String> { - let matcher = build_fallback_matcher(relative_pattern)?; - let walker = WalkBuilder::new(walk_root) - .ignore(apply_gitignore) - .git_ignore(apply_gitignore) - .git_global(apply_gitignore) - .git_exclude(apply_gitignore) - .hidden(ignore_hidden_files) - .build(); - - let mut best_matches = BinaryHeap::with_capacity(limit.saturating_add(1)); - for entry in walker { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - warn!("Glob walker fallback entry error (skipped): {}", err); - continue; - } - }; - - let path = entry.path().to_path_buf(); - let relative_path = match path.strip_prefix(walk_root) { - Ok(relative) => relative, - Err(_) => continue, - }; - let relative_path = normalize_path(relative_path); - - if match_relative_path( - &matcher, - &relative_path, - entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false), - ) { - let normalized_path = normalize_path(&path); - let candidate = GlobCandidate { - depth: normalized_path.split('/').count(), - path: normalized_path, - }; - - if best_matches.len() < limit { - best_matches.push(candidate); - } else if let Some(worst_match) = best_matches.peek() { - if candidate < *worst_match { - best_matches.pop(); - best_matches.push(candidate); - } - } - } - } - - let mut results = best_matches - .into_sorted_vec() - .into_iter() - .map(|candidate| candidate.path) - .collect::>(); - results.sort(); - Ok(results) -} - -fn call_rg(search_path: &str, pattern: &str, limit: usize) -> Result, String> { - let path = std::path::Path::new(search_path); - if !path.exists() { - return Err(format!("Search path '{}' does not exist", search_path)); - } - if !path.is_dir() { - return Err(format!("Search path '{}' is not a directory", search_path)); - } - - let search_path_abs = - dunce::canonicalize(Path::new(search_path)).map_err(|err| err.to_string())?; - let (walk_root, relative_pattern) = derive_walk_root(&search_path_abs, pattern); - let (apply_gitignore, ignore_hidden_files) = resolve_glob_config(pattern); - - if !walk_root.exists() || !walk_root.is_dir() || limit == 0 { - return Ok(Vec::new()); - } - - let args = build_rg_args(&relative_pattern, apply_gitignore, ignore_hidden_files); - let output = process_manager::create_command("rg") - .current_dir(&walk_root) - .args(&args) - .arg(".") - .output() - .map_err(|err| { - if err.kind() == std::io::ErrorKind::NotFound { - "ripgrep (rg) is required for Glob tool execution but was not found".to_string() - } else { - format!("Failed to execute rg for Glob tool: {}", err) - } - }); - - let output = match output { - Ok(output) => { - info!( - "Glob backend selected: backend=rg, search_root={}, pattern={}", - walk_root.display(), - relative_pattern - ); - output - } - Err(err) if err.contains("ripgrep (rg) is required") => { - info!( - "Glob backend selected: backend=fallback_walk, reason=rg_not_found, search_root={}, pattern={}", - walk_root.display(), - relative_pattern - ); - return collect_with_walk_fallback( - &walk_root, - &relative_pattern, - apply_gitignore, - ignore_hidden_files, - limit, - ); - } - Err(err) => return Err(err), - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let message = if stderr.is_empty() { - format!("rg --files failed with status {}", output.status) - } else { - format!("rg --files failed: {}", stderr) - }; - if stderr.contains("No such file or directory") || stderr.contains("not found") { - info!( - "Glob backend selected: backend=fallback_walk, reason=rg_execution_failed, search_root={}, pattern={}", - walk_root.display(), - relative_pattern - ); - return collect_with_walk_fallback( - &walk_root, - &relative_pattern, - apply_gitignore, - ignore_hidden_files, - limit, - ); - } - return Err(message); - } - - let all_paths = String::from_utf8_lossy(&output.stdout) - .lines() - .filter(|line| !line.is_empty()) - .map(|line| { - let relative_path = line.strip_prefix("./").unwrap_or(line); - let full_path = walk_root.join(relative_path); - normalize_path(&full_path) - }) - .collect::>(); - Ok(limit_paths(&all_paths, limit)) -} - -fn limit_paths(paths: &[String], limit: usize) -> Vec { - let mut depth_and_paths = paths - .iter() - .map(|path| { - let normalized_path = path.replace('\\', "/"); - let depth = normalized_path.split('/').count(); - (depth, normalized_path) - }) - .collect::>(); - depth_and_paths.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); - - let mut result = depth_and_paths - .into_iter() - .take(limit) - .map(|(_, path)| path) - .collect::>(); - result.sort(); - result -} - -fn build_remote_rg_command(search_dir: &str, pattern: &str) -> String { - let search_dir_path = Path::new(search_dir); - let (remote_walk_root, remote_pattern) = derive_walk_root(search_dir_path, pattern); - let (apply_gitignore, ignore_hidden_files) = resolve_glob_config(pattern); - - let mut parts = vec![ - "cd".to_string(), - format!( - "'{}'", - shell_escape(remote_walk_root.to_string_lossy().as_ref()) - ), - "&&".to_string(), - "rg".to_string(), - "--files".to_string(), - "--glob".to_string(), - format!("'{}'", shell_escape(&remote_pattern)), - "--sort".to_string(), - "path".to_string(), - ]; - - if !apply_gitignore { - parts.push("--no-ignore".to_string()); - } - - if !ignore_hidden_files { - parts.push("--hidden".to_string()); - } - - parts.push(".".to_string()); - parts.push("2>/dev/null".to_string()); - parts.join(" ") -} - -fn build_remote_find_command(search_dir: &str, pattern: &str, limit: usize) -> String { - let search_dir_path = Path::new(search_dir); - let (remote_walk_root, remote_pattern) = derive_walk_root(search_dir_path, pattern); - - let name_pattern = if remote_pattern.contains("**/") { - remote_pattern.replacen("**/", "", 1) - } else if remote_pattern.contains('/') || remote_pattern.contains('\\') { - "*".to_string() - } else { - remote_pattern - }; - - let escaped_dir = remote_walk_root.to_string_lossy().replace('\'', "'\\''"); - let escaped_pattern = name_pattern.replace('\'', "'\\''"); - - format!( - "find '{}' -maxdepth 10 -name '{}' -not -path '*/.git/*' -not -path '*/node_modules/*' 2>/dev/null | head -n {}", - escaped_dir, escaped_pattern, limit - ) -} +use std::path::PathBuf; +use tool_runtime::search::glob_search::{ + build_remote_find_command, build_remote_rg_command, collect_remote_glob_matches, + execute_local_glob, normalize_path, LocalGlobRequest, +}; pub struct GlobTool; @@ -606,20 +227,12 @@ impl Tool for GlobTool { BitFunError::tool(format!("Failed to glob on remote with rg: {}", e)) })?; - let matches: Vec = stdout - .lines() - .filter(|l| !l.is_empty()) - .map(|line| { - let relative_path = line.strip_prefix("./").unwrap_or(line); - normalize_path(&Path::new(&search_dir).join(relative_path)) - }) - .collect(); - let limited = limit_paths(&matches, limit) + let limited = collect_remote_glob_matches(&search_dir, &stdout, limit) .into_iter() .map(|path| { resolved - .logical_child_path(Path::new(&path)) - .unwrap_or(path) + .logical_child_path(&path) + .unwrap_or_else(|| normalize_path(&path)) }) .collect::>(); let result_text = if limited.is_empty() { @@ -685,19 +298,24 @@ impl Tool for GlobTool { } let resolved_str_for_rg = resolved_str.clone(); let pattern_for_rg = pattern.to_string(); - let matches = tokio::task::spawn_blocking(move || { - call_rg(&resolved_str_for_rg, &pattern_for_rg, limit) + let glob_result = tokio::task::spawn_blocking(move || { + execute_local_glob(LocalGlobRequest { + search_path: PathBuf::from(resolved_str_for_rg), + pattern: pattern_for_rg, + limit, + }) }) .await .map_err(|err| BitFunError::tool(format!("Glob tool task failed: {}", err)))? .map_err(BitFunError::tool)?; - let matches = matches + let matches = glob_result + .matches .into_iter() .map(|path| { resolved - .logical_child_path(Path::new(&path)) - .unwrap_or(path) + .logical_child_path(&path) + .unwrap_or_else(|| normalize_path(&path)) }) .collect::>(); @@ -724,11 +342,13 @@ impl Tool for GlobTool { #[cfg(test)] mod tests { - use super::{call_rg, derive_walk_root, extract_glob_base_directory}; - use crate::util::process_manager; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; + use tool_runtime::search::glob_search::{ + derive_walk_root, execute_local_glob, extract_glob_base_directory, normalize_path, + LocalGlobRequest, + }; fn make_temp_dir(name: &str) -> PathBuf { let unique = SystemTime::now() @@ -767,14 +387,6 @@ mod tests { #[test] fn keeps_shallowest_matches_from_rg_results() { - if process_manager::create_command("rg") - .arg("--version") - .output() - .is_err() - { - return; - } - let root = make_temp_dir("limit"); fs::create_dir_all(root.join("src/deep")).unwrap(); fs::create_dir_all(root.join("tests")).unwrap(); @@ -783,7 +395,16 @@ mod tests { fs::write(root.join("src/deep/mod.rs"), "").unwrap(); fs::write(root.join("tests/mod.rs"), "").unwrap(); - let matches = call_rg(root.to_string_lossy().as_ref(), "**/*.rs", 2).unwrap(); + let matches = execute_local_glob(LocalGlobRequest { + search_path: root.clone(), + pattern: "**/*.rs".to_string(), + limit: 2, + }) + .unwrap() + .matches + .into_iter() + .map(|path| normalize_path(&path)) + .collect::>(); assert_eq!(matches.len(), 2); assert!(matches.iter().any(|path| path.ends_with("/src/lib.rs"))); @@ -797,19 +418,20 @@ mod tests { #[test] fn wildcard_search_now_returns_files_only() { - if process_manager::create_command("rg") - .arg("--version") - .output() - .is_err() - { - return; - } - let root = make_temp_dir("files-only"); fs::create_dir_all(root.join("src/nested")).unwrap(); fs::write(root.join("src/nested/lib.rs"), "").unwrap(); - let matches = call_rg(root.to_string_lossy().as_ref(), "*", 10).unwrap(); + let matches = execute_local_glob(LocalGlobRequest { + search_path: root.clone(), + pattern: "*".to_string(), + limit: 10, + }) + .unwrap() + .matches + .into_iter() + .map(|path| normalize_path(&path)) + .collect::>(); assert!(matches.iter().all(|path| !path.ends_with("/src"))); assert!(!matches.is_empty()); diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs similarity index 89% rename from src/crates/core/src/agentic/tools/implementations/grep_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs index 9d6f24f4b4..c7add2f2d2 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs @@ -13,7 +13,9 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use tool_runtime::search::grep_search::{ - grep_search, GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, + apply_offset_and_limit, build_remote_grep_command, count_remote_grep_matches, grep_search, + relativize_result_text, render_remote_grep_result_text, GrepOptions, GrepSearchResult, + OutputMode, ProgressCallback, RemoteGrepCommandRequest, }; const DEFAULT_HEAD_LIMIT: usize = 250; @@ -58,10 +60,6 @@ impl GrepTool { .map(|limit| limit.saturating_add(offset)) } - fn shell_escape(value: &str) -> String { - value.replace('\'', "'\\''") - } - fn parse_glob_patterns(glob: Option<&str>) -> Vec { let Some(glob) = glob else { return Vec::new(); @@ -98,29 +96,6 @@ impl GrepTool { .map(|workspace| workspace.root_path_string()) } - fn relativize_result_text(result_text: &str, display_base: Option<&str>) -> String { - let Some(base) = display_base else { - return result_text.to_string(); - }; - - let normalized_base = base.replace('\\', "/").trim_end_matches('/').to_string(); - if normalized_base.is_empty() { - return result_text.to_string(); - } - - result_text - .lines() - .map(|line| { - if let Some(rest) = line.strip_prefix(&(normalized_base.clone() + "/")) { - rest.to_string() - } else { - line.to_string() - } - }) - .collect::>() - .join("\n") - } - async fn call_remote( &self, input: &Value, @@ -146,6 +121,8 @@ impl GrepTool { .get("output_mode") .and_then(|v| v.as_str()) .unwrap_or("files_with_matches"); + let output_mode_enum = + OutputMode::from_str(output_mode).map_err(|e| BitFunError::tool(e.to_string()))?; let show_line_numbers = input .get("-n") .and_then(|v| v.as_bool()) @@ -154,86 +131,38 @@ impl GrepTool { .get("context") .or_else(|| input.get("-C")) .and_then(|v| v.as_u64()) - .map(|v| v.to_string()); - let before_context = input - .get("-B") - .and_then(|v| v.as_u64()) - .map(|v| v.to_string()); - let after_context = input - .get("-A") - .and_then(|v| v.as_u64()) - .map(|v| v.to_string()); + .map(|v| v as usize); + let before_context = input.get("-B").and_then(|v| v.as_u64()).map(|v| v as usize); + let after_context = input.get("-A").and_then(|v| v.as_u64()).map(|v| v as usize); let glob_patterns = Self::parse_glob_patterns(input.get("glob").and_then(|v| v.as_str())); - let file_type = input.get("type").and_then(|v| v.as_str()); - - let escaped_path = Self::shell_escape(&resolved_path); - let escaped_pattern = Self::shell_escape(pattern); - let offset_cmd = if offset > 0 { - format!(" | tail -n +{}", offset + 1) - } else { - String::new() - }; - let limit_cmd = head_limit - .map(|limit| format!(" | head -n {}", limit)) - .unwrap_or_default(); + let file_type = input + .get("type") + .and_then(|v| v.as_str()) + .map(|value| value.to_string()); - let mut cmd = "rg --no-heading --hidden --max-columns 500".to_string(); - if case_insensitive { - cmd.push_str(" -i"); - } - if output_mode == "files_with_matches" { - cmd.push_str(" -l"); - } else if output_mode == "count" { - cmd.push_str(" -c"); - } else if show_line_numbers { - cmd.push_str(" --line-number"); - } - if output_mode == "content" { - if let Some(context) = context_c { - cmd.push_str(&format!(" -C {}", context)); - } else { - if let Some(before) = before_context { - cmd.push_str(&format!(" -B {}", before)); - } - if let Some(after) = after_context { - cmd.push_str(&format!(" -A {}", after)); - } - } - } - for glob_pattern in glob_patterns { - cmd.push_str(&format!(" --glob '{}'", Self::shell_escape(&glob_pattern))); - } - if let Some(ft) = file_type { - cmd.push_str(&format!(" --type '{}'", Self::shell_escape(ft))); - } - cmd.push_str(&format!( - " -e '{}' '{}' 2>/dev/null{}{}", - escaped_pattern, escaped_path, offset_cmd, limit_cmd - )); - - let full_cmd = format!( - "if command -v rg >/dev/null 2>&1; then {}; else grep -rn{} -e '{}' '{}' 2>/dev/null{}{}; fi", - cmd, - if case_insensitive { "i" } else { "" }, - escaped_pattern, - escaped_path, - offset_cmd, - limit_cmd, - ); + let full_cmd = build_remote_grep_command(&RemoteGrepCommandRequest { + pattern: pattern.to_string(), + path: resolved_path, + case_insensitive, + output_mode: output_mode_enum, + show_line_numbers, + context: context_c, + before_context, + after_context, + glob_patterns, + file_type, + head_limit, + offset, + }); let (stdout, _stderr, _exit_code) = ws_shell .exec(&full_cmd, Some(30_000)) .await .map_err(|e| BitFunError::tool(format!("Remote grep failed: {}", e)))?; - let lines: Vec<&str> = stdout.lines().collect(); - let total_matches = lines.len(); + let total_matches = count_remote_grep_matches(&stdout); let display_base = Self::display_base(context); - let result_text = if lines.is_empty() { - format!("No matches found for pattern '{}'", pattern) - } else { - Self::relativize_result_text(&stdout, display_base.as_deref()) - }; + let result_text = render_remote_grep_result_text(&stdout, pattern, display_base.as_deref()); Ok(vec![ToolResult::Result { data: json!({ @@ -432,7 +361,7 @@ impl GrepTool { ); } apply_offset_and_limit(&mut lines, offset, head_limit); - let rendered = Self::relativize_result_text(&lines.join("\n"), display_base); + let rendered = relativize_result_text(&lines.join("\n"), display_base); let file_count = if result.hits.is_empty() { result .outcome @@ -460,7 +389,7 @@ impl GrepTool { lines.sort(); let mut lines = lines.into_iter().collect::>(); apply_offset_and_limit(&mut lines, offset, head_limit); - let rendered = Self::relativize_result_text(&lines.join("\n"), display_base); + let rendered = relativize_result_text(&lines.join("\n"), display_base); (rendered, result.file_counts.len(), result.matched_lines) } _ => { @@ -473,7 +402,7 @@ impl GrepTool { files.sort(); files.dedup(); apply_offset_and_limit(&mut files, offset, head_limit); - let rendered = Self::relativize_result_text(&files.join("\n"), display_base); + let rendered = relativize_result_text(&files.join("\n"), display_base); let total_matches = files.len(); (rendered, total_matches, total_matches) } @@ -532,22 +461,6 @@ fn render_workspace_search_content_lines( lines } -fn apply_offset_and_limit(items: &mut Vec, offset: usize, head_limit: Option) { - if offset > 0 { - if offset >= items.len() { - items.clear(); - } else { - *items = items[offset..].to_vec(); - } - } - - if let Some(limit) = head_limit { - if items.len() > limit { - items.truncate(limit); - } - } -} - #[cfg(test)] mod tests { use super::{ @@ -561,6 +474,7 @@ mod tests { WorkspaceSearchRepoStatus, }; use serde_json::json; + use tool_runtime::search::grep_search::relativize_result_text; #[test] fn head_limit_defaults_and_zero_escape_hatch() { @@ -597,7 +511,7 @@ mod tests { #[test] fn relativizes_prefixed_result_lines() { let text = "/repo/src/main.rs:12:fn main()\n/repo/src/lib.rs:3:pub fn lib()"; - let relativized = GrepTool::relativize_result_text(text, Some("/repo")); + let relativized = relativize_result_text(text, Some("/repo")); assert_eq!( relativized, @@ -974,7 +888,6 @@ Usage: let (request, output_mode, show_line_numbers, offset, head_limit) = self.build_workspace_search_request(input, context)?; let pattern = request.pattern.clone(); - let search_mode = request.output_mode.search_mode(); let path = request .search_path .as_ref() @@ -1008,11 +921,10 @@ Usage: let workspace_search_elapsed_ms = search_started_at.elapsed().as_millis(); log::info!( - "Grep tool remote workspace-search result: pattern={}, path={}, output_mode={}, search_mode={:?}, file_count={}, total_matches={}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, workspace_search_ms={}", + "Grep tool remote workspace-search result: pattern={}, path={}, output_mode={}, file_count={}, total_matches={}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, workspace_search_ms={}", pattern, path, output_mode, - search_mode, file_count, total_matches, search_result.backend, @@ -1065,7 +977,6 @@ Usage: let (request, output_mode, show_line_numbers, offset, head_limit) = self.build_workspace_search_request(input, context)?; let pattern = request.pattern.clone(); - let search_mode = request.output_mode.search_mode(); let path = request .search_path .as_ref() @@ -1085,11 +996,10 @@ Usage: let workspace_search_elapsed_ms = search_started_at.elapsed().as_millis(); log::info!( - "Grep tool workspace-search result: pattern={}, path={}, output_mode={}, search_mode={:?}, file_count={}, total_matches={}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, workspace_search_ms={}", + "Grep tool workspace-search result: pattern={}, path={}, output_mode={}, file_count={}, total_matches={}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, workspace_search_ms={}", pattern, path, output_mode, - search_mode, file_count, total_matches, search_result.backend, diff --git a/src/crates/core/src/agentic/tools/implementations/harmony_build_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/harmony_build_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/harmony_build_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/harmony_build_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/harmonyos_project.rs b/src/crates/assembly/core/src/agentic/tools/implementations/harmonyos_project.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/harmonyos_project.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/harmonyos_project.rs diff --git a/src/crates/core/src/agentic/tools/implementations/log_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/log_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/log_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/log_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs similarity index 84% rename from src/crates/core/src/agentic/tools/implementations/ls_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs index 2afea6a2a9..7f0c050da6 100644 --- a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs @@ -13,10 +13,7 @@ use chrono::{DateTime, Local}; use serde_json::{json, Value}; use std::path::Path; use std::time::SystemTime; - -fn shell_escape(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} +use tool_runtime::fs::{build_remote_list_commands, parse_remote_list_entries}; /// LS tool - list directory tree pub struct LSTool { @@ -247,43 +244,22 @@ Usage: BitFunError::tool("Workspace shell not available for remote LS".to_string()) })?; - let ls_cmd = format!( - "find {} -maxdepth 1 -not -name '.*' -not -path {} | head -n {} | sort", - shell_escape(&resolved.resolved_path), - shell_escape(&resolved.resolved_path), - limit + 1 - ); + let list_commands = build_remote_list_commands(&resolved.resolved_path, limit); - let (stdout, _stderr, _exit_code) = - ws_shell.exec(&ls_cmd, Some(15_000)).await.map_err(|e| { + let (stdout, _stderr, _exit_code) = ws_shell + .exec(&list_commands.scan_command, Some(15_000)) + .await + .map_err(|e| { BitFunError::tool(format!("Failed to list remote directory: {}", e)) })?; - let mut file_lines = Vec::new(); - let mut dir_lines = Vec::new(); - - for line in stdout.lines().filter(|l| !l.is_empty()) { - let name = Path::new(line) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| line.to_string()); - let is_dir = line.ends_with('/'); - if is_dir || name.is_empty() { - dir_lines.push((name, line.to_string())); - } else { - file_lines.push((name, line.to_string())); - } - } - // Use a simpler stat-based listing for the text output - let stat_cmd = format!( - "ls -la --time-style=long-iso {} 2>/dev/null || ls -la {}", - shell_escape(&resolved.resolved_path), - shell_escape(&resolved.resolved_path) - ); - let (ls_output, _, _) = ws_shell.exec(&stat_cmd, Some(15_000)).await.map_err(|e| { - BitFunError::tool(format!("Failed to list remote directory: {}", e)) - })?; + let (ls_output, _, _) = ws_shell + .exec(&list_commands.listing_command, Some(15_000)) + .await + .map_err(|e| { + BitFunError::tool(format!("Failed to list remote directory: {}", e)) + })?; let result_text = format!( "Directory listing: {}\n\n{}", @@ -291,18 +267,13 @@ Usage: ls_output.trim() ); - let entries_json: Vec = stdout - .lines() - .filter(|l| !l.is_empty()) - .map(|line| { - let name = Path::new(line) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| line.to_string()); + let entries_json: Vec = parse_remote_list_entries(&stdout) + .into_iter() + .map(|entry| { json!({ - "name": name, - "path": line, - "is_dir": line.ends_with('/'), + "name": entry.name, + "path": entry.path, + "is_dir": entry.is_dir, }) }) .collect(); diff --git a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/mcp_tools.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs diff --git a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs similarity index 83% rename from src/crates/core/src/agentic/tools/implementations/mod.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 1d3ce8a201..8b4b394026 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -18,12 +18,13 @@ pub mod control_hub_tool; pub mod create_plan_tool; pub mod cron_tool; pub mod delete_file_tool; +pub mod exec_command; pub mod file_edit_tool; pub mod file_read_tool; pub mod file_write_tool; pub mod generative_ui_tool; pub mod get_file_diff_tool; -pub mod get_tool_spec_tool; +pub mod get_time_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; @@ -40,12 +41,17 @@ pub mod skill_tool; pub mod skills; pub mod task_tool; pub mod terminal_control_tool; +pub mod thread_goal_tools; pub mod todo_write_tool; pub mod util; -pub mod web_tools; +pub mod web; + +#[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")] +pub use crate::agentic::tools::product_runtime::GetToolSpecTool; pub mod harmony_build_tool; pub use ask_user_question_tool::AskUserQuestionTool; pub use bash_tool::BashTool; +pub use calendar_tool::CalendarTool; pub use code_review_tool::CodeReviewTool; pub use computer_use_mouse_click_tool::ComputerUseMouseClickTool; pub use computer_use_mouse_precise_tool::ComputerUseMousePreciseTool; @@ -55,12 +61,13 @@ pub use control_hub_tool::ControlHubTool; pub use create_plan_tool::CreatePlanTool; pub use cron_tool::CronTool; pub use delete_file_tool::DeleteFileTool; +pub use exec_command::{ExecCommandTool, ExecControlTool, WriteStdinTool}; pub use file_edit_tool::FileEditTool; pub use file_read_tool::FileReadTool; pub use file_write_tool::FileWriteTool; pub use generative_ui_tool::GenerativeUITool; pub use get_file_diff_tool::GetFileDiffTool; -pub use get_tool_spec_tool::GetToolSpecTool; +pub use get_time_tool::GetTimeTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; @@ -78,5 +85,6 @@ pub use session_message_tool::SessionMessageTool; pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; +pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; -pub use web_tools::{WebFetchTool, WebSearchTool}; +pub use web::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/playbook_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs similarity index 55% rename from src/crates/core/src/agentic/tools/implementations/session_control_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 65f81801aa..7530c89183 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -6,16 +6,29 @@ use super::util::normalize_path; use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; -use crate::agentic::core::SessionConfig; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde::Deserialize; +use bitfun_agent_runtime::runtime::AgentRuntime; +use bitfun_agent_runtime::session_control::{ + render_session_control_tool_use_message, resolve_session_control_cancel_route, + session_control_agent_type_or_default, session_control_cancel_result_message, + session_control_cancel_status, session_control_created_result_message, + session_control_creator_marker, session_control_deleted_result_message, + session_control_session_name_or_default, validate_session_control_input, validate_session_id, + SessionControlAction, SessionControlCancelRoute, SessionControlInput, + SessionControlValidationContext, SessionControlValidationResult, +}; +use bitfun_runtime_ports::{ + AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionListRequest, + AgentSessionSummary, AgentSessionWorkspaceRequest, AgentSubmissionSource, + AgentTurnCancellationRequest, +}; use serde_json::{json, Value}; -use std::path::Path; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// SessionControl tool - create, cancel, delete, or list persisted sessions pub struct SessionControlTool; @@ -50,31 +63,6 @@ impl SessionControlTool { } } - fn validate_session_id(session_id: &str) -> Result<(), String> { - if session_id.is_empty() { - return Err("session_id cannot be empty".to_string()); - } - if session_id == "." || session_id == ".." { - return Err("session_id cannot be '.' or '..'".to_string()); - } - if session_id.contains('/') || session_id.contains('\\') { - return Err("session_id cannot contain path separators".to_string()); - } - if !session_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return Err( - "session_id can only contain ASCII letters, numbers, '-' and '_'".to_string(), - ); - } - Ok(()) - } - - fn default_session_name() -> String { - "New Session".to_string() - } - fn escape_markdown_table_cell(value: &str) -> String { value .replace('\\', "\\\\") @@ -91,29 +79,7 @@ impl SessionControlTool { let creator_session_id = context.session_id.as_ref().ok_or_else(|| { BitFunError::tool("create requires a creator session in tool context".to_string()) })?; - Ok(format!("session-{}", creator_session_id)) - } - - fn validate_workspace_shape(&self, workspace: &str) -> ValidationResult { - if workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if !Path::new(workspace.trim()).is_absolute() { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; - } - - ValidationResult::default() + Ok(session_control_creator_marker(creator_session_id)) } async fn resolve_effective_workspace( @@ -121,17 +87,21 @@ impl SessionControlTool { action: SessionControlAction, session_id: Option<&str>, context: &ToolUseContext, - coordinator: &crate::agentic::coordination::ConversationCoordinator, + runtime: &AgentRuntime, ) -> BitFunResult { match action { SessionControlAction::Cancel | SessionControlAction::Delete => { let session_id = session_id.ok_or_else(|| { BitFunError::tool(format!("session_id is required for {}", action.as_str())) })?; - if let Some(resolved) = coordinator - .resolve_session_workspace_path(session_id) + if let Some(resolved) = runtime + .resolve_session_workspace_path(AgentSessionWorkspaceRequest { + session_id: session_id.to_string(), + }) .await - .map(|path| path.to_string_lossy().to_string()) + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })? { return Ok(resolved); } @@ -152,75 +122,36 @@ impl SessionControlTool { } } - fn validate_mutating_action_target( - &self, - action: SessionControlAction, - parsed: &SessionControlInput, - context: Option<&ToolUseContext>, - ) -> ValidationResult { - if parsed.agent_type.is_some() { - return ValidationResult { - result: false, - message: Some("agent_type is only allowed for create".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.session_name.is_some() { - return ValidationResult { - result: false, - message: Some("session_name is only allowed for create".to_string()), - error_code: Some(400), - meta: None, - }; - } - - let Some(session_id) = parsed.session_id.as_deref() else { - return ValidationResult { - result: false, - message: Some(format!("session_id is required for {}", action.as_str())), - error_code: Some(400), - meta: None, - }; - }; - if let Err(message) = Self::validate_session_id(session_id) { - return ValidationResult { - result: false, - message: Some(message), - error_code: Some(400), - meta: None, - }; + fn validation_context(context: Option<&ToolUseContext>) -> SessionControlValidationContext<'_> { + SessionControlValidationContext { + current_session_id: context.and_then(|value| value.session_id.as_deref()), + has_workspace_root: context.and_then(|value| value.workspace_root()).is_some(), } + } - if let Some(tool_context) = context { - if context - .and_then(|value| value.session_id.as_deref()) - .is_some_and(|current_session_id| current_session_id == session_id) - && tool_context.workspace_root().is_some() - { - return ValidationResult { - result: false, - message: Some(format!( - "cannot {} the current session from SessionControl", - action.as_str() - )), - error_code: Some(400), - meta: None, - }; - } + fn into_validation_result(result: SessionControlValidationResult) -> ValidationResult { + ValidationResult { + result: result.result, + message: result.message, + error_code: result.error_code, + meta: result.meta, } - - ValidationResult::default() } async fn ensure_session_exists( &self, - coordinator: &crate::agentic::coordination::ConversationCoordinator, - workspace_path: &Path, + runtime: &AgentRuntime, workspace: &str, session_id: &str, ) -> BitFunResult<()> { - let existing_sessions = coordinator.list_sessions(workspace_path).await?; + let existing_sessions = runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace.to_string(), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; if existing_sessions .iter() .any(|session| session.session_id == session_id) @@ -234,10 +165,14 @@ impl SessionControlTool { } } + fn system_time_from_epoch_ms(epoch_ms: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_millis(epoch_ms) + } + fn build_list_result_for_assistant( &self, workspace: &str, - sessions: &[crate::agentic::core::SessionSummary], + sessions: &[AgentSessionSummary], current_session_id: Option<&str>, ) -> String { if sessions.is_empty() { @@ -264,63 +199,16 @@ impl SessionControlTool { Self::escape_markdown_table_cell(&session.session_id), Self::escape_markdown_table_cell(&session.session_name), Self::escape_markdown_table_cell(&session.agent_type), - Self::format_system_time(session.created_at), - Self::format_system_time(session.last_activity_at), + Self::format_system_time(Self::system_time_from_epoch_ms(session.created_at_ms)), + Self::format_system_time(Self::system_time_from_epoch_ms( + session.last_active_at_ms + )), )); } lines.join("\n") } } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "lowercase")] -enum SessionControlAction { - Create, - Cancel, - Delete, - List, -} - -impl SessionControlAction { - fn as_str(&self) -> &'static str { - match self { - Self::Create => "create", - Self::Cancel => "cancel", - Self::Delete => "delete", - Self::List => "list", - } - } -} - -#[derive(Debug, Clone, Deserialize)] -enum SessionControlAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, -} - -impl SessionControlAgentType { - fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } - } -} - -#[derive(Debug, Clone, Deserialize)] -struct SessionControlInput { - action: SessionControlAction, - workspace: Option, - session_id: Option, - session_name: Option, - agent_type: Option, -} - #[async_trait] impl Tool for SessionControlTool { fn name(&self) -> &str { @@ -414,140 +302,14 @@ Arguments: } }; - if let Some(workspace) = parsed.workspace.as_deref() { - let should_validate_workspace = matches!( - parsed.action, - SessionControlAction::Create | SessionControlAction::List - ); - if !should_validate_workspace { - // Ignore workspace for cancel/delete so callers cannot accidentally - // scope these actions to the wrong workspace. - return match parsed.action { - SessionControlAction::Cancel => self.validate_mutating_action_target( - SessionControlAction::Cancel, - &parsed, - context, - ), - SessionControlAction::Delete => self.validate_mutating_action_target( - SessionControlAction::Delete, - &parsed, - context, - ), - _ => ValidationResult::default(), - }; - } - let workspace_validation = self.validate_workspace_shape(workspace); - if !workspace_validation.result { - return workspace_validation; - } - } - - match parsed.action { - SessionControlAction::Create => { - if parsed.workspace.is_none() { - return ValidationResult { - result: false, - message: Some("workspace is required for create".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.session_id.is_some() { - return ValidationResult { - result: false, - message: Some("session_id is not allowed for create".to_string()), - error_code: Some(400), - meta: None, - }; - } - if context - .and_then(|value| value.session_id.as_ref()) - .is_none() - { - return ValidationResult { - result: false, - message: Some( - "create requires a creator session in tool context".to_string(), - ), - error_code: Some(400), - meta: None, - }; - } - } - SessionControlAction::Cancel => { - return self.validate_mutating_action_target( - SessionControlAction::Cancel, - &parsed, - context, - ); - } - SessionControlAction::Delete => { - return self.validate_mutating_action_target( - SessionControlAction::Delete, - &parsed, - context, - ); - } - SessionControlAction::List => { - if parsed.workspace.is_none() { - return ValidationResult { - result: false, - message: Some("workspace is required for list".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.agent_type.is_some() { - return ValidationResult { - result: false, - message: Some("agent_type is only allowed for create".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.session_name.is_some() { - return ValidationResult { - result: false, - message: Some("session_name is only allowed for create".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.session_id.is_some() { - return ValidationResult { - result: false, - message: Some("session_id is not allowed for list".to_string()), - error_code: Some(400), - meta: None, - }; - } - } - } - - ValidationResult::default() + Self::into_validation_result(validate_session_control_input( + &parsed, + Self::validation_context(context), + )) } fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { - let action = input - .get("action") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - let workspace = input - .get("workspace") - .and_then(|value| value.as_str()) - .unwrap_or("unknown workspace"); - let session_id = input - .get("session_id") - .and_then(|value| value.as_str()) - .unwrap_or("auto"); - - match action { - "create" => format!("Create session in {}", workspace), - "cancel" => format!("Cancel active turn for session {}", session_id), - "delete" => format!("Delete session {}", session_id), - "list" => format!("List sessions in {}", workspace), - _ => format!("Manage sessions in {}", workspace), - } + render_session_control_tool_use_message(input) } async fn call_impl( @@ -559,6 +321,8 @@ Arguments: .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let runtime = CoreServiceAgentRuntime::agent_runtime(coordinator.clone()) + .map_err(BitFunError::tool)?; match params.action { SessionControlAction::Create => { @@ -567,40 +331,33 @@ Arguments: SessionControlAction::Create, None, context, - &coordinator, + &runtime, ) .await?; - let session_name = params - .session_name - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(Self::default_session_name); - let agent_type = params - .agent_type - .as_ref() - .map(|agent_type| agent_type.as_str().to_string()) - .unwrap_or_else(|| "agentic".to_string()); + let session_name = + session_control_session_name_or_default(params.session_name.as_deref()); + let agent_type = session_control_agent_type_or_default(params.agent_type.as_ref()); let created_by = self.creator_session_marker(context)?; - - let session = coordinator - .create_session_with_workspace_and_creator( - None, + let mut metadata = serde_json::Map::new(); + metadata.insert("createdBy".to_string(), json!(created_by)); + let session = runtime + .create_session(AgentSessionCreateRequest { session_name, agent_type, - SessionConfig { - workspace_path: Some(workspace.clone()), - ..Default::default() - }, - workspace.clone(), - Some(created_by.clone()), - ) - .await?; + workspace_path: Some(workspace.clone()), + metadata, + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; let created_session_id = session.session_id.clone(); let created_session_name = session.session_name.clone(); let created_agent_type = session.agent_type.clone(); - let result_for_assistant = format!( - "Created session '{}' in workspace '{}' using agent type '{}'.", - created_session_id, workspace, created_agent_type + let result_for_assistant = session_control_created_result_message( + &created_session_id, + &workspace, + &created_agent_type, ); Ok(vec![ToolResult::Result { @@ -622,69 +379,70 @@ Arguments: let session_id = params.session_id.as_deref().ok_or_else(|| { BitFunError::tool("session_id is required for cancel".to_string()) })?; - Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + validate_session_id(session_id).map_err(BitFunError::tool)?; let workspace = self .resolve_effective_workspace( SessionControlAction::Cancel, Some(session_id), context, - &coordinator, + &runtime, ) .await?; - let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( "cannot cancel the current session from SessionControl".to_string(), )); } - self.ensure_session_exists(&coordinator, workspace_path, &workspace, session_id) + self.ensure_session_exists(&runtime, &workspace, session_id) .await?; - let cancelled_turn_id = - match (context.session_id.as_deref(), get_global_scheduler()) { - (Some(requester_session_id), Some(scheduler)) => { - scheduler - .cancel_active_turn_for_session_from_requester( - session_id, - requester_session_id, - CANCEL_WAIT_TIMEOUT, - ) - .await? - } - (Some(_), None) => { - // Normally this should not happen: the runtime usually initializes - // the global scheduler before tools are allowed to run. - coordinator - .cancel_active_turn_for_session(session_id, CANCEL_WAIT_TIMEOUT) - .await? - } - (None, _) => { - // Normally this should not happen: SessionControl is expected to run - // inside a session-aware tool context. Fallback to plain cancellation - // so the core cancel behavior still works for nonstandard callers. - coordinator - .cancel_active_turn_for_session(session_id, CANCEL_WAIT_TIMEOUT) - .await? - } - }; - let had_active_turn = cancelled_turn_id.is_some(); - let status = if had_active_turn { - "cancel_requested" - } else { - "no_active_turn" - }; - let result_for_assistant = if let Some(turn_id) = cancelled_turn_id.as_deref() { - format!( - "Cancellation requested for the active turn '{}' in session '{}' within workspace '{}'. The session remains available for future work, and queued messages are not cleared.", - turn_id, session_id, workspace - ) - } else { - format!( - "Session '{}' in workspace '{}' has no active turn to cancel. The session remains available for future work.", - session_id, workspace - ) + let scheduler = get_global_scheduler(); + let cancel_route = resolve_session_control_cancel_route( + context.session_id.as_deref(), + scheduler.is_some(), + ); + let (runtime, requester_session_id) = match (cancel_route, scheduler) { + ( + SessionControlCancelRoute::RequesterViaScheduler { + requester_session_id, + }, + Some(scheduler), + ) => { + let runtime = CoreServiceAgentRuntime::agent_runtime_with_scheduler_ports( + coordinator.clone(), + scheduler, + ) + .map_err(BitFunError::tool)?; + (runtime, Some(requester_session_id)) + } + _ => { + // Fallback covers unusual tool contexts and startup states where the + // global scheduler is not available; concrete cancellation still works. + (runtime.clone(), None) + } }; + let cancelled_turn_id = runtime + .cancel_turn(AgentTurnCancellationRequest { + session_id: session_id.to_string(), + turn_id: None, + source: Some(AgentSubmissionSource::AgentSession), + requester_session_id, + reason: None, + wait_timeout_ms: Some(CANCEL_WAIT_TIMEOUT.as_millis() as u64), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })? + .turn_id; + let had_active_turn = cancelled_turn_id.is_some(); + let status = session_control_cancel_status(cancelled_turn_id.as_deref()); + let result_for_assistant = session_control_cancel_result_message( + session_id, + &workspace, + cancelled_turn_id.as_deref(), + ); Ok(vec![ToolResult::Result { data: json!({ @@ -704,28 +462,33 @@ Arguments: let session_id = params.session_id.as_deref().ok_or_else(|| { BitFunError::tool("session_id is required for delete".to_string()) })?; - Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + validate_session_id(session_id).map_err(BitFunError::tool)?; let workspace = self .resolve_effective_workspace( SessionControlAction::Delete, Some(session_id), context, - &coordinator, + &runtime, ) .await?; - let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( "cannot delete the current session from SessionControl".to_string(), )); } - self.ensure_session_exists(&coordinator, workspace_path, &workspace, session_id) + self.ensure_session_exists(&runtime, &workspace, session_id) .await?; - coordinator - .delete_session(workspace_path, session_id) - .await?; + runtime + .delete_session(AgentSessionDeleteRequest { + workspace_path: workspace.clone(), + session_id: session_id.to_string(), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; Ok(vec![ToolResult::Result { data: json!({ @@ -734,9 +497,8 @@ Arguments: "workspace": workspace.clone(), "session_id": session_id, }), - result_for_assistant: Some(format!( - "Deleted session '{}' from workspace '{}'.", - session_id, workspace + result_for_assistant: Some(session_control_deleted_result_message( + session_id, &workspace, )), image_attachments: None, }]) @@ -747,11 +509,17 @@ Arguments: SessionControlAction::List, None, context, - &coordinator, + &runtime, ) .await?; - let workspace_path = Path::new(&workspace); - let sessions = coordinator.list_sessions(workspace_path).await?; + let sessions = runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace.clone(), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; let current_session_id = self.current_workspace_session(context, &workspace); let result_for_assistant = self.build_list_result_for_assistant(&workspace, &sessions, current_session_id); @@ -793,9 +561,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } diff --git a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/session_history_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs similarity index 87% rename from src/crates/core/src/agentic/tools/implementations/session_message_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index b5e661b200..d28fde7a2d 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -1,15 +1,18 @@ use super::util::normalize_path; use crate::agentic::coordination::{ - get_global_coordinator, get_global_scheduler, AgentSessionReplyRoute, DialogSubmissionPolicy, - DialogTriggerSource, + get_global_coordinator, get_global_scheduler, DialogSubmissionPolicy, DialogTriggerSource, }; -use crate::agentic::core::{PromptEnvelope, SessionConfig}; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; +use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +use bitfun_runtime_ports::{ + AgentDialogPrependedReminder, AgentDialogTurnRequest, AgentSessionCreateRequest, + AgentSessionListRequest, AgentSessionReplyRoute, AgentSessionWorkspaceRequest, +}; use serde::Deserialize; use serde_json::{json, Value}; use std::path::Path; @@ -158,14 +161,18 @@ impl SessionMessageTool { Ok(format!("session-{}", creator_session_id)) } - fn format_forwarded_message(&self, message: &str) -> String { - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder( - "This request was sent by another agent, not human user. Do not use interactive tools for this request. In particular, do not call AskUserQuestion." - .to_string(), - ); - envelope.push_user_query(message.to_string()); - envelope.render() + fn format_forwarded_message( + &self, + message: &str, + ) -> (String, Vec) { + ( + message.to_string(), + vec![AgentDialogPrependedReminder { + kind: "session_message_request".to_string(), + text: "This request was sent by another agent, not human user. Do not use interactive tools for this request. In particular, do not call AskUserQuestion." + .to_string(), + }], + ) } } @@ -443,6 +450,11 @@ Allowed agent types when creating a session: .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; let scheduler = get_global_scheduler() .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; + let runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( + coordinator.clone(), + scheduler, + ) + .map_err(BitFunError::tool)?; let (target_session_id, target_agent_type, created_session_id, workspace) = if let Some(target_session_id) = params.session_id.clone() { @@ -455,10 +467,14 @@ Allowed agent types when creating a session: let workspace = if let Some(workspace) = params.workspace.as_deref() { self.resolve_workspace(workspace, context)? } else { - coordinator - .resolve_session_workspace_path(&target_session_id) + runtime + .resolve_session_workspace_path(AgentSessionWorkspaceRequest { + session_id: target_session_id.clone(), + }) .await - .map(|path| path.to_string_lossy().to_string()) + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })? .ok_or_else(|| { BitFunError::NotFound(format!( "Workspace for session '{}' could not be resolved", @@ -466,8 +482,14 @@ Allowed agent types when creating a session: )) })? }; - let workspace_path = Path::new(&workspace); - let existing_sessions = coordinator.list_sessions(workspace_path).await?; + let existing_sessions = runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace.clone(), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; let target_session = existing_sessions .iter() .find(|session| session.session_id == target_session_id.as_str()) @@ -515,19 +537,19 @@ Allowed agent types when creating a session: .as_str() .to_string(); let created_by = self.creator_session_marker(context)?; - let session = coordinator - .create_session_with_workspace_and_creator( - None, + let mut metadata = serde_json::Map::new(); + metadata.insert("createdBy".to_string(), json!(created_by)); + let session = runtime + .create_session(AgentSessionCreateRequest { session_name, - agent_type.clone(), - SessionConfig { - workspace_path: Some(workspace.clone()), - ..Default::default() - }, - workspace.clone(), - Some(created_by), - ) - .await?; + agent_type: agent_type.clone(), + workspace_path: Some(workspace.clone()), + metadata, + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; ( session.session_id.clone(), @@ -537,26 +559,30 @@ Allowed agent types when creating a session: ) }; - let forwarded_message = self.format_forwarded_message(¶ms.message); - - scheduler - .submit( - target_session_id.clone(), - forwarded_message, - Some(params.message.clone()), - None, - target_agent_type.clone(), - Some(workspace.clone()), - DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), - Some(AgentSessionReplyRoute { + let (forwarded_message, prepended_messages) = + self.format_forwarded_message(¶ms.message); + + runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: target_session_id.clone(), + message: forwarded_message, + original_message: Some(params.message.clone()), + turn_id: None, + agent_type: target_agent_type.clone(), + workspace_path: Some(workspace.clone()), + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + reply_route: Some(AgentSessionReplyRoute { source_session_id, source_workspace_path: source_workspace, }), - None, - None, - ) + prepended_reminders: prepended_messages, + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) .await - .map_err(BitFunError::tool)?; + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; Ok(vec![ToolResult::Result { data: json!({ @@ -602,9 +628,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), } } diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs similarity index 81% rename from src/crates/core/src/agentic/tools/implementations/skill_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index 2fd4662db8..a4abe92edf 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -29,15 +29,15 @@ impl SkillTool { When users ask you to perform tasks, check whether any skills listed in the current skill listing can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. How to use skills: -- Invoke skills using this tool with the skill name only (no arguments) +- Invoke skills using this tool with the listed skill name or stable key (no arguments) - The skill's prompt will expand and provide detailed instructions on how to complete the task - Examples: - `command: "pdf"` - invoke the pdf skill - `command: "xlsx"` - invoke the xlsx skill - - `command: "ms-office-suite:pdf"` - invoke using fully qualified name + - `command: "user::bitfun-system::ppt-design"` - invoke a specific built-in skill by stable key Important: -- Only use skills listed in the current skill listing's section +- Only use skills listed in the current skill listing's section, unless a trusted host task explicitly supplies an exact stable key - Do not invoke a skill that is already running "# .to_string() @@ -202,6 +202,7 @@ impl Tool for SkillTool { // Find and load skill through registry let registry = get_skill_registry(); + let use_stable_key = skill_name.split("::").count() == 3; let skill_data = if context.is_remote() { if let Some(ws_fs) = context.ws_fs() { let root = context @@ -209,11 +210,50 @@ impl Tool for SkillTool { .as_ref() .map(|w| w.root_path_string()) .unwrap_or_default(); + if use_stable_key { + registry + .find_and_load_skill_by_key_for_remote_workspace( + skill_name, + ws_fs, + &root, + context.agent_type.as_deref(), + ) + .await? + } else { + registry + .find_and_load_skill_for_remote_workspace( + skill_name, + ws_fs, + &root, + context.agent_type.as_deref(), + ) + .await? + } + } else { + if use_stable_key { + registry + .find_and_load_skill_by_key_for_workspace( + skill_name, + None, + context.agent_type.as_deref(), + ) + .await? + } else { + registry + .find_and_load_skill_for_workspace( + skill_name, + None, + context.agent_type.as_deref(), + ) + .await? + } + } + } else { + if use_stable_key { registry - .find_and_load_skill_for_remote_workspace( + .find_and_load_skill_by_key_for_workspace( skill_name, - ws_fs, - &root, + context.workspace_root(), context.agent_type.as_deref(), ) .await? @@ -221,19 +261,11 @@ impl Tool for SkillTool { registry .find_and_load_skill_for_workspace( skill_name, - None, + context.workspace_root(), context.agent_type.as_deref(), ) .await? } - } else { - registry - .find_and_load_skill_for_workspace( - skill_name, - context.workspace_root(), - context.agent_type.as_deref(), - ) - .await? }; let location_str = match skill_data.location { @@ -242,13 +274,15 @@ impl Tool for SkillTool { }; let result_for_assistant = format!( - "Skill '{}' loaded successfully. Note: any paths mentioned in this skill are relative to {}, not the workspace.\n\n{}", - skill_data.name, skill_data.path, skill_data.content + "Skill '{}' loaded successfully from stable key '{}'. Note: any paths mentioned in this skill are relative to {}, not the workspace.\n\n{}", + skill_data.name, skill_data.key, skill_data.path, skill_data.content ); let result = ToolResult::Result { data: json!({ "skill_name": skill_data.name, + "skill_key": skill_data.key, + "source_slot": skill_data.source_slot, "description": skill_data.description, "location": location_str, "content": skill_data.content, @@ -383,12 +417,14 @@ Use the remote project skill. unlocked_collapsed_tools: Vec::new(), custom_data: Default::default(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: Some(WorkspaceServices { - fs: Arc::new(FakeRemoteFs), - shell: Arc::new(FakeShell), - }), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::new( + Some(WorkspaceServices { + fs: Arc::new(FakeRemoteFs), + shell: Arc::new(FakeShell), + }), + None, + ), }; let description = SkillTool::build_available_skills_context_section(Some(&context)) @@ -422,12 +458,14 @@ Use the remote project skill. unlocked_collapsed_tools: Vec::new(), custom_data: Default::default(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: Some(WorkspaceServices { - fs: Arc::new(FakeRemoteFs), - shell: Arc::new(FakeShell), - }), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::new( + Some(WorkspaceServices { + fs: Arc::new(FakeRemoteFs), + shell: Arc::new(FakeShell), + }), + None, + ), }; let results = SkillTool::new() @@ -446,6 +484,41 @@ Use the remote project skill. .contains("# /cso")); } + #[tokio::test] + async fn stable_key_loads_the_exact_builtin_skill() { + let context = crate::agentic::tools::framework::ToolUseContext { + tool_call_id: None, + agent_type: Some("Cowork".to_string()), + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: Default::default(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::new(None, None), + }; + + let results = SkillTool::new() + .call_impl( + &json!({ "command": "user::bitfun-system::ppt-design" }), + &context, + ) + .await + .expect("stable key should load BitFun's built-in ppt-design skill"); + + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected result payload"); + }; + assert_eq!(data["skill_name"], "ppt-design"); + assert_eq!(data["skill_key"], "user::bitfun-system::ppt-design"); + assert_eq!(data["source_slot"], "bitfun-system"); + assert!(data["content"] + .as_str() + .unwrap_or_default() + .contains("references/editable-pptx.md")); + } + struct OrderingRemoteFs; #[async_trait] diff --git a/src/crates/core/src/agentic/tools/implementations/skills/builtin.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/skills/builtin.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs diff --git a/src/crates/core/src/agentic/tools/implementations/skills/catalog.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs similarity index 96% rename from src/crates/core/src/agentic/tools/implementations/skills/catalog.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs index 8df6c5a33b..fb32432931 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs @@ -25,6 +25,7 @@ pub enum BuiltinSkillId { GstackReview, GstackShip, Pdf, + PptDesign, Pptx, WritingSkills, Xlsx, @@ -152,6 +153,11 @@ const BUILTIN_SKILL_SPECS: &[BuiltinSkillSpec] = &[ dir_name: "pdf", group: BuiltinSkillGroup::Office, }, + BuiltinSkillSpec { + id: BuiltinSkillId::PptDesign, + dir_name: "ppt-design", + group: BuiltinSkillGroup::Office, + }, BuiltinSkillSpec { id: BuiltinSkillId::Pptx, dir_name: "pptx", @@ -193,6 +199,7 @@ mod tests { fn builtin_skill_groups_match_expected_sets() { assert_eq!(builtin_skill_group_key("docx"), Some("office")); assert_eq!(builtin_skill_group_key("pdf"), Some("office")); + assert_eq!(builtin_skill_group_key("ppt-design"), Some("office")); assert_eq!(builtin_skill_group_key("pptx"), Some("office")); assert_eq!(builtin_skill_group_key("xlsx"), Some("office")); assert_eq!(builtin_skill_group_key("find-skills"), Some("meta")); diff --git a/src/crates/core/src/agentic/tools/implementations/skills/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mod.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/skills/mod.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/mod.rs diff --git a/src/crates/core/src/agentic/tools/implementations/skills/mode_overrides.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/skills/mode_overrides.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs diff --git a/src/crates/core/src/agentic/tools/implementations/skills/policy.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs similarity index 95% rename from src/crates/core/src/agentic/tools/implementations/skills/policy.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs index 5f95fea1b5..bcbe142042 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/policy.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs @@ -185,6 +185,15 @@ mod tests { resolve_builtin_default_enabled("pdf", "agentic"), Some(false) ); + // ppt-design rides the office group: enabled in Cowork, not in coding modes. + assert_eq!( + resolve_builtin_default_enabled("ppt-design", "agentic"), + Some(false) + ); + assert_eq!( + resolve_builtin_default_enabled("ppt-design", "Cowork"), + Some(true) + ); assert_eq!( resolve_builtin_default_enabled("agent-browser", "agentic"), Some(true) diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs similarity index 92% rename from src/crates/core/src/agentic/tools/implementations/skills/registry.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index b1d811a008..0d0855f048 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -909,6 +909,41 @@ impl SkillRegistry { Ok(data) } + pub async fn find_and_load_skill_by_key_for_workspace( + &self, + skill_key: &str, + workspace_root: Option<&Path>, + agent_type: Option<&str>, + ) -> BitFunResult { + let candidates = self + .scan_skill_candidates_for_workspace(workspace_root) + .await; + let filtered = self + .apply_mode_filters_for_workspace(candidates, workspace_root, agent_type) + .await; + let info = filtered + .into_iter() + .map(|candidate| candidate.info) + .find(|skill| skill.key == skill_key) + .ok_or_else(|| { + BitFunError::tool(format!( + "Skill key '{}' was not found or is disabled for this mode", + skill_key + )) + })?; + + let skill_md_path = PathBuf::from(&info.path).join("SKILL.md"); + let content = fs::read_to_string(&skill_md_path) + .await + .map_err(|error| BitFunError::tool(format!("Failed to read skill file: {}", error)))?; + + let mut data = SkillData::from_markdown(info.path.clone(), &content, info.level, true)?; + data.key = info.key; + data.source_slot = info.source_slot; + data.dir_name = info.dir_name; + Ok(data) + } + pub async fn find_and_load_skill_for_remote_workspace( &self, skill_name: &str, @@ -933,6 +968,38 @@ impl SkillRegistry { Ok(data) } + pub async fn find_and_load_skill_by_key_for_remote_workspace( + &self, + skill_key: &str, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + agent_type: Option<&str>, + ) -> BitFunResult { + let candidates = self + .scan_skill_candidates_for_remote_workspace(fs, remote_root) + .await; + let filtered = self + .apply_mode_filters_for_remote_workspace(candidates, fs, remote_root, agent_type) + .await; + let info = filtered + .into_iter() + .map(|candidate| candidate.info) + .find(|skill| skill.key == skill_key) + .ok_or_else(|| { + BitFunError::tool(format!( + "Skill key '{}' was not found or is disabled for this mode", + skill_key + )) + })?; + + let content = Self::read_skill_md_for_remote_merge(&info, fs).await?; + let mut data = SkillData::from_markdown(info.path.clone(), &content, info.level, true)?; + data.key = info.key; + data.source_slot = info.source_slot; + data.dir_name = info.dir_name; + Ok(data) + } + pub async fn get_resolved_skills_xml_for_workspace( &self, workspace_root: Option<&Path>, diff --git a/src/crates/core/src/agentic/tools/implementations/skills/resolver.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/skills/resolver.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs diff --git a/src/crates/core/src/agentic/tools/implementations/skills/types.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/types.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/skills/types.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/skills/types.rs diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs similarity index 85% rename from src/crates/core/src/agentic/tools/implementations/task_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs index 06899009ae..f520d305cb 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task_tool.rs @@ -13,8 +13,8 @@ use crate::agentic::deep_review_policy::{ record_deep_review_runtime_auto_retry, record_deep_review_runtime_auto_retry_suppressed, record_deep_review_runtime_manual_retry, record_deep_review_task_budget, DeepReviewActiveReviewerGuard, DeepReviewCapacityQueueReason, DeepReviewConcurrencyPolicy, - DeepReviewExecutionPolicy, DeepReviewIncrementalCache, DeepReviewPolicyViolation, - DeepReviewRunManifestGate, DeepReviewSubagentRole, DEEP_REVIEW_AGENT_TYPE, + DeepReviewExecutionPolicy, DeepReviewPolicyViolation, DeepReviewRunManifestGate, + DeepReviewSubagentRole, DEEP_REVIEW_AGENT_TYPE, }; use crate::agentic::events::DeepReviewQueueStatus; use crate::agentic::tools::framework::{ @@ -84,18 +84,6 @@ impl TaskTool { )) } - fn deep_review_packet_id_for_cache( - subagent_type: &str, - description: Option<&str>, - run_manifest: Option<&Value>, - ) -> Option { - deep_review_task_adapter::deep_review_packet_id_for_cache( - subagent_type, - description, - run_manifest, - ) - } - async fn load_configured_tool_execution_timeout() -> Option { let service = GlobalConfigManager::get_service().await.ok()?; let ai_config: AIConfig = service.get_config(Some("ai")).await.ok()?; @@ -150,12 +138,11 @@ impl TaskTool { is_retry: bool, deep_review_subagent_role: Option, ) -> bool { - is_partial_timeout - && !is_retry - && matches!( - deep_review_subagent_role, - Some(DeepReviewSubagentRole::Reviewer) - ) + deep_review_task_adapter::should_emit_deep_review_retry_guidance( + is_partial_timeout, + is_retry, + deep_review_subagent_role, + ) } fn ensure_deep_review_retry_coverage( @@ -178,49 +165,17 @@ impl TaskTool { } fn auto_retry_suppression_reason(code: &str) -> &'static str { - match code { - "deep_review_auto_retry_disabled" => "auto_retry_disabled", - "deep_review_auto_retry_elapsed_guard_exceeded" => "elapsed_guard_exceeded", - "deep_review_retry_budget_exhausted" => "budget_exhausted", - "deep_review_retry_without_initial_attempt" => "without_initial_attempt", - "deep_review_retry_missing_coverage" => "missing_coverage", - "deep_review_retry_missing_packet_id" => "missing_coverage", - "deep_review_retry_missing_status" => "missing_coverage", - "deep_review_retry_non_retryable_status" => "non_retryable_status", - "deep_review_retry_unknown_packet" => "unknown_packet", - "deep_review_retry_missing_packet_scope" => "unknown_packet", - "deep_review_retry_timeout_required" => "timeout_not_reduced", - "deep_review_retry_timeout_not_reduced" => "timeout_not_reduced", - "deep_review_retry_empty_scope" => "empty_scope", - "deep_review_retry_scope_not_reduced" => "scope_not_reduced", - _ => "invalid_coverage", - } + deep_review_task_adapter::auto_retry_suppression_reason(code) } fn ensure_deep_review_auto_retry_allowed( conc_policy: &DeepReviewConcurrencyPolicy, dialog_turn_id: &str, ) -> Result<(), DeepReviewPolicyViolation> { - if !conc_policy.allow_bounded_auto_retry { - return Err(DeepReviewPolicyViolation::new( - "deep_review_auto_retry_disabled", - "DeepReview bounded automatic retry is disabled by Review Team settings", - )); - } - - if let Some(elapsed_seconds) = deep_review_turn_elapsed_seconds(dialog_turn_id) { - if elapsed_seconds > conc_policy.auto_retry_elapsed_guard_seconds { - return Err(DeepReviewPolicyViolation::new( - "deep_review_auto_retry_elapsed_guard_exceeded", - format!( - "DeepReview automatic retry elapsed guard exceeded (elapsed: {}s, guard: {}s)", - elapsed_seconds, conc_policy.auto_retry_elapsed_guard_seconds - ), - )); - } - } - - Ok(()) + deep_review_task_adapter::ensure_deep_review_auto_retry_allowed( + conc_policy, + deep_review_turn_elapsed_seconds(dialog_turn_id), + ) } fn prompt_with_deep_review_retry_scope(prompt: &str, retry_scope_files: &[String]) -> String { @@ -233,22 +188,6 @@ impl TaskTool { deep_review_task_adapter::capacity_decision_for_provider_error(error) } - fn deep_review_capacity_skip_result_for_provider_reason( - reason: DeepReviewCapacityQueueReason, - dialog_turn_id: &str, - subagent_type: &str, - conc_policy: &DeepReviewConcurrencyPolicy, - duration_ms: u128, - ) -> (Value, String) { - deep_review_task_adapter::capacity_skip_result_for_provider_reason( - reason, - dialog_turn_id, - subagent_type, - conc_policy, - duration_ms, - ) - } - fn deep_review_capacity_skip_result_for_provider_queue_outcome( reason: DeepReviewCapacityQueueReason, dialog_turn_id: &str, @@ -269,18 +208,6 @@ impl TaskTool { ) } - fn deep_review_provider_capacity_queue_wait_seconds_for_attempt( - decision: &crate::agentic::deep_review_policy::DeepReviewCapacityQueueDecision, - conc_policy: &DeepReviewConcurrencyPolicy, - retry_attempt_index: usize, - ) -> Option { - deep_review_task_adapter::provider_capacity_queue_wait_seconds_for_attempt( - decision, - conc_policy, - retry_attempt_index, - ) - } - async fn wait_for_deep_review_provider_capacity_retry( session_id: &str, dialog_turn_id: &str, @@ -413,23 +340,15 @@ impl TaskTool { reason: &str, duration_ms: u128, ) -> ToolResult { - let duration = u64::try_from(duration_ms).unwrap_or(u64::MAX); - let reason = if reason.trim().is_empty() { - "Subagent task was cancelled" - } else { - reason.trim() - }; - let result_for_assistant = format!( - "Subagent '{}' was cancelled by the user.\nTreat this reviewer as cancelled coverage, continue remaining reviewers when useful, and do not relaunch it automatically.", - subagent_type - ); + let (data, result_for_assistant) = + deep_review_task_adapter::deep_review_cancelled_reviewer_result( + subagent_type, + reason, + duration_ms, + ); ToolResult::Result { - data: json!({ - "duration": duration, - "status": "cancelled", - "reason": reason, - }), + data, result_for_assistant: Some(result_for_assistant), image_attachments: None, } @@ -548,8 +467,13 @@ impl Tool for TaskTool { Ok(self.render_description()) } - async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { - !Self::get_enabled_agents(context).await.is_empty() + async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { + // Keep Task prompt-visible even when no fresh subagents are currently + // available. Hiding it based on transient subagent availability makes + // the tool manifest drift across turns and causes provider prefix/KV + // cache misses. Task also still supports `fork_context=true` in that + // state, so removing it from the manifest would be behaviorally wrong. + true } fn short_description(&self) -> String { @@ -863,8 +787,7 @@ impl Tool for TaskTool { } if is_retry || requested_auto_retry || input.get("retry_coverage").is_some() { return Err(BitFunError::tool( - "DeepReview retry fields are not allowed when fork_context is true" - .to_string(), + "DeepReview retry fields are not allowed when fork_context is true".to_string(), )); } } @@ -1115,28 +1038,19 @@ impl Tool for TaskTool { // Check incremental review cache before queueing. A cache hit does // not consume runtime reviewer capacity or reviewer timeout. if role == DeepReviewSubagentRole::Reviewer && !is_retry { - if let Some(cache_value) = - run_manifest.as_ref().and_then(|m| m.get("deepReviewCache")) + if let Some(cache_hit) = + deep_review_task_adapter::deep_review_incremental_cache_hit_for_task( + subagent_type, + description.as_deref(), + run_manifest.as_ref(), + ) { - let cache = DeepReviewIncrementalCache::from_value(cache_value); - if cache.matches_manifest(run_manifest.as_ref().unwrap_or(&Value::Null)) { - if let Some(packet_id) = Self::deep_review_packet_id_for_cache( + let (data, cached_result) = + deep_review_task_adapter::deep_review_incremental_cache_hit_result( subagent_type, - description.as_deref(), - run_manifest.as_ref(), - ) { - if let Some(cached_output) = cache.get_packet(&packet_id) { - let cached_result = format!( - "Subagent '{}' result (from incremental review cache):\n\n{}\n", - subagent_type, cached_output - ); - return Ok(vec![ToolResult::ok( - json!({ "cached": true, "packet_id": packet_id }), - Some(cached_result), - )]); - } - } - } + &cache_hit, + ); + return Ok(vec![ToolResult::ok(data, Some(cached_result))]); } } @@ -1312,9 +1226,8 @@ impl Tool for TaskTool { image_attachments: None, }]); } - let mut provider_capacity_retry_reason: Option = None; - let mut provider_capacity_queue_elapsed_ms = 0_u64; - let mut provider_capacity_retry_attempts = 0_usize; + let mut provider_capacity_retry = + deep_review_task_adapter::DeepReviewProviderCapacityRetryRuntime::default(); let deep_review_subagent_id = subagent_type.as_deref().unwrap_or(""); let result = loop { let parent_info = SubagentParentInfo { @@ -1346,7 +1259,7 @@ impl Tool for TaskTool { context: subagent_context.clone().unwrap_or_default(), delegation_policy: context.delegation_policy().spawn_child(), }, - context.cancellation_token.as_ref(), + context.cancellation_token(), timeout_seconds, ) .await; @@ -1365,7 +1278,7 @@ impl Tool for TaskTool { elapsed_ms_u64(subagent_execution_started_at), result.ledger_event_id() ); - if let Some(reason) = provider_capacity_retry_reason { + if let Some(reason) = provider_capacity_retry.last_retry_reason() { Self::record_deep_review_provider_capacity_retry_success( &dialog_turn_id, reason, @@ -1389,7 +1302,7 @@ impl Tool for TaskTool { Some(DeepReviewSubagentRole::Reviewer) ) && matches!(error, BitFunError::Cancelled(_)) && !context - .cancellation_token + .cancellation_token() .as_ref() .is_some_and(|token| token.is_cancelled()) { @@ -1410,21 +1323,22 @@ impl Tool for TaskTool { if let Some(conc_policy) = deep_review_concurrency_policy.as_ref() { let decision = Self::deep_review_capacity_decision_for_provider_error(&error); - if let Some(reason) = - decision.queueable.then_some(decision.reason).flatten() + match provider_capacity_retry + .decide_after_error(&decision, conc_policy) { - drop(deep_review_active_guard.take()); - - if provider_capacity_retry_attempts - >= deep_review_task_adapter::DEEP_REVIEW_PROVIDER_CAPACITY_MAX_RETRY_ATTEMPTS - { + deep_review_task_adapter::DeepReviewProviderCapacityRetryDecision::NotQueueable => {} + deep_review_task_adapter::DeepReviewProviderCapacityRetryDecision::CapacitySkipped { + reason, + queue_elapsed_ms, + } => { + drop(deep_review_active_guard.take()); let (data, assistant_message) = Self::deep_review_capacity_skip_result_for_provider_queue_outcome( reason, &dialog_turn_id, deep_review_subagent_id, conc_policy, start_time.elapsed().as_millis(), - provider_capacity_queue_elapsed_ms, + queue_elapsed_ms, None, ); let effective_parallel_instances = data @@ -1442,7 +1356,7 @@ impl Tool for TaskTool { deep_review_active_reviewer_count(&dialog_turn_id), deep_review_is_optional_reviewer.then_some(1), effective_parallel_instances, - provider_capacity_queue_elapsed_ms, + queue_elapsed_ms, conc_policy.max_queue_wait_seconds, ) .await; @@ -1452,14 +1366,11 @@ impl Tool for TaskTool { image_attachments: None, }]); } - - if let Some(max_wait_seconds) = - Self::deep_review_provider_capacity_queue_wait_seconds_for_attempt( - &decision, - conc_policy, - provider_capacity_retry_attempts, - ) - { + deep_review_task_adapter::DeepReviewProviderCapacityRetryDecision::WaitForCapacity { + reason, + max_wait_seconds, + } => { + drop(deep_review_active_guard.take()); match Self::wait_for_deep_review_provider_capacity_retry( &session_id, &dialog_turn_id, @@ -1476,9 +1387,11 @@ impl Tool for TaskTool { queue_elapsed_ms, early_capacity_probe, } => { - provider_capacity_queue_elapsed_ms = - provider_capacity_queue_elapsed_ms - .saturating_add(queue_elapsed_ms); + provider_capacity_retry.record_ready_to_retry( + reason, + queue_elapsed_ms, + early_capacity_probe, + ); let effective_parallel_instances = deep_review_effective_parallel_instances( &dialog_turn_id, @@ -1537,12 +1450,6 @@ impl Tool for TaskTool { ))); } } - provider_capacity_retry_reason = Some(reason); - if !early_capacity_probe { - provider_capacity_retry_attempts = - provider_capacity_retry_attempts - .saturating_add(1); - } Self::record_deep_review_provider_capacity_retry( &dialog_turn_id, reason, @@ -1553,16 +1460,16 @@ impl Tool for TaskTool { queue_elapsed_ms, skip_reason, } => { - provider_capacity_queue_elapsed_ms = - provider_capacity_queue_elapsed_ms - .saturating_add(queue_elapsed_ms); + let total_provider_capacity_queue_elapsed_ms = + provider_capacity_retry + .record_queue_skipped(queue_elapsed_ms); let (data, assistant_message) = Self::deep_review_capacity_skip_result_for_provider_queue_outcome( reason, &dialog_turn_id, deep_review_subagent_id, conc_policy, start_time.elapsed().as_millis(), - provider_capacity_queue_elapsed_ms, + total_provider_capacity_queue_elapsed_ms, Some(skip_reason), ); return Ok(vec![ToolResult::Result { @@ -1573,39 +1480,6 @@ impl Tool for TaskTool { } } } - - let (data, assistant_message) = - Self::deep_review_capacity_skip_result_for_provider_reason( - reason, - &dialog_turn_id, - deep_review_subagent_id, - conc_policy, - start_time.elapsed().as_millis(), - ); - let effective_parallel_instances = data - .get("effective_parallel_instances") - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()); - Self::emit_deep_review_queue_state( - &session_id, - &dialog_turn_id, - &tool_call_id, - deep_review_subagent_id, - DeepReviewQueueStatus::CapacitySkipped, - Some(reason), - 0, - deep_review_active_reviewer_count(&dialog_turn_id), - deep_review_is_optional_reviewer.then_some(1), - effective_parallel_instances, - 0, - conc_policy.max_queue_wait_seconds, - ) - .await; - return Ok(vec![ToolResult::Result { - data, - result_for_assistant: Some(assistant_message), - image_attachments: None, - }]); } } } @@ -1626,11 +1500,6 @@ impl Tool for TaskTool { drop(deep_review_active_guard); let duration = start_time.elapsed().as_millis(); - let status = if result.is_partial_timeout() { - "partial_timeout" - } else { - "completed" - }; // Build retry hint for deep review reviewer timeouts. let retry_hint = if Self::should_emit_deep_review_retry_guidance( @@ -1646,43 +1515,22 @@ impl Tool for TaskTool { deep_review_effective_policy.as_ref(), &dialog_turn_id, ); - if max_retries > 0 && retries_used < max_retries { - format!( - "\n\nThis reviewer timed out. You may retry with 'retry: true' only if you can provide retry_coverage with source_packet_id, source_status='partial_timeout', covered_files, and a smaller retry_scope_files list. Retries used: {}/{}.", - retries_used, max_retries - ) - } else { - String::new() - } + deep_review_task_adapter::deep_review_retry_guidance(retries_used, max_retries) } else { String::new() }; - let result_for_assistant = if result.is_partial_timeout() { - format!( - "{} timed out with partial result:\n\n{}\n{}", - delegate_target_label, result.text, retry_hint - ) - } else { - format!( - "{} completed successfully with result:\n\n{}\n", - delegate_target_label, result.text - ) - }; - let mut data = json!({ - "duration": duration, - "context_mode": context_mode.as_str(), - "status": status - }); - if result.is_partial_timeout() { - data["partial_output"] = json!(result.text); - if let Some(reason) = result.reason.as_deref() { - data["reason"] = json!(reason); - } - if let Some(event_id) = result.ledger_event_id() { - data["ledger_event_id"] = json!(event_id); - } - } + let (data, result_for_assistant) = + deep_review_task_adapter::deep_review_task_completion_result( + &delegate_target_label, + &result.text, + context_mode.as_str(), + duration, + result.is_partial_timeout(), + result.reason.as_deref(), + result.ledger_event_id(), + &retry_hint, + ); Ok(vec![ToolResult::Result { data, @@ -1705,7 +1553,6 @@ mod tests { }; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; - use crate::util::BitFunError; use async_trait::async_trait; use bitfun_runtime_ports::DelegationPolicy; use serde_json::json; @@ -1848,6 +1695,14 @@ mod tests { .is_some_and(|message| message.contains("subagent_type is required"))); } + #[tokio::test] + async fn task_tool_stays_available_without_enabled_subagents() { + assert!( + TaskTool::new().is_available_in_context(None).await, + "Task should remain prompt-visible even when no fresh subagents are currently available" + ); + } + #[tokio::test] async fn validate_input_rejects_fork_context_conflicting_fields() { let validation = TaskTool::new() @@ -1890,9 +1745,8 @@ mod tests { ), ]), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), }; let error = TaskTool::new() @@ -2021,9 +1875,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), }; let deep_review_context = ToolUseContext { agent_type: Some("DeepReview".to_string()), @@ -2057,9 +1910,8 @@ mod tests { unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, - cancellation_token: None, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), }; let builtin_a = "AAAPromptOrderBuiltin"; @@ -2573,133 +2425,6 @@ mod tests { .is_err()); } - #[test] - fn deep_review_incremental_cache_hit_returns_cached_result() { - use crate::agentic::deep_review_policy::DeepReviewIncrementalCache; - - let mut cache = DeepReviewIncrementalCache::new("fp-test-123"); - cache.store_packet("ReviewSecurity", "Found 2 security issues"); - - // Cache hit - let result = cache.get_packet("ReviewSecurity"); - assert_eq!(result, Some("Found 2 security issues")); - - // Cache miss - assert_eq!(cache.get_packet("ReviewPerformance"), None); - } - - #[test] - fn deep_review_incremental_cache_fingerprint_mismatch_skips() { - use crate::agentic::deep_review_policy::DeepReviewIncrementalCache; - - let cache = DeepReviewIncrementalCache::new("fp-old"); - let manifest = serde_json::json!({ - "incrementalReviewCache": { - "fingerprint": "fp-new" - } - }); - // Fingerprint mismatch -> cache should not match - assert!(!cache.matches_manifest(&manifest)); - } - - #[test] - fn deep_review_cache_packet_id_prefers_task_description_packet() { - let manifest = serde_json::json!({ - "workPackets": [ - { - "packetId": "reviewer:ReviewSecurity:group-1-of-2", - "phase": "reviewer", - "subagentId": "ReviewSecurity" - }, - { - "packetId": "reviewer:ReviewSecurity:group-2-of-2", - "phase": "reviewer", - "subagentId": "ReviewSecurity" - } - ] - }); - - assert_eq!( - TaskTool::deep_review_packet_id_for_cache( - "ReviewSecurity", - Some("Security review [packet reviewer:ReviewSecurity:group-2-of-2]"), - Some(&manifest), - ), - Some("reviewer:ReviewSecurity:group-2-of-2".to_string()) - ); - } - - #[test] - fn deep_review_cache_packet_id_uses_unique_manifest_packet() { - let manifest = serde_json::json!({ - "workPackets": [ - { - "packetId": "reviewer:ReviewBusinessLogic", - "phase": "reviewer", - "subagentId": "ReviewBusinessLogic" - } - ] - }); - - assert_eq!( - TaskTool::deep_review_packet_id_for_cache( - "ReviewBusinessLogic", - Some("Logic review"), - Some(&manifest), - ), - Some("reviewer:ReviewBusinessLogic".to_string()) - ); - } - - #[test] - fn deep_review_cache_packet_id_does_not_guess_split_packets() { - let manifest = serde_json::json!({ - "workPackets": [ - { - "packetId": "reviewer:ReviewPerformance:group-1-of-2", - "phase": "reviewer", - "subagentId": "ReviewPerformance" - }, - { - "packetId": "reviewer:ReviewPerformance:group-2-of-2", - "phase": "reviewer", - "subagentId": "ReviewPerformance" - } - ] - }); - - assert_eq!( - TaskTool::deep_review_packet_id_for_cache( - "ReviewPerformance", - Some("Performance review"), - Some(&manifest), - ), - None - ); - } - - #[test] - fn deep_review_cache_packet_id_ignores_description_for_other_subagent() { - let manifest = serde_json::json!({ - "workPackets": [ - { - "packetId": "reviewer:ReviewSecurity:group-1-of-1", - "phase": "reviewer", - "subagentId": "ReviewSecurity" - } - ] - }); - - assert_eq!( - TaskTool::deep_review_packet_id_for_cache( - "ReviewPerformance", - Some("Performance review [packet reviewer:ReviewSecurity:group-1-of-1]"), - Some(&manifest), - ), - None - ); - } - #[test] fn deep_review_retry_guidance_includes_budget_info() { // Verify that the retry budget tracking functions work correctly @@ -2816,8 +2541,8 @@ mod tests { "timeoutSeconds": 600, "assignedScope": { "files": [ - "src/crates/core/src/auth.rs", - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/auth.rs", + "src/crates/assembly/core/src/token.rs" ] } } @@ -2845,8 +2570,8 @@ mod tests { "timeoutSeconds": 600, "assignedScope": { "files": [ - "src/crates/core/src/auth.rs", - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/auth.rs", + "src/crates/assembly/core/src/token.rs" ] } } @@ -2859,11 +2584,11 @@ mod tests { "source_packet_id": "reviewer:ReviewSecurity:group-1-of-1", "source_status": "partial_timeout", "covered_files": [ - "src/crates/core/src/auth.rs" + "src/crates/assembly/core/src/auth.rs" ], "retry_scope_files": [ - "src/crates/core/src/auth.rs", - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/auth.rs", + "src/crates/assembly/core/src/token.rs" ] } }); @@ -2886,8 +2611,8 @@ mod tests { "timeoutSeconds": 600, "assignedScope": { "files": [ - "src/crates/core/src/auth.rs", - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/auth.rs", + "src/crates/assembly/core/src/token.rs" ] } } @@ -2900,10 +2625,10 @@ mod tests { "source_packet_id": "reviewer:ReviewSecurity:group-1-of-1", "source_status": "partial_timeout", "covered_files": [ - "src/crates/core/src/auth.rs" + "src/crates/assembly/core/src/auth.rs" ], "retry_scope_files": [ - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/token.rs" ] } }); @@ -2926,8 +2651,8 @@ mod tests { "timeoutSeconds": 600, "assignedScope": { "files": [ - "src/crates/core/src/auth.rs", - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/auth.rs", + "src/crates/assembly/core/src/token.rs" ] } } @@ -2941,7 +2666,7 @@ mod tests { "capacity_reason": "auth_error", "covered_files": [], "retry_scope_files": [ - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/token.rs" ] } }); @@ -2979,12 +2704,14 @@ mod tests { .reason .expect("provider rate limit should surface as capacity_skipped"); let (data, assistant_message) = - TaskTool::deep_review_capacity_skip_result_for_provider_reason( + TaskTool::deep_review_capacity_skip_result_for_provider_queue_outcome( reason, turn_id, "ReviewSecurity", &policy, 42, + 0, + None, ); assert_eq!(data["status"], "capacity_skipped"); @@ -3012,81 +2739,6 @@ mod tests { ); } - #[test] - fn deep_review_provider_queue_wait_is_bounded_by_retry_after_and_policy() { - use crate::agentic::deep_review_policy::DeepReviewConcurrencyPolicy; - - let policy = DeepReviewConcurrencyPolicy { - max_parallel_instances: 3, - stagger_seconds: 0, - max_queue_wait_seconds: 30, - batch_extras_separately: true, - allow_bounded_auto_retry: false, - auto_retry_elapsed_guard_seconds: 180, - }; - let decision = TaskTool::deep_review_capacity_decision_for_provider_error( - &BitFunError::ai("Provider error: code=429, message=Retry-After: 45"), - ); - - assert_eq!( - TaskTool::deep_review_provider_capacity_queue_wait_seconds_for_attempt( - &decision, &policy, 0, - ), - Some(30) - ); - } - - #[test] - fn deep_review_provider_queue_wait_uses_exponential_backoff_attempts() { - use crate::agentic::deep_review_policy::DeepReviewConcurrencyPolicy; - - let policy = DeepReviewConcurrencyPolicy { - max_parallel_instances: 3, - stagger_seconds: 0, - max_queue_wait_seconds: 60, - batch_extras_separately: true, - allow_bounded_auto_retry: false, - auto_retry_elapsed_guard_seconds: 180, - }; - let decision = TaskTool::deep_review_capacity_decision_for_provider_error( - &BitFunError::ai("Provider error: code=429, message=too many concurrent requests"), - ); - - let waits = (0..3) - .map(|attempt| { - TaskTool::deep_review_provider_capacity_queue_wait_seconds_for_attempt( - &decision, &policy, attempt, - ) - }) - .collect::>(); - - assert_eq!(waits, vec![Some(60), Some(180), Some(540)]); - } - - #[test] - fn deep_review_provider_queue_wait_rejects_fail_fast_errors() { - use crate::agentic::deep_review_policy::DeepReviewConcurrencyPolicy; - - let policy = DeepReviewConcurrencyPolicy { - max_parallel_instances: 3, - stagger_seconds: 0, - max_queue_wait_seconds: 30, - batch_extras_separately: true, - allow_bounded_auto_retry: false, - auto_retry_elapsed_guard_seconds: 180, - }; - let decision = TaskTool::deep_review_capacity_decision_for_provider_error( - &BitFunError::ai("Provider error: code=invalid_model, message=model does not exist"), - ); - - assert_eq!( - TaskTool::deep_review_provider_capacity_queue_wait_seconds_for_attempt( - &decision, &policy, 0, - ), - None - ); - } - #[tokio::test] async fn deep_review_provider_capacity_queue_retries_when_active_reviewer_frees_capacity() { use crate::agentic::deep_review::task_adapter::DeepReviewProviderQueueWaitOutcome; @@ -3352,8 +3004,8 @@ mod tests { "timeoutSeconds": 600, "assignedScope": { "files": [ - "src/crates/core/src/auth.rs", - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/auth.rs", + "src/crates/assembly/core/src/token.rs" ] } } @@ -3366,10 +3018,10 @@ mod tests { "source_packet_id": "reviewer:ReviewSecurity:group-1-of-1", "source_status": "partial_timeout", "covered_files": [ - "src/crates/core/src/auth.rs" + "src/crates/assembly/core/src/auth.rs" ], "retry_scope_files": [ - "src/crates/core/src/token.rs" + "src/crates/assembly/core/src/token.rs" ] } }); @@ -3378,19 +3030,19 @@ mod tests { TaskTool::ensure_deep_review_retry_coverage(&input, "ReviewSecurity", Some(&manifest)) .expect("reduced retry scope should be accepted"); - assert_eq!(retry_scope, vec!["src/crates/core/src/token.rs"]); + assert_eq!(retry_scope, vec!["src/crates/assembly/core/src/token.rs"]); } #[test] fn deep_review_retry_scope_prompt_prepend_bounds_review_files() { let prompt = TaskTool::prompt_with_deep_review_retry_scope( "Continue the security review.", - &["src/crates/core/src/token.rs".to_string()], + &["src/crates/assembly/core/src/token.rs".to_string()], ); assert!(prompt.starts_with("")); assert!(prompt.contains("Review only the following retry_scope_files")); - assert!(prompt.contains("- src/crates/core/src/token.rs")); + assert!(prompt.contains("- src/crates/assembly/core/src/token.rs")); assert!(prompt.ends_with("Continue the security review.")); } } diff --git a/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs new file mode 100644 index 0000000000..5099b61b0c --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs @@ -0,0 +1,249 @@ +//! Built-in model tool handlers for persisted session thread goals. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::goal_mode::user_facing_thread_goal_error; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_agent_runtime::thread_goal_tools::{ + build_goal_tool_result, parse_create_goal_args, parse_update_goal_args, + parse_update_goal_status, CREATE_GOAL_TOOL_NAME, GET_GOAL_TOOL_NAME, UPDATE_GOAL_TOOL_NAME, +}; +use bitfun_runtime_ports::ThreadGoalStatus; +use serde_json::{json, Value}; + +fn require_coordinator( +) -> BitFunResult> { + get_global_coordinator() + .ok_or_else(|| BitFunError::Validation("coordinator is unavailable".to_string())) +} + +fn require_session_context(context: &ToolUseContext) -> BitFunResult<(String, std::path::PathBuf)> { + let session_id = context + .session_id + .clone() + .ok_or_else(|| BitFunError::Validation("session_id is unavailable".to_string()))?; + let workspace_path = context + .workspace_root() + .ok_or_else(|| BitFunError::Validation("workspace_path is unavailable".to_string()))? + .to_path_buf(); + Ok((session_id, workspace_path)) +} + +pub struct GetGoalTool; + +impl GetGoalTool { + pub fn new() -> Self { + Self + } +} + +impl Default for GetGoalTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for GetGoalTool { + fn name(&self) -> &str { + GET_GOAL_TOOL_NAME + } + + async fn description(&self) -> BitFunResult { + Ok("Get the current goal for this session, including status, budgets, token and elapsed-time usage, and remaining token budget.".to_string()) + } + + fn short_description(&self) -> String { + "Read the active session thread goal.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": {} + }) + } + + fn is_readonly(&self) -> bool { + true + } + + async fn call_impl( + &self, + _input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let coordinator = require_coordinator()?; + let (session_id, workspace_path) = require_session_context(context)?; + let goal = coordinator + .get_thread_goal(&session_id, workspace_path.as_path()) + .await + .map_err(user_facing_thread_goal_error)?; + let result = build_goal_tool_result(goal, false) + .map_err(|error| BitFunError::Validation(error.to_string()))?; + Ok(vec![ToolResult::Result { + data: result.data, + result_for_assistant: Some(result.result_for_assistant), + image_attachments: None, + }]) + } +} + +pub struct CreateGoalTool; + +impl CreateGoalTool { + pub fn new() -> Self { + Self + } +} + +impl Default for CreateGoalTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for CreateGoalTool { + fn name(&self) -> &str { + CREATE_GOAL_TOOL_NAME + } + + async fn description(&self) -> BitFunResult { + Ok(format!( + "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. \ +Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status." + )) + } + + fn short_description(&self) -> String { + "Start a new active session thread goal.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["objective"], + "properties": { + "objective": { + "type": "string", + "description": "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." + }, + "token_budget": { + "type": "integer", + "description": "Positive token budget for the new goal. Omit unless explicitly requested." + } + } + }) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let parsed = parse_create_goal_args(input.clone()) + .map_err(|error| BitFunError::Validation(error.to_string()))?; + let coordinator = require_coordinator()?; + let (session_id, workspace_path) = require_session_context(context)?; + let goal = coordinator + .create_thread_goal( + &session_id, + workspace_path.as_path(), + parsed.objective, + parsed.token_budget, + ) + .await + .map_err(user_facing_thread_goal_error)?; + let result = build_goal_tool_result(Some(goal), false) + .map_err(|error| BitFunError::Validation(error.to_string()))?; + Ok(vec![ToolResult::Result { + data: result.data, + result_for_assistant: Some(result.result_for_assistant), + image_attachments: None, + }]) + } +} + +pub struct UpdateGoalTool; + +impl UpdateGoalTool { + pub fn new() -> Self { + Self + } +} + +impl Default for UpdateGoalTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for UpdateGoalTool { + fn name(&self) -> &str { + UPDATE_GOAL_TOOL_NAME + } + + async fn description(&self) -> BitFunResult { + Ok( + "Update the existing goal. Use only to mark the goal achieved or genuinely blocked. \ +Set status to complete only when the objective has actually been achieved and no required work remains. \ +Set status to blocked only when the same blocking condition has repeated for at least three consecutive goal turns and the agent cannot make meaningful progress without user input or an external-state change. \ +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal." + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Mark the session thread goal complete or blocked.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { + "status": { + "type": "string", + "enum": ["complete", "blocked"], + "description": "Required. Set to complete only when the objective is achieved. Set to blocked only after the strict blocked audit is satisfied." + } + } + }) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let parsed = parse_update_goal_args(input.clone()) + .map_err(|error| BitFunError::Validation(error.to_string()))?; + let status = parse_update_goal_status(&parsed.status) + .map_err(|error| BitFunError::Validation(error.to_string()))?; + let coordinator = require_coordinator()?; + let (session_id, workspace_path) = require_session_context(context)?; + let goal = coordinator + .update_thread_goal_status( + &session_id, + workspace_path.as_path(), + status, + context.dialog_turn_id.as_deref(), + ) + .await + .map_err(user_facing_thread_goal_error)?; + let include_report = status == ThreadGoalStatus::Complete; + let result = build_goal_tool_result(Some(goal), include_report) + .map_err(|error| BitFunError::Validation(error.to_string()))?; + Ok(vec![ToolResult::Result { + data: result.data, + result_for_assistant: Some(result.result_for_assistant), + image_attachments: None, + }]) + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/todo_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/todo_write_tool.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs diff --git a/src/crates/core/src/agentic/tools/implementations/util.rs b/src/crates/assembly/core/src/agentic/tools/implementations/util.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/util.rs rename to src/crates/assembly/core/src/agentic/tools/implementations/util.rs diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs new file mode 100644 index 0000000000..c87bd803c1 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs @@ -0,0 +1,229 @@ +use super::readable::{ + extract_html_title, extract_markdown_with_text_fallback, is_html, normalize_requested_format, + RequestedFormat, +}; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// WebFetch tool +pub struct WebFetchTool; + +impl Default for WebFetchTool { + fn default() -> Self { + Self::new() + } +} + +impl WebFetchTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for WebFetchTool { + fn name(&self) -> &str { + "WebFetch" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Fetch content from a URL. + +Use this tool to: +- Read documentation from websites +- Fetch API responses +- Download readable content from web pages +- Access online resources + +Supports different output formats: +- raw: Raw response content (original HTML or text) +- markdown: Readable content mode. For HTML pages, BitFun extracts the main content and returns markdown when possible, automatically falling back to plain text when markdown conversion is not reliable. +- json: Parse JSON responses + +Example usage: +- Fetch raw HTML: {"url": "https://example.com", "format": "raw"} +- Fetch readable content: {"url": "https://example.com/article", "format": "markdown"} +- Get API data: {"url": "https://api.example.com/data", "format": "json"}"# + .to_string()) + } + + fn short_description(&self) -> String { + "Fetch content from a URL in raw, markdown, or JSON format.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch" + }, + "format": { + "type": "string", + "enum": ["raw", "markdown", "json"], + "description": "Output format. Use 'raw' for the original response, 'markdown' for readable content with automatic plain-text fallback, or 'json' for parsed JSON.", + "default": "markdown" + } + }, + "required": ["url"] + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if let Some(url) = input.get("url").and_then(|v| v.as_str()) { + if url.is_empty() { + return ValidationResult { + result: false, + message: Some("URL cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if !url.starts_with("http://") && !url.starts_with("https://") { + return ValidationResult { + result: false, + message: Some("URL must start with http:// or https://".to_string()), + error_code: Some(400), + meta: None, + }; + } + } else { + return ValidationResult { + result: false, + message: Some("url is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let url = input + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("url is required".to_string()))?; + + let requested_format = + normalize_requested_format(input.get("format").and_then(|v| v.as_str()))?; + + let client = reqwest::Client::builder() + .user_agent("BitFun/1.0") + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| BitFunError::tool(format!("Failed to create HTTP client: {}", e)))?; + + let response = client + .get(url) + .send() + .await + .map_err(|e| BitFunError::tool(format!("Failed to fetch URL: {}", e)))?; + + if !response.status().is_success() { + return Err(BitFunError::tool(format!( + "HTTP error {}: {}", + response.status(), + response + .status() + .canonical_reason() + .unwrap_or("Unknown error") + ))); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let content = response + .text() + .await + .map_err(|e| BitFunError::tool(format!("Failed to read response: {}", e)))?; + + let is_html_response = is_html(content_type.as_deref(), &content); + let fallback_title = if is_html_response { + extract_html_title(&content) + } else { + None + }; + + let (processed_content, content_representation, extractor, title) = match requested_format { + RequestedFormat::Raw => (content, "raw", "raw", fallback_title), + RequestedFormat::Json => { + serde_json::from_str::(&content) + .map_err(|e| BitFunError::tool(format!("Invalid JSON response: {}", e)))?; + (content, "json", "json", None) + } + RequestedFormat::Markdown => { + if is_html_response { + let readable = extract_markdown_with_text_fallback(&content, url)?; + ( + readable.content, + readable.content_representation, + readable.extractor, + readable.title, + ) + } else { + (content, "plain_text", "plain_text", None) + } + } + }; + + let result = ToolResult::Result { + data: json!({ + "url": url, + "title": title, + "format": match requested_format { + RequestedFormat::Raw => "raw", + RequestedFormat::Markdown => "markdown", + RequestedFormat::Json => "json", + }, + "content_representation": content_representation, + "extractor": extractor, + "content": processed_content, + "content_length": processed_content.len() + }), + result_for_assistant: Some(processed_content), + image_attachments: None, + }; + + Ok(vec![result]) + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs new file mode 100644 index 0000000000..1f1b6f5db8 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs @@ -0,0 +1,208 @@ +//! Web tool implementations. + +mod fetch; +mod readable; +mod search; + +pub use fetch::WebFetchTool; +pub use search::WebSearchTool; + +#[cfg(test)] +mod tests { + use super::fetch::WebFetchTool; + use super::readable::{ + extract_html_title, extract_markdown_with_text_fallback, html_to_text, is_html, + looks_noisy, normalize_requested_format, RequestedFormat, + }; + use super::search::WebSearchTool; + use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use serde_json::json; + use std::io::ErrorKind; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: std::collections::HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[tokio::test] + async fn webfetch_can_fetch_local_http_content() { + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(e) if e.kind() == ErrorKind::PermissionDenied => { + eprintln!( + "Skipping webfetch local server test due to sandbox socket restrictions: {}", + e + ); + return; + } + Err(e) => panic!("bind local test server: {}", e), + }; + let addr = listener.local_addr().expect("read local addr"); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept request"); + let mut req_buf = [0u8; 1024]; + let _ = socket.read(&mut req_buf).await; + + let body = "hello from webfetch"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + let _ = socket.shutdown().await; + }); + + let tool = WebFetchTool::new(); + let input = json!({ + "url": format!("http://{}/test", addr), + "format": "markdown" + }); + + let results = tool + .call(&input, &empty_context()) + .await + .unwrap_or_else(|e| { + panic!("tool call failed with detailed error: {:?}", e); + }); + assert_eq!(results.len(), 1); + + match &results[0] { + ToolResult::Result { + data, + result_for_assistant, + .. + } => { + assert_eq!(data["content"], "hello from webfetch"); + assert_eq!(data["format"], "markdown"); + assert_eq!(data["content_representation"], "plain_text"); + assert!(data["title"].is_null()); + assert_eq!(result_for_assistant.as_deref(), Some("hello from webfetch")); + } + other => panic!("unexpected tool result variant: {:?}", other), + } + + server.await.expect("server task"); + } + + #[test] + fn webfetch_text_alias_normalizes_to_markdown() { + assert!(matches!( + normalize_requested_format(Some("text")).expect("format alias should work"), + RequestedFormat::Markdown + )); + } + + #[test] + fn webfetch_html_to_text_extracts_plain_text() { + let html = r#" + +Test Page + + + +

          Hello World

          +

          This is a paragraph with bold text.

          +
          • Item one
          • Item two
          + +"#; + + let text = html_to_text(html); + assert!(!text.contains(" - -

          Hello World

          -

          This is a paragraph with bold text.

          -
          • Item one
          • Item two
          - -"#; - - let text = WebFetchTool::html_to_text(html); - assert!(!text.contains(" +
          +
          +
          - + diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 82c26f0950..eaec6ccf42 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@bitfun/web-ui", - "version": "0.2.8", + "version": "0.2.10", "private": true, "description": "BitFun Web UI - 支持 Desktop 和 Server 两种部署方式", "type": "module", diff --git a/src/web-ui/public/Logo-ICON-128.png b/src/web-ui/public/Logo-ICON-128.png new file mode 100644 index 0000000000..3b38487aa2 Binary files /dev/null and b/src/web-ui/public/Logo-ICON-128.png differ diff --git a/src/web-ui/public/agent-companion-pets/bitfun/pet.json b/src/web-ui/public/agent-companion-pets/bitfun/pet.json new file mode 100644 index 0000000000..ba1fff112d --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/bitfun/pet.json @@ -0,0 +1,6 @@ +{ + "id": "bitfun", + "displayName": "Bitfun", + "description": "BitFun's mascot — Bifang (毕方), a figure from Chinese mythology said to live on Mount Zhang'e. In the Classic of Mountains and Seas (Shan Hai Jing · Western Mountains), Bifang is described as crane-like with one foot, blue feathers marked with red, and a white beak.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/bitfun/spritesheet.webp b/src/web-ui/public/agent-companion-pets/bitfun/spritesheet.webp new file mode 100644 index 0000000000..091d9533c4 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/bitfun/spritesheet.webp differ diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 94bc6e7801..a19fe72e22 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -1,35 +1,62 @@ -import { useEffect, useCallback, useState, useRef } from 'react'; +import { lazy, Suspense, useEffect, useCallback, useLayoutEffect, useState, useRef } from 'react'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { useHasDismissibleLayer } from '@/infrastructure/hooks/useDismissibleLayer'; import { dismissibleLayerManager } from '@/infrastructure/services/DismissibleLayerManager'; import { ChatProvider } from '../infrastructure/contexts/ChatProvider'; import { ViewModeProvider } from '../infrastructure/contexts/ViewModeProvider'; import { SSHRemoteProvider } from '../features/ssh-remote'; -import AppLayout from './layout/AppLayout'; import { ContextMenuRenderer } from '../shared/context-menu-system/components/ContextMenuRenderer'; import { NotificationContainer, NotificationCenter, notificationService } from '../shared/notification-system'; import { AnnouncementProvider } from '../shared/announcement-system'; import { ConfirmDialogRenderer } from '../component-library'; import { createLogger } from '@/shared/utils/logger'; import { startupTrace } from '@/shared/utils/startupTrace'; -import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; -import { syncAgentCompanionDesktopWindow } from '@/infrastructure/config/services/AgentCompanionWindowService'; import { isTauriRuntime } from '@/infrastructure/runtime'; -import { buildAgentCompanionActivity, subscribeAgentCompanionActivity } from '@/flow_chat/utils/agentCompanionActivity'; -import { emitAgentCompanionActivity } from '@/flow_chat/services/AgentCompanionActivityBridge'; -import { BackgroundTaskCancelledError } from '@/shared/utils/backgroundTaskScheduler'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; -import SplashScreen from './components/SplashScreen/SplashScreen'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; -import { openAgentCompanionSession } from './services/openAgentCompanionSession'; import { useI18n } from '@/infrastructure/i18n'; import { scheduleDeferredStartupSystems } from './startup/deferredStartupSystems'; - -// Toolbar Mode -import { ToolbarModeProvider } from '../flow_chat'; +import { shouldScheduleDeferredStartupSystems } from './startup/deferredStartupGate'; +import { STARTUP_OVERLAY_HIDDEN_EVENT } from './startup/startupSignals'; +import { + getStartupOverlayElapsedMs, + hideStartupOverlay, + isStartupOverlayPresent, +} from './startup/startupOverlay'; +import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode'; const log = createLogger('App'); + +function isBackgroundTaskCancelledError(error: unknown): boolean { + return error instanceof Error && error.name === 'BackgroundTaskCancelledError'; +} + +interface AppLayoutStartupGateProps { + onReady: () => void; +} + +const LazyAppLayout = lazy(async () => { + startupTrace.markPhase('app_layout_import_start'); + try { + const module = await import('./layout/AppLayout'); + startupTrace.markPhase('app_layout_import_end'); + return { + default: function AppLayoutStartupGate({ onReady }: AppLayoutStartupGateProps) { + useLayoutEffect(() => { + startupTrace.markPhase('app_layout_ready'); + onReady(); + }, [onReady]); + + return ; + }, + }; + } catch (error) { + startupTrace.markPhase('app_layout_import_failed'); + throw error; + } +}); + /** * BitFun main application component. * @@ -45,32 +72,138 @@ const MIN_SPLASH_MS = 900; function App() { const { t } = useI18n('settings/basics'); - const { t: tCommon } = useI18n('common'); // Workspace loading state — drives splash exit timing const { loading: workspaceLoading } = useWorkspaceContext(); - // Splash screen state - const [splashVisible, setSplashVisible] = useState(true); - const [splashExiting, setSplashExiting] = useState(false); + const [startupOverlayVisible, setStartupOverlayVisible] = useState(isStartupOverlayPresent); const hasAppDismissibleLayer = useHasDismissibleLayer('app'); - const mountTimeRef = useRef(Date.now()); const mainWindowShownRef = useRef(false); + const userCloseRequestedRef = useRef(false); const interactiveShellReadyRef = useRef(false); + const interactiveShellReadyFrameRef = useRef(null); + const workspaceLoadingRef = useRef(workspaceLoading); + const appLayoutReadyRef = useRef(false); const [interactiveShellReady, setInteractiveShellReady] = useState(false); + const [appLayoutReady, setAppLayoutReady] = useState(false); + + workspaceLoadingRef.current = workspaceLoading; + + const releaseInteractiveShellReadyIfReady = useCallback((reason: string) => { + const latestWorkspaceLoading = workspaceLoadingRef.current; + const latestAppLayoutReady = appLayoutReadyRef.current; + startupTrace.markPhase('interactive_shell_ready_gate_check', { + workspaceLoading: latestWorkspaceLoading, + appLayoutReady: latestAppLayoutReady, + alreadyReady: interactiveShellReadyRef.current, + reason, + afterPaint: true, + }); + if (latestWorkspaceLoading || !latestAppLayoutReady || interactiveShellReadyRef.current) { + return; + } + interactiveShellReadyRef.current = true; + startupTrace.markPhase('interactive_shell_ready', { reason }); + window.dispatchEvent(new CustomEvent('bitfun:interactive-shell-ready', { + detail: { reason }, + })); + setInteractiveShellReady(true); + }, []); + + const markInteractiveShellReadyIfReady = useCallback((reason: string) => { + const latestWorkspaceLoading = workspaceLoadingRef.current; + const latestAppLayoutReady = appLayoutReadyRef.current; + startupTrace.markPhase('interactive_shell_ready_gate_check', { + workspaceLoading: latestWorkspaceLoading, + appLayoutReady: latestAppLayoutReady, + alreadyReady: interactiveShellReadyRef.current, + alreadyScheduled: interactiveShellReadyFrameRef.current !== null, + reason, + }); + if ( + latestWorkspaceLoading || + !latestAppLayoutReady || + interactiveShellReadyRef.current || + interactiveShellReadyFrameRef.current !== null + ) { + return; + } + + startupTrace.markPhase('interactive_shell_ready_after_paint_scheduled', { reason }); + interactiveShellReadyFrameRef.current = window.requestAnimationFrame(() => { + interactiveShellReadyFrameRef.current = null; + releaseInteractiveShellReadyIfReady(`${reason}-after-paint`); + }); + }, [releaseInteractiveShellReadyIfReady]); + + const handleAppLayoutReady = useCallback(() => { + startupTrace.markPhase('app_layout_ready_state_update_requested'); + appLayoutReadyRef.current = true; + setAppLayoutReady(true); + markInteractiveShellReadyIfReady('app-layout-ready'); + }, [markInteractiveShellReadyIfReady]); + + useEffect(() => { + return () => { + if (interactiveShellReadyFrameRef.current !== null) { + window.cancelAnimationFrame(interactiveShellReadyFrameRef.current); + interactiveShellReadyFrameRef.current = null; + } + }; + }, []); // Once the workspace finishes loading, wait for the remaining min-display // time and then begin the exit animation. useEffect(() => { - if (workspaceLoading) return; - const elapsed = Date.now() - mountTimeRef.current; + if (workspaceLoading || !appLayoutReady) return; + const elapsed = getStartupOverlayElapsedMs(); const remaining = Math.max(0, MIN_SPLASH_MS - elapsed); - const timer = window.setTimeout(() => setSplashExiting(true), remaining); - return () => window.clearTimeout(timer); - }, [workspaceLoading]); + let cancelled = false; + const timer = window.setTimeout(() => { + void hideStartupOverlay().then(() => { + if (!cancelled) { + setStartupOverlayVisible(false); + startupTrace.markPhase('startup_overlay_hidden'); + window.dispatchEvent(new CustomEvent(STARTUP_OVERLAY_HIDDEN_EVENT)); + } + }); + }, remaining); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [workspaceLoading, appLayoutReady]); + + useEffect(() => { + if (!isTauriRuntime()) { + return; + } + + let unlisten: (() => void) | null = null; + let disposed = false; + + void import('@tauri-apps/api/event') + .then(({ listen }) => listen('bitfun_main_window_close_requested', () => { + userCloseRequestedRef.current = true; + startupTrace.markPhase('main_window_user_close_requested', { reason: 'user-close-requested' }); + })) + .then(removeListener => { + if (disposed) { + removeListener(); + return; + } + unlisten = removeListener; + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to listen for main window close request in startup visibility guard', error); + } + }); - const handleSplashExited = useCallback(() => { - setSplashVisible(false); + return () => { + disposed = true; + unlisten?.(); + }; }, []); const showMainWindow = useCallback(async (reason: string) => { @@ -104,6 +237,14 @@ function App() { }, []); const verifyMainWindowVisible = useCallback(async (reason: string) => { + if (userCloseRequestedRef.current) { + log.debug('Skipping main window startup visibility retry after user close request', { + reason, + closeReason: 'user-close-requested', + }); + return; + } + if (!isTauriRuntime()) { void showMainWindow(reason); return; @@ -140,20 +281,15 @@ function App() { }, [showMainWindow]); useEffect(() => { - if (workspaceLoading || interactiveShellReadyRef.current) { - return; + if (appLayoutReady) { + appLayoutReadyRef.current = true; } - interactiveShellReadyRef.current = true; - startupTrace.markPhase('interactive_shell_ready'); - window.dispatchEvent(new CustomEvent('bitfun:interactive-shell-ready', { - detail: { reason: 'workspace-ready' }, - })); - setInteractiveShellReady(true); - }, [workspaceLoading]); + markInteractiveShellReadyIfReady('workspace-or-layout-state'); + }, [workspaceLoading, appLayoutReady, markInteractiveShellReadyIfReady]); // If the early reveal path fails, keep the old post-splash show as a retry. useEffect(() => { - if (splashVisible) { + if (startupOverlayVisible) { return; } @@ -162,7 +298,7 @@ function App() { }, 50); return () => window.clearTimeout(timer); - }, [splashVisible, verifyMainWindowVisible]); + }, [startupOverlayVisible, verifyMainWindowVisible]); // Safety net: if startup gets stuck, reveal the window so the user can see errors. useEffect(() => { @@ -173,25 +309,26 @@ function App() { return () => window.clearTimeout(timer); }, [verifyMainWindowVisible]); - // Non-critical systems are delayed until the shell is interactive. + // Non-critical systems are delayed until the shell is interactive and the + // startup overlay has fully handed off to the app surface. useEffect(() => { - if (!interactiveShellReady) { + if (!shouldScheduleDeferredStartupSystems({ interactiveShellReady, startupOverlayVisible })) { return; } - log.info('Application interactive, scheduling deferred systems'); + log.info('Application visible and interactive, scheduling deferred systems'); const startupSystemsHandle = scheduleDeferredStartupSystems(); startupSystemsHandle.promise.catch(error => { - if (!(error instanceof BackgroundTaskCancelledError)) { + if (!isBackgroundTaskCancelledError(error)) { log.warn('Deferred startup systems task failed', error); } }); return () => startupSystemsHandle.cancel(); - }, [interactiveShellReady]); + }, [interactiveShellReady, startupOverlayVisible]); useEffect(() => { - if (!interactiveShellReady || splashVisible) { + if (!interactiveShellReady || startupOverlayVisible) { return; } @@ -205,7 +342,7 @@ function App() { } editorWarmupHandle = scheduleMonacoStartupWarmup(); editorWarmupHandle.promise.catch(error => { - if (!disposed && !(error instanceof BackgroundTaskCancelledError)) { + if (!disposed && !isBackgroundTaskCancelledError(error)) { log.warn('Editor startup warmup task failed', error); } }); @@ -220,26 +357,42 @@ function App() { disposed = true; editorWarmupHandle?.cancel(); }; - }, [interactiveShellReady, splashVisible]); + }, [interactiveShellReady, startupOverlayVisible]); useEffect(() => { if (!isTauriRuntime() || !interactiveShellReady) return; let disposed = false; let startupSyncHandle: { promise: Promise; cancel: () => void } | null = null; - const emitCurrentAgentCompanionActivity = () => { - if (disposed) { - return; - } - void emitAgentCompanionActivity(buildAgentCompanionActivity()); - }; + let removeSettingsListener: (() => void) | null = null; + + void (async () => { + const [ + { aiExperienceConfigService }, + { syncAgentCompanionDesktopWindow }, + { buildAgentCompanionActivity }, + { emitAgentCompanionActivity }, + { backgroundTaskScheduler }, + ] = await Promise.all([ + import('@/infrastructure/config/services/AIExperienceConfigService'), + import('@/infrastructure/config/services/AgentCompanionWindowService'), + import('@/flow_chat/utils/agentCompanionActivity'), + import('@/flow_chat/services/AgentCompanionActivityBridge'), + import('@/shared/utils/backgroundTaskScheduler'), + ]); - void aiExperienceConfigService.getSettingsAsync().then(async settings => { if (disposed) { return; } - const { backgroundTaskScheduler, BackgroundTaskCancelledError } = await import('@/shared/utils/backgroundTaskScheduler'); + const emitCurrentAgentCompanionActivity = () => { + if (disposed) { + return; + } + void emitAgentCompanionActivity(buildAgentCompanionActivity()); + }; + + const settings = await aiExperienceConfigService.getSettingsAsync(); if (disposed) { return; } @@ -270,28 +423,57 @@ function App() { }); startupSyncHandle.promise.catch(error => { - if (!disposed && !(error instanceof BackgroundTaskCancelledError)) { + if (!disposed && !isBackgroundTaskCancelledError(error)) { log.warn('Initial Agent companion sync task failed', error); } }); - }); - const removeSettingsListener = aiExperienceConfigService.addChangeListener(settings => { - void syncAgentCompanionDesktopWindow(settings).then(() => { - emitCurrentAgentCompanionActivity(); - window.setTimeout(emitCurrentAgentCompanionActivity, 250); + removeSettingsListener = aiExperienceConfigService.addChangeListener(settings => { + void syncAgentCompanionDesktopWindow(settings).then(() => { + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + }); }); + })().catch(error => { + if (!disposed) { + log.warn('Failed to initialize Agent companion startup sync', error); + } }); + return () => { disposed = true; startupSyncHandle?.cancel(); - removeSettingsListener(); + removeSettingsListener?.(); }; }, [interactiveShellReady]); - useEffect(() => subscribeAgentCompanionActivity(activity => { - void emitAgentCompanionActivity(activity); - }), []); + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | null = null; + + void Promise.all([ + import('@/flow_chat/utils/agentCompanionActivity'), + import('@/flow_chat/services/AgentCompanionActivityBridge'), + ]) + .then(([{ subscribeAgentCompanionActivity }, { emitAgentCompanionActivity }]) => { + if (disposed) { + return; + } + unsubscribe = subscribeAgentCompanionActivity(activity => { + void emitAgentCompanionActivity(activity); + }); + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to subscribe Agent companion activity bridge', error); + } + }); + + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); useEffect(() => { let unlisten: (() => void) | null = null; @@ -302,6 +484,7 @@ function App() { const sessionId = event.payload?.sessionId; if (!sessionId) return; + const { openAgentCompanionSession } = await import('./services/openAgentCompanionSession'); await openAgentCompanionSession(sessionId); try { @@ -434,7 +617,9 @@ function App() { {/* Unified app layout with startup/workspace modes */} - + + + {/* Context menu renderer */} @@ -449,14 +634,6 @@ function App() { {/* Announcement / feature-demo / tips system */} - {/* Startup splash — sits above everything, exits once workspace is ready */} - {splashVisible && ( - - )} diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index 6887f6ce9e..3b113774d0 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -95,7 +95,7 @@ svg { flex-shrink: 0; margin-top: 2px; - color: var(--color-success, #22c55e); + color: var(--color-success); } &--success { @@ -389,172 +389,109 @@ } } -// ==================== Dependencies section animated entry ==================== +// ==================== Dependencies section ==================== .bitfun-about-dialog__dependencies-section { + margin-bottom: 16px; animation: about-section-enter 0.4s ease-out 0.25s both; } -@keyframes about-list-expand { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -// ==================== Footer ==================== - -.bitfun-about-dialog__footer { - flex-shrink: 0; - padding: 16px 24px 20px; - text-align: center; - position: relative; - animation: about-section-enter 0.4s ease-out 0.3s both; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 24px; - right: 24px; - height: 1px; - background: linear-gradient(90deg, transparent 0%, var(--border-subtle) 50%, transparent 100%); - } -} - -.bitfun-about-dialog__links { +.bitfun-about-dialog__dependencies-toggle { + width: 100%; display: flex; align-items: center; - justify-content: center; - gap: 8px; - margin-bottom: 10px; -} - -.bitfun-about-dialog__link { - font-size: 11px; - color: var(--color-text-muted); - text-decoration: none; + justify-content: space-between; + padding: 10px 14px; + background: transparent; + border: 1px dashed var(--border-subtle); + border-radius: 8px; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 500; cursor: pointer; - transition: color 0.2s ease; - background: none; - border: none; - padding: 0; - font-family: inherit; - + transition: all 0.25s ease; + &:hover { - color: var(--color-accent-400); - text-decoration: underline; + background: var(--element-bg-subtle); + border-color: var(--border-base); + border-style: solid; + color: var(--color-text-primary); + + svg { + color: var(--color-accent-500); + } + } + + span { + display: flex; + align-items: center; + gap: 8px; } -} - -.bitfun-about-dialog__link-sep { - font-size: 11px; - color: var(--border-subtle); -} - -// ==================== Sub-dialogs (open source / user agreement) ==================== - -.bitfun-about-dialog__sub-content { - padding: 20px 24px 24px; - display: flex; - flex-direction: column; - gap: 20px; - max-height: min(520px, calc(100vh - 200px)); - overflow-y: auto; -} - -.bitfun-about-dialog__sub-desc { - font-size: 12px; - color: var(--color-text-secondary); - line-height: 1.6; - margin: 0; -} - -// ==================== Category section ==================== - -.bitfun-about-dialog__sub-category { - display: flex; - flex-direction: column; - gap: 8px; -} - -.bitfun-about-dialog__sub-category-header { - display: flex; - align-items: center; - gap: 10px; - padding: 0 2px; -} -.bitfun-about-dialog__sub-category-title { - font-size: 13px; - font-weight: 600; - color: var(--color-text-primary); - margin: 0; + svg { + color: var(--color-text-muted); + transition: all 0.2s ease; + } } -.bitfun-about-dialog__sub-category-count { +.bitfun-about-dialog__dependencies-count { display: inline-flex; align-items: center; justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - font-size: 9px; + min-width: 20px; + height: 18px; + padding: 0 6px; + background: rgba(99, 102, 241, 0.1); + color: var(--color-accent-400); + font-size: 10px; font-weight: 600; - border-radius: 8px; - letter-spacing: 0.02em; + border-radius: 9px; } -.bitfun-about-dialog__sub-category-count--frontend { - background: rgba(59, 130, 246, 0.12); - color: #60A5FA; +.bitfun-about-dialog__dependencies-list { + margin-top: 8px; + padding: 4px; + background: transparent; + border: 1px solid var(--border-subtle); + border-radius: 8px; + animation: about-list-expand 0.25s ease-out; } -.bitfun-about-dialog__sub-category-count--backend { - background: rgba(52, 211, 153, 0.12); - color: #34D399; +@keyframes about-list-expand { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } } -// ==================== Dependencies list grid ==================== - .bitfun-about-dialog__dependencies-grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 6px; + gap: 4px; } .bitfun-about-dialog__dependency-item { display: flex; align-items: center; gap: 10px; - padding: 9px 12px; - background: linear-gradient(135deg, - rgba(255, 255, 255, 0.04) 0%, - rgba(255, 255, 255, 0.02) 100% - ); - border: 1px solid var(--border-subtle); - border-radius: 8px; + padding: 8px 10px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; transition: all 0.2s ease; min-width: 0; - + &:hover { - background: linear-gradient(135deg, - rgba(59, 130, 246, 0.08) 0%, - rgba(139, 92, 246, 0.05) 100% - ); - border-color: rgba(59, 130, 246, 0.25); - transform: translateY(-1px); - box-shadow: - 0 4px 12px rgba(96, 165, 250, 0.1), - 0 2px 4px rgba(0, 0, 0, 0.1); - + background: var(--element-bg-subtle); + border-color: var(--border-subtle); + .bitfun-about-dialog__dependency-icon { - background: rgba(59, 130, 246, 0.15); - + background: rgba(99, 102, 241, 0.1); + svg { color: var(--color-accent-500); } @@ -564,19 +501,19 @@ .bitfun-about-dialog__dependency-icon { flex-shrink: 0; - width: 28px; - height: 28px; + width: 26px; + height: 26px; display: flex; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.06); + background: var(--element-bg-subtle); border-radius: 6px; transition: all 0.2s ease; svg { - width: 13px; - height: 13px; - color: var(--color-text-muted); + width: 12px; + height: 12px; + color: var(--color-text-secondary); transition: color 0.2s ease; } } @@ -597,17 +534,6 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - background: none; - border: none; - padding: 0; - cursor: pointer; - text-align: left; - transition: color 0.2s ease; - - &:hover { - color: var(--color-accent-400); - text-decoration: underline; - } } .bitfun-about-dialog__dependency-license { @@ -618,47 +544,54 @@ .bitfun-about-dialog__dependency-tag { display: inline-flex; align-items: center; - padding: 2px 6px; + padding: 1px 5px; font-size: 8px; font-weight: 600; - border-radius: 4px; + border-radius: 3px; text-transform: uppercase; letter-spacing: 0.3px; margin-left: auto; flex-shrink: 0; - + + // Frontend tag - blue &--frontend { - background: rgba(59, 130, 246, 0.1); - color: #60A5FA; - border: 1px solid rgba(59, 130, 246, 0.15); + background: var(--color-accent-100); + color: var(--color-accent-500); + border: 1px solid var(--color-accent-200); } + // Backend tag - green &--backend { - background: rgba(52, 211, 153, 0.1); - color: #34D399; - border: 1px solid rgba(52, 211, 153, 0.15); + background: var(--color-success-bg); + color: var(--color-success); + border: 1px solid var(--color-success-border); } -} -.bitfun-about-dialog__sub-footnote { - font-size: 11px; - color: var(--color-text-muted); - margin: 0; - padding-top: 12px; - border-top: 1px solid var(--border-subtle); - text-align: center; + // General tag - purple + &--general { + background: var(--color-purple-100); + color: var(--color-purple-500); + border: 1px solid var(--color-purple-200); + } } -// ==================== Responsive ==================== - -@media (max-width: 580px) { - .bitfun-about-dialog__sub-content { - padding: 16px 16px 20px; - max-height: min(460px, calc(100vh - 160px)); - } +// ==================== Footer ==================== - .bitfun-about-dialog__dependencies-grid { - grid-template-columns: 1fr; +.bitfun-about-dialog__footer { + flex-shrink: 0; + padding: 16px 24px 20px; + text-align: center; + position: relative; + animation: about-section-enter 0.4s ease-out 0.3s both; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 24px; + right: 24px; + height: 1px; + background: linear-gradient(90deg, transparent 0%, var(--border-subtle) 50%, transparent 100%); } } @@ -697,6 +630,10 @@ padding: 0 16px; } + .bitfun-about-dialog__dependencies-grid { + grid-template-columns: 1fr; + } + .bitfun-about-dialog__footer { padding: 14px 16px 16px; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss index aec3ad12c5..48104cbbb8 100644 --- a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss @@ -159,7 +159,7 @@ $content-max: 1480px; &--skeleton { --skeleton-shimmer-0: rgba(255, 255, 255, 0); - --skeleton-shimmer-peak: rgba(255, 255, 255, 0.1); + --skeleton-shimmer-peak: var(--color-overlay-white-10); pointer-events: none; } } @@ -341,7 +341,7 @@ $content-max: 1480px; padding: 0 $size-gap-3; height: 28px; border: 1px solid var(--border-subtle); - background: rgba(255, 255, 255, 0.03); + background: var(--color-overlay-white-03); } .gallery-filter-chip.is-active, @@ -390,7 +390,7 @@ $content-max: 1480px; border-radius: $size-radius-full; background: rgba(52, 211, 153, 0.12); border: 1px solid rgba(52, 211, 153, 0.28); - color: #34d399; + color: var(--color-success); font-size: 11px; font-weight: $font-weight-semibold; } diff --git a/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.scss b/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.scss index ed69310dfe..a73af71dae 100644 --- a/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.scss +++ b/src/web-ui/src/app/components/MCPInteractionDialog/MCPInteractionDialog.scss @@ -11,7 +11,7 @@ align-items: center; gap: 8px; font-size: 12px; - color: var(--text-secondary, #9ca3af); + color: var(--text-secondary); } .mcp-interaction-dialog__server { @@ -21,7 +21,7 @@ } .mcp-interaction-dialog__queue { - color: var(--text-tertiary, #6b7280); + color: var(--text-tertiary); } .mcp-interaction-dialog__section { @@ -32,16 +32,16 @@ .mcp-interaction-dialog__label { font-size: 12px; - color: var(--text-secondary, #9ca3af); + color: var(--text-secondary); } .mcp-interaction-dialog__params { margin: 0; padding: 10px; border-radius: 8px; - background: var(--bg-tertiary, #111827); - border: 1px solid var(--border-primary, #374151); - color: var(--text-primary, #e5e7eb); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + color: var(--text-primary); font-size: 12px; line-height: 1.5; white-space: pre-wrap; @@ -55,9 +55,9 @@ min-height: 190px; padding: 10px; border-radius: 8px; - background: var(--bg-secondary, #0b1220); - border: 1px solid var(--border-primary, #374151); - color: var(--text-primary, #e5e7eb); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + color: var(--text-primary); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; line-height: 1.5; diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index b21fa5395d..9d25a609d3 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -489,7 +489,7 @@ const MainNav: React.FC = ({ - {t('nav.sessions.newCodeSessionShort')} + {t('shared:agents.code')} @@ -503,7 +503,7 @@ const MainNav: React.FC = ({ - {t('nav.sessions.newCoworkSessionShort')} + {t('shared:agents.cowork')} diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index f8ffda3da0..1fb2efa237 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -177,7 +177,7 @@ $_section-header-height: 24px; color: var(--color-text-primary); cursor: pointer; text-align: center; - box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 4%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-static-white) 4%, transparent); transition: transform $motion-fast $easing-standard, box-shadow $motion-fast $easing-standard, border-color $motion-fast $easing-standard, @@ -186,8 +186,8 @@ $_section-header-height: 24px; &:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--color-primary) 28%, transparent); - box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent), - 0 10px 22px rgba(0, 0, 0, 0.12); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-static-white) 5%, transparent), + 0 10px 22px var(--color-overlay-black-12); } &:focus-visible { @@ -226,7 +226,7 @@ $_section-header-height: 24px; border-radius: 999px; background: var(--bitfun-mode-pill-liquid-pro-bg); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), + inset 0 1px 0 color-mix(in srgb, var(--color-static-white) 12%, transparent), 0 2px 10px color-mix(in srgb, var(--color-primary) 12%, transparent); transform: translate3d(0, 0, 0) scale(1, 1); transform-origin: 50% 50%; @@ -241,7 +241,7 @@ $_section-header-height: 24px; transform: translate3d(100%, 0, 0) scale(1, 1); background: var(--bitfun-mode-pill-liquid-assistant-bg); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), + inset 0 1px 0 color-mix(in srgb, var(--color-static-white) 12%, transparent), 0 2px 12px color-mix(in srgb, var(--color-accent-500) 18%, transparent); } @@ -465,10 +465,10 @@ $_section-header-height: 24px; position: fixed; min-width: 180px; padding: $size-gap-1 0; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); border-radius: $size-radius-base; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + box-shadow: 0 4px 12px var(--color-overlay-black-25); z-index: 9999; transform-origin: top left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -652,7 +652,7 @@ $_section-header-height: 24px; &:hover { color: var(--color-text-primary); background: color-mix(in srgb, var(--element-bg-soft) 94%, transparent); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px var(--color-overlay-black-06); } &:active { @@ -667,7 +667,7 @@ $_section-header-height: 24px; &.is-active { color: var(--color-text-primary); background: color-mix(in srgb, var(--element-bg-soft) 94%, transparent); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px var(--color-overlay-black-06); } } @@ -707,7 +707,7 @@ $_section-header-height: 24px; &:hover { color: var(--color-text-primary); background: color-mix(in srgb, var(--element-bg-soft) 94%, transparent); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px var(--color-overlay-black-06); } &:active { @@ -724,7 +724,7 @@ $_section-header-height: 24px; color: var(--color-text-primary); background: color-mix(in srgb, var(--element-bg-soft) 94%, transparent); border: 1px solid var(--border-subtle); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px var(--color-overlay-black-06); } &.has-customizing-apps { @@ -738,10 +738,10 @@ $_section-header-height: 24px; width: 7px; height: 7px; border-radius: 999px; - background: #60a5fa; + background: var(--color-accent-500); box-shadow: 0 0 0 2px var(--color-bg-primary), - 0 0 8px rgba(96, 165, 250, 0.75); + 0 0 8px color-mix(in srgb, var(--color-accent-500) 75%, transparent); pointer-events: none; } } @@ -830,14 +830,14 @@ $_section-header-height: 24px; } &.is-customizing { - box-shadow: 0 0 0 2px color-mix(in srgb, #60a5fa 42%, transparent), - 0 8px 18px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-500) 42%, transparent), + 0 8px 18px var(--color-overlay-black-20); } &.is-active.is-customizing { box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22), 0 0 0 3px color-mix(in srgb, var(--color-primary) 28%, transparent), - 0 0 0 5px color-mix(in srgb, #60a5fa 28%, transparent); + 0 0 0 5px color-mix(in srgb, var(--color-accent-500) 28%, transparent); } &-customize-dot { @@ -847,8 +847,8 @@ $_section-header-height: 24px; width: 6px; height: 6px; border-radius: 999px; - background: #60a5fa; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.28), 0 0 7px rgba(96, 165, 250, 0.72); + background: var(--color-accent-500); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.28), 0 0 7px color-mix(in srgb, var(--color-accent-500) 72%, transparent); } &--more { @@ -944,10 +944,10 @@ $_section-header-height: 24px; vertical-align: top; box-sizing: border-box; padding: $size-gap-1 0; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); border-radius: $size-radius-base; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + box-shadow: 0 4px 12px var(--color-overlay-black-25); z-index: 9999; transform-origin: top left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -1235,7 +1235,7 @@ $_section-header-height: 24px; transform: translateY(-50%); width: 2px; height: 16px; - background: var(--btn-primary-bg, var(--color-text-primary)); + background: var(--btn-primary-bg); border-radius: 0 2px 2px 0; } @@ -1489,7 +1489,7 @@ $_section-header-height: 24px; // Footer notification: yellow only when there are unread messages (BellDot) .bitfun-nav-panel__footer .bitfun-notification-btn .bitfun-notification-btn__icon--has-message { - color: #ca8a04; + color: var(--color-warning); } .bitfun-nav-panel__footer .bitfun-notification-btn.bitfun-notification-btn--nav-hover-icon:not(.bitfun-notification-btn--has-progress) { @@ -1611,8 +1611,8 @@ $_section-header-height: 24px; gap: $size-gap-1; min-width: 140px; padding: $size-gap-1; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); border-radius: $size-radius-base; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28), 0 2px 8px rgba(0, 0, 0, 0.16); z-index: 9999; @@ -1701,10 +1701,10 @@ $_section-header-height: 24px; left: 0; min-width: 148px; padding: $size-gap-1; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); border-radius: $size-radius-base; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + box-shadow: 0 4px 12px var(--color-overlay-black-25); z-index: 9999; transform-origin: bottom left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -1717,7 +1717,7 @@ $_section-header-height: 24px; .bitfun-nav-panel__footer-menu-divider { height: 1px; margin: $size-gap-1 $size-gap-2; - background: var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--border-subtle); } .bitfun-nav-panel__footer-menu-item { @@ -1841,12 +1841,12 @@ $_section-header-height: 24px; } .bitfun-nav-panel__remote-disclaimer-btn--primary { - background: var(--color-accent-600, #2563eb); - color: #fff; - border-color: color-mix(in srgb, var(--color-accent-700, #1d4ed8) 35%, transparent); + background: var(--color-accent-600); + color: var(--color-static-white); + border-color: color-mix(in srgb, var(--color-accent-700) 35%, transparent); &:hover:not(:disabled) { - background: var(--color-accent-700, #1d4ed8); + background: var(--color-accent-700); } } diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss index 3949a22e93..d46b337bb2 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss @@ -41,8 +41,8 @@ :root[data-theme-type='dark'] .bitfun-nav-search-dialog__card { box-shadow: - 0 0 0 1px color-mix(in srgb, #fff 6%, transparent), - 0 1px 0 color-mix(in srgb, #fff 5%, transparent), + 0 0 0 1px color-mix(in srgb, var(--color-static-white) 6%, transparent), + 0 1px 0 color-mix(in srgb, var(--color-static-white) 5%, transparent), 0 4px 12px rgba(0, 0, 0, 0.35), 0 20px 48px -8px rgba(0, 0, 0, 0.65), 0 40px 72px -20px rgba(0, 0, 0, 0.45); diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss index 09f218b4be..0440886e9c 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss @@ -11,7 +11,7 @@ background: var(--color-bg-primary); border: 1px solid var(--border-subtle); border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + box-shadow: 0 8px 32px var(--color-overlay-black-30); overflow: hidden; animation: branch-quick-switch-enter 0.2s cubic-bezier(0.4, 0, 0.2, 1); } diff --git a/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx b/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx index 6d83ea771b..47e68bfb41 100644 --- a/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx @@ -123,4 +123,4 @@ const NavItem: React.FC = ({ ); }; -export default NavItem; +export default React.memo(NavItem); diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index f2d3e94c13..8327edf4f6 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { lazy, Suspense, useState, useCallback } from 'react'; import { Settings, Info, @@ -25,7 +25,6 @@ import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext' import { useNotification } from '@/shared/notification-system'; import NotificationButton from '../../TitleBar/NotificationButton'; import { AboutDialog } from '../../AboutDialog'; -import { RemoteConnectDialog } from '../../RemoteConnectDialog'; import { RemoteConnectDisclaimerContent, } from '../../RemoteConnectDialog/RemoteConnectDisclaimer'; @@ -33,6 +32,9 @@ import { getRemoteConnectDisclaimerAgreed, setRemoteConnectDisclaimerAgreed, } from '../../RemoteConnectDialog/remoteConnectDisclaimerStorage'; + +const RemoteConnectDialog = lazy(() => import('../../RemoteConnectDialog')); + const PersistentFooterActions: React.FC = () => { const { t } = useI18n('common'); const { openScene } = useSceneManager(); @@ -298,7 +300,11 @@ const PersistentFooterActions: React.FC = () => {
setShowAbout(false)} /> - setShowRemoteConnect(false)} /> + {showRemoteConnect && ( + + setShowRemoteConnect(false)} /> + + )} setShowRemoteDisclaimer(false)} diff --git a/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx b/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx index ce891682d5..d23c5d7de6 100644 --- a/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx @@ -78,4 +78,4 @@ const SectionHeader: React.FC = ({ ); }; -export default SectionHeader; +export default React.memo(SectionHeader); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 8acdb92c45..b70ed64cc7 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -240,10 +240,10 @@ align-items: center; height: 12px; padding: 0 4px; - border: 1px solid color-mix(in srgb, var(--color-accent-400, #8b5cf6) 34%, var(--border-subtle)); + border: 1px solid color-mix(in srgb, var(--color-accent-400) 34%, var(--border-subtle)); border-radius: 999px; - background: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 13%, var(--element-bg-soft)); - color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 62%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-accent-400) 13%, var(--element-bg-soft)); + color: color-mix(in srgb, var(--color-accent-400) 62%, var(--color-text-primary)); font-size: var(--font-size-xxs); font-weight: 600; letter-spacing: 0.02em; @@ -259,10 +259,10 @@ gap: 3px; height: 14px; padding: 0 5px; - border: 1px solid color-mix(in srgb, var(--color-accent-400, #8b5cf6) 44%, var(--border-subtle)); + border: 1px solid color-mix(in srgb, var(--color-accent-400) 44%, var(--border-subtle)); border-radius: 999px; - background: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 18%, var(--element-bg-soft)); - color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 82%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-accent-400) 18%, var(--element-bg-soft)); + color: color-mix(in srgb, var(--color-accent-400) 82%, var(--color-text-primary)); font-size: var(--font-size-xxs); font-weight: 600; letter-spacing: 0.02em; @@ -273,6 +273,37 @@ } } + &__inline-item-background-subagent-badge { + flex: 0 0 auto; + position: relative; + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-success) 36%, var(--border-subtle)); + border-radius: 999px; + background: color-mix(in srgb, var(--color-success) 12%, var(--element-bg-soft)); + color: color-mix(in srgb, var(--color-success) 76%, var(--color-text-primary)); + overflow: hidden; + } + + &__inline-item-background-subagent-icon { + grid-area: 1 / 1; + transition: opacity $motion-fast $easing-standard, transform $motion-fast $easing-standard; + + &.is-bot { + animation: bitfun-nav-background-subagent-bot-cycle 3.6s ease-in-out infinite; + } + + &.is-loader { + opacity: 0; + animation: + bitfun-nav-background-subagent-loader-cycle 3.6s ease-in-out infinite, + bitfun-nav-session-spin 1s linear infinite; + } + } + // Attention badge for high-priority states (ask_user / tool_confirm) &__inline-item-attention-badge { flex: 0 0 auto; @@ -281,10 +312,10 @@ gap: 3px; height: 14px; padding: 0 5px; - border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, var(--border-subtle)); + border: 1px solid color-mix(in srgb, var(--color-warning) 35%, var(--border-subtle)); border-radius: 999px; - background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent); - color: color-mix(in srgb, var(--color-warning, #f59e0b) 80%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: color-mix(in srgb, var(--color-warning) 80%, var(--color-text-primary)); font-size: var(--font-size-xxs); font-weight: 600; letter-spacing: 0.02em; @@ -320,18 +351,18 @@ width: 7px; height: 7px; border-radius: 50%; - border: 1px solid var(--color-bg-primary, #111114); - background: var(--color-success, #22c55e); + border: 1px solid var(--color-bg-primary); + background: var(--color-success); box-shadow: - 0 0 0 1px color-mix(in srgb, var(--color-success, #22c55e) 35%, transparent), - 0 0 6px color-mix(in srgb, var(--color-success, #22c55e) 42%, transparent); + 0 0 0 1px color-mix(in srgb, var(--color-success) 35%, transparent), + 0 0 6px color-mix(in srgb, var(--color-success) 42%, transparent); &.is-error, &.is-interrupted { - background: var(--color-error, #ef4444); + background: var(--color-error); box-shadow: - 0 0 0 1px color-mix(in srgb, var(--color-error, #ef4444) 35%, transparent), - 0 0 6px color-mix(in srgb, var(--color-error, #ef4444) 42%, transparent); + 0 0 0 1px color-mix(in srgb, var(--color-error) 35%, transparent), + 0 0 6px color-mix(in srgb, var(--color-error) 42%, transparent); } // High-priority attention states: larger, brighter, with subtle pulse @@ -344,17 +375,17 @@ } &.is-ask-user { - background: var(--color-warning, #f59e0b); + background: var(--color-warning); box-shadow: - 0 0 0 1.5px color-mix(in srgb, var(--color-warning, #f59e0b) 45%, transparent), - 0 0 8px color-mix(in srgb, var(--color-warning, #f59e0b) 55%, transparent); + 0 0 0 1.5px color-mix(in srgb, var(--color-warning) 45%, transparent), + 0 0 8px color-mix(in srgb, var(--color-warning) 55%, transparent); } &.is-tool-confirm { - background: var(--color-accent-500, #8b5cf6); + background: var(--color-accent-500); box-shadow: - 0 0 0 1.5px color-mix(in srgb, var(--color-accent-500, #8b5cf6) 45%, transparent), - 0 0 8px color-mix(in srgb, var(--color-accent-500, #8b5cf6) 55%, transparent); + 0 0 0 1.5px color-mix(in srgb, var(--color-accent-500) 45%, transparent), + 0 0 8px color-mix(in srgb, var(--color-accent-500) 55%, transparent); } // Pulse animation for high-priority states @@ -430,9 +461,9 @@ overflow-y: auto; padding: $size-gap-1 0; border-radius: $size-radius-base; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); + box-shadow: 0 4px 12px var(--color-overlay-black-25); z-index: 10000; transform-origin: top left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -470,8 +501,8 @@ } &.is-danger:hover:not(:disabled) { - color: var(--color-error, #ef4444); - background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 10%, transparent); } } @@ -568,8 +599,8 @@ background $motion-fast $easing-standard; &.confirm:hover { - color: var(--color-success, #22c55e); - background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent); + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 12%, transparent); } &.cancel:hover { @@ -584,7 +615,7 @@ border-radius: 50%; &.is-running { - color: var(--color-success, #4caf50); + color: var(--color-success); fill: currentColor; } @@ -621,13 +652,13 @@ margin-left: auto; &.is-healthy { - background: var(--color-success, #4caf50); - box-shadow: 0 0 4px color-mix(in srgb, var(--color-success, #4caf50) 40%, transparent); + background: var(--color-success); + box-shadow: 0 0 4px color-mix(in srgb, var(--color-success) 40%, transparent); } &.is-error { - background: var(--color-warning, #f59e0b); - box-shadow: 0 0 4px color-mix(in srgb, var(--color-warning, #f59e0b) 40%, transparent); + background: var(--color-warning); + box-shadow: 0 0 4px color-mix(in srgb, var(--color-warning) 40%, transparent); } } @@ -639,7 +670,7 @@ // Unhealthy MCP server indicator &__inline-item.is-unhealthy { .bitfun-nav-panel__inline-item-icon { - color: var(--color-warning, #f59e0b); + color: var(--color-warning); opacity: 0.9; } } @@ -655,6 +686,34 @@ } } +@keyframes bitfun-nav-background-subagent-bot-cycle { + 0%, + 62%, + 100% { + opacity: 1; + transform: scale(1); + } + + 70%, + 88% { + opacity: 0; + transform: scale(0.82); + } +} + +@keyframes bitfun-nav-background-subagent-loader-cycle { + 0%, + 62%, + 100% { + opacity: 0; + } + + 70%, + 88% { + opacity: 1; + } +} + @media (prefers-reduced-motion: reduce) { .bitfun-nav-panel { &__inline-item, @@ -672,6 +731,18 @@ animation: none; } + .bitfun-nav-panel__inline-item-background-subagent-icon { + animation: none; + + &.is-bot { + opacity: 1; + } + + &.is-loader { + opacity: 0; + } + } + .bitfun-nav-panel__items--session-blocks .bitfun-nav-panel__inline-item:hover .bitfun-nav-panel__inline-item-icon:not(.is-running) { transform: scale(1); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 1187a8d592..e38341e4b5 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -5,9 +5,9 @@ * Owns all data fetching / mutation for chat sessions. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3 } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -22,6 +22,10 @@ import { openMainSession, selectActiveBtwSessionTab, } from '@/flow_chat/services/openBtwSession'; +import { + dispatchHistorySessionOpenIntent, + shouldShowHistorySessionOpenIntent, +} from '@/flow_chat/services/sessionOpenIntent'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { compareSessionsForNavStable, @@ -36,17 +40,36 @@ import { deriveSessionReviewActivity, isReviewActivityBlocking, } from '@/flow_chat/utils/sessionReviewActivity'; +import { useBackgroundSubagentActivityStore } from '@/flow_chat/store/backgroundSubagentActivityStore'; +import type { + BackgroundSubagentActivity, + BackgroundSubagentActivityItem, +} from '@/flow_chat/utils/backgroundSubagentActivity'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; +import { scheduleAfterStartupPaint, scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; +import { + SESSION_METADATA_DEFERRED_FALLBACK_MS, + SESSION_METADATA_DEFERRED_FRAME_COUNT, + SESSION_METADATA_DEFERRED_SIGNAL, + getDeferredSessionMetadataDelayMs, + getInitialSessionMetadataLoadMode, + hasStartupOverlayHandedOff, +} from './sessionMetadataStartup'; +import { + getEffectiveTopLevelSessionCount, + getSessionExpandToggleState, + SESSIONS_LEVEL_0, + SESSIONS_LEVEL_1, +} from './sessionNavExpand'; import './SessionsSection.scss'; -/** Top-level parent sessions shown at each expand step (children still nest under visible parents). */ -const SESSIONS_LEVEL_0 = 5; -const SESSIONS_LEVEL_1 = 10; const log = createLogger('SessionsSection'); +const ScheduledJobsModal = lazy(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); type SessionMode = 'code' | 'cowork' | 'claw'; +type HistoryOpenIntentDispatchResult = 'none' | 'dispatched' | 'already-pending'; const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -61,6 +84,32 @@ const resolveSessionModeType = (session: Session): SessionMode => { const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); +const countTopLevelSessionsInScope = ( + sessions: Iterable, + workspacePath?: string, + remoteConnectionId?: string | null, + remoteSshHost?: string | null +): number => { + const scopedSessions = Array.from(sessions).filter((session: Session) => { + if (session.isTransient || session.sessionKind === 'subagent') { + return false; + } + if (workspacePath) { + return sessionBelongsToWorkspaceNavRow(session, workspacePath, remoteConnectionId, remoteSshHost); + } + return !session.workspacePath; + }); + + const knownIds = new Set(scopedSessions.map(session => session.sessionId)); + return scopedSessions.reduce((count, session) => { + const parentSessionId = resolveSessionRelationship(session).parentSessionId; + if (parentSessionId && knownIds.has(parentSessionId)) { + return count; + } + return count + 1; + }, 0); +}; + const getChildSessionBadge = (kind: Session['sessionKind']): string => { const normalizedKind = kind === 'review' || kind === 'deep_review' || kind === 'subagent' @@ -110,7 +159,7 @@ const SessionsSection: React.FC = ({ workspacePath, remoteConnectionId = null, remoteSshHost = null, - isActiveWorkspace: _isActiveWorkspace = true, + isActiveWorkspace = true, assistantLabel, showSessionModeIcon = true, isVisible = true, @@ -125,16 +174,19 @@ const SessionsSection: React.FC = ({ const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() ); + const backgroundSubagentActivities = useBackgroundSubagentActivityStore(state => state.activities); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [expandLevel, setExpandLevel] = useState<0 | 1 | 2>(0); const [metadataPageState, setMetadataPageState] = useState<{ totalTopLevelCount: number | null; + syncedTopLevelCount: number | null; nextCursor?: string; hasMore: boolean; isLoading: boolean; }>({ totalTopLevelCount: null, + syncedTopLevelCount: null, nextCursor: undefined, hasMore: false, isLoading: false, @@ -142,10 +194,12 @@ const SessionsSection: React.FC = ({ const [openMenuSessionId, setOpenMenuSessionId] = useState(null); const [sessionMenuPosition, setSessionMenuPosition] = useState<{ top: number; left: number } | null>(null); const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); + const [scheduledJobsSessionId, setScheduledJobsSessionId] = useState(null); const editInputRef = useRef(null); const sessionMenuPopoverRef = useRef(null); const sessionMenuAnchorRef = useRef(null); const metadataLoadRequestIdRef = useRef(0); + const initialMetadataLoadKeyRef = useRef(null); // Subscribe to state machine changes for running status useEffect(() => { @@ -172,10 +226,49 @@ const SessionsSection: React.FC = ({ }, [flowChatState.sessions]); useEffect(() => { - const unsub = flowChatStore.subscribe(s => setFlowChatState(s)); + const selector = (s: FlowChatState): string => { + const parts: string[] = [s.activeSessionId ?? '']; + for (const session of s.sessions.values()) { + const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + parts.push( + `${session.sessionId}|${session.isTransient ? '1':'0'}|${session.sessionKind}|` + + `${session.parentSessionId ?? ''}|${session.parentToolCallId ?? ''}|${session.subagentType ?? ''}|` + + `${session.workspacePath ?? ''}|${session.mode ?? ''}|${session.needsUserAttention ? '1':'0'}|` + + `${session.hasUnreadCompletion ? '1':'0'}|${latestTurn?.status ?? ''}|${session.title ?? ''}` + ); + } + return parts.join(';'); + }; + const unsub = flowChatStore.subscribeSelector(selector, (() => { + setFlowChatState(flowChatStore.getState()); + }), { isEqual: (a, b) => a === b }); return () => unsub(); }, []); + const backgroundSubagentActivityByParent = useMemo(() => { + const itemsByParent = new Map(); + for (const item of Object.values(backgroundSubagentActivities)) { + const items = itemsByParent.get(item.parentSessionId) ?? []; + items.push(item); + itemsByParent.set(item.parentSessionId, items); + } + + const activityByParent = new Map(); + for (const [parentSessionId, items] of itemsByParent) { + const sortedItems = [...items].sort((left, right) => ( + left.createdAt - right.createdAt || left.sessionId.localeCompare(right.sessionId) + )); + activityByParent.set(parentSessionId, { + runningCount: sortedItems.filter(item => item.status === 'processing').length, + finishingCount: sortedItems.filter(item => item.status === 'finishing').length, + totalCount: sortedItems.length, + items: sortedItems, + }); + } + + return activityByParent; + }, [backgroundSubagentActivities]); + useEffect(() => { if (editingSessionId && editInputRef.current) { editInputRef.current.focus(); @@ -185,9 +278,11 @@ const SessionsSection: React.FC = ({ useEffect(() => { metadataLoadRequestIdRef.current += 1; + initialMetadataLoadKeyRef.current = null; setExpandLevel(0); setMetadataPageState({ totalTopLevelCount: null, + syncedTopLevelCount: null, nextCursor: undefined, hasMore: false, isLoading: false, @@ -214,8 +309,15 @@ const SessionsSection: React.FC = ({ source ); if (metadataLoadRequestIdRef.current === requestId) { + const syncedTopLevelCount = countTopLevelSessionsInScope( + flowChatStore.getState().sessions.values(), + workspacePath, + remoteConnectionId, + remoteSshHost + ); setMetadataPageState({ totalTopLevelCount: page.totalTopLevelCount, + syncedTopLevelCount, nextCursor: page.nextCursor, hasMore: page.hasMore, isLoading: false, @@ -233,13 +335,119 @@ const SessionsSection: React.FC = ({ [workspacePath, remoteConnectionId, remoteSshHost] ); + const initialMetadataKey = useMemo( + () => [ + workspacePath ?? '', + remoteConnectionId ?? '', + remoteSshHost ?? '', + ].join('\n'), + [workspacePath, remoteConnectionId, remoteSshHost], + ); + + const loadInitialMetadataPage = useCallback( + async (source: string) => { + if (!workspacePath) { + return; + } + if (initialMetadataLoadKeyRef.current === initialMetadataKey) { + return; + } + + initialMetadataLoadKeyRef.current = initialMetadataKey; + const page = await loadMetadataPage(SESSIONS_LEVEL_0, undefined, source); + if (!page && initialMetadataLoadKeyRef.current === initialMetadataKey) { + initialMetadataLoadKeyRef.current = null; + } + }, + [initialMetadataKey, loadMetadataPage, workspacePath], + ); + useEffect(() => { if (!isVisible || !workspacePath) { return; } - void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_initial'); - }, [isVisible, workspacePath, remoteConnectionId, remoteSshHost, loadMetadataPage]); + const loadMode = getInitialSessionMetadataLoadMode({ + hasWorkspacePath: Boolean(workspacePath), + isActiveWorkspace, + isVisible, + startupOverlayHandedOff: hasStartupOverlayHandedOff(), + }); + + if (loadMode === 'skip') { + return; + } + + if (loadMode === 'immediate') { + void loadInitialMetadataPage('sessions_nav_initial_active'); + return; + } + + let cancelled = false; + let delayTimer: number | null = null; + const scheduleDeferredMetadataLoad = () => { + if (cancelled) { + return; + } + const delayMs = getDeferredSessionMetadataDelayMs(workspaceId ?? workspacePath); + const runDeferredLoad = () => { + delayTimer = null; + if (!cancelled) { + void loadInitialMetadataPage('sessions_nav_initial_deferred'); + } + }; + + if (delayMs > 0) { + delayTimer = window.setTimeout(runDeferredLoad, delayMs); + return; + } + runDeferredLoad(); + }; + const cancelStartupSchedule = loadMode === 'after-startup-paint' + ? scheduleAfterStartupPaint(scheduleDeferredMetadataLoad, { + frameCount: SESSION_METADATA_DEFERRED_FRAME_COUNT, + }) + : scheduleAfterStartupSignal(scheduleDeferredMetadataLoad, { + signalName: SESSION_METADATA_DEFERRED_SIGNAL, + fallbackTimeoutMs: SESSION_METADATA_DEFERRED_FALLBACK_MS, + frameCount: SESSION_METADATA_DEFERRED_FRAME_COUNT, + }); + + return () => { + cancelled = true; + cancelStartupSchedule(); + if (delayTimer !== null) { + window.clearTimeout(delayTimer); + } + }; + }, [ + isActiveWorkspace, + isVisible, + loadInitialMetadataPage, + workspaceId, + workspacePath, + ]); + + // When sessions are archived, reset stale metadata so the expand toggle + // doesn't linger with old counts after all sessions are gone. + useEffect(() => { + const handler = () => { + metadataLoadRequestIdRef.current += 1; + setExpandLevel(0); + setMetadataPageState({ + totalTopLevelCount: null, + syncedTopLevelCount: null, + nextCursor: undefined, + hasMore: false, + isLoading: false, + }); + if (isVisible && workspacePath) { + void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_post_archive'); + } + }; + window.addEventListener('bitfun:session-archived', handler); + return () => window.removeEventListener('bitfun:session-archived', handler); + }, [isVisible, workspacePath, loadMetadataPage]); useEffect(() => { if (!openMenuSessionId) return; @@ -358,9 +566,37 @@ const SessionsSection: React.FC = ({ return SESSIONS_LEVEL_0; }, [topLevelSessions.length, expandLevel]); - const totalTopLevelSessionCount = metadataPageState.totalTopLevelCount ?? topLevelSessions.length; - const hasMoreUnloadedSessions = - metadataPageState.hasMore || topLevelSessions.length < totalTopLevelSessionCount; + const totalTopLevelSessionCount = getEffectiveTopLevelSessionCount( + metadataPageState.totalTopLevelCount, + metadataPageState.syncedTopLevelCount, + topLevelSessions.length, + metadataPageState.isLoading + ); + const hasMoreUnloadedSessions = topLevelSessions.length < totalTopLevelSessionCount; + const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); + + useEffect(() => { + if ( + !isVisible || + !workspacePath || + metadataPageState.isLoading || + metadataPageState.totalTopLevelCount === null || + metadataPageState.syncedTopLevelCount === null || + topLevelSessions.length === metadataPageState.syncedTopLevelCount + ) { + return; + } + + void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_live_reconcile'); + }, [ + isVisible, + loadMetadataPage, + metadataPageState.isLoading, + metadataPageState.syncedTopLevelCount, + metadataPageState.totalTopLevelCount, + topLevelSessions.length, + workspacePath, + ]); const visibleItems = useMemo(() => { const visibleParents = topLevelSessions.slice(0, sessionDisplayLimit); @@ -374,12 +610,51 @@ const SessionsSection: React.FC = ({ }, [childrenByParent, sessionDisplayLimit, topLevelSessions]); const activeSessionId = flowChatState.activeSessionId; + const scheduledJobsSession = scheduledJobsSessionId + ? flowChatState.sessions.get(scheduledJobsSessionId) ?? null + : null; + const lastHistoryOpenIntentRef = useRef<{ sessionId: string; atMs: number } | null>(null); + + const dispatchHistoryOpenIntentForSession = useCallback( + (session: Session): HistoryOpenIntentDispatchResult => { + const sessionId = session.sessionId; + if ( + sessionId === activeSessionId || + !shouldShowHistorySessionOpenIntent(session, { + isRunning: runningSessionIds.has(sessionId), + }) + ) { + return 'none'; + } + + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const lastIntent = lastHistoryOpenIntentRef.current; + if ( + lastIntent && + lastIntent.sessionId === sessionId && + now - lastIntent.atMs < 250 + ) { + return 'already-pending'; + } + + lastHistoryOpenIntentRef.current = { sessionId, atMs: now }; + dispatchHistorySessionOpenIntent(sessionId, getTitle(session)); + return 'dispatched'; + }, + [activeSessionId, runningSessionIds], + ); const handleSwitch = useCallback( async (sessionId: string) => { if (editingSessionId) return; try { const session = flowChatStore.getState().sessions.get(sessionId); + const historyOpenIntentDispatch = session + ? dispatchHistoryOpenIntentForSession(session) + : 'none'; + if (session && historyOpenIntentDispatch !== 'none') { + flowChatManager.preloadHistoricalSessionForOpen(sessionId); + } const relationship = resolveSessionRelationship(session); const parentSessionId = relationship.parentSessionId; const mustActivateWorkspace = @@ -424,6 +699,7 @@ const SessionsSection: React.FC = ({ }, [ activeSessionId, + dispatchHistoryOpenIntentForSession, editingSessionId, setActiveWorkspace, workspaceId, @@ -431,6 +707,28 @@ const SessionsSection: React.FC = ({ ] ); + const handleSessionOpenPointerDown = useCallback( + (event: React.PointerEvent, session: Session) => { + if (editingSessionId || session.sessionId === activeSessionId) { + return; + } + if (event.button !== 0) { + return; + } + + const target = event.target as HTMLElement | null; + if (target?.closest('.bitfun-nav-panel__inline-item-actions, .bitfun-nav-panel__inline-item-edit')) { + return; + } + + const historyOpenIntentDispatch = dispatchHistoryOpenIntentForSession(session); + if (historyOpenIntentDispatch !== 'none') { + flowChatManager.preloadHistoricalSessionForOpen(session.sessionId); + } + }, + [activeSessionId, dispatchHistoryOpenIntentForSession, editingSessionId], + ); + const resolveSessionTitle = useCallback( (session: Session): string => { const rawTitle = getTitle(session); @@ -638,13 +936,20 @@ const SessionsSection: React.FC = ({ : null; const sessionModeKey = resolveSessionModeType(session); const sessionTitle = resolveSessionTitle(session); + const isRunning = runningSessionIds.has(session.sessionId); + const isHighPriority = !!session.needsUserAttention; + const backgroundSubagentActivity = !isChildSession + ? backgroundSubagentActivityByParent.get(session.sessionId) + : undefined; + const backgroundSubagentActivityCount = backgroundSubagentActivity?.totalCount ?? 0; + const showBackgroundSubagentActivity = !isChildSession && backgroundSubagentActivityCount > 0; const parentSessionId = relationship.parentSessionId; const parentSession = parentSessionId ? flowChatState.sessions.get(parentSessionId) : undefined; const parentTitle = parentSession ? resolveSessionTitle(parentSession) : ''; const parentTurnIndex = relationship.origin?.parentTurnIndex; const trimmedAssistant = assistantLabel?.trim() ?? ''; const showAssistantInTooltip = trimmedAssistant.length > 0; - const showRichTooltip = showAssistantInTooltip || isChildSession; + const showRichTooltip = showAssistantInTooltip || isChildSession || showBackgroundSubagentActivity; const tooltipContent = showRichTooltip ? (
{sessionTitle}
@@ -662,9 +967,26 @@ const SessionsSection: React.FC = ({ }) : t('nav.sessions.childSourceWithoutTurn', { parentTitle: parentTitle || t('nav.sessions.parentSession'), - })} + })}
) : null} + {showBackgroundSubagentActivity && backgroundSubagentActivity ? ( + <> +
+ {t('nav.sessions.backgroundSubagentsRunning', { + count: backgroundSubagentActivityCount, + })} +
+ {backgroundSubagentActivity.items.length > 0 ? ( +
+ {backgroundSubagentActivity.items + .slice(0, 2) + .map(item => item.title) + .join(' · ')} +
+ ) : null} + + ) : null}
) : ( sessionTitle @@ -677,7 +999,6 @@ const SessionsSection: React.FC = ({ ? Panda : Bot : Code2; - const isRunning = runningSessionIds.has(session.sessionId); const isRowActive = isSessionNavRowActive({ rowSessionId: session.sessionId, activeTabId, @@ -690,7 +1011,6 @@ const SessionsSection: React.FC = ({ const attentionKind = !isRunning && !isRowActive ? (session.needsUserAttention || session.hasUnreadCompletion || undefined) : undefined; - const isHighPriority = !!session.needsUserAttention; const row = (
= ({ data-testid="session-nav-item" data-session-id={session.sessionId} data-session-title={sessionTitle} + onPointerDown={event => handleSessionOpenPointerDown(event, session)} onClick={() => handleSwitch(session.sessionId)} > {showSessionModeIcon ? ( @@ -810,6 +1131,25 @@ const SessionsSection: React.FC = ({ {getReviewActivityBadge(reviewActivityKind)} ) : null} + {showBackgroundSubagentActivity ? ( + + + + + ) : null}
= ({ {t('nav.sessions.rename')} + )} + + {scheduledJobsSession && ( + + setScheduledJobsSessionId(null)} + workspacePath={scheduledJobsSession.workspacePath || workspacePath} + workspaceId={scheduledJobsSession.workspaceId || workspaceId} + remoteConnectionId={scheduledJobsSession.remoteConnectionId || remoteConnectionId} + remoteSshHost={scheduledJobsSession.remoteSshHost || remoteSshHost} + sessionId={scheduledJobsSession.sessionId} + targetKind="session" + lockSessionId + title={t('nav.scheduledJobs.title')} + targetLabel={resolveSessionTitle(scheduledJobsSession)} + targetDescription={scheduledJobsSession.workspacePath || workspacePath} + /> + + )}
); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts index 2885cb63a6..b7e16751fa 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts @@ -76,17 +76,22 @@ describe('SessionsSection layout styles', () => { const labelBlock = extractInlineItemBlock(stylesheet, 'label'); const btwBadgeBlock = extractInlineItemBlock(stylesheet, 'btw-badge'); const reviewBadgeBlock = extractInlineItemBlock(stylesheet, 'review-badge'); + const backgroundSubagentBadgeBlock = extractInlineItemBlock(stylesheet, 'background-subagent-badge'); expect(labelBlock).toContain('flex: 1 1 0;'); expect(labelBlock).toContain('overflow: hidden;'); expect(labelBlock).toContain('text-overflow: ellipsis;'); expect(btwBadgeBlock).toContain('white-space: nowrap;'); expect(btwBadgeBlock).toContain('overflow: visible;'); - expect(btwBadgeBlock).toContain('color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 62%, var(--color-text-primary));'); + expect(btwBadgeBlock).toContain('color: color-mix(in srgb, var(--color-accent-400) 62%, var(--color-text-primary));'); expect(btwBadgeBlock).toContain('font-weight: 600;'); expect(btwBadgeBlock).toContain('opacity: 0.96;'); expect(reviewBadgeBlock).toContain('white-space: nowrap;'); - expect(reviewBadgeBlock).toContain('color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 82%, var(--color-text-primary));'); + expect(reviewBadgeBlock).toContain('color: color-mix(in srgb, var(--color-accent-400) 82%, var(--color-text-primary));'); expect(reviewBadgeBlock).toContain('font-weight: 600;'); + expect(backgroundSubagentBadgeBlock).toContain('flex: 0 0 auto;'); + expect(backgroundSubagentBadgeBlock).toContain('display: inline-grid;'); + expect(backgroundSubagentBadgeBlock).toContain('width: 16px;'); + expect(backgroundSubagentBadgeBlock).toContain('height: 16px;'); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionMetadataStartup.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionMetadataStartup.test.ts new file mode 100644 index 0000000000..a9422a7ea9 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionMetadataStartup.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + SESSION_METADATA_DEFERRED_FALLBACK_MS, + SESSION_METADATA_DEFERRED_FRAME_COUNT, + SESSION_METADATA_DEFERRED_SIGNAL, + getDeferredSessionMetadataDelayMs, + getInitialSessionMetadataLoadMode, + hasStartupOverlayHandedOff, +} from './sessionMetadataStartup'; + +describe('session metadata startup scheduling', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('chooses the startup gate for each initial metadata load path', () => { + expect(getInitialSessionMetadataLoadMode({ + hasWorkspacePath: false, + isActiveWorkspace: true, + isVisible: true, + startupOverlayHandedOff: false, + })).toBe('skip'); + + expect(getInitialSessionMetadataLoadMode({ + hasWorkspacePath: true, + isActiveWorkspace: true, + isVisible: true, + startupOverlayHandedOff: false, + })).toBe('immediate'); + + expect(getInitialSessionMetadataLoadMode({ + hasWorkspacePath: true, + isActiveWorkspace: false, + isVisible: true, + startupOverlayHandedOff: false, + })).toBe('after-startup-signal'); + + expect(getInitialSessionMetadataLoadMode({ + hasWorkspacePath: true, + isActiveWorkspace: false, + isVisible: true, + startupOverlayHandedOff: true, + })).toBe('after-startup-paint'); + + expect(getInitialSessionMetadataLoadMode({ + hasWorkspacePath: true, + isActiveWorkspace: false, + isVisible: false, + startupOverlayHandedOff: true, + })).toBe('skip'); + }); + + it('keeps deferred metadata tied to the startup overlay handoff', () => { + expect(SESSION_METADATA_DEFERRED_SIGNAL).toBe('bitfun:startup-overlay-hidden'); + expect(SESSION_METADATA_DEFERRED_FALLBACK_MS).toBe(10000); + expect(SESSION_METADATA_DEFERRED_FRAME_COUNT).toBe(1); + }); + + it('uses a stable bounded stagger for background workspace metadata', () => { + const first = getDeferredSessionMetadataDelayMs('D:\\workspace\\BitFun'); + const second = getDeferredSessionMetadataDelayMs('D:\\workspace\\BitFun'); + const other = getDeferredSessionMetadataDelayMs('D:\\workspace\\Other'); + + expect(first).toBe(second); + expect(first).toBeGreaterThanOrEqual(0); + expect(first).toBeLessThanOrEqual(120); + expect(other).toBeGreaterThanOrEqual(0); + expect(other).toBeLessThanOrEqual(120); + expect(getDeferredSessionMetadataDelayMs()).toBe(0); + }); + + it('detects when a late-mounted section has already missed the overlay handoff event', () => { + vi.stubGlobal('document', { + getElementById: vi.fn(() => ({})), + }); + expect(hasStartupOverlayHandedOff()).toBe(false); + + vi.stubGlobal('document', { + getElementById: vi.fn(() => null), + }); + expect(hasStartupOverlayHandedOff()).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionMetadataStartup.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionMetadataStartup.ts new file mode 100644 index 0000000000..89277eb127 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionMetadataStartup.ts @@ -0,0 +1,55 @@ +import { STARTUP_OVERLAY_HIDDEN_EVENT } from '@/app/startup/startupSignals'; +import { isStartupOverlayPresent } from '@/app/startup/startupOverlay'; + +export const SESSION_METADATA_DEFERRED_SIGNAL = STARTUP_OVERLAY_HIDDEN_EVENT; +export const SESSION_METADATA_DEFERRED_FALLBACK_MS = 10000; +export const SESSION_METADATA_DEFERRED_FRAME_COUNT = 1; + +export type InitialSessionMetadataLoadMode = + | 'skip' + | 'immediate' + | 'after-startup-signal' + | 'after-startup-paint'; + +export function getInitialSessionMetadataLoadMode({ + hasWorkspacePath, + isActiveWorkspace, + isVisible, + startupOverlayHandedOff, +}: { + hasWorkspacePath: boolean; + isActiveWorkspace: boolean; + isVisible: boolean; + startupOverlayHandedOff: boolean; +}): InitialSessionMetadataLoadMode { + if (!hasWorkspacePath || !isVisible) { + return 'skip'; + } + + if (isActiveWorkspace) { + return 'immediate'; + } + + return startupOverlayHandedOff ? 'after-startup-paint' : 'after-startup-signal'; +} + +export function getDeferredSessionMetadataDelayMs(workspaceKey?: string | null): number { + const normalized = workspaceKey?.trim(); + if (!normalized) { + return 0; + } + + let hash = 0; + for (let index = 0; index < normalized.length; index += 1) { + hash = (hash * 31 + normalized.charCodeAt(index)) >>> 0; + } + + return (hash % 4) * 40; +} + +export function hasStartupOverlayHandedOff(): boolean { + if (typeof document === 'undefined') { + return true; + } + return !isStartupOverlayPresent(); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts new file mode 100644 index 0000000000..8f9b3f5eb4 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + getEffectiveTopLevelSessionCount, + getSessionExpandToggleState, +} from './sessionNavExpand'; + +describe('getSessionExpandToggleState', () => { + it('hides the toggle when five or fewer top-level sessions are available', () => { + expect(getSessionExpandToggleState(1, 0)).toMatchObject({ + collapsedRemainingCount: 0, + shouldRender: false, + }); + expect(getSessionExpandToggleState(5, 0)).toMatchObject({ + collapsedRemainingCount: 0, + shouldRender: false, + }); + }); + + it('shows the first expand step only when there are hidden top-level sessions', () => { + expect(getSessionExpandToggleState(6, 0)).toMatchObject({ + action: 'show-more', + collapsedRemainingCount: 1, + shouldRender: true, + }); + }); + + it('switches to show-all and show-less using the remaining top-level count', () => { + expect(getSessionExpandToggleState(12, 1)).toMatchObject({ + action: 'show-all', + expandedRemainingCount: 2, + shouldRender: true, + }); + expect(getSessionExpandToggleState(6, 1)).toMatchObject({ + action: 'show-less', + expandedRemainingCount: 0, + shouldRender: true, + }); + expect(getSessionExpandToggleState(12, 2)).toMatchObject({ + action: 'show-less', + shouldRender: true, + }); + }); +}); + +describe('getEffectiveTopLevelSessionCount', () => { + it('falls back to the live count before metadata is available', () => { + expect(getEffectiveTopLevelSessionCount(null, null, 3, false)).toBe(3); + }); + + it('keeps live creates visible even when metadata count is stale', () => { + expect(getEffectiveTopLevelSessionCount(1, 1, 6, false)).toBe(6); + }); + + it('reduces the total count when loaded sessions are deleted', () => { + expect(getEffectiveTopLevelSessionCount(6, 5, 4, false)).toBe(5); + expect(getEffectiveTopLevelSessionCount(6, 5, 3, false)).toBe(4); + }); + + it('avoids overcorrecting while a metadata refresh is still loading', () => { + expect(getEffectiveTopLevelSessionCount(12, 5, 10, true)).toBe(12); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts new file mode 100644 index 0000000000..a9722a4394 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts @@ -0,0 +1,52 @@ +export const SESSIONS_LEVEL_0 = 5; +export const SESSIONS_LEVEL_1 = 10; + +export type SessionExpandLevel = 0 | 1 | 2; +export type SessionExpandToggleAction = 'show-more' | 'show-all' | 'show-less'; + +export interface SessionExpandToggleState { + action: SessionExpandToggleAction; + collapsedRemainingCount: number; + expandedRemainingCount: number; + shouldRender: boolean; +} + +export function getEffectiveTopLevelSessionCount( + metadataTotalTopLevelCount: number | null, + syncedTopLevelCount: number | null, + liveTopLevelCount: number, + isMetadataLoading: boolean +): number { + if (metadataTotalTopLevelCount === null) { + return liveTopLevelCount; + } + + if (isMetadataLoading || syncedTopLevelCount === null) { + return Math.max(metadataTotalTopLevelCount, liveTopLevelCount); + } + + return Math.max( + liveTopLevelCount, + metadataTotalTopLevelCount + (liveTopLevelCount - syncedTopLevelCount) + ); +} + +export function getSessionExpandToggleState( + totalTopLevelSessionCount: number, + expandLevel: SessionExpandLevel +): SessionExpandToggleState { + const collapsedRemainingCount = Math.max(totalTopLevelSessionCount - SESSIONS_LEVEL_0, 0); + const expandedRemainingCount = Math.max(totalTopLevelSessionCount - SESSIONS_LEVEL_1, 0); + + return { + action: + expandLevel === 0 + ? 'show-more' + : expandLevel === 1 && expandedRemainingCount > 0 + ? 'show-all' + : 'show-less', + collapsedRemainingCount, + expandedRemainingCount, + shouldRender: collapsedRemainingCount > 0, + }; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 843499d341..fe7f7d3051 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, Archive, Loader2 } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; @@ -14,11 +14,16 @@ import { import { useNavSceneStore } from '@/app/stores/navSceneStore'; import { useApp } from '@/app/hooks/useApp'; import { useGitBasicInfo } from '@/tools/git/hooks/useGitState'; +import { gitStateManager } from '@/tools/git/state/GitStateManager'; import { workspaceAPI } from '@/infrastructure/api'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { + getHistorySessionOpenTransitionSnapshot, + subscribeHistorySessionOpenTransition, +} from '@/flow_chat/services/sessionOpenIntent'; import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace'; import type { AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; import { loadWorkspaceAcpMenuClients } from './workspaceAcpMenuClients'; @@ -33,10 +38,17 @@ import { import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; import { useWorkspaceSearchIndex } from '@/tools/file-explorer'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; -import WorkspaceRelatedPathsDialog from './WorkspaceRelatedPathsDialog'; -import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; -import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; +import { scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; +import { + getWorkspaceGitBasicInfoOptions, + suppressWorkspaceGitRefreshOnMountDuringSessionTransition, + WORKSPACE_GIT_PENDING_CANCEL_REASONS, + WORKSPACE_GIT_PENDING_CANCEL_SOURCES, +} from './workspaceGitRefreshOptions'; +const WorkspaceRelatedPathsDialog = lazy(() => import('./WorkspaceRelatedPathsDialog')); +const WorkspaceSessionBatchModal = lazy(() => import('./WorkspaceSessionBatchModal')); +const ScheduledJobsModal = lazy(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); interface WorkspaceItemProps { workspace: WorkspaceInfo; @@ -75,7 +87,24 @@ const WorkspaceItem: React.FC = ({ } = useWorkspaceContext(); const { switchLeftPanelTab } = useApp(); const openNavScene = useNavSceneStore(s => s.openNavScene); - const { isRepository } = useGitBasicInfo(workspace.rootPath); + const historySessionOpenTransition = useSyncExternalStore( + subscribeHistorySessionOpenTransition, + getHistorySessionOpenTransitionSnapshot, + getHistorySessionOpenTransitionSnapshot + ); + const gitBasicInfoOptions = suppressWorkspaceGitRefreshOnMountDuringSessionTransition( + getWorkspaceGitBasicInfoOptions(workspace, isActive), + historySessionOpenTransition !== null + ); + const { + isRepository, + isLoading: isGitBasicInfoLoading, + state: gitBasicInfoState, + refreshBasic: refreshGitBasicInfo, + } = useGitBasicInfo( + workspace.rootPath, + gitBasicInfoOptions + ); const [menuOpen, setMenuOpen] = useState(false); const [worktreeModalOpen, setWorktreeModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -87,6 +116,8 @@ const WorkspaceItem: React.FC = ({ const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); + const [scheduledJobsModalOpen, setScheduledJobsModalOpen] = useState(false); + const [sessionBatchModalOpen, setSessionBatchModalOpen] = useState(false); const [workspaceSearchEnabled, setWorkspaceSearchEnabled] = useState( () => aiExperienceConfigService.getSettings().enable_workspace_search, ); @@ -109,6 +140,7 @@ const WorkspaceItem: React.FC = ({ : workspace.name; const isLinkedWorktree = isLinkedWorktreeWorkspace(workspace); const relatedPathCount = workspace.relatedPaths?.length ?? 0; + const workspaceIsRemote = isRemoteWorkspace(workspace); const canShowSearchIndex = isActive && workspaceSearchEnabled @@ -116,16 +148,64 @@ const WorkspaceItem: React.FC = ({ workspace.workspaceKind === WorkspaceKind.Normal || workspace.workspaceKind === WorkspaceKind.Remote ); + const shouldRefreshGitBasicInfoOnMenuOpen = + !isActive && + !workspaceIsRemote && + !gitBasicInfoState && + !isGitBasicInfoLoading; + const isWorktreeActionDisabled = isGitBasicInfoLoading || !isRepository; const workspaceSearchIndex = useWorkspaceSearchIndex({ workspacePath: canShowSearchIndex ? workspace.rootPath : undefined, enabled: canShowSearchIndex, }); useEffect(() => { - setWorkspaceSearchEnabled(aiExperienceConfigService.getSettings().enable_workspace_search); - return aiExperienceConfigService.addChangeListener(settings => { + if (!isActive || workspaceIsRemote) { + return; + } + + const cancelPendingAutoGitRefresh = () => { + if (getHistorySessionOpenTransitionSnapshot() === null) { + return; + } + + for (const reason of WORKSPACE_GIT_PENDING_CANCEL_REASONS) { + for (const source of WORKSPACE_GIT_PENDING_CANCEL_SOURCES) { + gitStateManager.cancelPendingRefresh(workspace.rootPath, { + layers: ['basic'], + reason, + source, + }); + } + } + }; + + cancelPendingAutoGitRefresh(); + return subscribeHistorySessionOpenTransition(cancelPendingAutoGitRefresh); + }, [isActive, workspace.rootPath, workspaceIsRemote]); + + useEffect(() => { + let cancelled = false; + let unsubscribeSettings: (() => void) | null = null; + const cancelStartupSchedule = scheduleAfterStartupSignal(async () => { + const settings = await aiExperienceConfigService.getSettingsAsync(); + if (cancelled) { + return; + } setWorkspaceSearchEnabled(settings.enable_workspace_search); + unsubscribeSettings = aiExperienceConfigService.addChangeListener(nextSettings => { + setWorkspaceSearchEnabled(nextSettings.enable_workspace_search); + }); + }, { + signalName: 'bitfun:interactive-shell-ready', + fallbackTimeoutMs: 10000, + frameCount: 1, }); + return () => { + cancelled = true; + cancelStartupSchedule(); + unsubscribeSettings?.(); + }; }, []); // Remote connection status — optional: safe if not inside SSHRemoteProvider @@ -287,6 +367,14 @@ const WorkspaceItem: React.FC = ({ requestAnimationFrame(apply); }, []); + const handleMenuTriggerClick = useCallback(() => { + const nextOpen = !menuOpen; + setMenuOpen(nextOpen); + if (nextOpen && shouldRefreshGitBasicInfoOnMenuOpen) { + void refreshGitBasicInfo(); + } + }, [menuOpen, refreshGitBasicInfo, shouldRefreshGitBasicInfoOnMenuOpen]); + useEffect(() => { if (!menuOpen) return; const handleOutside = (event: MouseEvent) => { @@ -387,36 +475,15 @@ const WorkspaceItem: React.FC = ({ } }, [closeWorkspaceById, t, workspace.id]); - const handleArchiveAllSessions = useCallback(async () => { + const handleOpenSessionBatchModal = useCallback(() => { setMenuOpen(false); - const confirmed = await confirmWarning( - t('nav.sessions.archiveAllConfirmTitle'), - t('nav.sessions.archiveAllConfirmMessage') - ); - if (!confirmed) return; - try { - const remoteWorkspace = isRemoteWorkspace(workspace); - await sessionAPI.archiveAllSessions( - workspace.rootPath, - remoteWorkspace ? workspace.connectionId : undefined, - remoteWorkspace ? workspace.sshHost : undefined - ); - // Remove all workspace sessions from in-memory state (disk files preserved as archived) - flowChatManager.discardLocalSessionsForWorkspace({ - id: workspace.id, - rootPath: workspace.rootPath, - connectionId: workspace.connectionId, - sshHost: workspace.sshHost, - }); - window.dispatchEvent(new CustomEvent('bitfun:session-archived')); - notificationService.success(t('nav.sessions.archivedAll', { count: 0 }), { duration: 3000 }); - } catch (error) { - notificationService.error( - error instanceof Error ? error.message : t('nav.sessions.archiveAllFailed'), - { duration: 4000 } - ); - } - }, [workspace, t]); + setSessionBatchModalOpen(true); + }, []); + + const handleOpenScheduledJobs = useCallback(() => { + setMenuOpen(false); + setScheduledJobsModalOpen(true); + }, []); const handleRequestDeleteAssistant = useCallback(() => { setMenuOpen(false); @@ -761,7 +828,7 @@ const WorkspaceItem: React.FC = ({ @@ -774,13 +841,21 @@ const WorkspaceItem: React.FC = ({ role="menu" style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }} > - -
- + +
+
); } @@ -1068,7 +1160,7 @@ const WorkspaceItem: React.FC = ({ @@ -1083,11 +1175,11 @@ const WorkspaceItem: React.FC = ({ > {acpClients.map(client => { const label = client.name || client.id; @@ -1132,6 +1224,14 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.manageRelatedPaths')} +
{isLinkedWorktree ? ( )} @@ -1179,10 +1279,10 @@ const WorkspaceItem: React.FC = ({
); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index b2b0d9a0a3..d76a7fbad9 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -470,7 +470,7 @@ svg { flex-shrink: 0; - color: var(--color-success, #22c55e); + color: var(--color-success); } span { @@ -487,21 +487,21 @@ border-radius: 50%; &.is-connected { - background: var(--color-success, #22c55e); + background: var(--color-success); } &.is-unknown, &.is-error, &.is-disconnected { - background: var(--color-error, #ef4444); + background: var(--color-error); } &.is-unknown { - background: var(--color-text-tertiary, #94a3b8); + background: var(--color-text-tertiary); } &.is-connecting { - background: var(--color-warning, #f59e0b); + background: var(--color-warning); animation: bitfun-status-dot-pulse 1.4s ease-in-out infinite; } } @@ -526,11 +526,11 @@ } &.is-green { - background: var(--color-success, #36c275); + background: var(--color-success); } &.is-yellow { - background: var(--color-warning, #e8b54b); + background: var(--color-warning); } &.is-gray { @@ -538,7 +538,7 @@ } &.is-red { - background: var(--color-error, #e05d5d); + background: var(--color-error); } } @@ -560,15 +560,15 @@ color: var(--color-text-primary); &.is-green { - --bitfun-index-tone: var(--color-success, #36c275); + --bitfun-index-tone: var(--color-success); } &.is-yellow { - --bitfun-index-tone: var(--color-warning, #e8b54b); + --bitfun-index-tone: var(--color-warning); } &.is-red { - --bitfun-index-tone: var(--color-error, #e05d5d); + --bitfun-index-tone: var(--color-error); } } @@ -599,11 +599,11 @@ 0 0 10px color-mix(in srgb, var(--bitfun-index-tone) 26%, transparent); &.is-green { - --bitfun-index-tone: var(--color-success, #36c275); + --bitfun-index-tone: var(--color-success); } &.is-yellow { - --bitfun-index-tone: var(--color-warning, #e8b54b); + --bitfun-index-tone: var(--color-warning); } &.is-gray { @@ -611,7 +611,7 @@ } &.is-red { - --bitfun-index-tone: var(--color-error, #e05d5d); + --bitfun-index-tone: var(--color-error); } } @@ -644,11 +644,11 @@ color: color-mix(in srgb, var(--bitfun-index-tone) 78%, var(--color-text-primary)); &.is-green { - --bitfun-index-tone: var(--color-success, #36c275); + --bitfun-index-tone: var(--color-success); } &.is-yellow { - --bitfun-index-tone: var(--color-warning, #e8b54b); + --bitfun-index-tone: var(--color-warning); } &.is-gray { @@ -656,7 +656,7 @@ } &.is-red { - --bitfun-index-tone: var(--color-error, #e05d5d); + --bitfun-index-tone: var(--color-error); } } @@ -722,19 +722,19 @@ box-shadow: 0 0 12px color-mix(in srgb, var(--bitfun-index-tone) 28%, transparent); &.is-green { - --bitfun-index-tone: var(--color-success, #36c275); + --bitfun-index-tone: var(--color-success); background: linear-gradient( 90deg, - color-mix(in srgb, var(--bitfun-index-tone) 82%, #fff 18%), + color-mix(in srgb, var(--bitfun-index-tone) 82%, var(--color-static-white) 18%), var(--bitfun-index-tone) ); } &.is-yellow { - --bitfun-index-tone: var(--color-warning, #e8b54b); + --bitfun-index-tone: var(--color-warning); background: linear-gradient( 90deg, - color-mix(in srgb, var(--bitfun-index-tone) 84%, #fff 16%), + color-mix(in srgb, var(--bitfun-index-tone) 84%, var(--color-static-white) 16%), var(--bitfun-index-tone) ); } @@ -743,16 +743,16 @@ --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); background: linear-gradient( 90deg, - color-mix(in srgb, var(--bitfun-index-tone) 88%, #fff 12%), + color-mix(in srgb, var(--bitfun-index-tone) 88%, var(--color-static-white) 12%), var(--bitfun-index-tone) ); } &.is-red { - --bitfun-index-tone: var(--color-error, #e05d5d); + --bitfun-index-tone: var(--color-error); background: linear-gradient( 90deg, - color-mix(in srgb, var(--bitfun-index-tone) 84%, #fff 16%), + color-mix(in srgb, var(--bitfun-index-tone) 84%, var(--color-static-white) 16%), var(--bitfun-index-tone) ); } @@ -772,16 +772,16 @@ color: var(--color-text-secondary); &.is-warning { - color: color-mix(in srgb, var(--color-warning, #e8b54b) 84%, var(--color-text-primary)); - border-color: color-mix(in srgb, var(--color-warning, #e8b54b) 18%, transparent); - background: color-mix(in srgb, var(--color-warning, #e8b54b) 10%, transparent); + color: color-mix(in srgb, var(--color-warning) 84%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--color-warning) 18%, transparent); + background: color-mix(in srgb, var(--color-warning) 10%, transparent); } } &__workspace-index-tooltip-error { - color: color-mix(in srgb, var(--color-error, #e05d5d) 88%, var(--color-text-primary)); - border-color: color-mix(in srgb, var(--color-error, #e05d5d) 22%, transparent); - background: color-mix(in srgb, var(--color-error, #e05d5d) 11%, transparent); + color: color-mix(in srgb, var(--color-error) 88%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--color-error) 22%, transparent); + background: color-mix(in srgb, var(--color-error) 11%, transparent); } &__workspace-index-tooltip-actions { @@ -889,9 +889,9 @@ overflow-y: auto; padding: $size-gap-1 0; border-radius: $size-radius-base; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); + box-shadow: 0 4px 12px var(--color-overlay-black-25); z-index: 10000; transform-origin: top left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -901,7 +901,7 @@ &__workspace-item-menu-divider { height: 1px; margin: $size-gap-1 $size-gap-2; - background: var(--border-subtle, rgba(255, 255, 255, 0.08)); + background: var(--border-subtle); } &__workspace-item-menu-item { @@ -948,8 +948,8 @@ } &.is-danger:hover:not(:disabled) { - color: var(--color-error, #ef4444); - background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 10%, transparent); } } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss index 2db04304ca..1b940fe710 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss @@ -168,9 +168,9 @@ transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; &:hover { - border-color: color-mix(in srgb, var(--color-error, #e05d5d) 20%, transparent); - background: color-mix(in srgb, var(--color-error, #e05d5d) 9%, transparent); - color: var(--color-error, #e05d5d); + border-color: color-mix(in srgb, var(--color-error) 20%, transparent); + background: color-mix(in srgb, var(--color-error) 9%, transparent); + color: var(--color-error); } } @@ -209,10 +209,10 @@ &__error { padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--color-error, #e05d5d) 22%, transparent); + border: 1px solid color-mix(in srgb, var(--color-error) 22%, transparent); border-radius: 10px; - background: color-mix(in srgb, var(--color-error, #e05d5d) 8%, transparent); - color: color-mix(in srgb, var(--color-error, #e05d5d) 88%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-error) 8%, transparent); + color: color-mix(in srgb, var(--color-error) 88%, var(--color-text-primary)); font-size: 12px; line-height: 1.45; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx index 66733e00f7..973bc26f8b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { open as openDirectoryDialog } from '@tauri-apps/plugin-dialog'; import { Button, Input, Modal, Textarea } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; @@ -126,6 +125,7 @@ export const WorkspaceRelatedPathsDialog: React.FC { try { + const { open: openDirectoryDialog } = await import('@tauri-apps/plugin-dialog'); const selected = await openDirectoryDialog({ directory: true, multiple: false, diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.scss new file mode 100644 index 0000000000..7cb528abf3 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.scss @@ -0,0 +1,366 @@ +@keyframes workspace-session-batch-modal-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.workspace-session-batch-modal { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-height: 0; + height: min(76vh, 820px); + max-height: 100%; + padding-block: 2px 4px; + color: var(--color-text-primary); + + &__content-shell { + overflow: hidden; + } + + &__hero { + flex-shrink: 0; + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 12px 14px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border-subtle) 80%, transparent); + border-radius: 14px; + background: + radial-gradient(circle at 0% 0%, color-mix(in srgb, var(--color-accent-500) 16%, transparent), transparent 34%), + linear-gradient(180deg, color-mix(in srgb, var(--element-bg-soft) 78%, transparent), color-mix(in srgb, var(--element-bg-subtle) 72%, transparent)); + + &::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-text-primary) 8%, transparent); + } + } + + &__hero-icon { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid color-mix(in srgb, var(--color-accent-500) 24%, transparent); + border-radius: 11px; + background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-subtle)); + color: var(--color-accent-500); + } + + &__hero-copy { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + } + + &__workspace { + font-size: 13px; + font-weight: 700; + line-height: 1.25; + color: var(--color-text-primary); + } + + &__description { + font-size: 11px; + line-height: 1.45; + color: var(--color-text-secondary); + max-width: 56ch; + } + + &__toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 8px 12px; + border: 1px solid color-mix(in srgb, var(--border-subtle) 86%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--element-bg-subtle) 80%, transparent); + } + + &__toolbar-main { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-width: 0; + min-height: var(--button-height-sm, 32px); + flex-wrap: nowrap; + } + + &__toolbar-actions { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } + + &__toolbar-summary { + min-width: 0; + flex: 1 1 180px; + font-size: 11px; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 6px; + padding-bottom: 10px; + border: 1px solid color-mix(in srgb, var(--border-subtle) 84%, transparent); + border-radius: 14px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--element-bg-soft) 78%, transparent), color-mix(in srgb, var(--element-bg-subtle) 74%, transparent)); + } + + &__state { + min-height: 240px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + color: var(--color-text-secondary); + text-align: center; + + &.is-error { + gap: 14px; + } + } + + &__spinner { + animation: workspace-session-batch-modal-spin 1s linear infinite; + } + + &__row { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--border-subtle) 72%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--element-bg-base) 72%, transparent); + cursor: pointer; + transition: + transform 0.16s ease, + border-color 0.16s ease, + background 0.16s ease, + box-shadow 0.16s ease; + + & + & { + margin-top: 6px; + } + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--border-medium) 74%, transparent); + background: color-mix(in srgb, var(--element-bg-soft) 74%, transparent); + box-shadow: 0 10px 18px color-mix(in srgb, #000 6%, transparent); + } + + &.is-selected { + border-color: color-mix(in srgb, var(--color-accent-500) 34%, transparent); + background: + linear-gradient(180deg, color-mix(in srgb, var(--color-accent-500) 9%, var(--element-bg-base)), color-mix(in srgb, var(--color-accent-500) 5%, var(--element-bg-soft))); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent-500) 12%, transparent); + } + + &.is-child { + margin-left: 18px; + width: calc(100% - 18px); + } + } + + &__row-check { + align-self: start; + padding-top: 2px; + } + + &__row-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border-subtle) 84%, transparent); + background: color-mix(in srgb, var(--element-bg-subtle) 76%, transparent); + color: var(--color-text-secondary); + flex-shrink: 0; + + &.is-code { + color: color-mix(in srgb, var(--color-accent-500) 76%, var(--color-text-primary)); + } + + &.is-cowork { + color: color-mix(in srgb, #0f8f74 80%, var(--color-text-primary)); + } + + &.is-claw { + color: color-mix(in srgb, #d97706 80%, var(--color-text-primary)); + } + } + + &__row-content { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + + &__row-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + } + + &__row-title { + min-width: 0; + font-size: 13px; + font-weight: 650; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: -0.01em; + } + + &__row-updated { + flex-shrink: 0; + font-size: 10px; + color: var(--color-text-muted); + white-space: nowrap; + } + + &__row-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + &__pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-subtle) 78%, transparent); + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 600; + line-height: 1; + + &.is-code { + background: color-mix(in srgb, var(--color-accent-500) 10%, transparent); + color: color-mix(in srgb, var(--color-accent-500) 78%, var(--color-text-primary)); + } + + &.is-cowork { + background: color-mix(in srgb, #0f8f74 11%, transparent); + color: color-mix(in srgb, #0f8f74 82%, var(--color-text-primary)); + } + + &.is-claw { + background: color-mix(in srgb, #d97706 11%, transparent); + color: color-mix(in srgb, #d97706 82%, var(--color-text-primary)); + } + } + + &__footer { + flex-shrink: 0; + position: sticky; + bottom: 0; + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 0 0; + background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--color-bg-base) 92%, transparent) 36%); + } + + .btn { + display: inline-flex; + align-items: center; + gap: 8px; + } +} + +@media (max-width: 720px) { + .workspace-session-batch-modal { + height: min(78vh, 680px); + + &__hero { + align-items: start; + } + + &__toolbar { + flex-direction: column; + align-items: stretch; + } + + &__toolbar-main { + flex-direction: column; + align-items: stretch; + width: auto; + min-height: 0; + } + + &__toolbar-summary { + white-space: normal; + } + + &__row { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + } + + &__row-icon { + grid-column: 2; + margin-top: 2px; + } + + &__row-content { + grid-column: 2; + } + + &__row-check { + grid-row: 1 / span 2; + } + + &__row-head { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + &__footer { + flex-direction: column-reverse; + align-items: stretch; + } + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx new file mode 100644 index 0000000000..0c0067dd14 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx @@ -0,0 +1,484 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Archive, Bot, ClipboardList, Code2, FolderKanban, Loader2, Trash2 } from 'lucide-react'; +import { Button, Checkbox, Modal } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import type { SessionMetadata } from '@/shared/types/session-history'; +import { sessionBelongsToWorkspaceNavRow, compareSessionMetadataForDisplay } from '@/flow_chat/utils/sessionOrdering'; +import { deriveSessionRelationshipFromMetadata, resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import './WorkspaceSessionBatchModal.scss'; + +interface WorkspaceSessionBatchModalProps { + isOpen: boolean; + onClose: () => void; + workspacePath: string; + workspaceLabel: string; + remoteConnectionId?: string | null; + remoteSshHost?: string | null; +} + +type BatchActionKind = 'archive' | 'delete' | null; + +interface SessionBatchItem { + metadata: SessionMetadata; + parentSessionId?: string | null; + displayAsChild: boolean; +} + +const log = createLogger('WorkspaceSessionBatchModal'); + +type SessionMode = 'code' | 'cowork' | 'claw'; + +function resolveSessionMode(agentType: string | undefined): SessionMode { + const normalized = agentType?.trim().toLowerCase() ?? ''; + if (normalized === 'cowork') { + return 'cowork'; + } + if (normalized === 'claw') { + return 'claw'; + } + return 'code'; +} + +function buildSessionBatchItems(sessions: SessionMetadata[]): SessionBatchItem[] { + const sortedSessions = [...sessions].sort(compareSessionMetadataForDisplay); + const knownIds = new Set(sortedSessions.map(session => session.sessionId)); + return sortedSessions.map(metadata => { + const relationship = resolveSessionRelationship(deriveSessionRelationshipFromMetadata(metadata)); + return { + metadata, + parentSessionId: relationship.parentSessionId, + displayAsChild: Boolean(relationship.parentSessionId && knownIds.has(relationship.parentSessionId)), + }; + }); +} + +function getDeletionPlan(selectedIds: Set, sessions: SessionBatchItem[]): { rootIds: string[]; allIds: string[] } { + const parentById = new Map(); + const childrenByParent = new Map(); + + sessions.forEach(session => { + const sessionId = session.metadata.sessionId; + parentById.set(sessionId, session.parentSessionId); + if (session.parentSessionId) { + const siblings = childrenByParent.get(session.parentSessionId) || []; + siblings.push(sessionId); + childrenByParent.set(session.parentSessionId, siblings); + } + }); + + const rootIds = Array.from(selectedIds).filter(sessionId => { + let cursor = parentById.get(sessionId); + while (cursor) { + if (selectedIds.has(cursor)) { + return false; + } + cursor = parentById.get(cursor); + } + return true; + }); + + const allIds = new Set(); + const stack = [...rootIds]; + while (stack.length > 0) { + const sessionId = stack.pop()!; + if (allIds.has(sessionId)) { + continue; + } + allIds.add(sessionId); + const children = childrenByParent.get(sessionId) || []; + children.forEach(childId => stack.push(childId)); + } + + return { + rootIds, + allIds: Array.from(allIds), + }; +} + +const WorkspaceSessionBatchModal: React.FC = ({ + isOpen, + onClose, + workspacePath, + workspaceLabel, + remoteConnectionId = null, + remoteSshHost = null, +}) => { + const { t, formatDate, formatRelativeTime } = useI18n('common'); + const [sessions, setSessions] = useState([]); + const [selectedSessionIds, setSelectedSessionIds] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [actionKind, setActionKind] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + + const loadSessions = useCallback(async () => { + setIsLoading(true); + setLoadFailed(false); + try { + const metadataList = await sessionAPI.listSessions( + workspacePath, + remoteConnectionId || undefined, + remoteSshHost || undefined + ); + const filtered = metadataList.filter(metadata => { + if (metadata.status === 'archived') { + return false; + } + if ( + !sessionBelongsToWorkspaceNavRow( + metadata, + workspacePath, + remoteConnectionId, + remoteSshHost + ) + ) { + return false; + } + const relationship = resolveSessionRelationship(deriveSessionRelationshipFromMetadata(metadata)); + return !relationship.isSubagent; + }); + setSessions(buildSessionBatchItems(filtered)); + setSelectedSessionIds(new Set()); + } catch (error) { + log.error('Failed to load workspace sessions for batch management', { error, workspacePath }); + setLoadFailed(true); + } finally { + setIsLoading(false); + } + }, [remoteConnectionId, remoteSshHost, workspacePath]); + + useEffect(() => { + if (!isOpen) { + setSelectedSessionIds(new Set()); + setActionKind(null); + setLoadFailed(false); + return; + } + void loadSessions(); + }, [isOpen, loadSessions]); + + const allSessionIds = useMemo( + () => sessions.map(session => session.metadata.sessionId), + [sessions] + ); + const selectedCount = selectedSessionIds.size; + const allSelected = allSessionIds.length > 0 && selectedCount === allSessionIds.length; + const partiallySelected = selectedCount > 0 && selectedCount < allSessionIds.length; + const isBusy = isLoading || actionKind !== null; + const hasSessions = sessions.length > 0; + + const toggleSessionSelection = useCallback((sessionId: string) => { + setSelectedSessionIds(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + const handleToggleSelectAll = useCallback(() => { + setSelectedSessionIds(prev => { + if (prev.size === allSessionIds.length) { + return new Set(); + } + return new Set(allSessionIds); + }); + }, [allSessionIds]); + + const handleInvertSelection = useCallback(() => { + setSelectedSessionIds(prev => new Set(allSessionIds.filter(sessionId => !prev.has(sessionId)))); + }, [allSessionIds]); + + const refreshWorkspaceSessions = useCallback(async () => { + await flowChatManager.refreshWorkspaceSessions({ + rootPath: workspacePath, + connectionId: remoteConnectionId || undefined, + sshHost: remoteSshHost || undefined, + }); + }, [remoteConnectionId, remoteSshHost, workspacePath]); + + const handleArchiveSelected = useCallback(async () => { + if (selectedCount === 0) { + return; + } + const confirmed = await confirmWarning( + t('nav.sessions.bulkArchiveConfirmTitle'), + t('nav.sessions.bulkArchiveConfirmMessage', { count: selectedCount }) + ); + if (!confirmed) { + return; + } + + const selectedIds = Array.from(selectedSessionIds); + setActionKind('archive'); + try { + const results = await Promise.allSettled( + selectedIds.map(sessionId => + sessionAPI.archiveSession( + sessionId, + workspacePath, + remoteConnectionId || undefined, + remoteSshHost || undefined + ) + ) + ); + const successCount = results.filter(result => result.status === 'fulfilled').length; + if (successCount > 0) { + selectedIds.forEach(sessionId => { + flowChatManager.discardLocalSession(sessionId); + }); + await refreshWorkspaceSessions(); + window.dispatchEvent(new CustomEvent('bitfun:session-archived')); + notificationService.success(t('nav.sessions.archivedAll', { count: successCount }), { duration: 3000 }); + } + if (successCount !== selectedIds.length) { + notificationService.error(t('nav.sessions.bulkArchiveFailed'), { duration: 4000 }); + } + await loadSessions(); + } catch (error) { + log.error('Failed to archive selected sessions', { error, workspacePath }); + notificationService.error(t('nav.sessions.bulkArchiveFailed'), { duration: 4000 }); + } finally { + setActionKind(null); + } + }, [ + loadSessions, + refreshWorkspaceSessions, + remoteConnectionId, + remoteSshHost, + selectedCount, + selectedSessionIds, + t, + workspacePath, + ]); + + const handleDeleteSelected = useCallback(async () => { + if (selectedCount === 0) { + return; + } + const confirmed = await confirmWarning( + t('nav.sessions.bulkDeleteConfirmTitle'), + t('nav.sessions.bulkDeleteConfirmMessage', { count: selectedCount }) + ); + if (!confirmed) { + return; + } + + const deletionPlan = getDeletionPlan(selectedSessionIds, sessions); + setActionKind('delete'); + try { + const results = await Promise.allSettled( + deletionPlan.allIds.map(sessionId => + sessionAPI.deleteSession( + sessionId, + workspacePath, + remoteConnectionId || undefined, + remoteSshHost || undefined + ) + ) + ); + const successIds = new Set(); + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successIds.add(deletionPlan.allIds[index]); + } + }); + + const removableRootIds = deletionPlan.rootIds.filter(rootId => { + const cascadeIds = getDeletionPlan(new Set([rootId]), sessions).allIds; + return cascadeIds.every(id => successIds.has(id)); + }); + + if (removableRootIds.length > 0) { + removableRootIds.forEach(sessionId => { + flowChatManager.discardLocalSession(sessionId); + }); + await refreshWorkspaceSessions(); + notificationService.success(t('nav.sessions.deletedSelected', { count: successIds.size }), { duration: 3000 }); + } + if (successIds.size !== deletionPlan.allIds.length) { + notificationService.error(t('nav.sessions.bulkDeleteFailed'), { duration: 4000 }); + } + await loadSessions(); + } catch (error) { + log.error('Failed to delete selected sessions', { error, workspacePath }); + notificationService.error(t('nav.sessions.bulkDeleteFailed'), { duration: 4000 }); + } finally { + setActionKind(null); + } + }, [ + loadSessions, + refreshWorkspaceSessions, + remoteConnectionId, + remoteSshHost, + selectedCount, + selectedSessionIds, + sessions, + t, + workspacePath, + ]); + + return ( + {} : onClose} + title={t('nav.sessions.manage')} + size="xlarge" + contentClassName="modal__content--fill-flex workspace-session-batch-modal__content-shell" + closeOnOverlayClick={!isBusy} + > +
+
+
+ +
+
+
{workspaceLabel}
+
+ {t('nav.sessions.batchManageDescription')} +
+
+
+ +
+
+ { handleToggleSelectAll(); }} + disabled={isBusy || allSessionIds.length === 0} + label={allSelected ? t('actions.deselectAll') : t('actions.selectAll')} + /> + {selectedCount > 0 ? ( +
+ +
+ ) : null} +
+ {hasSessions + ? t('nav.sessions.batchSelectionSummary', { count: selectedCount, total: sessions.length }) + : t('nav.sessions.noSessionsToManage')} +
+
+
+ +
+ {isLoading ? ( +
+ + {t('nav.sessions.loading')} +
+ ) : loadFailed ? ( +
+ {t('nav.sessions.batchLoadFailed')} + +
+ ) : sessions.length === 0 ? ( +
+ {t('nav.sessions.noSessionsToManage')} +
+ ) : ( + sessions.map(({ metadata, displayAsChild }) => { + const isSelected = selectedSessionIds.has(metadata.sessionId); + const sessionMode = resolveSessionMode(metadata.agentType); + const SessionIcon = + sessionMode === 'cowork' + ? ClipboardList + : sessionMode === 'claw' + ? Bot + : Code2; + return ( + + ); + }) + )} +
+ +
+ + + +
+
+
+ ); +}; + +export default WorkspaceSessionBatchModal; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts new file mode 100644 index 0000000000..748298577d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + WorkspaceKind, + WorkspaceType, + type WorkspaceInfo, +} from '@/shared/types/global-state'; +import { + getWorkspaceGitBasicInfoOptions, + suppressWorkspaceGitRefreshOnMountDuringSessionTransition, + WORKSPACE_GIT_PENDING_CANCEL_REASONS, + WORKSPACE_GIT_PENDING_CANCEL_SOURCES, +} from './workspaceGitRefreshOptions'; + +const createWorkspace = (workspaceKind: WorkspaceKind): WorkspaceInfo => ({ + id: `${workspaceKind}-workspace`, + name: 'BitFun', + rootPath: '/workspace/BitFun', + workspaceType: WorkspaceType.SingleProject, + workspaceKind, + languages: [], + openedAt: '2026-06-02T00:00:00Z', + lastAccessed: '2026-06-02T00:00:00Z', + tags: [], + ...(workspaceKind === WorkspaceKind.Remote + ? { + connectionId: 'remote-connection', + sshHost: 'remote.example.com', + } + : {}), +}); + +describe('getWorkspaceGitBasicInfoOptions', () => { + it('limits history-transition cancellation to passive workspace git auto refreshes', () => { + expect(WORKSPACE_GIT_PENDING_CANCEL_REASONS).toEqual(['mount', 'visibility']); + expect(WORKSPACE_GIT_PENDING_CANCEL_SOURCES).toEqual([ + 'workspace_item_git_basic_info', + 'workspace_git_initializer', + ]); + }); + + it('refreshes active local workspace rows on mount', () => { + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Normal), true)) + .toEqual({ + isActive: true, + refreshOnMount: true, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + debugSource: 'workspace_item_git_basic_info', + cancelPendingRefreshSources: ['workspace_git_initializer'], + }); + }); + + it('defers inactive local workspace row refresh until activation', () => { + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Normal), false)) + .toEqual({ + isActive: false, + refreshOnMount: false, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + debugSource: 'workspace_item_git_basic_info', + cancelPendingRefreshSources: ['workspace_git_initializer'], + }); + }); + + it('keeps remote workspace rows on the existing default git refresh behavior', () => { + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), false)) + .toBeUndefined(); + }); + + it('suppresses only mount refresh during a history session transition', () => { + const options = getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Normal), true); + + expect(suppressWorkspaceGitRefreshOnMountDuringSessionTransition(options, true)) + .toEqual({ + isActive: true, + refreshOnMount: false, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + debugSource: 'workspace_item_git_basic_info', + cancelPendingRefreshSources: ['workspace_git_initializer'], + }); + }); + + it('does not allocate replacement options outside history session transitions', () => { + const options = getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Normal), true); + + expect(suppressWorkspaceGitRefreshOnMountDuringSessionTransition(options, false)) + .toBe(options); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts new file mode 100644 index 0000000000..acf0f5d6fe --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts @@ -0,0 +1,42 @@ +import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; +import type { GitBasicInfoOptions } from '@/tools/git/hooks/useGitState'; + +export const WORKSPACE_GIT_BASIC_INFO_SOURCE = 'workspace_item_git_basic_info'; +const WORKSPACE_GIT_AUTO_REFRESH_SOURCES = ['workspace_git_initializer']; +export const WORKSPACE_GIT_PENDING_CANCEL_SOURCES = [ + WORKSPACE_GIT_BASIC_INFO_SOURCE, + ...WORKSPACE_GIT_AUTO_REFRESH_SOURCES, +] as const; +export const WORKSPACE_GIT_PENDING_CANCEL_REASONS = ['mount', 'visibility'] as const; + +export function getWorkspaceGitBasicInfoOptions( + workspace: WorkspaceInfo, + isActive: boolean +): GitBasicInfoOptions | undefined { + if (isRemoteWorkspace(workspace)) { + return undefined; + } + + return { + isActive, + refreshOnMount: isActive, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + debugSource: WORKSPACE_GIT_BASIC_INFO_SOURCE, + cancelPendingRefreshSources: WORKSPACE_GIT_AUTO_REFRESH_SOURCES, + }; +} + +export function suppressWorkspaceGitRefreshOnMountDuringSessionTransition( + options: GitBasicInfoOptions | undefined, + transitionActive: boolean +): GitBasicInfoOptions | undefined { + if (!options || !transitionActive) { + return options; + } + + return { + ...options, + refreshOnMount: false, + }; +} diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss index 11ac99326d..3c0104e59c 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss @@ -132,7 +132,7 @@ user-select: none; font-size: 12px; padding: 10px 12px; - background: rgba(255, 255, 255, 0.02); + background: var(--color-overlay-white-02); border: 1px dashed var(--border-subtle); border-radius: 8px; color: var(--color-text-primary); @@ -198,7 +198,7 @@ width: 100%; font-size: 12px; padding: 10px 12px; - background: rgba(255, 255, 255, 0.02); + background: var(--color-overlay-white-02); border: 1px solid var(--border-subtle); border-radius: 8px; color: var(--color-text-primary); @@ -299,12 +299,12 @@ flex-shrink: 0; width: 14px; height: 14px; - color: #ef4444; + color: var(--color-error); } span { font-size: 12px; - color: #ef4444; + color: var(--color-error); } } diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx index eba5863704..48b11dc757 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx @@ -12,7 +12,6 @@ import { Check, X } from 'lucide-react'; -// import { open } from '@tauri-apps/plugin-dialog'; import { useTranslation } from 'react-i18next'; import { createLogger } from '@/shared/utils/logger'; import { Modal, Button, Input } from '@/component-library'; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index e61d6cf3ff..02e02fde67 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -26,7 +26,7 @@ } &:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-accent-500, #3b82f6) 55%, transparent); + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 55%, transparent); outline-offset: 2px; border-radius: 4px; } @@ -83,7 +83,7 @@ width: 7px; height: 7px; border-radius: 50%; - background: var(--color-success, #22c55e); + background: var(--color-success); flex-shrink: 0; } @@ -121,7 +121,7 @@ } &.is-connected { - color: var(--color-success, #22c55e); + color: var(--color-success); font-weight: 600; } @@ -143,7 +143,7 @@ width: 5px; height: 5px; border-radius: 50%; - background: var(--color-success, #22c55e); + background: var(--color-success); flex-shrink: 0; } @@ -164,7 +164,7 @@ height: 200px; border-radius: 10px; border: 1px solid var(--border-subtle); - background: #ffffff; + background: var(--color-static-white); display: flex; align-items: center; justify-content: center; @@ -172,12 +172,12 @@ transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.15s ease; &:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 8px var(--color-overlay-black-10); } &:active { transform: scale(0.95); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + box-shadow: 0 1px 2px var(--color-overlay-black-08); } svg { @@ -302,7 +302,7 @@ } &:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-accent-500, #3b82f6) 55%, transparent); + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 55%, transparent); outline-offset: 2px; border-radius: 4px; } @@ -380,16 +380,16 @@ transition: all 0.15s ease; &--connect { - background: var(--color-accent-600, #2563eb); - color: #fff; - border: 1px solid color-mix(in srgb, var(--color-accent-700, #1d4ed8) 35%, transparent); + background: var(--color-accent-600); + color: var(--color-static-white); + border: 1px solid color-mix(in srgb, var(--color-accent-700) 35%, transparent); &:hover:not(:disabled) { - background: var(--color-accent-700, #1d4ed8); + background: var(--color-accent-700); } &:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-accent-500, #3b82f6) 55%, transparent); + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 55%, transparent); outline-offset: 1px; } @@ -483,7 +483,7 @@ } &.is-active { - color: #fff; + color: var(--color-static-white); background: $color-success; font-weight: $font-weight-semibold; box-shadow: 0 1px 4px rgba(110, 184, 140, 0.35); @@ -513,7 +513,7 @@ display: inline-block; margin: 0 auto 8px; padding: 10px; - background: #fff; + background: var(--color-static-white); border-radius: 8px; box-sizing: border-box; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index e4446dde73..9cd8292030 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -49,9 +49,9 @@ function isWeixinRasterQrSrc(raw: string): boolean { } const NETWORK_TABS: { id: NetworkTab; labelKey: string }[] = [ - { id: 'lan', labelKey: 'remoteConnect.tabLan' }, + { id: 'lan', labelKey: 'shared:connectionMethods.lan' }, { id: 'ngrok', labelKey: 'remoteConnect.tabNgrok' }, - { id: 'bitfun_server', labelKey: 'remoteConnect.tabBitfunServer' }, + { id: 'bitfun_server', labelKey: 'shared:connectionMethods.bitfunServer' }, { id: 'custom_server', labelKey: 'remoteConnect.tabCustomServer' }, ]; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss index e3845bbcf9..8390bf8a93 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss @@ -65,11 +65,11 @@ } .bitfun-remote-disclaimer__btn--primary { - background: var(--color-accent-600, #2563eb); - color: #fff; - border-color: color-mix(in srgb, var(--color-accent-700, #1d4ed8) 35%, transparent); + background: var(--color-accent-600); + color: var(--color-static-white); + border-color: color-mix(in srgb, var(--color-accent-700) 35%, transparent); &:hover:not(:disabled) { - background: var(--color-accent-700, #1d4ed8); + background: var(--color-accent-700); } } diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss b/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss index f951a5eb16..26fc92f024 100644 --- a/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss @@ -16,7 +16,7 @@ display: flex; align-items: center; justify-content: center; - background: var(--bitfun-startup-bg, var(--color-bg-primary, #f3f3f5)); + background: var(--bitfun-startup-bg, var(--color-bg-primary)); pointer-events: none; // ── Exit state ───────────────────────────────────────────────────────────── @@ -65,8 +65,8 @@ left: 50%; min-width: 220px; max-width: min(360px, calc(100vw - 48px)); - color: var(--color-text-secondary); - font-size: var(--font-size-sm); + color: var(--color-text-tertiary); + font-size: var(--font-size-xs); line-height: 1.4; text-align: center; opacity: 0; @@ -78,7 +78,7 @@ } .splash-screen__message--visible { - opacity: 1; + opacity: 0.62; transform: translate(-50%, 0); } @@ -86,10 +86,12 @@ // Idle logo: soft opacity pulse with a slight breathing scale @keyframes splash-logo-idle { - 0%, 100% { - opacity: 0.85; - transform: scale(0.98); + 0%, + 100% { + opacity: 0.42; + transform: scale(0.985); } + 50% { opacity: 1; transform: scale(1); @@ -109,6 +111,10 @@ animation: none; } + .splash-screen__logo { + filter: none; + } + .splash-screen__message { transition: none; } diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx b/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx index 68e5dd54ef..713a88461f 100644 --- a/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx @@ -29,55 +29,55 @@ describe('SplashScreen', () => { dom.window.close(); }); - it('reveals the workspace loading message only after the delay', () => { + it('reveals the subtle startup hint only after the delay', () => { act(() => { root.render( {}} - delayedMessage="Loading workspace..." + delayedMessage="Starting BitFun..." delayedMessageMs={1000} /> ); }); - const message = container.querySelector('.splash-screen__message'); - expect(message?.textContent).toBe('Loading workspace...'); - expect(message?.classList.contains('splash-screen__message--visible')).toBe(false); + expect(container.querySelector('.splash-screen__message')).toBeNull(); act(() => { vi.advanceTimersByTime(999); }); - expect(message?.classList.contains('splash-screen__message--visible')).toBe(false); + expect(container.querySelector('.splash-screen__message')).toBeNull(); act(() => { vi.advanceTimersByTime(1); }); + const message = container.querySelector('.splash-screen__message'); + expect(message?.textContent).toBe('Starting BitFun...'); expect(message?.classList.contains('splash-screen__message--visible')).toBe(true); }); - it('does not reveal the workspace loading message during the normal startup splash window by default', () => { + it('does not reveal the subtle startup hint during the normal startup splash window by default', () => { act(() => { root.render( {}} - delayedMessage="Loading workspace..." + delayedMessage="Starting BitFun..." /> ); }); - const message = container.querySelector('.splash-screen__message'); - expect(message?.classList.contains('splash-screen__message--visible')).toBe(false); + expect(container.querySelector('.splash-screen__message')).toBeNull(); act(() => { vi.advanceTimersByTime(1799); }); - expect(message?.classList.contains('splash-screen__message--visible')).toBe(false); + expect(container.querySelector('.splash-screen__message')).toBeNull(); act(() => { vi.advanceTimersByTime(1); }); + const message = container.querySelector('.splash-screen__message'); expect(message?.classList.contains('splash-screen__message--visible')).toBe(true); }); @@ -87,7 +87,7 @@ describe('SplashScreen', () => { {}} - delayedMessage="Loading workspace..." + delayedMessage="Starting BitFun..." delayedMessageMs={1000} /> ); @@ -97,7 +97,6 @@ describe('SplashScreen', () => { vi.advanceTimersByTime(1000); }); - const message = container.querySelector('.splash-screen__message'); - expect(message?.classList.contains('splash-screen__message--visible')).toBe(false); + expect(container.querySelector('.splash-screen__message')).toBeNull(); }); }); diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx b/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx index 10f22a3a95..d8330983d0 100644 --- a/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx @@ -56,15 +56,16 @@ const SplashScreen: React.FC = ({
BitFun
- {delayedMessage && ( + {showDelayedMessage && delayedMessage && !isExiting && (
diff --git a/src/web-ui/src/app/components/TitleBar/NotificationButton.scss b/src/web-ui/src/app/components/TitleBar/NotificationButton.scss index 3364e9f742..80fd2954a0 100644 --- a/src/web-ui/src/app/components/TitleBar/NotificationButton.scss +++ b/src/web-ui/src/app/components/TitleBar/NotificationButton.scss @@ -74,7 +74,7 @@ padding: 4px 8px; font-size: var(--font-size-xs); color: var(--color-text-primary); - box-shadow: var(--shadow-md); + box-shadow: var(--shadow-base); max-width: 300px; overflow: hidden; text-overflow: ellipsis; diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.scss b/src/web-ui/src/app/components/panels/BranchSelectModal.scss index d4edfe430f..3430693b51 100644 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.scss +++ b/src/web-ui/src/app/components/panels/BranchSelectModal.scss @@ -36,7 +36,7 @@ background: var(--color-bg-primary); border: 1px solid var(--border-subtle); border-radius: 12px; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); + box-shadow: 0 16px 48px var(--color-overlay-black-40); width: 380px; max-width: calc(100vw - 48px); max-height: min(520px, calc(100vh - 48px)); @@ -125,10 +125,10 @@ .branch-select-dialog__error { padding: 8px 12px; - background: rgba(244, 67, 54, 0.08); - border: 1px solid rgba(244, 67, 54, 0.2); + background: var(--color-error-bg); + border: 1px solid var(--color-error-border); border-radius: 6px; - color: #f44336; + color: var(--color-error); font-size: 12px; } @@ -261,7 +261,7 @@ &--worktree { color: var(--color-warning); - background: rgba(255, 152, 0, 0.1); + background: var(--color-warning-bg); } } diff --git a/src/web-ui/src/app/components/panels/DiffFullscreenViewer.css b/src/web-ui/src/app/components/panels/DiffFullscreenViewer.css index 593ee0caf9..5fe1f16563 100644 --- a/src/web-ui/src/app/components/panels/DiffFullscreenViewer.css +++ b/src/web-ui/src/app/components/panels/DiffFullscreenViewer.css @@ -56,7 +56,7 @@ justify-content: space-between; padding: 12px 20px; background: rgba(0, 0, 0, 0.9); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-bottom: 1px solid var(--color-overlay-white-10); backdrop-filter: blur(8px); flex-shrink: 0; z-index: 2; @@ -116,14 +116,14 @@ font-weight: 500; cursor: pointer; transition: all 0.2s ease; - background: rgba(255, 255, 255, 0.05); + background: var(--color-overlay-white-05); color: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid var(--color-overlay-white-10); } .header-btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 12px var(--color-overlay-black-30); } .header-btn:disabled { @@ -133,39 +133,39 @@ } .header-btn.accept-btn { - background: rgba(110, 184, 140, 0.15); - color: #6eb88c; - border-color: rgba(110, 184, 140, 0.3); + background: var(--color-success-bg); + color: var(--color-success); + border-color: var(--color-success-border); } .header-btn.accept-btn:hover:not(:disabled) { - background: rgba(110, 184, 140, 0.25); - border-color: rgba(110, 184, 140, 0.5); - box-shadow: 0 4px 12px rgba(110, 184, 140, 0.2); + background: color-mix(in srgb, var(--color-success) 25%, transparent); + border-color: color-mix(in srgb, var(--color-success) 50%, transparent); + box-shadow: 0 4px 12px color-mix(in srgb, var(--color-success) 20%, transparent); } .header-btn.reject-btn { - background: rgba(199, 112, 112, 0.15); - color: #c77070; - border-color: rgba(199, 112, 112, 0.3); + background: var(--color-error-bg); + color: var(--color-error); + border-color: var(--color-error-border); } .header-btn.reject-btn:hover:not(:disabled) { - background: rgba(199, 112, 112, 0.25); - border-color: rgba(199, 112, 112, 0.5); - box-shadow: 0 4px 12px rgba(199, 112, 112, 0.2); + background: color-mix(in srgb, var(--color-error) 25%, transparent); + border-color: color-mix(in srgb, var(--color-error) 50%, transparent); + box-shadow: 0 4px 12px color-mix(in srgb, var(--color-error) 20%, transparent); } .header-btn.close-btn:hover:not(:disabled) { - background: rgba(199, 112, 112, 0.2); - color: #c77070; - border-color: rgba(199, 112, 112, 0.3); + background: color-mix(in srgb, var(--color-error) 20%, transparent); + color: var(--color-error); + border-color: var(--color-error-border); } .header-divider { width: 1px; height: 20px; - background: rgba(255, 255, 255, 0.1); + background: var(--color-overlay-white-10); margin: 0 4px; } @@ -221,7 +221,7 @@ left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.8); + background: var(--color-overlay-black-80); display: flex; flex-direction: column; align-items: center; @@ -287,7 +287,7 @@ .diff-fullscreen-header { background: rgba(0, 0, 0, 0.95); - border-bottom-color: rgba(255, 255, 255, 0.15); + border-bottom-color: var(--color-overlay-white-15); } .diff-fullscreen-content { diff --git a/src/web-ui/src/app/components/panels/FilesPanel.scss b/src/web-ui/src/app/components/panels/FilesPanel.scss index 755bbb7866..34fd2373a4 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.scss +++ b/src/web-ui/src/app/components/panels/FilesPanel.scss @@ -137,7 +137,7 @@ } &__search-index-error-text { - color: var(--color-error-600); + color: var(--color-error); } &__search-index-actions { diff --git a/src/web-ui/src/app/components/panels/TerminalEditModal.scss b/src/web-ui/src/app/components/panels/TerminalEditModal.scss index f34beea1f5..59c71236aa 100644 --- a/src/web-ui/src/app/components/panels/TerminalEditModal.scss +++ b/src/web-ui/src/app/components/panels/TerminalEditModal.scss @@ -36,7 +36,7 @@ background: var(--color-bg-primary); border: 1px solid var(--border-subtle); border-radius: 12px; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); + box-shadow: 0 16px 48px var(--color-overlay-black-40); width: 380px; max-width: calc(100vw - 48px); overflow: hidden; diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss b/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss index ec16297373..aaa036298e 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss @@ -359,8 +359,8 @@ gap: $size-gap-2; padding: $size-gap-4; margin: $size-gap-4; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); + background: var(--color-error-bg); + border: 1px solid var(--color-error-border); border-radius: $size-radius-base; color: var(--color-error); diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index 0fc84d4020..dc1d8cfcff 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -96,9 +96,7 @@ const PlanViewer = React.lazy(() => // Uses ConnectedTerminal to auto-connect backend const TerminalTabPanel = React.lazy(() => - import('@/tools/terminal').then(module => ({ - default: module.ConnectedTerminal - })) + import('@/tools/terminal/components/ConnectedTerminal') ); const BrowserPanel = React.lazy(() => @@ -127,6 +125,12 @@ const SessionUsagePanel = React.lazy(() => })) ); +const BackgroundCommandOutputPanel = React.lazy(() => + import('@/flow_chat/components/background-command/BackgroundCommandOutputPanel').then(module => ({ + default: module.BackgroundCommandOutputPanel + })) +); + const ReviewPlatformPanel = React.lazy(() => import('@/app/components/panels/review-platform/ReviewPlatformPanel') ); @@ -162,7 +166,7 @@ const FlexiblePanel: React.FC = memo(({ isActive = true, onFileMissingFromDiskChange, }) => { - const { t } = useI18n('components'); + const { t, formatDate } = useI18n('components'); // Use ref to save latest content, avoiding it in callback dependencies const contentRef = React.useRef(content); @@ -666,7 +670,12 @@ const FlexiblePanel: React.FC = memo(({
{t('flexiblePanel.aiSession.startTime')} - {content.data?.start_time ? new Date(content.data.start_time).toLocaleString() : t('flexiblePanel.aiSession.unknownTime')} + {content.data?.start_time + ? formatDate(new Date(content.data.start_time), { + dateStyle: 'medium', + timeStyle: 'short', + }) + : t('flexiblePanel.aiSession.unknownTime')}
@@ -794,6 +803,13 @@ const FlexiblePanel: React.FC = memo(({ ); + case 'background-command-output': + return ( + {t('flexiblePanel.loading.terminal')}
}> + + + ); + case 'review-platform': return ( Loading pull requests...
}> diff --git a/src/web-ui/src/app/components/panels/base/PanelHeader.scss b/src/web-ui/src/app/components/panels/base/PanelHeader.scss index a4a823ce14..e59b2f1773 100644 --- a/src/web-ui/src/app/components/panels/base/PanelHeader.scss +++ b/src/web-ui/src/app/components/panels/base/PanelHeader.scss @@ -5,7 +5,7 @@ justify-content: center; // Unified header height: IconButton(xs=24px) + padding(6px*2)=36px padding: 6px 12px; - background: var(--color-bg-primary, rgba(255, 255, 255, 0.03)); + background: var(--color-bg-primary); border-bottom: 1px solid var(--border-base); flex-shrink: 0; min-height: 36px; @@ -14,7 +14,7 @@ margin: 0; font-size: 12px; font-weight: 500; - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + color: var(--color-text-primary); text-align: center; overflow: hidden; text-overflow: ellipsis; diff --git a/src/web-ui/src/app/components/panels/base/types.ts b/src/web-ui/src/app/components/panels/base/types.ts index 0888041ee2..1c765a441b 100644 --- a/src/web-ui/src/app/components/panels/base/types.ts +++ b/src/web-ui/src/app/components/panels/base/types.ts @@ -27,6 +27,7 @@ export type PanelContentType = | 'plan-viewer' | 'btw-session' | 'session-usage' + | 'background-command-output' | 'review-platform' | 'review-platform-pr-detail' | 'terminal' diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index cb846342b0..b2ff99ad42 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -19,6 +19,7 @@ import { Globe, Activity, GitPullRequest, + Terminal, } from 'lucide-react'; import { PanelContentType, PanelContentConfig } from './types'; @@ -208,6 +209,14 @@ export const PANEL_CONTENT_CONFIGS: Record supportsDownload: false, showHeader: false }, + 'background-command-output': { + type: 'background-command-output', + displayName: 'Command Output', + icon: Terminal, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, 'review-platform': { type: 'review-platform', displayName: 'Pull Requests', diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.scss b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.scss index be14ec1668..cea4dfad13 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.scss @@ -8,7 +8,7 @@ flex-direction: column; width: 100%; height: 100%; - background: var(--color-bg-primary, #1a1a1a); + background: var(--color-bg-primary); overflow: hidden; // Create isolated stacking context to prevent high z-index elements // from covering external floating UI (ChatInput, popovers, etc.) diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index 0bc15f5d63..5282e2ca4c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -11,6 +11,7 @@ import { EmptyState } from './empty-state'; import { useCanvasStore } from './stores'; import { useTabLifecycle, useKeyboardShortcuts, usePanelTabCoordinator } from './hooks'; import type { AnchorPosition } from './types'; +import { TAB_EVENTS } from './types'; import { openMainSession, selectActiveBtwSessionTab } from '@/flow_chat/services/openBtwSession'; import { isSamePath } from '@/shared/utils/pathUtils'; import './ContentCanvas.scss'; @@ -18,7 +19,7 @@ export interface ContentCanvasProps { /** Workspace path */ workspacePath?: string; /** App mode */ - mode?: 'agent' | 'project' | 'git'; + mode?: 'agent' | 'project' | 'git' | 'bottom-terminal'; /** Whether the containing scene is currently visible */ isSceneActive?: boolean; /** Interaction callback */ @@ -27,6 +28,16 @@ export interface ContentCanvasProps { onBeforeClose?: (content: any) => Promise; /** Disable pop-out and panel-close controls (used in panel-view scene) */ disablePopOut?: boolean; + /** Override the event this canvas listens to for creating tabs. */ + createTabEventName?: string; + /** Override the expansion event this canvas dispatches/listens for. */ + expandPanelEventName?: string; + /** Custom collapsed state for canvases hosted outside the right panel. */ + isPanelCollapsed?: boolean; + /** Custom expand behavior for canvases hosted outside the right panel. */ + onExpandPanel?: () => void; + /** Custom collapse behavior for canvases hosted outside the right panel. */ + onCollapsePanel?: () => void; } export const ContentCanvas: React.FC = ({ @@ -35,6 +46,11 @@ export const ContentCanvas: React.FC = ({ isSceneActive = true, onInteraction, disablePopOut = false, + createTabEventName, + expandPanelEventName = TAB_EVENTS.EXPAND_RIGHT_PANEL, + isPanelCollapsed, + onExpandPanel, + onCollapsePanel, }) => { // Store state const { @@ -52,12 +68,20 @@ export const ContentCanvas: React.FC = ({ | undefined; const lastSyncedBtwTabIdRef = useRef(null); // Initialize hooks - const { handleCloseWithDirtyCheck, handleCloseAllWithDirtyCheck } = useTabLifecycle({ mode }); + const { handleCloseWithDirtyCheck, handleCloseAllWithDirtyCheck } = useTabLifecycle({ + mode, + createTabEventName, + expandPanelEventName, + }); useKeyboardShortcuts({ enabled: true, handleCloseWithDirtyCheck }); // Panel/tab state coordinator (auto manage expand/collapse) const { collapsePanel } = usePanelTabCoordinator({ autoCollapseOnEmpty: true, autoExpandOnTabOpen: true, + isCollapsed: isPanelCollapsed, + onExpand: onExpandPanel, + onCollapse: onCollapsePanel, + expandEventName: expandPanelEventName, }); useEffect(() => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/anchor-zone/AnchorZone.scss b/src/web-ui/src/app/components/panels/content-canvas/anchor-zone/AnchorZone.scss index 79e1e8c2cb..01eca9aa3e 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/anchor-zone/AnchorZone.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/anchor-zone/AnchorZone.scss @@ -5,7 +5,7 @@ .canvas-anchor-zone { display: flex; flex-direction: column; - background: var(--color-bg-primary, #1a1a1a); + background: var(--color-bg-primary); border: 1px solid var(--border-base); overflow: hidden; @@ -56,7 +56,7 @@ // Resizing state &.is-resizing { .canvas-anchor-zone__resizer-line { - background: var(--color-primary, #60a5fa); + background: var(--color-primary); } } @@ -89,7 +89,7 @@ &:hover { .canvas-anchor-zone__resizer-line { - background: var(--color-primary, #60a5fa); + background: var(--color-primary); } } } @@ -105,7 +105,7 @@ align-items: center; justify-content: space-between; padding: 6px 12px; - background: var(--color-bg-secondary, rgba(255, 255, 255, 0.03)); + background: var(--color-bg-secondary); border-bottom: 1px solid var(--border-base); flex-shrink: 0; } @@ -116,10 +116,10 @@ gap: 8px; font-size: 12px; font-weight: 500; - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + color: var(--color-text-primary); svg { - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); } } @@ -138,19 +138,19 @@ padding: 0; background: none; border: none; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); cursor: pointer; border-radius: 4px; transition: background-color 0.15s ease, color 0.15s ease; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } } &__close-btn:hover { - color: var(--color-danger, #f87171); + color: var(--color-danger); } // Content area diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.scss b/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.scss index 56bcf1f3a8..7159c48415 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.scss @@ -22,7 +22,7 @@ content: ''; position: absolute; inset: 2px; - border: 1px dashed var(--color-accent-400, rgba(96, 165, 250, 0.4)); + border: 1px dashed var(--color-accent-400); border-radius: 4px; pointer-events: none; z-index: 5; @@ -52,7 +52,7 @@ content: ''; position: absolute; inset: 4px; - border: 1px dashed var(--color-primary, #60a5fa); + border: 1px dashed var(--color-primary); border-radius: 4px; opacity: 0.4; transition: all 0.15s ease; @@ -68,8 +68,8 @@ opacity: 1; border-style: solid; border-width: 2px; - border-color: var(--color-primary, #60a5fa); - background: var(--color-primary-alpha, rgba(96, 165, 250, 0.12)); + border-color: var(--color-primary); + background: var(--color-primary-alpha); } } @@ -79,10 +79,10 @@ justify-content: center; gap: 6px; padding: 6px 14px; - background: var(--color-primary, #60a5fa); + background: var(--color-primary); border: none; border-radius: 4px; - box-shadow: 0 2px 8px rgba(96, 165, 250, 0.4); + box-shadow: 0 2px 8px var(--color-accent-400); opacity: 0.85; transform: scale(1); transition: all 0.15s ease; @@ -91,9 +91,9 @@ span { font-size: 12px; font-weight: 600; - color: #fff; + color: var(--color-static-white); white-space: nowrap; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + text-shadow: 0 1px 2px var(--color-overlay-black-20); } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss index 38aedbd254..981c33ebde 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss @@ -2,7 +2,7 @@ display: flex; width: 100%; height: 100%; - background: var(--color-bg-primary, #1a1a1a); + background: var(--color-bg-primary); &:not(.is-split):not(.is-grid) { .canvas-editor-area__primary { diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.scss b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.scss index b90afb8280..9f06ffbb7a 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.scss @@ -8,7 +8,7 @@ width: 100%; height: 100%; min-height: 0; - background: var(--color-bg-primary, #1a1a1a); + background: var(--color-bg-primary); border-radius: 4px; overflow: hidden; @@ -54,7 +54,7 @@ justify-content: center; width: 100%; height: 100%; - background: var(--color-bg-secondary, rgba(255, 255, 255, 0.02)); + background: var(--color-bg-secondary); } &__empty-content { @@ -62,7 +62,7 @@ flex-direction: column; align-items: center; gap: 12px; - color: var(--color-text-tertiary, rgba(255, 255, 255, 0.4)); + color: var(--color-text-tertiary); span { font-size: 13px; diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/SplitHandle.scss b/src/web-ui/src/app/components/panels/content-canvas/editor-area/SplitHandle.scss index 33425f5b3a..d1a2a6a9e4 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/SplitHandle.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/SplitHandle.scss @@ -23,7 +23,7 @@ &:hover, &.is-dragging { - background: var(--color-primary-alpha, rgba(96, 165, 250, 0.2)); + background: var(--color-primary-alpha); } .canvas-split-handle__line { @@ -46,7 +46,7 @@ &:hover, &.is-dragging { - background: var(--color-primary-alpha, rgba(96, 165, 250, 0.2)); + background: var(--color-primary-alpha); } .canvas-split-handle__line { @@ -70,7 +70,7 @@ &:hover &__line, &.is-dragging &__line { - background: var(--color-primary, #60a5fa); + background: var(--color-primary); } // Grip handle @@ -79,10 +79,10 @@ align-items: center; justify-content: center; padding: 4px; - background: var(--color-bg-secondary, rgba(30, 30, 30, 0.9)); + background: var(--color-bg-secondary); border: 1px solid var(--border-base); border-radius: 4px; - color: var(--color-text-tertiary, rgba(255, 255, 255, 0.4)); + color: var(--color-text-tertiary); opacity: 0; transition: opacity 0.15s ease, color 0.15s ease; } @@ -94,8 +94,8 @@ &:hover &__grip, &.is-dragging &__grip { - color: var(--color-primary, #60a5fa); - border-color: var(--color-primary, #60a5fa); + color: var(--color-primary); + border-color: var(--color-primary); } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss index 27c6dc9fe4..3b8cf3188f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss @@ -28,13 +28,13 @@ border: none; border-radius: 4px; background: transparent; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); cursor: pointer; transition: background 0.15s, color 0.15s; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.08)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } } @@ -57,7 +57,7 @@ p { margin: 0; font-size: 16px; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); } } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts index 62762f9020..7031d58987 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts @@ -18,6 +18,14 @@ interface UsePanelTabCoordinatorOptions { autoCollapseOnEmpty?: boolean; /** Auto-expand when a tab opens */ autoExpandOnTabOpen?: boolean; + /** Custom collapsed state for non-right-panel canvases. */ + isCollapsed?: boolean; + /** Custom expand behavior for non-right-panel canvases. */ + onExpand?: () => void; + /** Custom collapse behavior for non-right-panel canvases. */ + onCollapse?: () => void; + /** Event that requests panel expansion. Defaults to right-panel expansion. */ + expandEventName?: string; } /** @@ -33,6 +41,10 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = const { autoCollapseOnEmpty = true, autoExpandOnTabOpen = true, + isCollapsed, + onExpand, + onCollapse, + expandEventName = TAB_EVENTS.EXPAND_RIGHT_PANEL, } = options; const { @@ -51,7 +63,9 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = // Sync refs useEffect(() => { - if (state?.layout) { + if (typeof isCollapsed === 'boolean') { + rightPanelCollapsedRef.current = isCollapsed; + } else if (state?.layout) { rightPanelCollapsedRef.current = state.layout.rightPanelCollapsed ?? true; } toggleRightPanelRef.current = toggleRightPanel; @@ -59,13 +73,18 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = if (!isInitializedRef.current) { isInitializedRef.current = true; } - }, [state?.layout, toggleRightPanel]); + }, [state?.layout, toggleRightPanel, isCollapsed]); /** * Expand right panel (with debounce and state checks). * Set width first, then expand to avoid flicker. */ const expandPanel = useCallback(() => { + if (onExpand) { + onExpand(); + return; + } + if (rightPanelCollapsedRef.current && toggleRightPanelRef.current && updateRightPanelWidth) { // Restore last width if available, otherwise use default const lastWidth = loadPanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT); @@ -83,12 +102,17 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = } }); } - }, [updateRightPanelWidth]); + }, [onExpand, updateRightPanelWidth]); /** * Collapse right panel (with debounce and state checks). */ const collapsePanel = useCallback(() => { + if (onCollapse) { + onCollapse(); + return; + } + if (!rightPanelCollapsedRef.current && toggleRightPanelRef.current) { requestAnimationFrame(() => { if (toggleRightPanelRef.current) { @@ -96,7 +120,7 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = } }); } - }, []); + }, [onCollapse]); /** * Watch tab count changes and manage panel state. @@ -142,12 +166,12 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = } }; - window.addEventListener(TAB_EVENTS.EXPAND_RIGHT_PANEL, handleExpandRightPanel); + window.addEventListener(expandEventName, handleExpandRightPanel); return () => { - window.removeEventListener(TAB_EVENTS.EXPAND_RIGHT_PANEL, handleExpandRightPanel); + window.removeEventListener(expandEventName, handleExpandRightPanel); }; - }, [autoExpandOnTabOpen, expandPanel]); + }, [autoExpandOnTabOpen, expandPanel, expandEventName]); return { // Utilities diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts index 73027265a7..b876692259 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts @@ -9,7 +9,13 @@ */ import { useCallback, useEffect } from 'react'; -import { useCanvasStore, useAgentCanvasStore, useProjectCanvasStore, useGitCanvasStore } from '../stores'; +import { + useCanvasStore, + useAgentCanvasStore, + useProjectCanvasStore, + useGitCanvasStore, + useBottomTerminalCanvasStore, +} from '../stores'; import type { EditorGroupId, PanelContent, CreateTabEventDetail } from '../types'; import { TAB_EVENTS } from '../types'; import { useI18n } from '@/infrastructure/i18n'; @@ -17,7 +23,11 @@ import { drainPendingTabs } from '@/shared/services/pendingTabQueue'; import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; interface UseTabLifecycleOptions { /** App mode / target canvas */ - mode?: 'agent' | 'project' | 'git'; + mode?: 'agent' | 'project' | 'git' | 'bottom-terminal'; + /** Override the external tab creation event for specialized canvases. */ + createTabEventName?: string; + /** Override the panel expansion event dispatched after tab activation. */ + expandPanelEventName?: string; } interface UseTabLifecycleReturn { @@ -44,14 +54,20 @@ interface UseTabLifecycleReturn { * Tab lifecycle management hook. */ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLifecycleReturn => { - const { mode = 'agent' } = options; + const { + mode = 'agent', + createTabEventName, + expandPanelEventName = TAB_EVENTS.EXPAND_RIGHT_PANEL, + } = options; const { t } = useI18n('components'); const canvasStoreApi = mode === 'project' ? useProjectCanvasStore : mode === 'git' ? useGitCanvasStore - : useAgentCanvasStore; + : mode === 'bottom-terminal' + ? useBottomTerminalCanvasStore + : useAgentCanvasStore; const { addTab, @@ -187,8 +203,9 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif * Listen for left-panel terminal close events to sync right-panel tabs. */ useEffect(() => { - const store = mode === 'project' ? useProjectCanvasStore - : mode === 'git' ? useGitCanvasStore + const store = mode === 'project' ? useProjectCanvasStore + : mode === 'git' ? useGitCanvasStore + : mode === 'bottom-terminal' ? useBottomTerminalCanvasStore : useAgentCanvasStore; const handleTerminalSessionDestroyed = (event: CustomEvent<{ sessionId: string }>) => { @@ -207,8 +224,9 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif * Listen for left-panel terminal rename events to sync right-panel tabs. */ useEffect(() => { - const store = mode === 'project' ? useProjectCanvasStore - : mode === 'git' ? useGitCanvasStore + const store = mode === 'project' ? useProjectCanvasStore + : mode === 'git' ? useGitCanvasStore + : mode === 'bottom-terminal' ? useBottomTerminalCanvasStore : useAgentCanvasStore; const handleTerminalSessionRenamed = (event: CustomEvent<{ sessionId: string; newName: string }>) => { @@ -227,12 +245,14 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif * Listen for external tab creation events. */ useEffect(() => { - const eventName = - mode === 'project' + const eventName = createTabEventName ?? + (mode === 'project' ? TAB_EVENTS.PROJECT_CREATE_TAB : mode === 'git' ? TAB_EVENTS.GIT_CREATE_TAB - : TAB_EVENTS.AGENT_CREATE_TAB; + : mode === 'bottom-terminal' + ? TAB_EVENTS.BOTTOM_TERMINAL_CREATE_TAB + : TAB_EVENTS.AGENT_CREATE_TAB); const handleCreateTab = (event: CustomEvent) => { const { @@ -273,8 +293,7 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif // Switch to existing tab switchToTab(existing.tab.id, existing.groupId); - // Trigger right panel expansion - window.dispatchEvent(new CustomEvent(TAB_EVENTS.EXPAND_RIGHT_PANEL)); + window.dispatchEvent(new CustomEvent(expandPanelEventName)); return; } } @@ -285,22 +304,23 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif // Open all tabs in active state by default (no preview replacement) addTab(content, 'active', groupId); - // Trigger right panel expansion - window.dispatchEvent(new CustomEvent(TAB_EVENTS.EXPAND_RIGHT_PANEL)); + window.dispatchEvent(new CustomEvent(expandPanelEventName)); }; window.addEventListener(eventName, handleCreateTab as EventListener); // Drain any tab events that were enqueued before this listener was // registered (happens when the scene was just mounted for the first time). - const pendingMode = mode === 'project' ? 'project' : mode === 'git' ? 'git' : 'agent'; - const pending = drainPendingTabs(pendingMode); - pending.forEach(detail => handleCreateTab({ detail } as CustomEvent)); + if (mode !== 'bottom-terminal') { + const pendingMode = mode === 'project' ? 'project' : mode === 'git' ? 'git' : 'agent'; + const pending = drainPendingTabs(pendingMode); + pending.forEach(detail => handleCreateTab({ detail } as CustomEvent)); + } return () => { window.removeEventListener(eventName, handleCreateTab as EventListener); }; - }, [mode, findTabByMetadata, updateTabContent, switchToTab, addTab, activeGroupId, layout.splitMode, setSplitMode]); + }, [mode, createTabEventName, expandPanelEventName, findTabByMetadata, updateTabContent, switchToTab, addTab, activeGroupId, layout.splitMode, setSplitMode]); return { openPreview, diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.scss b/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.scss index 1a1d0b7128..06ce19ef19 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.scss @@ -58,7 +58,7 @@ gap: 4px; height: 22px; padding: 0 8px; - background: var(--element-bg-soft, var(--color-bg-secondary)); + background: var(--element-bg-soft); border: 1px solid var(--border-base); color: var(--color-text-secondary); cursor: pointer; @@ -67,7 +67,7 @@ transition: all 0.15s ease; &:hover { - background: var(--element-bg-base, var(--element-bg-medium)); + background: var(--element-bg-base); border-color: var(--border-base); color: var(--color-text-primary); } @@ -154,9 +154,9 @@ } &.is-active { - background: var(--group-color, var(--color-accent-500, var(--color-primary))); + background: var(--group-color, var(--color-accent-500)); color: var(--color-text-primary); - box-shadow: 0 1px 3px var(--color-shadow, rgba(0, 0, 0, 0.2)); + box-shadow: 0 1px 3px var(--color-shadow, var(--color-overlay-black-20)); .canvas-mission-control__group-filter-indicator { opacity: 0; @@ -170,7 +170,7 @@ width: 6px; height: 6px; border-radius: 50%; - background: var(--group-color, var(--color-accent-500, var(--color-primary))); + background: var(--group-color, var(--color-accent-500)); opacity: 0.6; transition: opacity 0.15s ease; } @@ -245,7 +245,7 @@ padding: 0 4px; font-size: 9px; font-family: inherit; - background: var(--element-bg-soft, var(--color-bg-secondary)); + background: var(--element-bg-soft); border: 1px solid var(--border-base); border-radius: 2px; color: var(--color-text-secondary); diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/SearchFilter.scss b/src/web-ui/src/app/components/panels/content-canvas/mission-control/SearchFilter.scss index 0a179e3b82..5cce85c53f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/SearchFilter.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/SearchFilter.scss @@ -15,14 +15,14 @@ border: 1px solid var(--border-base); &:hover { - border-color: var(--border-medium, var(--color-primary, var(--color-accent-500))); + border-color: var(--border-medium, var(--color-primary)); border-style: solid; } } &.search--focused .search__wrapper { border-style: solid; - border-color: var(--color-accent-500, var(--color-primary)); + border-color: var(--color-accent-500); background: var(--color-accent-50, var(--color-accent-100)); } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss index 01d55f1701..3d43bf2a6d 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss @@ -29,21 +29,21 @@ // Active state - solid border &.is-active { border-style: solid; - border-color: var(--color-primary, var(--color-accent-500)); + border-color: var(--color-primary); .canvas-thumbnail-card__header { - border-color: var(--color-accent-200, rgba(96, 165, 250, 0.12)); + border-color: var(--color-accent-200); } .canvas-thumbnail-card__icon { - color: var(--color-primary, var(--color-accent-500)); + color: var(--color-primary); } } // Pinned state - dashed border with stronger color &.is-pinned { border-style: dashed; - border-color: var(--color-accent-300, rgba(96, 165, 250, 0.3)); + border-color: var(--color-accent-300); .canvas-thumbnail-card__pin-badge { display: flex; @@ -104,7 +104,7 @@ display: none; flex-shrink: 0; font-size: 8px; - color: var(--color-warning, #f59e0b); + color: var(--color-warning); } &__actions { @@ -129,18 +129,18 @@ transition: all 0.1s ease; &:hover { - background: var(--element-bg-base, var(--element-bg-soft)); + background: var(--element-bg-base); color: var(--color-text-primary); } &.is-active { - color: var(--color-primary, var(--color-accent-500)); + color: var(--color-primary); } } &__close-btn:hover { - background: var(--color-error-alpha, rgba(239, 68, 68, 0.15)); - color: var(--color-error, #ef4444); + background: var(--color-error-bg); + color: var(--color-error); } // Preview area @@ -218,7 +218,7 @@ justify-content: center; width: 14px; height: 14px; - background: var(--color-primary, var(--color-accent-500)); + background: var(--color-primary); border-radius: 2px; color: var(--color-text-primary); @@ -237,23 +237,23 @@ font-size: 9px; font-weight: 600; color: var(--color-text-primary); - background: var(--group-color, var(--color-accent-500, rgba(59, 130, 246, 0.8))); + background: var(--group-color, var(--color-accent-500, var(--color-accent-700))); border-radius: 3px; z-index: 1; - box-shadow: 0 1px 3px var(--color-shadow, rgba(0, 0, 0, 0.3)); + box-shadow: 0 1px 3px var(--color-shadow, var(--color-overlay-black-30)); backdrop-filter: blur(4px); - text-shadow: 0 1px 2px var(--color-shadow, rgba(0, 0, 0, 0.2)); + text-shadow: 0 1px 2px var(--color-shadow, var(--color-overlay-black-20)); &--primary { - background: var(--group-color, var(--color-accent-500, rgba(59, 130, 246, 0.85))); + background: var(--group-color, var(--color-accent-500)); } &--secondary { - background: var(--group-color, var(--color-success, rgba(16, 185, 129, 0.85))); + background: var(--group-color, var(--color-success)); } &--tertiary { - background: var(--group-color, var(--color-warning, rgba(245, 158, 11, 0.85))); + background: var(--group-color, var(--color-warning)); } } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/quick-look/QuickLook.scss b/src/web-ui/src/app/components/panels/content-canvas/quick-look/QuickLook.scss index 57491b3ad1..a9b01ea1b1 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/quick-look/QuickLook.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/quick-look/QuickLook.scss @@ -8,10 +8,10 @@ width: 500px; max-width: calc(100vw - 32px); max-height: 400px; - background: var(--color-bg-elevated, #1e1e1e); + background: var(--color-bg-elevated); border: 1px solid var(--border-base); border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + box-shadow: 0 8px 32px var(--color-overlay-black-50); display: flex; flex-direction: column; overflow: hidden; @@ -34,7 +34,7 @@ align-items: center; justify-content: space-between; padding: 8px 12px; - background: var(--color-bg-secondary, rgba(255, 255, 255, 0.03)); + background: var(--color-bg-secondary); border-bottom: 1px solid var(--border-base); } @@ -51,7 +51,7 @@ white-space: nowrap; font-size: 13px; font-weight: 500; - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + color: var(--color-text-primary); } } @@ -64,14 +64,14 @@ padding: 0; background: none; border: none; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.5)); + color: var(--color-text-secondary); cursor: pointer; border-radius: 3px; flex-shrink: 0; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } } @@ -91,23 +91,23 @@ padding: 0; background: none; border: none; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); cursor: pointer; border-radius: 4px; transition: background-color 0.15s ease, color 0.15s ease; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } } &__pin-btn:hover { - color: var(--color-primary, #60a5fa); + color: var(--color-primary); } &__close-btn:hover { - color: var(--color-danger, #f87171); + color: var(--color-danger); } // Content area @@ -138,8 +138,8 @@ gap: 4px; padding: 6px 12px; font-size: 11px; - color: var(--color-text-tertiary, rgba(255, 255, 255, 0.4)); - background: var(--color-bg-secondary, rgba(255, 255, 255, 0.02)); + color: var(--color-text-tertiary); + background: var(--color-bg-secondary); border-top: 1px solid var(--border-subtle); } diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index ff69861891..6128cb1f04 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -1070,7 +1070,7 @@ const createCanvasStoreHook = () => create()( })) ); -export type CanvasStoreMode = 'agent' | 'project' | 'git' | 'panel-view'; +export type CanvasStoreMode = 'agent' | 'project' | 'git' | 'panel-view' | 'bottom-terminal'; /** * Selects which canvas store instance is used by the current subtree. @@ -1082,6 +1082,7 @@ export const useAgentCanvasStore = createCanvasStoreHook(); export const useProjectCanvasStore = createCanvasStoreHook(); export const useGitCanvasStore = createCanvasStoreHook(); export const usePanelViewCanvasStore = createCanvasStoreHook(); +export const useBottomTerminalCanvasStore = createCanvasStoreHook(); // ==================== Agent canvas: per-workspace snapshots (AuxPane / Session scene) ==================== // Switching active workspace saves the current agent canvas under the previous workspace id and restores @@ -1209,10 +1210,12 @@ export function useCanvasStore(selector?: (state: CanvasStore) => T): T | Can const projectValue = useProjectCanvasStore(resolvedSelector); const gitValue = useGitCanvasStore(resolvedSelector); const panelViewValue = usePanelViewCanvasStore(resolvedSelector); + const bottomTerminalValue = useBottomTerminalCanvasStore(resolvedSelector); if (mode === 'project') return projectValue; if (mode === 'git') return gitValue; if (mode === 'panel-view') return panelViewValue; + if (mode === 'bottom-terminal') return bottomTerminalValue; return agentValue; } diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts index 1b96cbc5d8..dbd007190c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts @@ -9,6 +9,7 @@ export { useProjectCanvasStore, useGitCanvasStore, usePanelViewCanvasStore, + useBottomTerminalCanvasStore, useGroupTabs, useActiveTabId, useLayout, diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss index 7103721a68..e16b6a6aba 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss @@ -21,15 +21,15 @@ border-bottom: 1px solid transparent; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.05)); + background: var(--color-bg-hover); } // ==================== State styles ==================== // Active state - match canvas-tab-bar is-active-group background &.is-active { - background: var(--color-bg-scene, rgba(255, 255, 255, 0.08)); - border-bottom: 1px solid var(--color-primary, #60a5fa); + background: var(--color-bg-scene); + border-bottom: 1px solid var(--color-primary); } // Preview state - italic title @@ -64,7 +64,7 @@ &.is-file-deleted { .canvas-tab__title { - color: var(--color-text-secondary, rgba(255, 255, 255, 0.55)); + color: var(--color-text-secondary); } } @@ -78,7 +78,7 @@ // Type icon (e.g. task-detail) &__type-icon { flex-shrink: 0; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.5)); + color: var(--color-text-secondary); display: flex; align-items: center; } @@ -93,13 +93,13 @@ padding: 0; background: none; border: none; - color: var(--color-primary, #60a5fa); + color: var(--color-primary); cursor: pointer; border-radius: 2px; flex-shrink: 0; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); + background: var(--color-bg-hover); } } @@ -112,7 +112,7 @@ white-space: nowrap; font-size: 12px; // Inactive state: lighter text color - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); line-height: 1.4; transition: color 0.15s ease, font-weight 0.15s ease; } @@ -124,7 +124,7 @@ // Active state title color is brighter &.is-active &__title { - color: var(--color-text-primary, rgba(255, 255, 255, 0.95)); + color: var(--color-text-primary); } // Dirty indicator @@ -132,7 +132,7 @@ display: none; flex-shrink: 0; font-size: 10px; - color: var(--color-warning, #f59e0b); + color: var(--color-warning); margin-left: 2px; } @@ -146,7 +146,7 @@ padding: 0; background: none; border: none; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); cursor: pointer; border-radius: 2px; flex-shrink: 0; @@ -154,8 +154,8 @@ transition: opacity 0.15s ease, background-color 0.15s ease; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } } @@ -174,7 +174,7 @@ padding: 0; background: none; border: none; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); cursor: pointer; border-radius: 2px; flex-shrink: 0; @@ -182,8 +182,8 @@ transition: opacity 0.15s ease, background-color 0.15s ease; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } } @@ -202,7 +202,7 @@ top: 0; bottom: 0; width: 2px; - background: var(--color-primary, #60a5fa); + background: var(--color-primary); border-radius: 1px; z-index: 10; pointer-events: none; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss index f8ada6e2e1..dfef027ffd 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss @@ -7,12 +7,12 @@ align-items: center; height: 36px; padding: 0 4px; - background: var(--color-bg-scene, #1a1a1a); + background: var(--color-bg-scene); border-bottom: 1px solid var(--border-base); // Active group - slightly different background for active tabs &.is-active-group { - background: var(--color-bg-scene, #1a1a1a); + background: var(--color-bg-scene); } // Tab list @@ -51,18 +51,18 @@ background: transparent; border: none; border-radius: 4px; - color: var(--color-text-secondary, rgba(255, 255, 255, 0.6)); + color: var(--color-text-secondary); cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease; &:hover { - background: var(--color-bg-hover, rgba(255, 255, 255, 0.1)); - color: var(--color-text-primary, rgba(255, 255, 255, 0.9)); + background: var(--color-bg-hover); + color: var(--color-text-primary); } &--close-all:hover { background: rgba(239, 68, 68, 0.15); - color: #ef4444; + color: var(--color-error); } } } @@ -74,7 +74,7 @@ top: 4px; bottom: 4px; width: 2px; - background: var(--color-primary, #60a5fa); + background: var(--color-primary); border-radius: 1px; z-index: 10; pointer-events: none; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.scss index 5e95cd2423..126748beda 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.scss @@ -33,8 +33,8 @@ &.is-open { border-style: solid; - border-color: var(--color-primary, #60a5fa); - color: var(--color-primary, #60a5fa); + border-color: var(--color-primary); + color: var(--color-primary); } // Styles when overflow exists @@ -59,28 +59,28 @@ &__badge { font-size: 10px; font-weight: 600; - color: var(--color-primary, #60a5fa); + color: var(--color-primary); margin-left: 2px; transition: color 0.15s ease; } // Keep badge color on hover &:hover &__badge { - color: var(--color-primary, #60a5fa); + color: var(--color-primary); } // Badge color when open &.is-open &__badge { - color: var(--color-primary, #60a5fa); + color: var(--color-primary); } } // Overflow menu .canvas-tab-overflow-menu { - background: var(--color-bg-elevated, var(--color-bg-primary, #1a1a1a)); + background: var(--color-bg-elevated); border: 1px solid var(--border-base); border-radius: 6px; - box-shadow: 0 8px 24px var(--color-shadow, rgba(0, 0, 0, 0.5)); + box-shadow: 0 8px 24px var(--color-shadow, var(--color-overlay-black-50)); min-width: 200px; max-width: 280px; max-height: 360px; @@ -157,7 +157,7 @@ } &::-webkit-scrollbar-thumb { - background: var(--color-scrollbar, rgba(255, 255, 255, 0.15)); + background: var(--color-scrollbar, var(--color-overlay-white-15)); border-radius: 3px; } } @@ -174,7 +174,7 @@ &:hover { // Use element-bg-medium for clear hover in light/dark themes // Light theme: blue-gray tint; dark theme: translucent white - background: var(--element-bg-medium, var(--element-bg-base, var(--color-bg-hover))); + background: var(--element-bg-medium); .canvas-tab-overflow-menu__item-close { opacity: 1; @@ -186,7 +186,7 @@ background: var(--color-accent-100, var(--color-accent-200, var(--element-bg-base))); .canvas-tab-overflow-menu__item-title { - color: var(--color-primary, var(--color-accent-500)); + color: var(--color-primary); } } @@ -228,7 +228,7 @@ transition: all 0.1s ease; &:hover { - background: var(--color-error-alpha); + background: var(--color-error-bg); color: var(--color-error); } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/content.ts b/src/web-ui/src/app/components/panels/content-canvas/types/content.ts index 74636cc0bb..e388bf435f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/types/content.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/types/content.ts @@ -73,4 +73,8 @@ export const TAB_EVENTS = { GIT_CREATE_TAB: 'git-create-tab', /** Expand right panel */ EXPAND_RIGHT_PANEL: 'expand-right-panel', + /** Create tab in the session bottom terminal panel */ + BOTTOM_TERMINAL_CREATE_TAB: 'bottom-terminal-create-tab', + /** Expand the session bottom terminal panel */ + EXPAND_BOTTOM_TERMINAL_PANEL: 'expand-bottom-terminal-panel', } as const; diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss index 859e26e7bb..b0f15d3c69 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss @@ -128,20 +128,20 @@ } &__account--connected { - color: color-mix(in srgb, #3fb950 92%, var(--color-text-secondary)); + color: color-mix(in srgb, var(--color-success) 92%, var(--color-text-secondary)); } &__account--not_required { - color: color-mix(in srgb, #58a6ff 90%, var(--color-text-secondary)); + color: color-mix(in srgb, var(--color-accent-500) 90%, var(--color-text-secondary)); } &__account--expired, &__account--error { - color: #f85149; + color: var(--color-error); } &__account--unsupported { - color: #d29922; + color: var(--color-warning); } &__icon-button.icon-btn { @@ -204,9 +204,9 @@ gap: 12px; margin: 10px 12px; padding: 12px; - border: 1px solid color-mix(in srgb, #d29922 42%, var(--border-base)); + border: 1px solid color-mix(in srgb, var(--color-warning) 42%, var(--border-base)); border-radius: 10px; - background: color-mix(in srgb, #d29922 10%, var(--color-bg-elevated)); + background: color-mix(in srgb, var(--color-warning) 10%, var(--color-bg-elevated)); } &__auth-gate--detail { @@ -221,10 +221,10 @@ width: 34px; height: 34px; flex: 0 0 auto; - border: 1px solid color-mix(in srgb, #d29922 42%, var(--border-base)); + border: 1px solid color-mix(in srgb, var(--color-warning) 42%, var(--border-base)); border-radius: 8px; - color: #d29922; - background: color-mix(in srgb, #d29922 14%, transparent); + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); } &__auth-gate-copy { @@ -403,7 +403,7 @@ display: grid; justify-items: center; gap: 10px; - color: #f85149; + color: var(--color-error); } &__pr-row { @@ -484,31 +484,31 @@ } &__decision--approved { - color: #3fb950; - background: rgba(63, 185, 80, 0.12); + color: var(--color-success); + background: var(--color-success-bg); } &__decision--changes_requested { - color: #f85149; - background: rgba(248, 81, 73, 0.12); + color: var(--color-error); + background: var(--color-error-bg); } &__decision--commented, &__decision--pending { - color: #d29922; - background: rgba(210, 153, 34, 0.13); + color: var(--color-warning); + background: var(--color-warning-bg); } &__state-icon--open { - color: #3fb950; + color: var(--color-success); } &__state-icon--merged { - color: #a371f7; + color: var(--color-purple-500); } &__state-icon--closed { - color: #f85149; + color: var(--color-error); } &__detail { @@ -878,11 +878,11 @@ } &__review-session--completed &__review-session-icon { - color: #3fb950; + color: var(--color-success); } &__review-session--error &__review-session-icon { - color: #f85149; + color: var(--color-error); } &__review-session-icon { @@ -981,11 +981,11 @@ } &__additions { - color: #3fb950 !important; + color: var(--color-success) !important; } &__deletions { - color: #f85149 !important; + color: var(--color-error) !important; } &__file-list, @@ -1053,19 +1053,19 @@ } &__file-status--added { - color: #3fb950; - background: rgba(63, 185, 80, 0.12); + color: var(--color-success); + background: var(--color-success-bg); } &__file-status--modified, &__file-status--renamed { - color: #58a6ff; - background: rgba(88, 166, 255, 0.12); + color: var(--color-accent-500); + background: var(--color-accent-100); } &__file-status--deleted { - color: #f85149; - background: rgba(248, 81, 73, 0.12); + color: var(--color-error); + background: var(--color-error-bg); } &__file-delta { @@ -1086,7 +1086,7 @@ padding: 8px 0; background: color-mix(in srgb, var(--color-bg-primary) 72%, transparent); color: var(--color-text-secondary); - font-family: var(--font-family-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace); + font-family: var(--font-family-mono); font-size: 11px; line-height: 1.55; tab-size: 2; @@ -1100,22 +1100,22 @@ } &__diff-line--hunk { - color: #a371f7; - background: rgba(163, 113, 247, 0.1); + color: var(--color-purple-500); + background: var(--color-purple-100); } &__diff-line--add { - color: #7ee787; - background: rgba(63, 185, 80, 0.1); + color: var(--color-success); + background: var(--color-success-bg); } &__diff-line--delete { - color: #ffa198; - background: rgba(248, 81, 73, 0.1); + color: var(--color-error); + background: var(--color-error-bg); } &__diff-line--meta { - color: #79c0ff; + color: var(--color-accent-500); } &__diff-empty { @@ -1130,10 +1130,10 @@ gap: 8px; min-height: 36px; padding: 8px 10px; - border: 1px solid color-mix(in srgb, #f85149 34%, var(--border-base)); + border: 1px solid color-mix(in srgb, var(--color-error) 34%, var(--border-base)); border-radius: 7px; - background: rgba(248, 81, 73, 0.08); - color: #f85149; + background: var(--color-error-bg); + color: var(--color-error); font-size: 11px; span { @@ -1179,18 +1179,18 @@ } &__ci-item--passed { - border-color: color-mix(in srgb, #3fb950 28%, var(--border-base)); - background: color-mix(in srgb, #3fb950 8%, var(--color-bg-elevated)); + border-color: color-mix(in srgb, var(--color-success) 28%, var(--border-base)); + background: color-mix(in srgb, var(--color-success) 8%, var(--color-bg-elevated)); } &__ci-item--failed { - border-color: color-mix(in srgb, #f85149 30%, var(--border-base)); - background: color-mix(in srgb, #f85149 8%, var(--color-bg-elevated)); + border-color: color-mix(in srgb, var(--color-error) 30%, var(--border-base)); + background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-elevated)); } &__ci-item--pending { - border-color: color-mix(in srgb, #d29922 24%, var(--border-base)); - background: color-mix(in srgb, #d29922 7%, var(--color-bg-elevated)); + border-color: color-mix(in srgb, var(--color-warning) 24%, var(--border-base)); + background: color-mix(in srgb, var(--color-warning) 7%, var(--color-bg-elevated)); } &__ci-head { @@ -1268,18 +1268,18 @@ } &__ci-status--passed { - color: #3fb950; - background: rgba(63, 185, 80, 0.12); + color: var(--color-success); + background: var(--color-success-bg); } &__ci-status--failed { - color: #f85149; - background: rgba(248, 81, 73, 0.12); + color: var(--color-error); + background: var(--color-error-bg); } &__ci-status--pending { - color: #d29922; - background: rgba(210, 153, 34, 0.13); + color: var(--color-warning); + background: var(--color-warning-bg); } &__ci-meta { @@ -1348,7 +1348,7 @@ border-radius: 6px; background: color-mix(in srgb, var(--color-bg-primary) 86%, transparent); color: var(--color-text-primary); - font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, Consolas, monospace); + font-family: var(--font-family-mono); font-size: 11px; line-height: 1.45; white-space: pre-wrap; @@ -1458,15 +1458,15 @@ } &__thread-tag--resolved { - color: color-mix(in srgb, #3fb950 90%, var(--color-text-secondary)); - border-color: color-mix(in srgb, #3fb950 24%, var(--border-base)); - background: color-mix(in srgb, #3fb950 10%, transparent); + color: color-mix(in srgb, var(--color-success) 90%, var(--color-text-secondary)); + border-color: color-mix(in srgb, var(--color-success) 24%, var(--border-base)); + background: color-mix(in srgb, var(--color-success) 10%, transparent); } &__thread-tag--open { - color: color-mix(in srgb, #f1c40f 82%, var(--color-text-secondary)); - border-color: color-mix(in srgb, #f1c40f 24%, var(--border-base)); - background: color-mix(in srgb, #f1c40f 10%, transparent); + color: color-mix(in srgb, var(--color-warning) 82%, var(--color-text-secondary)); + border-color: color-mix(in srgb, var(--color-warning) 24%, var(--border-base)); + background: color-mix(in srgb, var(--color-warning) 10%, transparent); } &__thread-meta { diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index 25080ac1bd..d5670f2354 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -26,6 +26,7 @@ import { Button, IconButton, Input, MarkdownRenderer, Modal, Select, Tabs, TabPa import { reviewPlatformAPI, systemAPI, type ReviewPlatformAccount, type ReviewPlatformAuthChallenge, type ReviewPlatformCiItem, type ReviewPlatformCiLog, type ReviewPlatformCommit, type ReviewPlatformDetailSection, type ReviewPlatformFile, type ReviewPlatformPagination, type ReviewPlatformPullRequest, type ReviewPlatformPullRequestDetail, type ReviewPlatformPullRequestDetailPage, type ReviewPlatformRemote, type ReviewPlatformRepositoryRef, type ReviewPlatformThread, type ReviewPlatformWorkspaceSnapshot } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { notificationService } from '@/shared/notification-system'; +import { i18nService } from '@/infrastructure/i18n'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { FlowToolItem, Session } from '@/flow_chat/types/flow-chat'; @@ -211,20 +212,20 @@ function formatRelativeTime(value: string): string { if (!Number.isFinite(time)) return ''; const diffMs = Date.now() - time; const minutes = Math.max(1, Math.floor(diffMs / 60000)); - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 60) return i18nService.t('common:reviewPlatform.relativeTime.minutesAgo', { count: minutes }); const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; + if (hours < 24) return i18nService.t('common:reviewPlatform.relativeTime.hoursAgo', { count: hours }); + return i18nService.t('common:reviewPlatform.relativeTime.daysAgo', { count: Math.floor(hours / 24) }); } function formatAbsoluteTime(value: string): string { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; - return new Intl.DateTimeFormat(undefined, { + return i18nService.formatDate(date, { dateStyle: 'medium', timeStyle: 'short', - }).format(date); + }); } function getPrIcon(pr: ReviewPlatformPullRequest) { diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss new file mode 100644 index 0000000000..38426208d0 --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss @@ -0,0 +1,5 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +.scheduled-jobs-modal__body { + padding: $size-gap-4; +} diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx new file mode 100644 index 0000000000..d89f32e6ac --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Modal } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import ScheduledJobsView from '@/app/components/scheduled-jobs/ScheduledJobsView'; +import type { CronJobTargetKind } from '@/infrastructure/api'; +import type { WorkspaceKind } from '@/shared/types'; +import './ScheduledJobsModal.scss'; + +interface ScheduledJobsModalProps { + isOpen: boolean; + onClose: () => void; + workspacePath?: string; + workspaceId?: string; + workspaceKind?: WorkspaceKind; + remoteConnectionId?: string | null; + remoteSshHost?: string | null; + sessionId?: string; + targetKind: CronJobTargetKind; + lockSessionId?: boolean; + title?: string; + targetLabel?: string; + targetDescription?: string; +} + +const ScheduledJobsModal: React.FC = ({ + isOpen, + onClose, + workspacePath, + workspaceId, + workspaceKind, + remoteConnectionId, + remoteSshHost, + sessionId, + targetKind, + lockSessionId = false, + title, + targetLabel, + targetDescription, +}) => { + const { t } = useI18n('common'); + + return ( + +
+ +
+
+ ); +}; + +export default ScheduledJobsModal; diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss new file mode 100644 index 0000000000..b56a94d211 --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss @@ -0,0 +1,553 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +// ── Root ────────────────────────────────────────────────────────────────────── + +.asv { + display: flex; + flex-direction: column; + gap: $size-gap-3; + width: 100%; + + // ── Header ───────────────────────────────────────────────────────────────── + + &__head { + display: flex; + align-items: center; + gap: $size-gap-3; + padding-bottom: $size-gap-3; + min-width: 0; + + &--actions-only { + justify-content: flex-end; + } + } + + &__head-title { + flex: 1; + font-size: var(--font-size-2xs); + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + } + + &__target { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: $size-gap-2; + color: var(--color-text-secondary); + } + + &__target-kind { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-base) 78%, transparent); + font-size: 12px; + font-weight: $font-weight-semibold; + color: var(--color-text-secondary); + line-height: 1; + white-space: nowrap; + } + + &__target-main, + &__target-sub { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__target-main { + flex: 0 1 auto; + max-width: 42%; + font-size: 13px; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + &__target-sub { + flex: 1 1 auto; + font-size: 12px; + color: var(--color-text-muted); + } + + &__new-job.btn { + flex-shrink: 0; + background: var(--element-bg-medium); + color: var(--color-text-primary); + + &:hover:not(:disabled) { + background: var(--element-bg-strong); + color: var(--color-text-primary); + } + + &:active:not(:disabled) { + background: var(--element-bg-base); + color: var(--color-text-primary); + } + } + + &__spin { + animation: asv-spin 0.8s linear infinite; + } + + // ── Empty state ───────────────────────────────────────────────────────────── + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $size-gap-3; + padding: $size-gap-10 $size-gap-5; + text-align: center; + } + + &__empty-title { + margin: 0; + font-size: var(--font-size-sm); + font-weight: $font-weight-semibold; + color: var(--color-text-secondary); + } + + &__empty-text { + margin: 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + line-height: $line-height-relaxed; + } + + // ── Job list ──────────────────────────────────────────────────────────────── + + &__list { + display: flex; + flex-direction: column; + gap: $size-gap-3; + border: none; + border-radius: 0; + overflow: visible; + background: transparent; + } + + // ── Job cards ─────────────────────────────────────────────────────────────── + + &__item { + display: flex; + align-items: flex-start; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border: none; + border-radius: $size-radius-lg; + background: var(--element-bg-base); + cursor: pointer; + position: relative; + transform: translateY(0); + box-shadow: none; + transition: + background $motion-fast $easing-standard, + transform $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; + + &:hover { + z-index: 1; + background: var(--element-bg-soft); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + } + + &:active { + transform: translateY(-1px); + box-shadow: var(--shadow-xs); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; + } + + &.is-expanded { + background: var(--element-bg-soft); + box-shadow: var(--shadow-xs); + } + } + + &__item-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__item-top { + display: flex; + align-items: center; + gap: $size-gap-2; + min-width: 0; + justify-content: space-between; + } + + &__item-name { + flex: 1 1 auto; + min-width: 0; + font-size: var(--font-size-sm); + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__item-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-3; + min-width: 0; + } + + &__item-meta { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + line-height: $line-height-relaxed; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &--dim { + color: var(--color-text-muted); + flex-shrink: 0; + } + } + + &__item-next-run { + text-align: right; + } + + &__item-error { + font-size: var(--font-size-xs); + color: var(--color-danger-500); + line-height: $line-height-relaxed; + word-break: break-word; + } + + &__item-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: $size-gap-2; + } + + &__switch-wrap { + flex-shrink: 0; + display: flex; + align-items: center; + } + + &__field--switch { + min-width: 0; + } + + &__editor { + display: flex; + flex-direction: column; + gap: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-xl; + background: + linear-gradient(180deg, color-mix(in srgb, var(--element-bg-soft) 82%, transparent), var(--element-bg-subtle)); + box-shadow: + inset 0 1px 0 var(--color-overlay-white-03), + 0 10px 24px var(--color-overlay-black-08); + overflow: hidden; + } + + // ── Form ────────────────────────────────────────────────────────────────── + + &__form { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding: $size-gap-3; + } + + &__form-row { + display: flex; + flex-direction: column; + + &--inline { + display: grid; + grid-template-columns: minmax(72px, 88px) minmax(0, 1fr); + gap: $size-gap-2; + align-items: center; + } + + &--two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $size-gap-4; + align-items: start; + } + + &--prompt { + align-items: start; + } + } + + &__field, + &__field-meta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + &__field-control { + min-width: 0; + } + + &__field-label { + font-size: 11px; + font-weight: $font-weight-semibold; + color: var(--color-text-secondary); + line-height: 1.35; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + &__field-note { + font-size: var(--font-size-2xs); + color: var(--color-text-muted); + line-height: 1.4; + } + + &__control-grid { + display: grid; + gap: $size-gap-2; + min-width: 0; + + &--schedule { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + } + + &__toggle-card { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + box-sizing: border-box; + height: 28px; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-base) 78%, transparent); + white-space: nowrap; + min-width: 126px; + + .bitfun-switch { + align-items: center; + gap: 0; + } + + .bitfun-switch__wrapper { + margin-top: 0; + } + } + + &__toggle-label { + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + } + + &__agent-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-3; + width: 100%; + min-width: 0; + } + + &__agent-option-label, + &__agent-option-description { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__agent-option-label { + flex: 0 0 auto; + max-width: 40%; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + } + + &__agent-option-description { + flex: 1 1 auto; + text-align: right; + font-size: 12px; + color: var(--color-text-muted); + } + + &__agent-select { + .select__dropdown { + max-height: 156px; + } + } + + &__session-select { + .select__dropdown { + max-height: 156px; + } + } + + &__warning { + font-size: var(--font-size-xs); + color: var(--color-danger-500); + line-height: $line-height-relaxed; + padding: $size-gap-2 $size-gap-3; + border-radius: $size-radius-base; + background: rgba(248, 113, 113, 0.06); + border: 1px solid rgba(248, 113, 113, 0.2); + } + + &__prompt-textarea { + position: relative; + gap: 0; + + .bitfun-textarea__field { + min-height: 92px; + padding: 10px 12px 24px; + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-base) 82%, transparent); + } + + .bitfun-textarea__footer { + position: absolute; + right: 10px; + bottom: 6px; + justify-content: flex-end; + min-height: 0; + pointer-events: none; + } + + .bitfun-textarea__hint-wrapper { + display: none; + } + + .bitfun-textarea__count { + padding: 1px 5px; + border-radius: $size-radius-sm; + background: color-mix(in srgb, var(--element-bg-base) 88%, transparent); + font-size: 11px; + line-height: 1.2; + } + } + + &__form-actions { + display: flex; + justify-content: flex-end; + gap: $size-gap-2; + padding-top: $size-gap-3; + margin-top: $size-gap-1; + border-top: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + } + + &__action-btn.btn { + min-width: 92px; + font-size: 13px; + font-weight: $font-weight-medium; + letter-spacing: 0; + } + + &__action-btn--ghost.btn { + color: var(--color-text-secondary); + } +} + +@media (max-width: 760px) { + .asv { + &__form-row { + &--inline { + grid-template-columns: 1fr; + gap: $size-gap-2; + } + } + + &__control-grid { + &--schedule { + grid-template-columns: 1fr; + } + } + + &__toggle-card { + justify-content: space-between; + } + + &__head { + align-items: flex-start; + } + + &__target { + flex-direction: column; + align-items: flex-start; + gap: $size-gap-1; + } + + &__target-main, + &__target-sub { + max-width: 100%; + width: 100%; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .asv__item { + transition: background $motion-fast $easing-standard; + + &:hover, + &:active { + transform: none; + } + } +} + +// ── Animations ──────────────────────────────────────────────────────────────── + +@keyframes asv-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes asv-expand { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +// ── Legacy embed wrapper (keep compatible) ───────────────────────────────── + +.nursery-assistant-schedule-embed { + width: 100%; + + .asv { + width: 100%; + } +} + +.nursery-assistant-schedule-embed-fallback { + min-height: 80px; +} diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx new file mode 100644 index 0000000000..456ec5f853 --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx @@ -0,0 +1,1174 @@ +/** + * ScheduledJobsView — inline view for managing scheduled jobs. + * + * Designed for compact side panels and modals: single-column layout, + * job list at top, inline editor expands below the selected job. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { RefreshCw, Trash2 } from 'lucide-react'; +import { + Button, + IconButton, + Input, + Select, + Switch, + Textarea, + confirmDanger, +} from '@/component-library'; +import { + cronAPI, + type CreateCronJobRequest, + type CronJob, + type CronJobTarget, + type CronJobTargetKind, + type CronSchedule, + type CronWorkspaceRef, + type UpdateCronJobRequest, +} from '@/infrastructure/api'; +import { agentAPI, type ModeInfo } from '@/infrastructure/api/service-api/AgentAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import { + compareSessionsForDisplay, + sessionBelongsToWorkspaceNavRow, +} from '@/flow_chat/utils/sessionOrdering'; +import { notificationService } from '@/shared/notification-system/services/NotificationService'; +import { createLogger } from '@/shared/utils/logger'; +import { i18nService } from '@/infrastructure/i18n'; +import { resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; +import { WorkspaceKind } from '@/shared/types'; +import { normalizePath } from '@/shared/utils/pathUtils'; +import './ScheduledJobsView.scss'; + +const log = createLogger('ScheduledJobsView'); +const MINUTE_IN_MS = 60_000; +const NEW_JOB_ID = '__new__'; +const DEFAULT_AGENT_TYPE = 'agentic'; +const ASSISTANT_WORKSPACE_AGENT_TYPE = 'Claw'; +const SCHEDULED_JOBS_CHANGED_EVENT = 'bitfun:scheduled-jobs-changed'; + +type ScheduleKind = CronSchedule['kind']; + +interface JobDraft { + name: string; + text: string; + enabled: boolean; + sessionId: string; + agentType: string; + scheduleKind: ScheduleKind; + at: string; + everyMinutes: string; + anchorMs: string; + expr: string; + tz: string; +} + +interface JobDraftValidationErrors { + name: boolean; + sessionId: boolean; + agentType: boolean; + text: boolean; + at: boolean; + everyMinutes: boolean; + cronExpr: boolean; +} + +export interface ScheduledJobsViewProps { + workspacePath?: string; + workspaceId?: string; + workspaceKind?: WorkspaceKind; + remoteConnectionId?: string | null; + remoteSshHost?: string | null; + sessionId?: string; + assistantName?: string; + headerTitle?: string | null; + targetLabel?: string; + targetDescription?: string; + targetKind?: CronJobTargetKind; + lockSessionId?: boolean; + assistantWorkspaceMode?: boolean; +} + +function getCurrentLocalDateTimeInput(): string { + return toLocalDateTimeInput(new Date().toISOString()); +} + +function toLocalDateTimeInput(isoTimestamp: string): string { + const date = new Date(isoTimestamp); + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60_000); + return localDate.toISOString().slice(0, 16); +} + +function timestampMsToLocalDateTimeInput(timestampMs: number): string { + return toLocalDateTimeInput(new Date(timestampMs).toISOString()); +} + +function formatEveryMinutes(everyMs: number): string { + const everyMinutes = everyMs / MINUTE_IN_MS; + if (Number.isInteger(everyMinutes)) return String(everyMinutes); + return everyMinutes.toFixed(2).replace(/\.?0+$/, ''); +} + +function getDefaultAgentType( + targetKind: CronJobTargetKind, + workspaceKind?: WorkspaceKind, +): string { + if (targetKind === 'workspace' && workspaceKind === WorkspaceKind.Assistant) { + return ASSISTANT_WORKSPACE_AGENT_TYPE; + } + return DEFAULT_AGENT_TYPE; +} + +function createEmptyDraft(defaultSessionId = '', defaultAgentType = DEFAULT_AGENT_TYPE): JobDraft { + return { + name: '', + text: '', + enabled: true, + sessionId: defaultSessionId, + agentType: defaultAgentType, + scheduleKind: 'at', + at: getCurrentLocalDateTimeInput(), + everyMinutes: '60', + anchorMs: '', + expr: '0 8 * * *', + tz: '', + }; +} + +function buildWorkspaceRef( + workspacePath?: string, + workspaceId?: string, + remoteConnectionId?: string | null, + remoteSshHost?: string | null, +): CronWorkspaceRef | null { + const normalizedWorkspacePath = normalizePath(workspacePath?.trim() ?? ''); + if (!normalizedWorkspacePath) { + return null; + } + + return { + workspacePath: normalizedWorkspacePath, + workspaceId: workspaceId?.trim() || undefined, + remoteConnectionId: remoteConnectionId?.trim() || undefined, + remoteSshHost: remoteSshHost?.trim() || undefined, + }; +} + +function buildTargetFromDraft( + targetKind: CronJobTargetKind, + draft: JobDraft, + workspace: CronWorkspaceRef, +): CronJobTarget { + if (targetKind === 'session') { + return { + kind: 'session', + sessionId: draft.sessionId.trim(), + workspace, + }; + } + + return { + kind: 'workspace', + workspace, + launch: { + agentType: draft.agentType.trim() || DEFAULT_AGENT_TYPE, + }, + }; +} + +function jobToDraft(job: CronJob, defaultAgentType: string): JobDraft { + const base = createEmptyDraft('', defaultAgentType); + const draft: JobDraft = { + ...base, + name: job.name, + text: job.payload.text, + enabled: job.enabled, + }; + if (job.target.kind === 'session') { + draft.sessionId = job.target.sessionId; + } else { + draft.agentType = job.target.launch.agentType || defaultAgentType; + } + if (job.schedule.kind === 'at') { + draft.scheduleKind = 'at'; + draft.at = toLocalDateTimeInput(job.schedule.at); + } else if (job.schedule.kind === 'every') { + draft.scheduleKind = 'every'; + draft.everyMinutes = formatEveryMinutes(job.schedule.everyMs); + draft.anchorMs = job.schedule.anchorMs != null + ? timestampMsToLocalDateTimeInput(job.schedule.anchorMs) + : ''; + } else { + draft.scheduleKind = 'cron'; + draft.expr = job.schedule.expr; + draft.tz = job.schedule.tz ?? ''; + } + return draft; +} + +function buildScheduleFromDraft(draft: JobDraft): CronSchedule { + if (draft.scheduleKind === 'at') { + return { kind: 'at', at: new Date(draft.at).toISOString() }; + } + if (draft.scheduleKind === 'every') { + const everyMinutes = Number(draft.everyMinutes); + const anchorMs = draft.anchorMs.trim() ? new Date(draft.anchorMs).getTime() : undefined; + return { kind: 'every', everyMs: Math.round(everyMinutes * MINUTE_IN_MS), anchorMs }; + } + return { kind: 'cron', expr: draft.expr.trim(), tz: draft.tz.trim() || undefined }; +} + +function validateDraft( + targetKind: CronJobTargetKind, + draft: JobDraft, +): JobDraftValidationErrors { + const everyMinutes = Number(draft.everyMinutes); + return { + name: !draft.name.trim(), + sessionId: targetKind === 'session' && !draft.sessionId.trim(), + agentType: targetKind === 'workspace' && !draft.agentType.trim(), + text: !draft.text.trim(), + at: draft.scheduleKind === 'at' && !draft.at.trim(), + everyMinutes: + draft.scheduleKind === 'every' + && (!draft.everyMinutes.trim() || !Number.isFinite(everyMinutes) || everyMinutes <= 0), + cronExpr: draft.scheduleKind === 'cron' && !draft.expr.trim(), + }; +} + +function hasValidationErrors(errors: JobDraftValidationErrors): boolean { + return ( + errors.name + || errors.sessionId + || errors.agentType + || errors.text + || errors.at + || errors.everyMinutes + || errors.cronExpr + ); +} + +function getNextExecutionAtMs(job: CronJob): number | null { + return job.state.pendingTriggerAtMs ?? job.state.retryAtMs ?? job.state.nextRunAtMs ?? null; +} + +function formatScheduleSummary( + schedule: CronSchedule, + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string, + t: (key: string, params?: Record) => string, +): string { + switch (schedule.kind) { + case 'at': + return `${t('nav.scheduledJobs.scheduleKinds.at')}: ${formatTimestamp(new Date(schedule.at).getTime(), formatDate, t)}`; + case 'every': + return t('nav.scheduledJobs.scheduleSummary.every', { everyMinutes: formatEveryMinutes(schedule.everyMs) }); + case 'cron': + return schedule.tz + ? t('nav.scheduledJobs.scheduleSummary.cronWithTz', { expr: schedule.expr, tz: schedule.tz }) + : t('nav.scheduledJobs.scheduleSummary.cron', { expr: schedule.expr }); + default: + return ''; + } +} + +function formatJobMetaSummary( + job: CronJob, + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string, + t: (key: string, params?: Record) => string, + options?: { + showTarget: boolean; + resolveSessionLabel: (sessionId: string) => string | undefined; + }, +): string { + const scheduleSummary = formatScheduleSummary(job.schedule, formatDate, t); + if (options?.showTarget) { + if (job.target.kind === 'session') { + const sessionLabel = options.resolveSessionLabel(job.target.sessionId) || job.target.sessionId; + return `${sessionLabel} · ${scheduleSummary}`; + } + return `${t('nav.scheduledJobs.targets.newSession')} · ${scheduleSummary}`; + } + if (job.target.kind === 'workspace') { + return `${job.target.launch.agentType} · ${scheduleSummary}`; + } + return scheduleSummary; +} + +function formatTimestamp( + timestampMs: number | null | undefined, + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string, + t: (key: string, params?: Record) => string, +): string { + if (!timestampMs || !Number.isFinite(timestampMs)) return t('nav.scheduledJobs.never'); + return formatDate(timestampMs, { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }); +} + +function resolveSessionLabel(session: Session): string { + return resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); +} + +function buildWorkspaceAgentOptions( + modes: ModeInfo[], + currentAgentType: string, + defaultAgentType: string, + workspaceKind?: WorkspaceKind, +) { + if (workspaceKind === WorkspaceKind.Assistant) { + return [ + { + value: defaultAgentType, + label: defaultAgentType, + description: undefined, + }, + ]; + } + + const options = modes + .filter(mode => mode.id !== ASSISTANT_WORKSPACE_AGENT_TYPE) + .map(mode => ({ + value: mode.id, + label: mode.name?.trim() || mode.id, + description: mode.description?.trim() || undefined, + })); + + const fallbackAgentTypes = [currentAgentType, defaultAgentType] + .map(value => value.trim()) + .filter(value => Boolean(value) && value !== ASSISTANT_WORKSPACE_AGENT_TYPE); + + for (const agentType of fallbackAgentTypes) { + if (!options.some(option => option.value === agentType)) { + options.push({ + value: agentType, + label: agentType, + description: undefined, + }); + } + } + + return options; +} + +const ScheduledJobsView: React.FC = ({ + workspacePath, + workspaceId, + workspaceKind, + remoteConnectionId, + remoteSshHost, + sessionId, + headerTitle, + targetLabel, + targetDescription, + targetKind = 'session', + lockSessionId = false, + assistantWorkspaceMode = false, +}) => { + const { t, formatDate } = useI18n('common'); + const instanceIdRef = useRef(`scheduled-jobs-${Math.random().toString(36).slice(2)}`); + const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + const [jobs, setJobs] = useState([]); + const [availableModes, setAvailableModes] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedJobId, setSelectedJobId] = useState(null); + const [expandedJobId, setExpandedJobId] = useState(null); + const [validationErrors, setValidationErrors] = useState({ + name: false, + sessionId: false, + agentType: false, + text: false, + at: false, + everyMinutes: false, + cronExpr: false, + }); + + const defaultAgentType = useMemo( + () => assistantWorkspaceMode + ? ASSISTANT_WORKSPACE_AGENT_TYPE + : getDefaultAgentType(targetKind, workspaceKind), + [assistantWorkspaceMode, targetKind, workspaceKind], + ); + const workspaceRef = useMemo( + () => buildWorkspaceRef(workspacePath, workspaceId, remoteConnectionId, remoteSshHost), + [remoteConnectionId, remoteSshHost, workspaceId, workspacePath], + ); + + const [draft, setDraft] = useState(() => + createEmptyDraft(sessionId ?? '', defaultAgentType), + ); + const draftTargetKind: CronJobTargetKind = assistantWorkspaceMode && !draft.sessionId.trim() + ? 'workspace' + : targetKind; + + const notifyScheduledJobsChanged = useCallback(() => { + window.dispatchEvent(new CustomEvent(SCHEDULED_JOBS_CHANGED_EVENT, { + detail: { sourceId: instanceIdRef.current }, + })); + }, []); + + useEffect(() => { + const unsubscribe = flowChatStore.subscribe((state) => setFlowChatState(state)); + return unsubscribe; + }, []); + + useEffect(() => { + let cancelled = false; + + const loadAvailableModes = async () => { + try { + const modes = await agentAPI.getAvailableModes(); + if (!cancelled) { + setAvailableModes(modes); + } + } catch (error) { + log.error('Failed to load available modes for scheduled job editor', { error }); + } + }; + + if (targetKind === 'workspace' && !assistantWorkspaceMode) { + void loadAvailableModes(); + } + + return () => { + cancelled = true; + }; + }, [assistantWorkspaceMode, targetKind]); + + const workspaceSessions = useMemo(() => { + return Array.from(flowChatState.sessions.values()) + .filter(s => { + if (s.parentSessionId) return false; + if (workspaceId && s.workspaceId && s.workspaceId !== workspaceId) return false; + const trimmedWorkspacePath = workspacePath?.trim() ?? ''; + if (!trimmedWorkspacePath) return !s.workspacePath; + return sessionBelongsToWorkspaceNavRow( + s, + trimmedWorkspacePath, + remoteConnectionId, + remoteSshHost, + ); + }) + .sort(compareSessionsForDisplay); + }, [flowChatState.sessions, remoteConnectionId, remoteSshHost, workspaceId, workspacePath]); + + const defaultSessionIdForWorkspace = useMemo( + () => sessionId || workspaceSessions[0]?.sessionId || '', + [sessionId, workspaceSessions], + ); + + const sortedJobs = useMemo(() => [...jobs].sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + const diff = b.configUpdatedAtMs - a.configUpdatedAtMs; + return diff !== 0 ? diff : b.createdAtMs - a.createdAtMs; + }), [jobs]); + + const loadJobs = useCallback(async () => { + setLoading(true); + const request = { + workspacePath: workspaceRef?.workspacePath, + workspaceId: workspaceRef?.workspaceId ?? undefined, + remoteConnectionId: workspaceRef?.remoteConnectionId ?? undefined, + sessionId: targetKind === 'session' && lockSessionId && !assistantWorkspaceMode ? sessionId || undefined : undefined, + targetKind: assistantWorkspaceMode ? undefined : targetKind, + }; + try { + const result = await cronAPI.listJobs(request); + setJobs(result); + setExpandedJobId(current => { + if (current === NEW_JOB_ID) { + return current; + } + if (current && result.some(j => j.id === current)) return current; + return null; + }); + setSelectedJobId(current => { + if (current && result.some(j => j.id === current)) return current; + return null; + }); + } catch (error) { + log.error('Failed to load scheduled jobs', { error }); + notificationService.error( + t('nav.scheduledJobs.messages.loadFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setLoading(false); + } + }, [assistantWorkspaceMode, lockSessionId, sessionId, t, targetKind, workspaceRef]); + + useEffect(() => { void loadJobs(); }, [loadJobs]); + + useEffect(() => { + const handleScheduledJobsChanged = (event: Event) => { + const sourceId = (event as CustomEvent<{ sourceId?: string }>).detail?.sourceId; + if (sourceId === instanceIdRef.current) return; + void loadJobs(); + }; + + window.addEventListener(SCHEDULED_JOBS_CHANGED_EVENT, handleScheduledJobsChanged); + return () => { + window.removeEventListener(SCHEDULED_JOBS_CHANGED_EVENT, handleScheduledJobsChanged); + }; + }, [loadJobs]); + + useEffect(() => { + setDraft(prev => ({ + ...prev, + sessionId: targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : prev.sessionId || defaultSessionIdForWorkspace) + : prev.sessionId, + agentType: targetKind === 'workspace' || assistantWorkspaceMode + ? ( + workspaceKind === WorkspaceKind.Assistant || assistantWorkspaceMode + ? defaultAgentType + : ( + !prev.agentType + || prev.agentType === ASSISTANT_WORKSPACE_AGENT_TYPE + ? defaultAgentType + : prev.agentType + ) + ) + : prev.agentType, + })); + }, [ + assistantWorkspaceMode, + defaultAgentType, + defaultSessionIdForWorkspace, + lockSessionId, + sessionId, + targetKind, + workspaceKind, + ]); + + const resetValidationErrors = useCallback(() => { + setValidationErrors({ + name: false, + sessionId: false, + agentType: false, + text: false, + at: false, + everyMinutes: false, + cronExpr: false, + }); + }, []); + + const handleCreateNew = useCallback(() => { + setSelectedJobId(null); + resetValidationErrors(); + setDraft(createEmptyDraft( + targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : defaultSessionIdForWorkspace) + : '', + defaultAgentType, + )); + setExpandedJobId(NEW_JOB_ID); + }, [ + defaultAgentType, + defaultSessionIdForWorkspace, + assistantWorkspaceMode, + lockSessionId, + resetValidationErrors, + sessionId, + targetKind, + ]); + + const handleEditJob = useCallback((job: CronJob) => { + if (expandedJobId === job.id) { + setExpandedJobId(null); + setSelectedJobId(null); + return; + } + setSelectedJobId(job.id); + resetValidationErrors(); + setDraft(jobToDraft(job, defaultAgentType)); + setExpandedJobId(job.id); + }, [defaultAgentType, expandedJobId, resetValidationErrors]); + + const handleCloseEditor = useCallback(() => { + setExpandedJobId(null); + setSelectedJobId(null); + resetValidationErrors(); + setDraft(createEmptyDraft( + targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : defaultSessionIdForWorkspace) + : '', + defaultAgentType, + )); + }, [ + defaultAgentType, + defaultSessionIdForWorkspace, + assistantWorkspaceMode, + lockSessionId, + resetValidationErrors, + sessionId, + targetKind, + ]); + + const handleDeleteJob = useCallback(async (job: CronJob) => { + const confirmed = await confirmDanger( + t('nav.scheduledJobs.deleteDialog.title', { name: job.name }), + null, + ); + if (!confirmed) return; + try { + await cronAPI.deleteJob(job.id); + if (selectedJobId === job.id || expandedJobId === job.id) { handleCloseEditor(); } + await loadJobs(); + notifyScheduledJobsChanged(); + } catch (error) { + log.error('Failed to delete scheduled job', { jobId: job.id, error }); + notificationService.error( + t('nav.scheduledJobs.messages.deleteFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }, [expandedJobId, handleCloseEditor, loadJobs, notifyScheduledJobsChanged, selectedJobId, t]); + + const handleToggleEnabled = useCallback(async (job: CronJob, enabled: boolean) => { + try { + await cronAPI.updateJob(job.id, { enabled }); + await loadJobs(); + notifyScheduledJobsChanged(); + } catch (error) { + log.error('Failed to toggle scheduled job', { jobId: job.id, error }); + notificationService.error( + t('nav.scheduledJobs.messages.updateFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }, [loadJobs, notifyScheduledJobsChanged, t]); + + const handleSave = useCallback(async () => { + const nextValidationErrors = validateDraft(draftTargetKind, draft); + setValidationErrors(nextValidationErrors); + if (hasValidationErrors(nextValidationErrors)) { return; } + if (!workspaceRef) { return; } + + const schedule = buildScheduleFromDraft(draft); + const target = buildTargetFromDraft(draftTargetKind, draft, workspaceRef); + + setSaving(true); + try { + if (selectedJobId) { + const request: UpdateCronJobRequest = { + name: draft.name.trim(), + payload: { text: draft.text.trim() }, + enabled: draft.enabled, + schedule, + target, + }; + const updated = await cronAPI.updateJob(selectedJobId, request); + setSelectedJobId(null); + setDraft(jobToDraft(updated, defaultAgentType)); + setExpandedJobId(null); + } else { + const request: CreateCronJobRequest = { + name: draft.name.trim(), + payload: { text: draft.text.trim() }, + enabled: draft.enabled, + schedule, + target, + }; + await cronAPI.createJob(request); + setSelectedJobId(null); + setExpandedJobId(null); + setDraft(createEmptyDraft( + targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : defaultSessionIdForWorkspace) + : '', + defaultAgentType, + )); + } + await loadJobs(); + notifyScheduledJobsChanged(); + } catch (error) { + log.error('Failed to save scheduled job', { error }); + notificationService.error( + t('nav.scheduledJobs.messages.saveFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setSaving(false); + } + }, [ + defaultAgentType, + defaultSessionIdForWorkspace, + draft, + draftTargetKind, + loadJobs, + notifyScheduledJobsChanged, + assistantWorkspaceMode, + lockSessionId, + selectedJobId, + sessionId, + t, + targetKind, + workspaceRef, + ]); + + const sessionOptions = useMemo( + () => workspaceSessions.map(s => ({ + value: s.sessionId, + label: resolveSessionLabel(s), + })), + [workspaceSessions], + ); + const sessionLabelById = useMemo(() => { + const labels = new Map(); + workspaceSessions.forEach(session => { + labels.set(session.sessionId, resolveSessionLabel(session)); + }); + return labels; + }, [workspaceSessions]); + const workspaceAgentOptions = useMemo( + () => buildWorkspaceAgentOptions( + availableModes, + draft.agentType, + defaultAgentType, + workspaceKind, + ), + [availableModes, defaultAgentType, draft.agentType, workspaceKind], + ); + + const canSave = assistantWorkspaceMode + ? Boolean(workspaceRef) + : targetKind === 'session' + ? Boolean(workspaceRef && draft.sessionId.trim()) + : Boolean(workspaceRef && draft.agentType.trim()); + const effectiveHeaderTitle = headerTitle === undefined ? t('nav.scheduledJobs.title') : headerTitle; + const targetTypeLabel = targetKind === 'session' + ? t('nav.scheduledJobs.targets.session') + : t('shared:features.workspace'); + const hasHeaderContent = Boolean(targetLabel?.trim() || effectiveHeaderTitle); + const emptyTitle = targetKind === 'workspace' && !assistantWorkspaceMode + ? t('nav.scheduledJobs.empty.workspaceTitle') + : t('nav.scheduledJobs.empty.title'); + + return ( +
+
+ {targetLabel?.trim() ? ( +
+ {targetTypeLabel} + {targetLabel} + {targetDescription?.trim() ? ( + {targetDescription} + ) : null} +
+ ) : effectiveHeaderTitle ? ( + {effectiveHeaderTitle} + ) : null} + +
+ + {expandedJobId === NEW_JOB_ID ? renderEditor() : null} + + {loading ? ( +
+ +
+ ) : sortedJobs.length === 0 && expandedJobId !== NEW_JOB_ID ? ( +
+

{emptyTitle}

+
+ ) : sortedJobs.length > 0 ? ( +
+ {sortedJobs.map(job => { + const isExpanded = expandedJobId === job.id; + return ( + +
handleEditJob(job)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleEditJob(job); + } + }} + > +
+
+ {job.name} +
+
e.stopPropagation()} + role="presentation" + > + { + void handleToggleEnabled(job, e.currentTarget.checked); + }} + aria-label={t('nav.scheduledJobs.actions.toggleEnabled')} + /> +
+ { e.stopPropagation(); void handleDeleteJob(job); }} + > + + +
+
+
+
+ {formatJobMetaSummary(job, formatDate, t, { + showTarget: assistantWorkspaceMode, + resolveSessionLabel: sessionId => sessionLabelById.get(sessionId), + })} +
+
+ {t('nav.scheduledJobs.nextRunLabel')}: {formatTimestamp(getNextExecutionAtMs(job), formatDate, t)} +
+
+ {job.state.lastError ? ( +
{job.state.lastError}
+ ) : null} +
+
+ {isExpanded ? renderEditor() : null} +
+ ); + })} +
+ ) : null} +
+ ); + + function renderEditor() { + return ( +
+ {renderForm()} +
+ ); + } + + function renderForm() { + return ( +
+ {targetKind === 'session' && !assistantWorkspaceMode && !canSave ? ( +

{t('nav.scheduledJobs.messages.sessionRequired')}

+ ) : null} + +
+
+ {t('nav.scheduledJobs.fields.name')} +
+
+ { + const name = e.currentTarget.value; + setValidationErrors(current => ({ ...current, name: false })); + setDraft(c => ({ ...c, name })); + }} + error={validationErrors.name} + placeholder={t('nav.scheduledJobs.placeholders.name')} + /> +
+
+ +
+
+ {t('nav.scheduledJobs.fields.scheduleKind')} +
+
+
+ { + const at = e.currentTarget.value; + setValidationErrors(current => ({ ...current, at: false })); + setDraft(c => ({ ...c, at })); + }} + /> +
+
+ )} + + {draft.scheduleKind === 'every' && ( + <> +
+
+ {t('nav.scheduledJobs.fields.everyMs')} +
+
+ { + const everyMinutes = e.currentTarget.value; + setValidationErrors(current => ({ ...current, everyMinutes: false })); + setDraft(c => ({ ...c, everyMinutes })); + }} + placeholder="60" + /> +
+
+
+
+ {t('nav.scheduledJobs.fields.anchorMs')} +
+
+ { + const anchorMs = e.currentTarget.value; + setDraft(c => ({ ...c, anchorMs })); + }} + placeholder={t('nav.scheduledJobs.placeholders.anchorMs')} + /> +
+
+ + )} + + {draft.scheduleKind === 'cron' && ( + <> +
+
+ {t('nav.scheduledJobs.fields.cronExpr')} +
+
+ { + const expr = e.currentTarget.value; + setValidationErrors(current => ({ ...current, cronExpr: false })); + setDraft(c => ({ ...c, expr })); + }} + placeholder="0 8 * * *" + /> + + {t('nav.scheduledJobs.hints.cronExpr')} + +
+
+
+
+ {t('nav.scheduledJobs.fields.timezone')} +
+
+ { + const tz = e.currentTarget.value; + setDraft(c => ({ ...c, tz })); + }} + placeholder={t('nav.scheduledJobs.placeholders.timezone')} + /> +
+
+ + )} + + {assistantWorkspaceMode ? ( +
+
+ {t('nav.scheduledJobs.fields.session')} +
+
+ ( +
+ {option.label} + {option.description ? ( + {option.description} + ) : null} +
+ )} + onChange={value => { + const agentType = String(value); + setValidationErrors(current => ({ ...current, agentType: false })); + setDraft(c => ({ ...c, agentType })); + }} + placeholder={t('nav.scheduledJobs.placeholders.agentType')} + /> +
+
+ ) : !lockSessionId ? ( +
+
+ {t('nav.scheduledJobs.fields.session')} +
+
+