From 2f230d7220814b09f70502269ce734601888e15d Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 14 Aug 2026 10:44:36 +0800 Subject: [PATCH] perf(agent-runtime)!: isolate leaf capability closures Split DeepResearch and native Hook settings from the full portable runtime, close all direct consumer feature profiles, and preserve provider-owned workspace IO semantics. BREAKING CHANGE: Rust Runtime SDK v6 embedders must explicitly enable the agent-runtime feature; DeepResearch report helpers now require an injected WorkspaceFileSystem. --- .../agent-runtime-services-design.md | 10 +- docs/architecture/cli-product-line-design.md | 2 +- .../rust-build-dependency-boundaries.md | 2 + docs/performance/01-compile-performance.md | 37 +- docs/plans/core-decomposition-plan.md | 4 +- scripts/check-core-boundaries.test.mjs | 70 ++- .../cargo-dependency-boundaries.mjs | 16 +- .../explicit-test-topology.mjs | 24 +- .../core-boundaries/rules/feature-rules.mjs | 149 ++++++- .../core-boundaries/rules/source-rules.mjs | 6 +- .../rules/source/forbidden-rules.mjs | 18 + .../rules/source/public-api-rules.mjs | 48 ++ .../rules/source/required-rules.mjs | 54 ++- scripts/core-boundaries/self-test.mjs | 40 +- src/apps/cli/Cargo.toml | 2 +- src/apps/desktop/Cargo.toml | 2 +- src/apps/sdk-host/Cargo.toml | 2 +- src/apps/server/Cargo.toml | 2 +- src/crates/assembly/core/Cargo.toml | 6 +- .../src/agentic/execution/execution_engine.rs | 19 +- .../assembly/product-capabilities/Cargo.toml | 2 +- .../runtime-ports/src/workspace_ports.rs | 11 + src/crates/execution/agent-runtime/AGENTS.md | 27 +- src/crates/execution/agent-runtime/Cargo.toml | 104 ++++- .../agent-runtime/examples/sdk_minimal.rs | 4 +- .../agent-runtime/src/event_queue.rs | 63 +-- src/crates/execution/agent-runtime/src/lib.rs | 39 ++ .../agent-runtime/src/native_hooks/mod.rs | 6 + .../execution/agent-runtime/src/runtime.rs | 59 ++- src/crates/execution/agent-runtime/src/sdk.rs | 2 +- .../tests/agent_definition_contracts.rs | 1 + .../tests/agent_interaction_contracts.rs | 3 +- .../tests/agent_long_horizon_contracts.rs | 5 +- .../tests/agent_session_contracts.rs | 1 + .../agent_session_contracts/sdk_smoke.rs | 2 +- .../deep_research_contracts.rs | 3 + .../tests/native_hook_execution_contracts.rs | 1 + .../native_hook_settings_contracts.rs | 2 + src/crates/interfaces/acp/Cargo.toml | 1 + src/crates/interfaces/app-server/Cargo.toml | 2 +- src/crates/interfaces/sdk-host/Cargo.toml | 2 +- .../services/services-integrations/AGENTS.md | 8 +- .../services/services-integrations/Cargo.toml | 11 +- .../src/deep_research.rs | 419 +++++++++++++++--- .../src/remote_ssh/workspace_services.rs | 31 ++ 45 files changed, 1127 insertions(+), 195 deletions(-) rename src/crates/execution/agent-runtime/tests/{agent_long_horizon_contracts => }/deep_research_contracts.rs (96%) rename src/crates/execution/agent-runtime/tests/{agent_interaction_contracts => }/native_hook_settings_contracts.rs (99%) diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 9c9c5b28d..671b5a611 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -68,7 +68,7 @@ Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可 私有 SDK Host 或目标机器 Runtime 中。任何 Rust 部署都只管理自己进程树内的服务与 Node/Bun Plugin Host;不能因为多个 GUI/TUI/Remote Client 连接就复制 Runtime 状态模块,或按 Client/Workspace 创建 Plugin Host。 -Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v5 preview: +Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v6 preview: 小版本更新允许增加可选 builder hook、有默认实现的端口方法或注册表查询能力,但不得向外部可用 Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段,也不得改变既有端口语义、错误分类、session / turn 标识含义或 默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更,必须提升接口版本并提供兼容迁移路径。 @@ -83,6 +83,12 @@ v3 为 `AgentDialogTurnRequest` 增加来源无关的 `execution` 事实。现 v4 将活动 Turn 的文本 steer 纳入 `AgentDialogTurnPort`,复用同一个 Runtime owner 和精确 Session/Turn 身份校验;默认端口实现仍返回 `NotAvailable`,未选择该能力的 provider 不需要建立第二套 queue 或 transport。 +v6 将完整 Rust Runtime SDK 从空默认编译面移入 `agent-runtime` owner feature。现有 Rust embedder +迁移时在 `bitfun-agent-runtime` 依赖上显式选择 `features = ["agent-runtime"]`;启用后 `sdk` 模块、 +公开路径和运行时行为保持不变。只消费 DeepResearch 编号或 Hook 设置的调用方应分别选择 +`deep-research` 或 `native-hook-settings`,不需要继承完整 Runtime。仓库内最小 SDK example 通过 +`required-features = ["agent-runtime"]` 明确记录这一版本边界。 + 只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有具体服务管理器、读取产品命令 注册表、理解 ACP/内部端口或依赖全局可变状态,公开 SDK 发布边界就不成立。公开 SDK 的完整 术语、能力等价和版本要求以 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。 @@ -426,7 +432,7 @@ impl AgentRuntime { 该 Rust 接口是内部产品入口复用的当前形态,不是公开 Python/TypeScript SDK 的目标 API。它必须只接收 已组装的类型化部件,不负责创建 文件系统、终端、MCP、AI 客户端、Remote 提供方或产品命令。 -当前 v4 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 +当前 v6 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 model-round cancellation token、结构化 AgentInput 或更复杂的事件游标纳入公开 SDK, 必须分别评审 Rust Runtime SDK、SDK Host protocol 和公开 SDK API 的版本,并保留旧路径兼容。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index a4492d158..2c793f308 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -213,7 +213,7 @@ CLI 只消费 typed summary 与 typed action: | 变更范围 | 最小验证 | | --- | --- | | TUI state/input/render | focused reducer/input/render test + `cargo test -p bitfun-cli` | -| Agent Runtime SDK/port | `cargo test -p bitfun-agent-runtime` + owner focused test | +| Agent Runtime SDK/port | `cargo test -p bitfun-agent-runtime --no-default-features --features agent-runtime --lib` + owner focused test | | Shared IPC | protocol round-trip、controller/idle、timeout/outcome-unknown、disconnect cancel | | Core turn/tool | 权限 allow/ask/deny、取消、事件、上下文、持久化恢复 | | `exec` output | stdout/stderr、单一最终状态、session conflict、Ctrl+C、Patch | diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index c55326667..6d30313b5 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -61,6 +61,8 @@ Core library 的默认 feature 集合为空;完整产品必须显式选择 `pr Core 的 `agent-runtime` 只承载 Agent 生命周期基线和明确的基线工具,不得再次把 MCP、Remote Connect、模型目录、Browser/Web、Git/LSP 或产品工具组藏成 capability union。具体 service 由同名 owner feature 选择,内置工具由 `tools-*` 选择;`product-full` 显式相加全部 owner,CLI/ACP 等窄入口则按真实命令与构造路径列出自己的闭包。 +执行层的 `bitfun-agent-runtime` 自身也保持空默认:完整生命周期由 `agent-runtime` 选择,DeepResearch 纯编号由 `deep-research` 选择,原生 Hook 配置解析与进程执行分别由 `native-hook-settings`、`native-hook-runtime` 选择。叶能力仍留在原 owner crate 内,不为依赖收敛新建 DTO/runtime crate;完整产品必须显式恢复真实 owner,不能依赖 workspace feature union 偶然补齐。 + Owner feature 不等于“无前置依赖”。当实现确实调用较低层基线时,依赖必须按 `owner → baseline` 显式组合,禁止反向把 owner 藏回基线:例如 Core MCP 工具桥和 Remote Connect 依赖 Agent 生命周期,Workspace Search 依赖本地 Workspace Runtime。每个新增或调整后的 owner 闭包都必须单独 `cargo check`,避免被 Desktop/CLI 的 feature union 偶然补齐。 只为已经启用的 optional dependency 增加子能力时,使用 Cargo 的弱依赖转发 diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index a8037a593..274959bdc 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -318,6 +318,41 @@ Clap、Tracing Subscriber、Notify 等默认即产品契约或缺少独立收益 重复版本数量只用于发现候选,不能直接转化为治理任务。`oxc`、`rquickjs`、vendored `git2`、`sherpa-onnx` 等重依赖都有真实 capability owner;只有某个产品入口不消费对应能力时,才允许让它退出该入口的构建图。 +以下以 `gcwing/main@d1d1dd9e8` 为变更前基线,把 `bitfun-agent-runtime` 内部长期共存的完整 Runtime、 +DeepResearch 纯编号和原生 Hook 配置/执行拆成同 crate 的 owner feature。没有新增 crate 或兼容 `full` +umbrella;统计仍按三个既定 target triple、`normal,build` 版本化 package instance 去重: + +| 闭包 | Windows | macOS | Linux | 边界结果 | +|---|---:|---:|---:|---| +| Agent Runtime feature-free | 76 → 1 | 79 → 1 | 78 → 1 | 空默认只保留 crate 本身;所有运行时源码和第三方依赖由 owner feature 选择 | +| Agent Runtime `agent-runtime` | 76 → 76 | 79 → 79 | 78 → 78 | 完整 Runtime API、Hook 执行和依赖闭包保持不变 | +| Agent Runtime `native-hook-settings` | 76 → 10 | 79 → 10 | 78 → 10 | Hook 配置解析不再编译进程执行、Session、Tool 和 Runtime Services | +| Services Integrations `deep-research` | 77 → 36 | 80 → 38 | 79 → 37 | 只保留纯编号、WorkspaceFS port 与报告 IO;完整 Agent Runtime 退出 | +| Services Integrations `hook-import` | 121 → 89 | 112 → 79 | 111 → 78 | 只复用 Hook settings contract;Session/Agent lifecycle 退出 | +| Core `product-full` | 569 → 569 | 556 → 556 | 600 → 600 | 完整产品显式恢复全部真实 owner,package 闭包不缩水 | + +测试闭包也按真实 owner 收敛:Agent Runtime 的 DeepResearch target 在 `normal,build,dev` 口径从 +`76/79/78` 降到 `6/6/6`,Hook settings target 降到 `10/10/10`;Services DeepResearch 测试从 +`80/85/85` 降到 `44/48/47`。为保持 feature 与进程失败域,Agent Runtime 显式 integration target 从 +5 个增为 7 个:DeepResearch 与 Hook settings 各自独立,Unix Hook 子进程测试继续独立;没有把这些 +focused target 加进 CI,也没有新增 job、矩阵或仓库级命令。 + +该轮同时修复 DeepResearch post-turn IO 的既有远程路径错误:Core 现在把当前 session 注入的 +`WorkspaceFileSystem` 传给 Services Integrations,本地和 Remote SSH 都通过同一 provider 读取报告、 +引用表并写回 sidecar;远程逻辑路径不再被 Windows host 当成本机 `Path` 探测。provider 缺失时明确 +跳过并记录 warning,不允许回退到宿主文件系统。纯编号算法、报告内容、sidecar schema 和本地 +best-effort 行为保持不变。路径拼接语义也由 `WorkspaceFileSystem` provider 持有:本地 provider +继承宿主路径规则,Remote SSH 对绝对、home 和相对 workspace root 均保持 POSIX 分隔符。 + +`bitfun-services-integrations::deep_research::run_for_session_workspace` 与 +`try_renumber_research_report` 的公开函数签名现在要求显式传入 `WorkspaceFileSystem`;仓内唯一生产 +调用者已迁移。这是为消除远程路径宿主回退而做的有意源码契约收紧,不能描述为对未知的仓外 path/git +consumer 零影响。完整 Rust Runtime SDK 同时将兼容版本提升为 v6:仓外 embedder 需要在 +`bitfun-agent-runtime` 依赖上显式选择 `agent-runtime`;启用后原 `sdk` 公开路径和行为保持不变。 + +这里仍只报告依赖图和 focused-test 输入,不宣称完整产品 wall-clock 提速。根 `Cargo.lock` package +集合与字节均不变,新增/升级/降级 package 为 0;`.github` 和 `ci.yml` 不变。 + ### 3.3 CI 与本地验证 - 现有 CI 已覆盖 workspace check、Core/Desktop lib、平台敏感 owner 测试和独立 runtime/CLI 验证;本轮不新增 job、矩阵或 changed-path 分类器。 @@ -340,7 +375,7 @@ Clap、Tracing Subscriber、Notify 等默认即产品契约或缺少独立收益 | 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 | | Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile | | SDK Host 闭包 | 从 `product-full` 改为与当前协议/构造路径一致的显式 Core owner closure;保留 ring TLS 初始化,本机 SDK 行为不变,未交付的远程执行能力不再进入构建图 | -| Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | +| Agent Runtime 测试 | 28 个 integration executable 已完成职责聚合;当前 7 个 target 中,DeepResearch、Hook settings 与 Hook 子进程按 feature/进程失败域独立,其余保持聚合 | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | | External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | | Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 9a0f0af31..69faf6c53 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -25,7 +25,7 @@ | CLI / Desktop / ACP | CLI 与 ACP 已分别提交对应 `DeliveryProfile`、消费 Runtime Parts/SDK,并选择经过评审的 Core owner feature closure;Desktop 仍启用 `bitfun-core/product-full`,主交互已消费由现有 owner 构造的窄口径 SDK 接口 | 三个入口仍复用单一 Core runtime owner;完整 Desktop profile 和剩余兼容操作仍需逐项迁移 | | Server | 当前生产路由只形成 health/info/ping 基线 | 没有插件状态或独立产品组装完整流程 | | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | -| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v3 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | +| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v6 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用消息结构或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded bind、静态 fallback 和任务生命周期由 Desktop 窄宿主端口持有 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位已完成并由生命周期与边界测试保护 | | CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期及本地确定性流式模型夹具驱动的活动 turn 进程测试,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;本地模型 HTTP 403 授权拒绝、流中断后的重试失败、Linux PTY/Windows ConPTY Chat resize/取消、`exec` Ctrl+C 及 Patch I/O 失败已有分层回归,真实供应商审批交互、macOS 活动 PTY 与 OS 级终端故障注入仍需补齐 | @@ -129,7 +129,7 @@ Core 只为插件兼容提供已有 owner 的窄接口:真实工具、类型 | 文档与仓库边界 | `pnpm run check:repo-hygiene`,`node --test scripts/check-core-boundaries.test.mjs`,`node scripts/check-core-boundaries.mjs` | | 入口 profile 迁移 | 对应 app 的 check/test、入口级 smoke、profile/服务可用性断言、旧路径等价用例 | | Relay 共享 owner / Cargo 方向 | standalone 与 embedded focused tests、Cargo 依赖方向失败用例、Desktop 宿主启停/失败回滚/静态缓存行为测试 | -| Agent Runtime / SDK | `cargo test -p bitfun-agent-runtime`,最小 no-`bitfun-core` 嵌入测试 | +| Agent Runtime / SDK | `cargo test -p bitfun-agent-runtime --no-default-features --features agent-runtime --lib`,最小 no-`bitfun-core` 嵌入测试 | | 插件首个执行切片 | runtime ports、`PluginRuntimeClient`、生态 adapter、`ScriptToolRuntime`、Plugin Host 与真实固定 fixture 的端到端调用 | | CLI | `cargo check -p bitfun-cli`,`cargo test -p bitfun-cli`,结构化协议和 package smoke | diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 067a4578d..7e78f01f9 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -35,8 +35,15 @@ import { capabilityContractDependencyRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, + guardedEmptyInternalDefaultManifestPaths, optionalDependencyFeatureOwnerRules, } from './core-boundaries/rules/feature-rules.mjs'; +import { + agentRuntimeRootPublicModules, + forbiddenContentRules, + publicApiAllowlistRules, + requiredContentRules, +} from './core-boundaries/rules/source-rules.mjs'; const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url); const MODULES = [ @@ -57,6 +64,59 @@ const MODULES = [ const TEST_ROOT = join('C:', 'repo'); +test('Agent Runtime leaf capabilities have one managed feature and source contract', async () => { + const rule = capabilityContractDependencyRules.find( + (candidate) => candidate.packageName === 'bitfun-agent-runtime', + ); + assert.ok(rule, 'bitfun-agent-runtime must be a managed capability target'); + assert.deepEqual(Object.keys(rule.featureProfiles).sort(), [ + 'agent-runtime', + 'deep-research', + 'default', + 'native-hook-runtime', + 'native-hook-settings', + ]); + assert.equal(rule.consumers.size, 10); + assert.ok( + guardedEmptyInternalDefaultManifestPaths.includes( + 'src/crates/execution/agent-runtime/Cargo.toml', + ), + ); + assert.ok(requiredContentRules.some( + (sourceRule) => sourceRule.path === 'src/crates/execution/agent-runtime/src/lib.rs' + && sourceRule.reason.includes('leaf capability modules'), + )); + const publicApiRule = publicApiAllowlistRules.find( + (sourceRule) => sourceRule.path === 'src/crates/execution/agent-runtime/src/lib.rs', + ); + assert.ok(publicApiRule, 'bitfun-agent-runtime root must have a closed public module allowlist'); + assert.deepEqual( + new Set(publicApiRule.allowedSymbols), + new Set(agentRuntimeRootPublicModules), + ); + const flatRootRule = forbiddenContentRules.find( + (sourceRule) => sourceRule.path === 'src/crates/execution/agent-runtime/src/lib.rs' + && sourceRule.reason.includes('flat feature-owned module wrapper'), + ); + assert.ok(flatRootRule, 'bitfun-agent-runtime root must reject non-wrapper source lines'); + const rootSource = await readFile( + new URL('../src/crates/execution/agent-runtime/src/lib.rs', import.meta.url), + 'utf8', + ); + assert.equal(flatRootRule.patterns[0].regex.test(rootSource), false); + for (const mutation of [ + '#[doc(hidden)] pub mod accidental_feature_free_api;', + 'pub union AccidentalFeatureFreeApi { value: u64 }', + 'const DOC: &str = "{";\npub mod accidental_feature_free_api;', + ]) { + assert.equal( + flatRootRule.patterns[0].regex.test(`${rootSource}\n${mutation}`), + true, + `Agent Runtime root must reject mutation: ${mutation}`, + ); + } +}); + test('Core and ACP defaults preserve their explicit assembly contracts', async () => { const [coreManifest, acpManifest] = await Promise.all([ readFile(new URL('../src/crates/assembly/core/Cargo.toml', import.meta.url), 'utf8'), @@ -3586,11 +3646,11 @@ test('unreviewed consumers cannot add capability contract dependency edges', asy ], ); - const messages = findTestCapabilityViolations(findCapabilityContractConsumerViolations, [ - runtimePorts, - agentTools, - unreviewed, - ]).map( + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts, agentTools, unreviewed], + capabilityContractDependencyRules.slice(0, 2), + ).map( (violation) => violation.message, ); assert.equal(messages.length, 1, messages.join('\n')); diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 4cc3edeb2..523c3ab19 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -133,7 +133,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['browser-control', ['time']], ['canvas-runtime', ['fs']], ['debug-log', ['rt']], - ['deep-research', ['fs']], + ['deep-research', []], ['git', ['fs', 'io-util', 'macros', 'rt', 'time']], ['file-watch', ['rt', 'sync']], ['function-agents', ['fs', 'io-util', 'macros', 'rt', 'time']], @@ -181,6 +181,10 @@ const CORE_TOKIO_AGGREGATES = new Set([ 'tools-browser-web', 'tools-mcp', ]); +const AGENT_RUNTIME_TOKIO_FEATURES = new Map([ + ['native-hook-runtime', ['io-util', 'macros', 'process', 'rt', 'time']], + ['agent-runtime', ['io-util', 'macros', 'process', 'rt', 'sync', 'time']], +]); const TOKIO_DEPENDENCY_POLICY_EXCLUDED_PACKAGES = new Set(); @@ -720,10 +724,14 @@ export function findTokioDependencyFeatureViolations(packages) { const featureOwnedCoreRuntime = pkg.name === 'bitfun-core' && (dependency.kind ?? null) === null; + const featureOwnedAgentRuntime = + pkg.name === 'bitfun-agent-runtime' + && (dependency.kind ?? null) === null; if ( featureOwnedIntegrationRuntime || featureOwnedServicesCoreRuntime || featureOwnedCoreRuntime + || featureOwnedAgentRuntime ) { const actual = [...features].sort(); const expected = [...(featureOwnedCoreRuntime @@ -767,6 +775,12 @@ export function findTokioDependencyFeatureViolations(packages) { CORE_TOKIO_AGGREGATES, )); } + if (pkg.name === 'bitfun-agent-runtime') { + violations.push(...findOwnedTokioFeatureViolations( + pkg, + AGENT_RUNTIME_TOKIO_FEATURES, + )); + } } return violations; diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 1afa6dd09..01196cee6 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -2,11 +2,25 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, posix, relative } from 'node:path'; export const agentRuntimeIntegrationTestTargets = [ - { name: 'agent_definition_contracts', path: 'tests/agent_definition_contracts.rs' }, - { name: 'agent_interaction_contracts', path: 'tests/agent_interaction_contracts.rs' }, - { name: 'agent_long_horizon_contracts', path: 'tests/agent_long_horizon_contracts.rs' }, - { name: 'agent_session_contracts', path: 'tests/agent_session_contracts.rs' }, - { name: 'native_hook_execution_contracts', path: 'tests/native_hook_execution_contracts.rs' }, + { name: 'agent_definition_contracts', path: 'tests/agent_definition_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { name: 'agent_interaction_contracts', path: 'tests/agent_interaction_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { name: 'agent_long_horizon_contracts', path: 'tests/agent_long_horizon_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { name: 'agent_session_contracts', path: 'tests/agent_session_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { + name: 'deep_research_contracts', + path: 'tests/deep_research_contracts.rs', + requiredFeatures: ['deep-research'], + }, + { + name: 'native_hook_execution_contracts', + path: 'tests/native_hook_execution_contracts.rs', + requiredFeatures: ['native-hook-runtime'], + }, + { + name: 'native_hook_settings_contracts', + path: 'tests/native_hook_settings_contracts.rs', + requiredFeatures: ['native-hook-settings'], + }, ]; export const cliIntegrationTestTargets = [ diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 2bb2832f7..27383a9b0 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -20,6 +20,7 @@ export const guardedEmptyInternalDefaultManifestPaths = [ 'src/crates/assembly/product-capabilities/Cargo.toml', 'src/crates/contracts/product-domains/Cargo.toml', 'src/crates/contracts/runtime-ports/Cargo.toml', + 'src/crates/execution/agent-runtime/Cargo.toml', 'src/crates/execution/tool-contracts/Cargo.toml', 'src/crates/execution/tool-execution/Cargo.toml', 'src/crates/execution/tool-provider-groups/Cargo.toml', @@ -105,6 +106,34 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'ts-rs', ownerFeatures: ['ts'] }, ], }, + { + crateName: 'agent-runtime', + reviewedAggregateFeatures: ['native-hook-runtime'], + reason: + 'agent-runtime optional dependencies must stay behind the full runtime, DeepResearch, or native-hook owner slice', + dependencies: [ + { depName: 'async-trait', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-core-types', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-events', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-harness', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-runtime-services', ownerFeatures: ['agent-runtime'] }, + { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, + { depName: 'hex', ownerFeatures: ['agent-runtime'] }, + { depName: 'log', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime', 'deep-research', 'native-hook-settings'] }, + { depName: 'serde', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] }, + { depName: 'serde_yaml', ownerFeatures: ['agent-runtime'] }, + { depName: 'sha2', ownerFeatures: ['agent-runtime'] }, + { depName: 'thiserror', ownerFeatures: ['agent-runtime'] }, + { depName: 'tokio', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'tokio-util', ownerFeatures: ['agent-runtime'] }, + { depName: 'uuid', ownerFeatures: ['agent-runtime'] }, + ], + }, { crateName: 'core', reason: @@ -116,7 +145,7 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], }, - { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime', 'deep-research'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'bitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, @@ -216,10 +245,10 @@ export const optionalDependencyFeatureOwnerRules = [ dependencies: [ { depName: 'aes', ownerFeatures: ['remote-connect'] }, { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, + { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'deep-research', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], + ownerFeatures: ['deep-research', 'git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], }, { depName: 'base64', @@ -228,7 +257,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, { depName: 'bitfun-core-types', ownerFeatures: ['remote-connect', 'speech'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, - { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh', 'review-platform', 'workspace-search'], @@ -349,8 +378,8 @@ export const capabilityContractDependencyRules = [ 'runtime-event-port', 'terminal-port', 'workspace-ports', - ]), - ])], + ], { optional: true }), + ], [], ['agent-runtime'])], ['bitfun-agent-runtime-ipc', capabilityConsumer([ capabilityEdge(['agent-api', 'git-port']), ])], @@ -423,6 +452,7 @@ export const capabilityContractDependencyRules = [ ['bitfun-services-integrations', capabilityConsumer( [capabilityEdge([], { optional: true })], [ + capabilityForwarder('deep-research', 'workspace-ports'), capabilityForwarder('git', 'git-port'), capabilityForwarder('remote-connect', 'agent-api'), capabilityForwarder('remote-connect', 'remote-workspace-ports'), @@ -457,7 +487,11 @@ export const capabilityContractDependencyRules = [ ['server'], ['default'], )], - ['bitfun-agent-runtime', capabilityConsumer([capabilityEdge()])], + ['bitfun-agent-runtime', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [], + ['agent-runtime'], + )], ['bitfun-agent-stream', capabilityConsumer([ capabilityEdge([], { kind: 'dev' }), ])], @@ -496,6 +530,100 @@ export const capabilityContractDependencyRules = [ ['tool-runtime', capabilityConsumer([capabilityEdge()])], ]), }, + { + packageName: 'bitfun-agent-runtime', + manifestPath: 'src/crates/execution/agent-runtime/Cargo.toml', + featureProfiles: { + default: [], + 'deep-research': ['dep:regex'], + 'native-hook-settings': ['dep:regex', 'dep:serde_json'], + 'native-hook-runtime': [ + 'native-hook-settings', + 'dep:log', + 'dep:serde', + 'dep:serde_json', + 'dep:tokio', + 'tokio/io-util', + 'tokio/macros', + 'tokio/process', + 'tokio/rt', + 'tokio/time', + ], + 'agent-runtime': [ + 'native-hook-runtime', + 'dep:async-trait', + 'dep:bitfun-agent-stream', + 'dep:bitfun-agent-tools', + 'dep:bitfun-core-types', + 'dep:bitfun-events', + 'dep:bitfun-harness', + 'dep:bitfun-runtime-ports', + 'dep:bitfun-runtime-services', + 'dep:dashmap', + 'dep:hex', + 'dep:log', + 'dep:regex', + 'dep:serde', + 'dep:serde_json', + 'dep:serde_yaml', + 'dep:sha2', + 'dep:thiserror', + 'dep:tokio', + 'dep:tokio-util', + 'dep:uuid', + 'tokio/macros', + 'tokio/rt', + 'tokio/sync', + ], + }, + consumers: new Map([ + ['bitfun-acp', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [capabilityForwarder('server', 'agent-runtime')], + ['server'], + ['default'], + )], + ['bitfun-app-server', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-cli', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-core', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('agent-runtime', 'agent-runtime'), + capabilityForwarder('deep-research', 'deep-research', true), + ], + ['agent-runtime'], + ['external-sources', 'mcp-runtime', 'plugin-runtime', 'product-full', 'remote-connect', 'tools-mcp'], + )], + ['bitfun-desktop', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-product-capabilities', capabilityConsumer([ + capabilityEdge(['agent-runtime'], { kind: 'dev' }), + ])], + ['bitfun-sdk-host', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-sdk-host-app', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-server', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-services-integrations', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('deep-research', 'deep-research'), + capabilityForwarder('hook-import', 'native-hook-settings'), + ], + ['deep-research', 'hook-import'], + ['product-full'], + )], + ]), + }, ]; export const coreProductFullFeatureAssemblyRule = { @@ -600,6 +728,7 @@ export const acpClosedFeatureProfileRules = [ requiredFeatureRefs: [ 'dep:bitfun-agent-tools', 'dep:bitfun-agent-runtime', + 'bitfun-agent-runtime/agent-runtime', 'dep:bitfun-core-types', 'dep:bitfun-core', 'dep:sha2', @@ -624,6 +753,7 @@ export const coreClosedFeatureProfileRules = [ requiredFeatureRefs: [ 'ai-adapter-runtime', 'dep:bitfun-agent-runtime', + 'bitfun-agent-runtime/agent-runtime', 'dep:bitfun-agent-content', 'dep:bitfun-agent-stream', 'dep:bitfun-agent-tools', @@ -883,7 +1013,10 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'deep-research', - requiredFeatureRefs: ['bitfun-services-integrations/deep-research'], + requiredFeatureRefs: [ + 'bitfun-agent-runtime?/deep-research', + 'bitfun-services-integrations/deep-research', + ], exact: true, reason: 'deep-research must own only research report post-processing', }, diff --git a/scripts/core-boundaries/rules/source-rules.mjs b/scripts/core-boundaries/rules/source-rules.mjs index a318928e7..edbc0afd5 100644 --- a/scripts/core-boundaries/rules/source-rules.mjs +++ b/scripts/core-boundaries/rules/source-rules.mjs @@ -5,5 +5,9 @@ export { forbiddenContentRules, forbiddenContentUnderRules, } from './source/forbidden-rules.mjs'; -export { publicApiAllowlistRules, publicApiContractSlices } from './source/public-api-rules.mjs'; +export { + agentRuntimeRootPublicModules, + publicApiAllowlistRules, + publicApiContractSlices, +} from './source/public-api-rules.mjs'; export { requiredContentRules } from './source/required-rules.mjs'; diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index adfdd80cd..aaee7858b 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,6 +1,24 @@ // Boundary rules for source ownership, facades, and required owner content. +import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; + +const agentRuntimeRootUnexpectedLine = new RegExp( + `^(?!(?:[ \\t]*|[ \\t]*\\/\\/!.*|[ \\t]*#\\[cfg\\(feature = "(?:agent-runtime|deep-research|native-hook-settings)"\\)\\][ \\t]*|[ \\t]*pub mod (?:${agentRuntimeRootPublicModules.join('|')});[ \\t]*)\\r?$).+$`, + 'm', +); + export const forbiddenContentRules = [ + { + path: 'src/crates/execution/agent-runtime/src/lib.rs', + reason: + 'Agent Runtime root is a flat feature-owned module wrapper, not a feature-free implementation surface', + patterns: [ + { + regex: agentRuntimeRootUnexpectedLine, + message: 'unexpected Agent Runtime root content outside the reviewed cfg/module pairs', + }, + ], + }, { path: 'src/apps/cli/src/tui_backend.rs', reason: diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 56e7ba8ee..387c54252 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -16,6 +16,48 @@ export const publicApiContractSlices = [ 'external-integration-policy-contract', ]; +export const agentRuntimeRootPublicModules = [ + 'agents', + 'checkpoint', + 'context_profile', + 'custom_agent', + 'custom_subagent', + 'deep_research', + 'deep_review', + 'dialog_turn', + 'event_bus', + 'event_queue', + 'event_router', + 'event_source', + 'events', + 'evidence_ledger', + 'file_read_state', + 'native_hooks', + 'output_surface', + 'permission', + 'post_call_hooks', + 'prompt', + 'prompt_cache', + 'prompt_markup', + 'remote_file_delivery', + 'runtime', + 'scheduled_job', + 'scheduler', + 'sdk', + 'session', + 'session_control', + 'session_state', + 'session_state_manager', + 'side_question', + 'skill_agent_snapshot', + 'skills', + 'subagent_task', + 'thread_goal', + 'thread_goal_tools', + 'turn_cancellation', + 'user_questions', +]; + const contractSlices = { frontendBackendCapabilityService: 'frontend-backend-capability-service', bitfunPluginExtension: 'bitfun-plugin-extension-contract', @@ -1264,6 +1306,12 @@ export const managedPluginSourceServicePublicApiEntries = [ ); export const publicApiAllowlistRules = [ + { + path: 'src/crates/execution/agent-runtime/src/lib.rs', + reason: + 'Agent Runtime root must expose only the reviewed feature-owned capability modules', + allowedSymbols: agentRuntimeRootPublicModules, + }, { path: 'src/crates/contracts/runtime-ports/src/plugin.rs', reason: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 94ee305d0..a02f31199 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1,5 +1,7 @@ // Boundary rules for source ownership, facades, and required owner content. +import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; + export const requiredContentRules = [ ...[ 'src/apps/cli/Cargo.toml', @@ -874,6 +876,10 @@ export const requiredContentRules = [ regex: /default = \[\]/, message: 'agent-runtime default feature set must stay empty', }, + { + regex: /\[\[example\]\]\r?\nname = "sdk_minimal"\r?\npath = "examples\/sdk_minimal\.rs"\r?\nrequired-features = \["agent-runtime"\]/, + message: 'sdk_minimal example must declare the versioned agent-runtime owner feature', + }, ], }, { @@ -2773,7 +2779,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs', + path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', reason: 'agent-runtime must keep behavior-equivalence contracts for DeepResearch citation renumbering', patterns: [ @@ -5124,6 +5130,16 @@ export const requiredContentRules = [ regex: /\bpub trait WorkspaceFileSystem\b/, message: 'missing workspace file-system port contract', }, + { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', + regex: /\bfn join_path\(&self, root: &str, components: &\[&str\]\) -> String\b/, + message: 'workspace filesystem providers must own their path joining syntax', + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs', + regex: /fn join_path\(&self, root: &str, components: &\[&str\]\) -> String \{\r?\n\s*join_posix_path\(root, components\)/, + message: 'remote workspace filesystem must keep POSIX path joining independent of the host', + }, { path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub trait WorkspaceShell\b/, @@ -5404,6 +5420,42 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/agent-runtime/src/lib.rs', + reason: 'Agent Runtime leaf capability modules must stay behind their exact owner features', + patterns: [ + { + regex: /#\[cfg\(feature = "deep-research"\)\]\r?\npub mod deep_research;/, + message: 'deep-research must gate its pure report capability', + }, + { + regex: /#\[cfg\(feature = "native-hook-settings"\)\]\r?\npub mod native_hooks;/, + message: 'native-hook-settings must gate the portable hook facade', + }, + ...agentRuntimeRootPublicModules + .filter((moduleName) => !['deep_research', 'native_hooks'].includes(moduleName)) + .map((moduleName) => ({ + regex: new RegExp(`#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`), + message: `${moduleName} must stay behind the full agent-runtime owner`, + })), + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/native_hooks/mod.rs', + reason: 'native hook execution modules must stay behind native-hook-runtime', + patterns: [ + ...['engine', 'output', 'payload'].flatMap((moduleName) => [ + { + regex: new RegExp(`#\\[cfg\\(feature = "native-hook-runtime"\\)\\]\\r?\\nmod ${moduleName};`), + message: `${moduleName} must stay behind native-hook-runtime`, + }, + { + regex: new RegExp(`#\\[cfg\\(feature = "native-hook-runtime"\\)\\]\\r?\\npub use ${moduleName}(?:::|\\{)`), + message: `${moduleName} exports must stay behind native-hook-runtime`, + }, + ]), + ], + }, { path: 'src/crates/contracts/runtime-ports/src/lib.rs', reason: 'runtime-ports capability features must gate their owned source modules and exports', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 5f0253e82..6e7272d51 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -61,10 +61,13 @@ export function runManifestParserSelfTest({ const explicitTestManifest = [ '[package]', 'autotests = false', - ...agentRuntimeIntegrationTestTargets.flatMap(({ name, path }) => [ + ...agentRuntimeIntegrationTestTargets.flatMap(({ name, path, requiredFeatures }) => [ '[[test]]', `name = "${name}"`, `path = "${path}"`, + ...(requiredFeatures === undefined + ? [] + : [`required-features = [${requiredFeatures.map((feature) => `"${feature}"`).join(', ')}]`]), ]), '[lints]', ].join('\n'); @@ -158,7 +161,6 @@ export function runManifestParserSelfTest({ ? { ...target, leaves: ['tests/agent_definition_contracts/prompt_contracts.rs'], - forbidRequiredFeatures: true, } : target )); @@ -179,8 +181,8 @@ export function runManifestParserSelfTest({ ...explicitTestFixture, expectedTargets: reviewedLeafTargets, manifestText: explicitTestManifest.replace( - 'path = "tests/agent_definition_contracts.rs"', - `path = "tests/agent_definition_contracts.rs"\n${requiredFeaturesDeclaration}`, + 'required-features = ["agent-runtime"]', + requiredFeaturesDeclaration, ), }); if (!unexpectedRequiredFeaturesErrors.some((error) => error.includes('required-features'))) { @@ -189,10 +191,14 @@ export function runManifestParserSelfTest({ } const independentRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ ...explicitTestFixture, - expectedTargets: reviewedLeafTargets, + expectedTargets: reviewedLeafTargets.map((target) => ( + target.path === 'tests/native_hook_execution_contracts.rs' + ? { ...target, requiredFeatures: ['native-hooks'] } + : target + )), manifestText: explicitTestManifest.replace( - 'path = "tests/native_hook_execution_contracts.rs"', - 'path = "tests/native_hook_execution_contracts.rs"\nrequired-features = ["native-hooks"]', + 'required-features = ["native-hook-runtime"]', + 'required-features = ["native-hooks"]', ), }); if (independentRequiredFeaturesErrors.length > 0) { @@ -614,6 +620,7 @@ export function runManifestParserSelfTest({ [ 'dep:bitfun-agent-tools', 'dep:bitfun-agent-runtime', + 'bitfun-agent-runtime/agent-runtime', 'dep:bitfun-core-types', 'dep:bitfun-core', 'dep:sha2', @@ -1433,6 +1440,9 @@ export function runManifestParserSelfTest({ const pluginPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/plugin.rs', ); + const agentRuntimePublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/execution/agent-runtime/src/lib.rs', + ); const pluginRootReexportRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/lib.rs', ); @@ -1528,6 +1538,20 @@ export function runManifestParserSelfTest({ ) { throw new Error('public API parser must collect top-level items and re-exports without impl methods'); } + const parsedAgentRuntimeModules = collectTopLevelRustPublicSymbols(` + #[cfg(feature = "agent-runtime")] + pub mod agents; + pub mod accidental_feature_free_api; + `); + if (!agentRuntimePublicApiRule?.allowedSymbols?.includes('agents')) { + throw new Error('Agent Runtime public API allowlist must include reviewed owner modules'); + } + if ( + !parsedAgentRuntimeModules.includes('accidental_feature_free_api') || + agentRuntimePublicApiRule.allowedSymbols.includes('accidental_feature_free_api') + ) { + throw new Error('Agent Runtime public API allowlist must reject an unreviewed feature-free module'); + } const parsedExternalSubagentIds = collectTopLevelRustPublicSymbols(` external_subagent_id!(ExternalSubagentLocalId, "local"); external_subagent_id!( @@ -4352,7 +4376,7 @@ export function runManifestParserSelfTest({ contracts: ['renumber_research_report', 'ResearchCitationRenumberOutput', 'ResearchCitationDisplayMapEntry', 'rejected_index_rows_dropped', 'should_post_process_research_report'], }, { - path: 'src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs', + path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', contracts: ['deep_research_citation_renumber_owner_preserves_report_and_display_map_contracts', 'deep_research_citation_renumber_owner_is_idempotent_without_citations'], }, { diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 068903cea..e98d8add2 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -53,7 +53,7 @@ bitfun-core = { path = "../../crates/assembly/core", features = [ bitfun-events = { path = "../../crates/contracts/events" } bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client", "server"] } -bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "git-port", "permission", "plugin-runtime", "workspace-ports"] } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 6ee211984..ec66a9456 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -21,7 +21,7 @@ serde_json = { workspace = true } # Internal crates bitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] } bitfun-relay-service = { path = "../../crates/services/relay-service" } -bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } bitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "speech"] } diff --git a/src/apps/sdk-host/Cargo.toml b/src/apps/sdk-host/Cargo.toml index 269d51daa..ae070db37 100644 --- a/src/apps/sdk-host/Cargo.toml +++ b/src/apps/sdk-host/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-core = { path = "../../crates/assembly/core", features = [ "agent-runtime", "document-read", diff --git a/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index c7f9a8f47..fb8b2bb2e 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -16,7 +16,7 @@ bitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] # in-memory channel transport. The websocket handler routes agent kernel RPCs # through this client so agent interfaces uniformly go through app-server. bitfun-app-server = { path = "../../crates/interfaces/app-server" } -bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-events = { path = "../../crates/contracts/events" } agent-client-protocol = { workspace = true } diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 61af6a74c..031796247 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -195,6 +195,7 @@ product-full = [ agent-runtime = [ "ai-adapter-runtime", "dep:bitfun-agent-runtime", + "bitfun-agent-runtime/agent-runtime", "dep:bitfun-agent-content", "dep:bitfun-agent-stream", "dep:bitfun-agent-tools", @@ -298,7 +299,10 @@ web-tools = [ "bitfun-services-integrations/web-tools", "tool-runtime/web-readable", ] -deep-research = ["bitfun-services-integrations/deep-research"] +deep-research = [ + "bitfun-agent-runtime?/deep-research", + "bitfun-services-integrations/deep-research", +] script-tool-runtime = [ "bitfun-runtime-ports/script-tool-runtime", "bitfun-services-integrations/script-tool-runtime", diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 96da1aa62..17e247837 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -4539,11 +4539,20 @@ impl ExecutionEngine { 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; + if let Some(workspace_services) = context.workspace_services.as_ref() { + bitfun_services_integrations::deep_research::run_for_session_workspace( + workspace_services.fs.as_ref(), + &workspace.root_path().to_string_lossy(), + &context.session_id, + ) + .await; + } else { + warn!( + "citation_renumber: skipped because workspace filesystem services are unavailable: session_id={}, workspace={}", + context.session_id, + workspace.root_path().display() + ); + } } } } diff --git a/src/crates/assembly/product-capabilities/Cargo.toml b/src/crates/assembly/product-capabilities/Cargo.toml index 06f00221a..c7ba01588 100644 --- a/src/crates/assembly/product-capabilities/Cargo.toml +++ b/src/crates/assembly/product-capabilities/Cargo.toml @@ -25,7 +25,7 @@ bitfun-tool-packs = { path = "../../execution/tool-provider-groups" } [dev-dependencies] async-trait = { workspace = true } -bitfun-agent-runtime = { path = "../../execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../execution/agent-runtime", features = ["agent-runtime"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["agent-api"] } bitfun-runtime-services = { path = "../../execution/runtime-services", features = ["test-support"] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/src/crates/contracts/runtime-ports/src/workspace_ports.rs b/src/crates/contracts/runtime-ports/src/workspace_ports.rs index 8567b56e5..a78259bb5 100644 --- a/src/crates/contracts/runtime-ports/src/workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/workspace_ports.rs @@ -146,6 +146,17 @@ pub enum WorkspacePathKind { /// Unified file system operations that work for both local and remote workspaces. #[async_trait::async_trait] pub trait WorkspaceFileSystem: Send + Sync { + /// Join path components using the syntax understood by this provider. + /// + /// Local providers inherit the host path syntax. Remote providers must + /// override this when their filesystem uses a different syntax than the + /// host process. + fn join_path(&self, root: &str, components: &[&str]) -> String { + let mut path = PathBuf::from(root); + path.extend(components); + path.to_string_lossy().into_owned() + } + async fn read_file(&self, path: &str) -> anyhow::Result>; /// Read binary content up to `max_bytes`. /// diff --git a/src/crates/execution/agent-runtime/AGENTS.md b/src/crates/execution/agent-runtime/AGENTS.md index de5ab03d3..5025d990f 100644 --- a/src/crates/execution/agent-runtime/AGENTS.md +++ b/src/crates/execution/agent-runtime/AGENTS.md @@ -7,6 +7,19 @@ session/config/context facts, lifecycle helper state, and the narrow port-backed `sdk` / `AgentRuntime` facade that can be built and tested without `bitfun-core`. +## Feature Boundaries + +- `deep-research` exposes only provider-neutral citation renumbering. +- `native-hook-settings` exposes Codex-compatible hook settings parsing and + validation without process execution. +- `native-hook-runtime` extends settings with payload, output, and managed + child-process execution. +- `agent-runtime` selects the complete portable runtime and includes + `native-hook-runtime`; it intentionally does not include `deep-research`. +- `default` stays empty. Consumers select the smallest owner feature they use; + do not add a compatibility `full` feature or rely on another workspace + consumer to create a Cargo feature union. + ## Guardrails - Do not depend on `bitfun-core`, app crates, Tauri, ACP protocol, web UI, @@ -64,7 +77,7 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without ## Test Target Layout -Integration contracts use five explicit Cargo targets so package-level checks +Integration contracts use seven explicit Cargo targets so package-level checks do not relink the same feature-free dependency closure for every source file, while platform-specific process tests retain executable-level isolation: @@ -72,8 +85,10 @@ while platform-specific process tests retain executable-level isolation: |---|---| | `agent_definition_contracts` | Agent definitions, discovery, prompts, prompt cache, and skills | | `agent_session_contracts` | Events, scheduling, sessions, SDK behavior, and workspace-reference ports | -| `agent_interaction_contracts` | Permissions, questions, and hook execution | -| `agent_long_horizon_contracts` | DeepResearch, DeepReview, and long-running thread-goal behavior | +| `agent_interaction_contracts` | Permissions, questions, hook payloads, and post-call hook behavior (`agent-runtime`) | +| `agent_long_horizon_contracts` | DeepReview and long-running thread-goal behavior (`agent-runtime`) | +| `deep_research_contracts` | Citation numbering without the complete Agent Runtime (`deep-research`) | +| `native_hook_settings_contracts` | Hook settings parsing without process execution (`native-hook-settings`) | | `native_hook_execution_contracts` | Unix-only native process execution, timeout, and cleanup behavior | Add a contract to the nearest existing target. Do not add another top-level @@ -94,8 +109,10 @@ Use the focused contract form by default. Run the package-wide form only when a change crosses several runtime targets: ```bash -cargo test --locked -p bitfun-agent-runtime --test :: -cargo test --locked -p bitfun-agent-runtime +cargo test --locked -p bitfun-agent-runtime --no-default-features --features agent-runtime,deep-research --lib --tests +cargo test --locked -p bitfun-agent-runtime --no-default-features --features deep-research --test deep_research_contracts +cargo test --locked -p bitfun-agent-runtime --no-default-features --features native-hook-settings --test native_hook_settings_contracts +cargo test --locked -p bitfun-agent-runtime --no-default-features --features agent-runtime --test :: ``` Run `pnpm run check:core-boundaries` only when Cargo dependencies, explicit test diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index 936c9e1a4..dacf62db2 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -10,54 +10,110 @@ autotests = false name = "bitfun_agent_runtime" crate-type = ["rlib"] +[[example]] +name = "sdk_minimal" +path = "examples/sdk_minimal.rs" +required-features = ["agent-runtime"] + [features] default = [] +deep-research = ["dep:regex"] +native-hook-settings = ["dep:regex", "dep:serde_json"] +native-hook-runtime = [ + "native-hook-settings", + "dep:log", + "dep:serde", + "dep:serde_json", + "dep:tokio", + "tokio/io-util", + "tokio/macros", + "tokio/process", + "tokio/rt", + "tokio/time", +] +agent-runtime = [ + "native-hook-runtime", + "dep:async-trait", + "dep:bitfun-agent-stream", + "dep:bitfun-agent-tools", + "dep:bitfun-core-types", + "dep:bitfun-events", + "dep:bitfun-harness", + "dep:bitfun-runtime-ports", + "dep:bitfun-runtime-services", + "dep:dashmap", + "dep:hex", + "dep:log", + "dep:regex", + "dep:serde", + "dep:serde_json", + "dep:serde_yaml", + "dep:sha2", + "dep:thiserror", + "dep:tokio", + "dep:tokio-util", + "dep:uuid", + "tokio/macros", + "tokio/rt", + "tokio/sync", +] [dependencies] -async-trait = { workspace = true } -bitfun-agent-stream = { path = "../agent-stream" } -bitfun-agent-tools = { path = "../tool-contracts" } -bitfun-core-types = { path = "../../contracts/core-types" } -bitfun-events = { path = "../../contracts/events" } -bitfun-harness = { path = "../harness" } -bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["agent-api", "git-port", "permission", "plugin-runtime", "remote-workspace-ports", "runtime-event-port", "terminal-port", "workspace-ports"] } -bitfun-runtime-services = { path = "../runtime-services" } -dashmap = { workspace = true } -hex = { workspace = true } -log = { workspace = true } -regex = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml = { workspace = true } -sha2 = { workspace = true } -thiserror = { workspace = true } -uuid = { workspace = true } -tokio = { workspace = true, features = ["io-util", "macros", "process", "rt", "sync", "time"] } -tokio-util = { workspace = true } - -[dev-dependencies] -bitfun-runtime-services = { path = "../runtime-services", features = ["test-support"] } -tokio = { workspace = true, features = ["rt-multi-thread"] } +async-trait = { workspace = true, optional = true } +bitfun-agent-stream = { path = "../agent-stream", optional = true } +bitfun-agent-tools = { path = "../tool-contracts", optional = true } +bitfun-core-types = { path = "../../contracts/core-types", optional = true } +bitfun-events = { path = "../../contracts/events", optional = true } +bitfun-harness = { path = "../harness", optional = true } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports", optional = true, features = ["agent-api", "git-port", "permission", "plugin-runtime", "remote-workspace-ports", "runtime-event-port", "terminal-port", "workspace-ports"] } +bitfun-runtime-services = { path = "../runtime-services", optional = true } +dashmap = { workspace = true, optional = true } +hex = { workspace = true, optional = true } +log = { workspace = true, optional = true } +regex = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +serde_yaml = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +uuid = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } [[test]] name = "agent_definition_contracts" path = "tests/agent_definition_contracts.rs" +required-features = ["agent-runtime"] [[test]] name = "agent_session_contracts" path = "tests/agent_session_contracts.rs" +required-features = ["agent-runtime"] [[test]] name = "agent_interaction_contracts" path = "tests/agent_interaction_contracts.rs" +required-features = ["agent-runtime"] [[test]] name = "agent_long_horizon_contracts" path = "tests/agent_long_horizon_contracts.rs" +required-features = ["agent-runtime"] [[test]] name = "native_hook_execution_contracts" path = "tests/native_hook_execution_contracts.rs" +required-features = ["native-hook-runtime"] + +[[test]] +name = "deep_research_contracts" +path = "tests/deep_research_contracts.rs" +required-features = ["deep-research"] + +[[test]] +name = "native_hook_settings_contracts" +path = "tests/native_hook_settings_contracts.rs" +required-features = ["native-hook-settings"] [lints] workspace = true diff --git a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs index 3005ac6d1..00a8d01ce 100644 --- a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs +++ b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs @@ -46,10 +46,10 @@ impl AgentSubmissionPort for ExampleAgentProvider { } } -#[tokio::main] +#[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let compatibility = AgentRuntimeSdkCompatibility::current(); - assert_eq!(compatibility.api_version, 5); + assert_eq!(compatibility.api_version, 6); let provider = Arc::new(ExampleAgentProvider::default()); let events = AgentEventStream::new(); diff --git a/src/crates/execution/agent-runtime/src/event_queue.rs b/src/crates/execution/agent-runtime/src/event_queue.rs index 53b5ca667..75257564a 100644 --- a/src/crates/execution/agent-runtime/src/event_queue.rs +++ b/src/crates/execution/agent-runtime/src/event_queue.rs @@ -392,8 +392,7 @@ impl StreamEventSink for EventQueue { mod tests { use super::{EventQueue, EventQueueConfig}; use bitfun_events::AgenticEvent; - use std::sync::Arc; - use tokio::sync::Barrier; + use std::sync::{Arc, Barrier}; #[tokio::test] async fn full_legacy_queue_does_not_drop_broadcast_delivery() { @@ -466,43 +465,55 @@ mod tests { } } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_publishers_have_one_order_for_all_subscribers() { + #[test] + fn concurrent_publishers_have_one_order_for_all_subscribers() { const EVENT_COUNT: usize = 64; let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let mut first = queue.subscribe(); let mut second = queue.subscribe(); let barrier = Arc::new(Barrier::new(EVENT_COUNT)); - let mut tasks = Vec::with_capacity(EVENT_COUNT); + let mut publishers = Vec::with_capacity(EVENT_COUNT); for index in 0..EVENT_COUNT { let queue = queue.clone(); let barrier = barrier.clone(); - tasks.push(tokio::spawn(async move { - barrier.wait().await; - queue - .enqueue( - AgenticEvent::SessionStateChanged { - session_id: format!("event-{index}"), - new_state: "idle".to_string(), - }, - None, - ) - .await - .expect("event should enqueue") + publishers.push(std::thread::spawn(move || { + barrier.wait(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("publisher runtime") + .block_on(async move { + queue + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: format!("event-{index}"), + new_state: "idle".to_string(), + }, + None, + ) + .await + .expect("event should enqueue") + }) })); } - for task in tasks { - task.await.expect("publisher should complete"); + for publisher in publishers { + publisher.join().expect("publisher should complete"); } - let mut first_ids = Vec::with_capacity(EVENT_COUNT); - let mut second_ids = Vec::with_capacity(EVENT_COUNT); - for _ in 0..EVENT_COUNT { - first_ids.push(first.recv().await.expect("first broadcast").id); - second_ids.push(second.recv().await.expect("second broadcast").id); - } - assert_eq!(first_ids, second_ids); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("subscriber runtime") + .block_on(async move { + let mut first_ids = Vec::with_capacity(EVENT_COUNT); + let mut second_ids = Vec::with_capacity(EVENT_COUNT); + for _ in 0..EVENT_COUNT { + first_ids.push(first.recv().await.expect("first broadcast").id); + second_ids.push(second.recv().await.expect("second broadcast").id); + } + assert_eq!(first_ids, second_ids); + }); } #[test] diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index 7c4b2e6b6..6685e7657 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -3,42 +3,81 @@ //! This crate owns runtime decisions that can be built and tested without //! depending on `bitfun-core` concrete session or scheduler lifecycle. +#[cfg(feature = "agent-runtime")] pub mod agents; +#[cfg(feature = "agent-runtime")] pub mod checkpoint; +#[cfg(feature = "agent-runtime")] pub mod context_profile; +#[cfg(feature = "agent-runtime")] pub mod custom_agent; +#[cfg(feature = "agent-runtime")] pub mod custom_subagent; +#[cfg(feature = "deep-research")] pub mod deep_research; +#[cfg(feature = "agent-runtime")] pub mod deep_review; +#[cfg(feature = "agent-runtime")] pub mod dialog_turn; +#[cfg(feature = "agent-runtime")] pub mod event_bus; +#[cfg(feature = "agent-runtime")] pub mod event_queue; +#[cfg(feature = "agent-runtime")] pub mod event_router; +#[cfg(feature = "agent-runtime")] pub mod event_source; +#[cfg(feature = "agent-runtime")] pub mod events; +#[cfg(feature = "agent-runtime")] pub mod evidence_ledger; +#[cfg(feature = "agent-runtime")] pub mod file_read_state; +#[cfg(feature = "native-hook-settings")] pub mod native_hooks; +#[cfg(feature = "agent-runtime")] pub mod output_surface; +#[cfg(feature = "agent-runtime")] pub mod permission; +#[cfg(feature = "agent-runtime")] pub mod post_call_hooks; +#[cfg(feature = "agent-runtime")] pub mod prompt; +#[cfg(feature = "agent-runtime")] pub mod prompt_cache; +#[cfg(feature = "agent-runtime")] pub mod prompt_markup; +#[cfg(feature = "agent-runtime")] pub mod remote_file_delivery; +#[cfg(feature = "agent-runtime")] pub mod runtime; +#[cfg(feature = "agent-runtime")] pub mod scheduled_job; +#[cfg(feature = "agent-runtime")] pub mod scheduler; +#[cfg(feature = "agent-runtime")] pub mod sdk; +#[cfg(feature = "agent-runtime")] pub mod session; +#[cfg(feature = "agent-runtime")] pub mod session_control; +#[cfg(feature = "agent-runtime")] pub mod session_state; +#[cfg(feature = "agent-runtime")] pub mod session_state_manager; +#[cfg(feature = "agent-runtime")] pub mod side_question; +#[cfg(feature = "agent-runtime")] pub mod skill_agent_snapshot; +#[cfg(feature = "agent-runtime")] pub mod skills; +#[cfg(feature = "agent-runtime")] pub mod subagent_task; +#[cfg(feature = "agent-runtime")] pub mod thread_goal; +#[cfg(feature = "agent-runtime")] pub mod thread_goal_tools; +#[cfg(feature = "agent-runtime")] pub mod turn_cancellation; +#[cfg(feature = "agent-runtime")] pub mod user_questions; diff --git a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs index e20a8e39c..c3f0a2cc7 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs @@ -16,13 +16,19 @@ //! - the external hook catalog (`bitfun-product-domains`): read-only //! inspection of other AI applications' hook configuration. +#[cfg(feature = "native-hook-runtime")] mod engine; +#[cfg(feature = "native-hook-runtime")] mod output; +#[cfg(feature = "native-hook-runtime")] mod payload; mod settings; +#[cfg(feature = "native-hook-runtime")] pub use engine::{AgentHookEngine, MAX_HOOK_MODEL_OUTPUT_BYTES}; +#[cfg(feature = "native-hook-runtime")] pub use output::{AgentHookOutcome, AgentHookPermissionOutcome}; +#[cfg(feature = "native-hook-runtime")] pub use payload::{ AgentHookEventPayload, AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, }; diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 6beb61b9c..1e7c0d3e8 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -1686,11 +1686,54 @@ mod tests { ClockPort, DialogQueuePriority, DialogSubmissionPolicy, DialogSubmitOutcome, FileSystemPort, PluginDispatchEnvelope, PluginResponseEnvelope, PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeUnavailableReason, PortErrorKind, PortResult, - RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, SessionStorePort, - SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, ThreadGoal, - ThreadGoalStatus, TranscriptContent, TranscriptMessage, WorkspacePort, + RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, + SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, + SessionStorePort, SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, + ThreadGoal, ThreadGoalStatus, TranscriptContent, TranscriptMessage, WorkspacePort, }; - use bitfun_runtime_services::{test_support::FakeRuntimePort, RuntimeServicesBuilder}; + use bitfun_runtime_services::RuntimeServicesBuilder; + + #[derive(Debug)] + struct TestRuntimePort { + capability: RuntimeServiceCapability, + } + + impl TestRuntimePort { + fn new(capability: RuntimeServiceCapability) -> Self { + Self { capability } + } + } + + impl RuntimeServicePort for TestRuntimePort { + fn capability(&self) -> RuntimeServiceCapability { + self.capability + } + } + + impl FileSystemPort for TestRuntimePort {} + impl WorkspacePort for TestRuntimePort {} + + #[async_trait::async_trait] + impl SessionStorePort for TestRuntimePort { + async fn resolve_session_storage_path( + &self, + request: SessionStoragePathRequest, + ) -> PortResult { + Ok(SessionStoragePathResolution::new( + request.workspace_path.clone(), + request.workspace_path, + SessionStorageKind::Local, + request.remote_connection_id, + request.remote_ssh_host, + )) + } + } + + impl ClockPort for TestRuntimePort { + fn now_unix_millis(&self) -> i64 { + 0 + } + } #[derive(Debug, Default)] struct FakeAgentRuntimePorts { @@ -2242,13 +2285,13 @@ mod tests { fn runtime_services_with_events(events: Arc) -> RuntimeServices { let filesystem: Arc = - Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::FileSystem)); + Arc::new(TestRuntimePort::new(RuntimeServiceCapability::FileSystem)); let workspace: Arc = - Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Workspace)); + Arc::new(TestRuntimePort::new(RuntimeServiceCapability::Workspace)); let session_store: Arc = - Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::SessionStore)); + Arc::new(TestRuntimePort::new(RuntimeServiceCapability::SessionStore)); let clock: Arc = - Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Clock)); + Arc::new(TestRuntimePort::new(RuntimeServiceCapability::Clock)); RuntimeServicesBuilder::new() .with_filesystem(filesystem) diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index cc9f1eacd..70895219b 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -pub const AGENT_RUNTIME_SDK_API_VERSION: u32 = 5; +pub const AGENT_RUNTIME_SDK_API_VERSION: u32 = 6; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts.rs index 519ec16eb..5d8fc901f 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts.rs @@ -1,4 +1,5 @@ //! Agent definition, discovery, prompt, and skill contracts. +#![cfg(feature = "agent-runtime")] #[path = "agent_definition_contracts/agent_registry_contracts.rs"] mod agent_registry_contracts; diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs index 4ff9513d3..7667b7ecf 100644 --- a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs @@ -1,9 +1,8 @@ //! Permission, question, and hook interaction contracts. +#![cfg(feature = "agent-runtime")] #[path = "agent_interaction_contracts/native_hook_payload_contracts.rs"] mod native_hook_payload_contracts; -#[path = "agent_interaction_contracts/native_hook_settings_contracts.rs"] -mod native_hook_settings_contracts; #[path = "agent_interaction_contracts/permission_contracts.rs"] mod permission_contracts; #[path = "agent_interaction_contracts/post_call_hook_contracts.rs"] diff --git a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts.rs index dec83990f..dac35ae7b 100644 --- a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts.rs @@ -1,7 +1,6 @@ -//! DeepResearch, DeepReview, and long-running thread-goal contracts. +//! DeepReview and long-running thread-goal contracts. +#![cfg(feature = "agent-runtime")] -#[path = "agent_long_horizon_contracts/deep_research_contracts.rs"] -mod deep_research_contracts; #[path = "agent_long_horizon_contracts/deep_review_policy_contracts.rs"] mod deep_review_policy_contracts; #[path = "agent_long_horizon_contracts/thread_goal_contracts.rs"] diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts.rs index 9cad89f6d..ee1919c7e 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts.rs @@ -1,4 +1,5 @@ //! Session, scheduler, event, SDK, and workspace-reference contracts. +#![cfg(feature = "agent-runtime")] #[path = "agent_session_contracts/events_contracts.rs"] mod events_contracts; diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs index 5d886846f..14d841523 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs @@ -53,7 +53,7 @@ struct FakeSessionClosePort { fn sdk_facade_exposes_versioned_preview_compatibility_contract() { let compatibility = AgentRuntimeSdkCompatibility::current(); - assert_eq!(compatibility.api_version, 5); + assert_eq!(compatibility.api_version, 6); assert_eq!(compatibility.crate_version, env!("CARGO_PKG_VERSION")); assert_eq!(compatibility.stability, AgentRuntimeSdkStability::Preview); } diff --git a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs b/src/crates/execution/agent-runtime/tests/deep_research_contracts.rs similarity index 96% rename from src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs rename to src/crates/execution/agent-runtime/tests/deep_research_contracts.rs index 35e8e4a91..4e13effc2 100644 --- a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/deep_research_contracts.rs @@ -1,3 +1,6 @@ +//! DeepResearch report and citation contracts. +#![cfg(feature = "deep-research")] + use bitfun_agent_runtime::deep_research::renumber_research_report; #[test] diff --git a/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs b/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs index 9c85ae9e6..0fc2c31b8 100644 --- a/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/native_hook_execution_contracts.rs @@ -7,6 +7,7 @@ //! //! Unix-only: the fixtures are `sh` one-liners. #![cfg(unix)] +#![cfg(feature = "native-hook-runtime")] use bitfun_agent_runtime::native_hooks::{ AgentHookEngine, AgentHookEventPayload, AgentHookOutcome, AgentHookPayload, diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_settings_contracts.rs b/src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs similarity index 99% rename from src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_settings_contracts.rs rename to src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs index 334616085..af76a10b7 100644 --- a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_settings_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/native_hook_settings_contracts.rs @@ -4,6 +4,8 @@ //! shape, the fixed event list, matcher semantics, handler fields, timeout //! defaults, and the layer/limit rules. +#![cfg(feature = "native-hook-settings")] + use bitfun_agent_runtime::native_hooks::{ AgentHookEvent, AgentHookScope, AgentHookSettings, AgentHookSettingsIssue, AgentHookSettingsLayer, MAX_HOOK_HANDLERS, diff --git a/src/crates/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index 2d6ad2dbd..f517b57dd 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -18,6 +18,7 @@ client = [ server = [ "dep:bitfun-agent-tools", "dep:bitfun-agent-runtime", + "bitfun-agent-runtime/agent-runtime", "dep:bitfun-core-types", "dep:bitfun-core", "dep:sha2", diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index 8917efedd..46fc3e42b 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -15,7 +15,7 @@ bitfun-app-server-protocol = { path = "../app-server-protocol" } # narrower Core owner feature when a newly registered domain needs it; the # protocol surface must not inherit the broad bitfun-core/product-full union. bitfun-core = { path = "../../assembly/core", features = ["external-sources", "git", "i18n-runtime", "remote-connect"] } -bitfun-agent-runtime = { path = "../../execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../execution/agent-runtime", features = ["agent-runtime"] } bitfun-events = { path = "../../contracts/events" } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["agent-api"] } diff --git a/src/crates/interfaces/sdk-host/Cargo.toml b/src/crates/interfaces/sdk-host/Cargo.toml index 7e7b60a1f..cfcad4df7 100644 --- a/src/crates/interfaces/sdk-host/Cargo.toml +++ b/src/crates/interfaces/sdk-host/Cargo.toml @@ -10,7 +10,7 @@ name = "bitfun_sdk_host" [dependencies] async-trait = { workspace = true } -bitfun-agent-runtime = { path = "../../execution/agent-runtime" } +bitfun-agent-runtime = { path = "../../execution/agent-runtime", features = ["agent-runtime"] } bitfun-core-types = { path = "../../contracts/core-types" } bitfun-events = { path = "../../contracts/events" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["agent-api"] } diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 3e6895503..666ae721f 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -91,8 +91,11 @@ slices that are outside pure product logic but still platform-neutral. new session/process group are outside the managed boundary. - Announcement remote fetch/cache lives here; product assembly supplies config values such as endpoint, locale, version, platform, and cache path. -- DeepResearch report IO here may own report/citation sidecar filesystem work; - provider-neutral citation numbering stays in `bitfun-agent-runtime`. +- DeepResearch report IO here owns report/citation sidecar filesystem work; + provider-neutral citation numbering stays in `bitfun-agent-runtime`. The IO + path must use the injected `WorkspaceFileSystem` for both local and remote + workspaces; never probe or fall back to the host filesystem for a remote + workspace path. ## Verification @@ -107,6 +110,7 @@ cargo check -p bitfun-services-integrations --no-default-features cargo test -p bitfun-services-integrations --no-default-features --features mcp --test mcp_contracts cargo test -p bitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: cargo test -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts +cargo test --locked -p bitfun-services-integrations --no-default-features --features deep-research --lib deep_research::tests:: pnpm run check:core-boundaries ``` diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 6120c284e..fe7b1cdbb 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -121,7 +121,13 @@ canvas-runtime = [ "uuid", ] debug-log = ["anyhow", "chrono", "reqwest", "reqwest/json", "reqwest/rustls", "tokio/rt", "uuid"] -deep-research = ["bitfun-agent-runtime", "tokio/fs"] +deep-research = [ + "anyhow", + "async-trait", + "dep:bitfun-agent-runtime", + "bitfun-agent-runtime/deep-research", + "bitfun-runtime-ports/workspace-ports", +] git = [ "async-trait", "bitfun-runtime-ports/git-port", @@ -226,7 +232,8 @@ plugin-source = [ "windows", ] hook-import = [ - "bitfun-agent-runtime", + "dep:bitfun-agent-runtime", + "bitfun-agent-runtime/native-hook-settings", "bitfun-product-domains/external-sources", "bitfun-services-core/local-storage", "dep:bitfun-product-domains", diff --git a/src/crates/services/services-integrations/src/deep_research.rs b/src/crates/services/services-integrations/src/deep_research.rs index 85c5908e5..6a9fbb59a 100644 --- a/src/crates/services/services-integrations/src/deep_research.rs +++ b/src/crates/services/services-integrations/src/deep_research.rs @@ -6,11 +6,11 @@ use bitfun_agent_runtime::deep_research::{ renumber_research_report, ResearchCitationDisplayMapEntry, }; +use bitfun_runtime_ports::WorkspaceFileSystem; use log::{debug, info, warn}; use serde_json::json; use std::fmt; -use std::path::{Path, PathBuf}; -use tokio::fs; +use std::path::PathBuf; #[derive(Debug)] pub enum DeepResearchReportIoError { @@ -63,42 +63,52 @@ pub struct RenumberStats { /// Operates on the per-session WORK_DIR at /// `/.bitfun/sessions//research/`, where both the /// report and the audit files live. -pub async fn run_for_session_workspace(workspace_root: &Path, session_id: &str) { - let work_dir = workspace_root - .join(".bitfun") - .join("sessions") - .join(session_id) - .join("research"); - let report_path = work_dir.join("report.md"); - - if !report_path.exists() { - debug!( - "citation_renumber: {} not found, nothing to renumber", - report_path.display() - ); - return; +pub async fn run_for_session_workspace( + fs: &dyn WorkspaceFileSystem, + workspace_root: &str, + session_id: &str, +) { + let work_dir = fs.join_path( + workspace_root, + &[".bitfun", "sessions", session_id, "research"], + ); + let report_path = fs.join_path(&work_dir, &["report.md"]); + + match fs.exists(&report_path).await { + Ok(false) => { + debug!( + "citation_renumber: {} not found, nothing to renumber", + report_path + ); + return; + } + Err(error) => { + warn!( + "citation_renumber: skipped (best-effort failure): path={}, err=check report existence failed: {}", + report_path, error + ); + return; + } + Ok(true) => {} } - match try_renumber_research_report(&report_path, &work_dir).await { + match try_renumber_research_report(fs, &report_path, &work_dir).await { Ok(stats) if stats.citations_renumbered == 0 => { debug!( "citation_renumber: no cit_XXX references found in {}; skipping", - report_path.display() + report_path ); } Ok(stats) => { info!( "citation_renumber: renumbered {} citations in {} ({} rejected refs in body)", - stats.citations_renumbered, - report_path.display(), - stats.rejected_refs_in_body + stats.citations_renumbered, report_path, stats.rejected_refs_in_body ); } Err(err) => { warn!( "citation_renumber: skipped (best-effort failure): path={}, err={}", - report_path.display(), - err + report_path, err ); } } @@ -110,32 +120,43 @@ pub async fn run_for_session_workspace(workspace_root: &Path, session_id: &str) /// citation registry's `status=ACCEPTED|REJECTED` flags so REJECTED rows can /// be skipped during numbering. pub async fn try_renumber_research_report( - report_path: &Path, - work_dir: &Path, + fs: &dyn WorkspaceFileSystem, + report_path: &str, + work_dir: &str, ) -> DeepResearchReportIoResult { - if !report_path.exists() { + if !fs + .exists(report_path) + .await + .map_err(|error| DeepResearchReportIoError::ReadReport(workspace_io_error(error)))? + { return Ok(RenumberStats::default()); } - let report = fs::read_to_string(report_path) + let report = fs + .read_file_text(report_path) .await - .map_err(DeepResearchReportIoError::ReadReport)?; + .map_err(|error| DeepResearchReportIoError::ReadReport(workspace_io_error(error)))?; - let registry_path = work_dir.join("citations.md"); - let registry_content = if registry_path.exists() { - match fs::read_to_string(®istry_path).await { + let registry_path = fs.join_path(work_dir, &["citations.md"]); + let registry_content = match fs.exists(®istry_path).await { + Ok(true) => match fs.read_file_text(®istry_path).await { Ok(content) => Some(content), - Err(e) => { + Err(error) => { warn!( "citation_renumber: failed to read citations.md ({}): {}", - registry_path.display(), - e + registry_path, error ); None } + }, + Ok(false) => None, + Err(error) => { + warn!( + "citation_renumber: failed to inspect citations.md ({}): {}", + registry_path, error + ); + None } - } else { - None }; let output = renumber_research_report(&report, registry_content.as_deref()); @@ -143,7 +164,7 @@ pub async fn try_renumber_research_report( if output.display_map.is_empty() { debug!( "citation_renumber: no eligible cit_XXX references in {}", - report_path.display() + report_path ); return Ok(RenumberStats { citations_renumbered: output.stats.citations_renumbered, @@ -151,9 +172,9 @@ pub async fn try_renumber_research_report( }); } - fs::write(report_path, &output.report) + fs.write_file(report_path, output.report.as_bytes()) .await - .map_err(DeepResearchReportIoError::WriteReport)?; + .map_err(|error| DeepResearchReportIoError::WriteReport(workspace_io_error(error)))?; if output.stats.rejected_index_rows_dropped > 0 { warn!( @@ -162,7 +183,11 @@ pub async fn try_renumber_research_report( ); } - let _ = write_display_map_sidecar(work_dir, report_path, &output.display_map).await; + if let Err(error) = + write_display_map_sidecar(fs, work_dir, report_path, &output.display_map).await + { + warn!("citation_renumber: {error}"); + } Ok(RenumberStats { citations_renumbered: output.stats.citations_renumbered, @@ -171,11 +196,12 @@ pub async fn try_renumber_research_report( } async fn write_display_map_sidecar( - parent: &Path, - report_path: &Path, + fs: &dyn WorkspaceFileSystem, + parent: &str, + report_path: &str, display_map: &[ResearchCitationDisplayMapEntry], -) -> DeepResearchReportIoResult { - let map_path = parent.join("display_map.json"); +) -> DeepResearchReportIoResult { + let map_path = fs.join_path(parent, &["display_map.json"]); let entries = display_map .iter() .map(|entry| { @@ -187,25 +213,197 @@ async fn write_display_map_sidecar( .collect::>(); let body = json!({ "version": 1, - "report_path": report_path.to_string_lossy(), + "report_path": report_path, "citation_count": display_map.len(), "entries": entries, }); let serialized = serde_json::to_string_pretty(&body) .map_err(DeepResearchReportIoError::SerializeDisplayMap)?; - fs::write(&map_path, serialized).await.map_err(|source| { - DeepResearchReportIoError::WriteDisplayMap { - path: map_path.clone(), - source, - } - })?; + fs.write_file(&map_path, serialized.as_bytes()) + .await + .map_err(|source| DeepResearchReportIoError::WriteDisplayMap { + path: PathBuf::from(&map_path), + source: workspace_io_error(source), + })?; Ok(map_path) } +fn workspace_io_error(error: anyhow::Error) -> std::io::Error { + match error.downcast::() { + Ok(error) => error, + Err(error) => std::io::Error::other(error), + } +} + #[cfg(test)] mod tests { use super::*; + use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePathKind}; + use std::collections::HashMap; use std::env; + use std::path::Path; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + + struct HostWorkspaceFs; + + #[async_trait::async_trait] + impl WorkspaceFileSystem for HostWorkspaceFs { + async fn read_file(&self, path: &str) -> anyhow::Result> { + Ok(std::fs::read(path)?) + } + + async fn read_file_text(&self, path: &str) -> anyhow::Result { + Ok(std::fs::read_to_string(path)?) + } + + async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { + if let Some(parent) = Path::new(path).parent() { + std::fs::create_dir_all(parent)?; + } + Ok(std::fs::write(path, contents)?) + } + + async fn exists(&self, path: &str) -> anyhow::Result { + Ok(Path::new(path).try_exists()?) + } + + async fn is_file(&self, path: &str) -> anyhow::Result { + Ok(std::fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false)) + } + + async fn is_dir(&self, path: &str) -> anyhow::Result { + Ok(std::fs::metadata(path) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false)) + } + + async fn read_dir(&self, path: &str) -> anyhow::Result> { + let mut entries = Vec::new(); + let read_dir = std::fs::read_dir(path)?; + for entry in read_dir { + let entry = entry?; + let metadata = entry.metadata()?; + entries.push(WorkspaceDirEntry { + name: entry.file_name().to_string_lossy().to_string(), + path: entry.path().to_string_lossy().to_string(), + is_dir: metadata.is_dir(), + is_symlink: metadata.file_type().is_symlink(), + }); + } + Ok(entries) + } + } + + #[derive(Clone, Default)] + struct RecordingWorkspaceFs { + files: Arc>>>, + operations: Arc>>, + } + + impl RecordingWorkspaceFs { + fn insert(&self, path: &str, contents: impl Into>) { + self.files + .lock() + .unwrap() + .insert(path.to_string(), contents.into()); + } + + fn text(&self, path: &str) -> Option { + self.files + .lock() + .unwrap() + .get(path) + .map(|contents| String::from_utf8(contents.clone()).unwrap()) + } + + fn observed_operations(&self) -> Vec { + self.operations.lock().unwrap().clone() + } + + fn record(&self, operation: &str, path: &str) { + self.operations + .lock() + .unwrap() + .push(format!("{operation}:{path}")); + } + } + + #[async_trait::async_trait] + impl WorkspaceFileSystem for RecordingWorkspaceFs { + fn join_path(&self, root: &str, components: &[&str]) -> String { + let mut path = root.trim_end_matches('/').to_string(); + if path.is_empty() && root.starts_with('/') { + path.push('/'); + } + for component in components { + if !path.is_empty() && !path.ends_with('/') { + path.push('/'); + } + path.push_str(component.trim_matches('/')); + } + path + } + + async fn read_file(&self, path: &str) -> anyhow::Result> { + self.record("read", path); + self.files + .lock() + .unwrap() + .get(path) + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing test file: {path}")) + } + + async fn read_file_text(&self, path: &str) -> anyhow::Result { + Ok(String::from_utf8(self.read_file(path).await?)?) + } + + async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { + self.record("write", path); + self.files + .lock() + .unwrap() + .insert(path.to_string(), contents.to_vec()); + Ok(()) + } + + async fn exists(&self, path: &str) -> anyhow::Result { + self.record("exists", path); + Ok(self.files.lock().unwrap().contains_key(path)) + } + + async fn is_file(&self, path: &str) -> anyhow::Result { + self.exists(path).await + } + + async fn is_dir(&self, _path: &str) -> anyhow::Result { + Ok(false) + } + + async fn path_kind_no_follow( + &self, + path: &str, + ) -> anyhow::Result> { + Ok(self.exists(path).await?.then_some(WorkspacePathKind::File)) + } + + async fn read_dir(&self, _path: &str) -> anyhow::Result> { + Ok(Vec::new()) + } + } + + #[test] + fn workspace_io_error_preserves_standard_io_kind() { + let error = workspace_io_error(anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + ))); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + } /// Minimal tempdir helper to avoid pulling in the `tempfile` crate just /// for one test. Removes the dir on drop. @@ -236,17 +434,15 @@ mod tests { let dir = ScratchDir::new("e2e"); let work_dir = dir.path().join("research"); let report_dir = dir.path().join("report-out"); - fs::create_dir_all(&work_dir).await.unwrap(); - fs::create_dir_all(&report_dir).await.unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + std::fs::create_dir_all(&report_dir).unwrap(); let citations = "\ cit_001 | claim a | url=u1 | authority=high | status=ACCEPTED cit_002 | claim b | url=u2 | authority=low | status=REJECTED | reason=contradicted cit_005 | claim c | url=u3 | authority=medium "; - fs::write(work_dir.join("citations.md"), citations) - .await - .unwrap(); + std::fs::write(work_dir.join("citations.md"), citations).unwrap(); let report = "\ # Deep Research Report @@ -268,15 +464,19 @@ cit_005 | claim c | url=u3 | authority=medium | cit_005 | claim c | u3 | "; let report_path = report_dir.join("test-subject-2026-05-13.md"); - fs::write(&report_path, report).await.unwrap(); + std::fs::write(&report_path, report).unwrap(); - let stats = try_renumber_research_report(&report_path, &work_dir) - .await - .unwrap(); + let stats = try_renumber_research_report( + &HostWorkspaceFs, + &report_path.to_string_lossy(), + &work_dir.to_string_lossy(), + ) + .await + .unwrap(); assert_eq!(stats.citations_renumbered, 2); assert_eq!(stats.rejected_refs_in_body, 1); - let after = fs::read_to_string(&report_path).await.unwrap(); + let after = std::fs::read_to_string(&report_path).unwrap(); assert!(after.contains("mentioning [1] first")); assert!(after.contains("claim with [2] here")); assert!(after.contains("A pair: [1, 2]")); @@ -300,14 +500,15 @@ cit_005 | claim c | url=u3 | authority=medium "display_map.json must NOT be written next to the report" ); let map: serde_json::Value = - serde_json::from_str(&fs::read_to_string(sidecar).await.unwrap()).unwrap(); + serde_json::from_str(&std::fs::read_to_string(sidecar).unwrap()).unwrap(); assert_eq!(map["citation_count"], 2); } #[tokio::test] async fn run_for_session_is_no_op_when_session_has_no_report() { let dir = ScratchDir::new("no-session-report"); - run_for_session_workspace(dir.path(), "missing-session").await; + let workspace_root = dir.path().to_string_lossy().to_string(); + run_for_session_workspace(&HostWorkspaceFs, &workspace_root, "missing-session").await; let work_dir = dir .path() @@ -315,8 +516,8 @@ cit_005 | claim c | url=u3 | authority=medium .join("sessions") .join("incomplete-session") .join("research"); - fs::create_dir_all(&work_dir).await.unwrap(); - run_for_session_workspace(dir.path(), "incomplete-session").await; + std::fs::create_dir_all(&work_dir).unwrap(); + run_for_session_workspace(&HostWorkspaceFs, &workspace_root, "incomplete-session").await; assert!(!work_dir.join("display_map.json").exists()); } @@ -331,7 +532,7 @@ cit_005 | claim c | url=u3 | authority=medium .join("sessions") .join(session_id) .join("research"); - fs::create_dir_all(&work_dir).await.unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); let report_path = work_dir.join("report.md"); let report = "\ @@ -346,23 +547,99 @@ Para 1 references cit_005 first. Para 2 references cit_001. | cit_001 | claim a | u1 | | cit_005 | claim c | u3 | "; - fs::write(&report_path, report).await.unwrap(); + std::fs::write(&report_path, report).unwrap(); - fs::write( + std::fs::write( work_dir.join("citations.md"), "cit_001 | claim a | url=u1 | authority=high | status=ACCEPTED\n\ cit_005 | claim c | url=u3 | authority=medium\n", ) - .await .unwrap(); - run_for_session_workspace(dir.path(), session_id).await; + let workspace_root = dir.path().to_string_lossy().to_string(); + run_for_session_workspace(&HostWorkspaceFs, &workspace_root, session_id).await; - let after = fs::read_to_string(&report_path).await.unwrap(); + let after = std::fs::read_to_string(&report_path).unwrap(); assert!(after.contains("references [1] first")); assert!(after.contains("references [2].")); assert!(after.contains("[2] cit_001")); assert!(after.contains("[1] cit_005")); assert!(work_dir.join("display_map.json").exists()); } + + #[tokio::test] + async fn remote_workspace_paths_are_processed_only_through_the_workspace_fs_port() { + let fs = RecordingWorkspaceFs::default(); + let workspace_root = "/root/project"; + let session_id = "remote-session"; + let work_dir = format!("{workspace_root}/.bitfun/sessions/{session_id}/research"); + let report_path = format!("{work_dir}/report.md"); + let citations_path = format!("{work_dir}/citations.md"); + let display_map_path = format!("{work_dir}/display_map.json"); + fs.insert( + &report_path, + b"Finding cit_005 then cit_001.\n\n## Citation Index\n\n| ID | Claim |\n|---|---|\n| cit_001 | a |\n| cit_005 | b |\n".to_vec(), + ); + fs.insert( + &citations_path, + b"cit_001 | claim a | status=ACCEPTED\ncit_005 | claim b | status=ACCEPTED\n".to_vec(), + ); + + run_for_session_workspace(&fs, workspace_root, session_id).await; + + let report = fs.text(&report_path).expect("rewritten remote report"); + assert!(report.contains("Finding [1] then [2].")); + let display_map = fs + .text(&display_map_path) + .expect("remote display map sidecar"); + assert!(display_map.contains("\"citation_count\": 2")); + assert_eq!( + fs.observed_operations(), + vec![ + format!("exists:{report_path}"), + format!("exists:{report_path}"), + format!("read:{report_path}"), + format!("exists:{citations_path}"), + format!("read:{citations_path}"), + format!("write:{report_path}"), + format!("write:{display_map_path}"), + ] + ); + } + + #[tokio::test] + async fn missing_workspace_file_never_falls_back_to_the_host_filesystem() { + let host_workspace = ScratchDir::new("no-host-fallback"); + let session_id = "remote-session"; + let host_work_dir = host_workspace + .path() + .join(".bitfun") + .join("sessions") + .join(session_id) + .join("research"); + std::fs::create_dir_all(&host_work_dir).unwrap(); + let host_report = host_work_dir.join("report.md"); + let original_report = "Finding cit_001.\n"; + std::fs::write(&host_report, original_report).unwrap(); + + let remote_fs = RecordingWorkspaceFs::default(); + let workspace_root = host_workspace.path().to_string_lossy().to_string(); + run_for_session_workspace(&remote_fs, &workspace_root, session_id).await; + + assert_eq!( + std::fs::read_to_string(&host_report).unwrap(), + original_report + ); + assert!(!host_work_dir.join("display_map.json").exists()); + assert_eq!( + remote_fs.observed_operations(), + vec![format!( + "exists:{}", + remote_fs.join_path( + &workspace_root, + &[".bitfun", "sessions", session_id, "research", "report.md"] + ) + )] + ); + } } diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs index 11d0fb49d..6aa2696fd 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs @@ -82,8 +82,26 @@ impl RemoteWorkspaceFs { } } +fn join_posix_path(root: &str, components: &[&str]) -> String { + let mut path = root.trim_end_matches('/').to_string(); + if path.is_empty() && root.starts_with('/') { + path.push('/'); + } + for component in components { + if !path.is_empty() && !path.ends_with('/') { + path.push('/'); + } + path.push_str(component.trim_matches('/')); + } + path +} + #[async_trait] impl WorkspaceFileSystem for RemoteWorkspaceFs { + fn join_path(&self, root: &str, components: &[&str]) -> String { + join_posix_path(root, components) + } + async fn read_file(&self, path: &str) -> anyhow::Result> { self.file_service.read_file(&self.connection_id, path).await } @@ -226,6 +244,19 @@ mod bounded_read_tests { } } + #[test] + fn remote_workspace_paths_keep_posix_syntax_for_absolute_home_and_relative_roots() { + for (root, expected) in [ + ("/", "/.bitfun/report.md"), + ("~", "~/.bitfun/report.md"), + ("~/repo", "~/repo/.bitfun/report.md"), + ("repo", "repo/.bitfun/report.md"), + (".", "./.bitfun/report.md"), + ] { + assert_eq!(join_posix_path(root, &[".bitfun", "report.md"]), expected); + } + } + #[test] fn bounded_binary_preflight_preserves_errors_and_follows_document_symlinks() { assert_eq!(