From 125c41215e20d06dfdfde96cfc58c8d3b8f44e14 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 10 Aug 2026 22:54:37 +0800 Subject: [PATCH] perf(core): isolate document and subscription capabilities --- Cargo.toml | 2 +- .../rust-build-dependency-boundaries.md | 13 +- docs/performance/01-compile-performance.md | 25 ++- scripts/check-core-boundaries.test.mjs | 94 ++++++-- .../cargo-dependency-boundaries.mjs | 95 +++++--- scripts/core-boundaries/checker.mjs | 3 +- .../manifest-feature-helpers.mjs | 9 +- .../core-boundaries/rules/feature-rules.mjs | 43 +++- .../rules/source/required-rules.mjs | 44 ++-- scripts/core-boundaries/self-test.mjs | 39 ---- src/apps/cli/Cargo.toml | 4 +- src/apps/desktop/Cargo.toml | 2 +- src/crates/adapters/ai-adapters/Cargo.toml | 3 +- src/crates/assembly/core/AGENTS.md | 6 +- src/crates/assembly/core/Cargo.toml | 9 +- .../tools/implementations/file_read_tool.rs | 211 +++++++++++++++--- .../src/infrastructure/ai/client_factory.rs | 133 ++++++++++- .../assembly/core/src/infrastructure/mod.rs | 2 +- .../tool-execution/src/fs/document.rs | 53 ++++- .../execution/tool-execution/src/fs/mod.rs | 11 +- src/crates/interfaces/acp/Cargo.toml | 2 + .../miniapp-market-service/Cargo.toml | 2 +- .../services/services-integrations/Cargo.toml | 24 +- .../services/skin-market-service/Cargo.toml | 2 +- 24 files changed, 639 insertions(+), 192 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ef2b93bec..2e53e5769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,7 +133,7 @@ encoding_rs = "0.8.35" url = "2" # HTTP client -reqwest = { version = "0.13.4", default-features = false, features = ["http2", "json", "stream", "multipart", "query", "form"] } +reqwest = { version = "0.13.4", default-features = false } semver = "1.0" # Debug Log HTTP Server diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 8815f04bd..fb608230e 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -58,6 +58,11 @@ Core 的 `agent-runtime` 只承载 Agent 生命周期基线和明确的基线工 Owner feature 不等于“无前置依赖”。当实现确实调用较低层基线时,依赖必须按 `owner → baseline` 显式组合,禁止反向把 owner 藏回基线:例如 Core MCP 工具桥和 Remote Connect 依赖 Agent 生命周期,Workspace Search 依赖本地 Workspace Runtime。每个新增或调整后的 owner 闭包都必须单独 `cargo check`,避免被 Desktop/CLI 的 feature union 偶然补齐。 +只为已经启用的 optional dependency 增加子能力时,使用 Cargo 的弱依赖转发 +`dependency?/feature`,并把 modifier 与 runtime owner 分开命名和看护。modifier 单独启用不得激活 +runtime dependency;真实产品入口必须同时显式选择 owner 与 modifier。不要为了复用一个子 feature +把完整 adapter、service 或 tool runtime 拉回窄闭包。 + Function Agent 的 Git/AI 适配由 `function-agents` 选择,MiniApp 的 domain/runtime/market 闭包由 `tools-miniapp` 选择;不得再通过一个通用 `product-domains` Core feature 把两者、 Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品装配计划若声明了当前 @@ -77,11 +82,11 @@ Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品 - target-specific dependency 放在最接近平台实现的 owner,不因单一平台需求污染跨平台 crate; - 修改共享 dependency feature 视为构建影响变更,必须检查真实产品组合的 feature graph。 -### 3.4 Reqwest TLS 后端由客户端 owner 选择 +### 3.4 Reqwest 能力由客户端 owner 选择 -- workspace 级 `reqwest` 只统一版本以及跨产品共享的 HTTP、序列化和流能力,不启用 TLS 后端; -- 真正创建 HTTPS client 的 app、service 或 adapter 必须在自身依赖声明中显式选择 `reqwest/rustls`,只使用 `reqwest::Url` 的 contract/assembly 路径不加载 TLS; -- capability crate 的每个 Reqwest owner feature 必须独立带齐 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; +- workspace 级 `reqwest` 只统一版本并关闭默认 feature,不替任何客户端选择 HTTP/2、序列化、表单、流、代理或 TLS 能力; +- 真正创建 client 的 app、service 或 adapter 必须在自身依赖声明中显式选择实际使用的 Reqwest feature 和 `reqwest/rustls`;只使用 `reqwest::Url` 的 contract/assembly 路径不加载传输能力; +- capability crate 的每个 Reqwest owner feature 必须独立带齐自己的数据/传输 feature 与 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; - 边界检查以 Cargo metadata 的解码结果看护全部直接 consumer,并检查 resolved Reqwest feature union,防止传递依赖重新激活 Native TLS; - 不并列启用 native-tls 兼容栈。只有真实产品场景无法由 Rustls 平台证书验证承载时,才以明确行为证据评审替换方案,而不是重新叠加第二后端。 diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index e84c31ea5..509ed20b2 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -2,7 +2,7 @@ > 最近核实:2026-08-10 > -> 实现复核基线:`gcwing/main@e63084bc5` +> 实现复核基线:`gcwing/main@734e5b05f` > > 性能 A/B 基线:`gcwing/main@1f538b96d` > @@ -17,8 +17,9 @@ | 结论 | 说明 | |---|---| | 服务测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;选中的 `local-storage`、MCP、基础 SSH 闭包从 16 个集成 executable 降到 8 个 | -| Agent Runtime 基线不再隐藏全量 capability union | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;MCP、Remote Connect、Browser/Web、Git、模型目录等由产品入口显式组合,三平台 normal/build 闭包各减少 105–110 个版本化 package instance | -| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择的隐含能力,Windows/macOS/Linux 分别减少 10/13/22 | +| Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | +| App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | +| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择或未使用的隐含能力,累计在 Windows/macOS/Linux 分别减少 12/15/24 | | Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | | focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | @@ -94,6 +95,21 @@ package/version,不等同于实际秒数。路径 package 因 A/B worktree 路 | Desktop | 792 → 792 | 807 → 807 | 892 → 892 | 完整产品继续使用既有跨平台截图行为,本轮不以扩大根 lock 依赖宇宙换取单平台闭包下降 | | Installer | 333 → 327 | — | — | Windows 独立 workspace;直接 dependency 18 → 10 | +在最新实现复核基线 `gcwing/main@734e5b05f` 上,本轮继续把两个重型能力从 Core 基线改为弱 +modifier。计数先移除 Cargo tree 的重复展示标记 `(*)`,再按 package/version 去重: + +| 本轮闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `agent-runtime` | 343 → 274 | 330 → 266 | 375 → 265 | 文档扩展识别保留;转换和本地订阅凭据明确不可用 | +| App Server | 490 → 429 | 477 → 421 | 508 → 430 | 现有 handler/DTO 保持,未消费的两个能力退出 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 显式恢复 `document-read` 与 `subscription-auth` | +| CLI | 649 → 649 | 649 → 649 | 672 → 672 | 显式保持原有能力 | +| ACP | 589 → 587 | 574 → 572 | 594 → 592 | 保持原有能力,同时退出 Reqwest 未使用的 `mime_guess`/`unicase` | + +本轮没有新增 crate 或第三方 dependency。收益来自两类现有重闭包退出窄入口:`anydoc` 及其 +文档解析/压缩依赖,以及订阅凭据的 keyring/加密/本地存储依赖。完整产品 package 集合不变, +因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 + Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows `agent-runtime` 闭包中,`bitfun-services-integrations` 的 Cargo active feature 从 61 个降到 6 个, 只保留 `workspace-search` 及其 5 个直接依赖 feature;`bitfun-product-domains` 从 13 个降到 5 个, @@ -106,7 +122,7 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | -| 本轮完成 | Core Agent Runtime capability、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | +| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | | 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | | 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | @@ -131,6 +147,7 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 | | 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 | | Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | +| 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 | | Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile | | Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 8eb356677..7aa64e468 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -414,8 +414,29 @@ test('Core Agent Runtime baseline excludes concrete capability unions', () => { } }); +test('Core optional document and subscription capabilities have independent modifiers', () => { + const ruleByFeature = new Map( + coreClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + ); + assert.deepEqual(ruleByFeature.get('document-read')?.requiredFeatureRefs, [ + 'tool-runtime?/document-read', + ]); + assert.deepEqual(ruleByFeature.get('subscription-auth')?.requiredFeatureRefs, [ + 'bitfun-ai-adapters?/subscription-auth', + ]); + assert.deepEqual(ruleByFeature.get('ai-adapter-runtime')?.requiredFeatureRefs, [ + 'dep:bitfun-ai-adapters', + ]); + assert.ok( + !ruleByFeature.get('tools-basic')?.requiredFeatureRefs.includes('tool-runtime/document-read'), + 'baseline tools must not activate document conversion', + ); +}); + test('Core product-full explicitly assembles service and tool capability owners', () => { for (const required of [ + 'document-read', + 'subscription-auth', 'model-catalog', 'mcp-runtime', 'remote-connect', @@ -487,6 +508,8 @@ test('explicit product entrypoint bitfun-core feature selections pass', () => { const ACP_REVIEWED_CORE_FEATURES = [ 'agent-runtime', + 'document-read', + 'subscription-auth', 'deep-research', 'lsp', 'external-sources', @@ -1399,7 +1422,7 @@ test('services integrations Reqwest policy uses Cargo-decoded feature references reqwest = ["dep:reqwest"] announcement = ["reqwest", "reqwest/rustls"] file-watch = ["reqwest?/__native-tls"] -mcp = ["reqwest"] +mcp = ["reqwest", "reqwest/rustls", "reqwest/json"] models-dev = ["reqwest", "reqwest/rustls", "reqwest/system-proxy"] speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] `); @@ -1407,8 +1430,9 @@ speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] const messages = findServicesIntegrationsReqwestFeatureViolations(pkg) .map((violation) => violation.message) .join('\n'); + assert.match(messages, /announcement.*missing Reqwest feature reference reqwest\/json/); assert.match(messages, /file-watch.*outside its reviewed owner features/); - assert.match(messages, /mcp.*missing reqwest\/rustls/); + assert.match(messages, /mcp.*missing Reqwest feature reference reqwest\/stream/); assert.doesNotMatch(messages, /models-dev.*system-proxy/); assert.match(messages, /speech.*unreviewed Reqwest feature reference reqwest\/http3/); }); @@ -1422,11 +1446,7 @@ test('direct Reqwest clients reject extra decoded dependency and package feature uses_default_features: false, features: [ 'http2', - 'json', 'stream', - 'multipart', - 'query', - 'form', 'rustls', '__native-tls', ], @@ -1449,15 +1469,19 @@ test('direct Reqwest clients reject extra decoded dependency and package feature }); test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { - const baseFeatures = ['http2', 'json', 'stream', 'multipart', 'query', 'form']; - const valid = packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ - name: 'reqwest', - kind: null, - optional: false, - uses_default_features: false, - features: [...baseFeatures, 'rustls', 'socks'], - }]); - const missingSocks = packageAt( + const baseFeatures = ['http2', 'json', 'stream']; + const valid = { + ...packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ + name: 'reqwest', + kind: null, + optional: false, + uses_default_features: false, + features: [...baseFeatures, 'rustls', 'socks'], + }]), + features: { 'subscription-auth': ['reqwest/form'] }, + }; + const missingSocks = { + ...packageAt( 'bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ @@ -1467,7 +1491,9 @@ test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { uses_default_features: false, features: [...baseFeatures, 'rustls'], }], - ); + ), + features: { 'subscription-auth': ['reqwest/form'] }, + }; assert.deepEqual(findReqwestDependencyFeatureViolations([valid]), []); const messages = findReqwestDependencyFeatureViolations([missingSocks]) @@ -1477,14 +1503,14 @@ test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { }); test('Reqwest metadata policy covers URL-only and future dependency owners', () => { - const baseFeatures = ['http2', 'json', 'stream', 'multipart', 'query', 'form']; + const coreFeatures = []; const core = { ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml', [{ name: 'reqwest', kind: null, optional: true, uses_default_features: false, - features: baseFeatures, + features: coreFeatures, }]), features: { product: ['dep:reqwest', 'reqwest/__native-tls'] }, }; @@ -1493,7 +1519,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () kind: null, optional: false, uses_default_features: false, - features: [...baseFeatures, 'rustls'], + features: ['http2', 'rustls', 'stream'], }]); const duplicate = packageAt( 'bitfun-services-integrations', @@ -1504,7 +1530,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () kind: null, optional: true, uses_default_features: false, - features: baseFeatures, + features: ['http2'], }, { name: 'reqwest', @@ -1513,7 +1539,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () optional: true, target: 'cfg(windows)', uses_default_features: false, - features: [...baseFeatures, '__native-tls'], + features: ['http2', '__native-tls'], }, ], ); @@ -1526,6 +1552,22 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () assert.match(messages, /bitfun-services-integrations.*exactly one normal Reqwest dependency/); }); +test('Reqwest consumers inherit the workspace version without duplicating feature rules', async () => { + const { requiredContentRules } = await import( + './core-boundaries/rules/source/required-rules.mjs' + ); + const rules = requiredContentRules.filter((rule) => + rule.reason.includes('Reqwest consumers must inherit the workspace-owned compatible version') + ); + + assert.equal(rules.length, 7); + for (const rule of rules) { + const pattern = rule.patterns[0].regex; + assert.match('reqwest = { workspace = true, features = ["rustls"] }', pattern); + assert.doesNotMatch('reqwest = { version = "99", features = ["rustls"] }', pattern); + } +}); + test('resolved Reqwest feature union rejects every native TLS backend alias', () => { const violations = findResolvedReqwestNativeTlsViolations( [ @@ -1980,7 +2022,10 @@ test('split core boundary check keeps self-test and default execution behavior', }); test('optional dependency ownership rejects undeclared direct feature owners', async () => { - const { unexpectedDependencyOwnerFeatures } = await import( + const { + featureReferencesOptionalDependencyOwner, + unexpectedDependencyOwnerFeatures, + } = await import( './core-boundaries/manifest-feature-helpers.mjs' ); const features = new Map([ @@ -1996,8 +2041,11 @@ test('optional dependency ownership rejects undeclared direct feature owners', a depName: 'example', ownerFeatures: ['declared'], }).map(([featureName]) => featureName), - ['missing', 'feature-ref'], + ['missing', 'feature-ref', 'weak-ref'], ); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('declared'), 'example'), true); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('weak-ref'), 'example'), true); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('unrelated'), 'example'), false); }); test('services-core capability profiles keep heavy owners out of the empty profile', async () => { diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index b76ccc00a..e4c21b01e 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -243,36 +243,37 @@ function reqwestDependencyFeatureReferences(references) { ); } -const REQWEST_TRANSPORT_FEATURES = [ - 'form', - 'http2', - 'json', - 'multipart', - 'query', - 'stream', -]; const REQWEST_PACKAGE_PROFILES = new Map([ - ['bitfun-core', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true }], + ['bitfun-core', { dependencyFeatures: [], optional: true }], ['bitfun-services-integrations', { - dependencyFeatures: REQWEST_TRANSPORT_FEATURES, + dependencyFeatures: ['http2'], optional: true, servicesOwners: true, }], ['bitfun-ai-adapters', { - dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls', 'socks'], + dependencyFeatures: ['http2', 'json', 'rustls', 'socks', 'stream'], optional: false, - allowedPackageFeatureRefs: new Set(['reqwest/rustls']), + allowedPackageFeatureRefs: new Set(['reqwest/form']), + requiredPackageFeatureRefs: new Map([ + ['subscription-auth', new Set(['reqwest/form'])], + ]), }], - ...[ - 'bitfun-cli', - 'bitfun-desktop', - 'bitfun-miniapp-market-service', - 'bitfun-skin-market-service', - ].map((packageName) => [packageName, { - dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls'], + ['bitfun-cli', { + dependencyFeatures: ['http2', 'rustls', 'stream'], optional: false, - allowedPackageFeatureRefs: new Set(['reqwest/rustls']), - }]), + }], + ['bitfun-desktop', { + dependencyFeatures: ['http2', 'json', 'query', 'rustls', 'stream'], + optional: false, + }], + ['bitfun-miniapp-market-service', { + dependencyFeatures: ['form', 'http2', 'json', 'rustls'], + optional: false, + }], + ['bitfun-skin-market-service', { + dependencyFeatures: ['http2', 'json', 'rustls'], + optional: false, + }], ]); function findReqwestPackageProfileViolations(pkg, profile) { @@ -356,6 +357,18 @@ function findReqwestPackageProfileViolations(pkg, profile) { } } } + for (const [featureName, requiredReferences] of profile.requiredPackageFeatureRefs ?? []) { + const actualReferences = new Set(pkg.features?.[featureName] ?? []); + for (const reference of requiredReferences) { + if (!actualReferences.has(reference)) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing Reqwest feature reference ${reference}`, + }); + } + } + } } return violations; @@ -511,6 +524,20 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { const violations = []; const featureGraph = pkg.features ?? {}; const ownerFeatures = new Set(servicesReqwestOwnerFeatures); + const ownerFeatureReferences = new Map([ + ['announcement', ['reqwest/json']], + ['browser-control', ['reqwest/json']], + ['debug-log', ['reqwest/json']], + ['mcp', ['reqwest/json', 'reqwest/stream']], + ['miniapp-market', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], + ['miniapp-runtime', ['reqwest/stream']], + ['models-dev', ['reqwest/system-proxy']], + ['remote-connect', ['reqwest/json', 'reqwest/multipart', 'reqwest/query']], + ['remote-ssh-concrete', ['reqwest/stream']], + ['review-platform', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], + ['speech', ['reqwest/stream']], + ['web-tools', ['reqwest/json']], + ]); for (const featureName of servicesReqwestOwnerFeatures) { const references = featureGraph[featureName]; @@ -536,6 +563,15 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { message: `${pkg.name}:${featureName} is missing reqwest/rustls`, }); } + for (const reference of ownerFeatureReferences.get(featureName) ?? []) { + if (!references.includes(reference)) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing Reqwest feature reference ${reference}`, + }); + } + } } for (const [featureName, references] of Object.entries(featureGraph)) { @@ -556,12 +592,15 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { }); continue; } + const allowedReferences = new Set([ + 'reqwest', + 'dep:reqwest', + 'reqwest/rustls', + ...(ownerFeatureReferences.get(featureName) ?? []), + ]); for (const reference of reqwestReferences) { if ( - reference !== 'reqwest' - && reference !== 'dep:reqwest' - && reference !== 'reqwest/rustls' - && !(featureName === 'models-dev' && reference === 'reqwest/system-proxy') + !allowedReferences.has(reference) ) { violations.push({ path: pkg.manifest_path, @@ -773,6 +812,8 @@ export function findProductEntrypointCoreFeatureViolations( const reviewedCoreFeatureClosures = new Map([ ['bitfun-cli', [ 'agent-runtime', + 'document-read', + 'subscription-auth', 'remote-connect', 'deep-research', 'lsp', @@ -791,6 +832,8 @@ export function findProductEntrypointCoreFeatureViolations( ]], ['bitfun-acp', [ 'agent-runtime', + 'document-read', + 'subscription-auth', 'deep-research', 'lsp', 'external-sources', @@ -817,6 +860,7 @@ export function findProductEntrypointCoreFeatureViolations( 'browser-control', 'canvas-runtime', 'deep-research', + 'document-read', 'external-sources', 'file-watch', 'filesystem', @@ -834,6 +878,7 @@ export function findProductEntrypointCoreFeatureViolations( 'scheduled-jobs', 'script-tool-runtime', 'ssh-remote', + 'subscription-auth', 'terminal', 'tool-packs', 'tools-agent-control', diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 0742b296e..a9081ca1c 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -32,6 +32,7 @@ import { runManifestParserSelfTest } from './self-test.mjs'; import { featureReferencesDependency, featureReferencesFeature, + featureReferencesOptionalDependencyOwner, unexpectedDependencyOwnerFeatures, unexpectedReachableLocalFeatures, } from './manifest-feature-helpers.mjs'; @@ -545,7 +546,7 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { }); continue; } - if (!featureReferencesDependency(feature, dependency.depName)) { + if (!featureReferencesOptionalDependencyOwner(feature, dependency.depName)) { failures.push({ path: manifestPath, line: feature.line, diff --git a/scripts/core-boundaries/manifest-feature-helpers.mjs b/scripts/core-boundaries/manifest-feature-helpers.mjs index 4445e2070..0dd50b252 100644 --- a/scripts/core-boundaries/manifest-feature-helpers.mjs +++ b/scripts/core-boundaries/manifest-feature-helpers.mjs @@ -10,6 +10,13 @@ export function featureReferencesDependency(feature, depName) { ); } +export function featureReferencesOptionalDependencyOwner(feature, depName) { + return Boolean( + featureReferencesDependency(feature, depName) + || feature?.refs.some((reference) => reference.startsWith(`${depName}?/`)), + ); +} + export function featureReferencesFeature(feature, featureName) { return Boolean(feature && feature.refs.includes(featureName)); } @@ -17,7 +24,7 @@ export function featureReferencesFeature(feature, featureName) { export function unexpectedDependencyOwnerFeatures(features, dependency) { return [...features.entries()].filter( ([featureName, feature]) => - featureReferencesDependency(feature, dependency.depName) + featureReferencesOptionalDependencyOwner(feature, dependency.depName) && !dependency.ownerFeatures.includes(featureName), ); } diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index a6e5bc83b..d21a30da3 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -58,7 +58,7 @@ export const optionalDependencyFeatureOwnerRules = [ reason: 'runtime-ports may expose product-domain permission ports only through the explicit permission contract slice', dependencies: [ - { depName: 'bitfun-product-domains', ownerFeatures: ['permission'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['permission', 'ts'] }, ], }, { @@ -67,7 +67,10 @@ export const optionalDependencyFeatureOwnerRules = [ 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', dependencies: [ { depName: 'axum', ownerFeatures: ['debug-log', 'mcp-runtime'] }, - { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime'] }, + { + depName: 'bitfun-ai-adapters', + ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], + }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, @@ -85,6 +88,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'function-agents', 'plugin-source', 'tools-miniapp', + 'ts', ], }, { depName: 'bitfun-runtime-services', ownerFeatures: ['runtime-services'] }, @@ -109,6 +113,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'script-tool-runtime', 'ssh-remote', 'tools-miniapp', + 'ts', 'web-tools', 'workspace-search', ], @@ -138,7 +143,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'include_dir', ownerFeatures: ['agent-runtime'] }, { depName: 'indexmap', ownerFeatures: ['agent-runtime'] }, { depName: 'md5', ownerFeatures: ['agent-runtime'] }, - { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'mcp-runtime', 'tools-miniapp'] }, + { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] }, { depName: 'rusqlite', ownerFeatures: ['agent-runtime'] }, { depName: 'semver', ownerFeatures: ['tools-miniapp'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, @@ -147,7 +152,10 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'notify', ownerFeatures: ['lsp', 'workspace-watch'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['browser-control'] }, { depName: 'tower-http', ownerFeatures: ['debug-log'] }, - { depName: 'tool-runtime', ownerFeatures: ['agent-runtime', 'tools-basic', 'web-tools'] }, + { + depName: 'tool-runtime', + ownerFeatures: ['agent-runtime', 'document-read', 'web-tools'], + }, ], }, { @@ -240,6 +248,8 @@ export const coreProductFullFeatureAssemblyRule = { featureName: 'product-full', requiredFeatureRefs: [ 'agent-runtime', + 'document-read', + 'subscription-auth', 'browser-control', 'deep-research', 'mcp-runtime', @@ -514,12 +524,35 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'scheduled-jobs is an additive Agent Runtime modifier for cron parsing and timezone scheduling', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'document-read', + requiredFeatureRefs: ['tool-runtime?/document-read'], + exact: true, + reason: + 'document-read must add conversion only when the Agent tool runtime owner is selected', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'subscription-auth', + requiredFeatureRefs: ['bitfun-ai-adapters?/subscription-auth'], + exact: true, + reason: + 'subscription-auth must add local credential resolution only when the AI adapter runtime owner is selected', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'ai-adapter-runtime', + requiredFeatureRefs: ['dep:bitfun-ai-adapters'], + exact: true, + reason: + 'ai-adapter-runtime must own provider protocol clients without implicitly enabling local subscription credentials', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'tools-basic', requiredFeatureRefs: [ 'bitfun-tool-packs/basic', - 'tool-runtime/document-read', 'workspace-search', ], allowedTransitiveFeatureRefs: [ diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 5510e2d0a..8b4dc744d 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1,44 +1,24 @@ // Boundary rules for source ownership, facades, and required owner content. export const requiredContentRules = [ - { - path: 'Cargo.toml', - reason: - 'workspace Reqwest defaults must stay transport-only so client owners select one TLS backend explicitly', - patterns: [ - { - regex: /^reqwest[ \t]*=[ \t]*\{[ \t]*version[ \t]*=[ \t]*"[^"]+",[ \t]*default-features[ \t]*=[ \t]*false,[ \t]*features[ \t]*=[ \t]*\[[ \t]*"http2",[ \t]*"json",[ \t]*"stream",[ \t]*"multipart",[ \t]*"query",[ \t]*"form"[ \t]*\][ \t]*\}[ \t]*$/m, - message: - 'workspace Reqwest dependency must use the reviewed transport/data feature allowlist', - }, - ], - }, ...[ 'src/apps/cli/Cargo.toml', 'src/apps/desktop/Cargo.toml', + 'src/crates/adapters/ai-adapters/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', 'src/crates/services/miniapp-market-service/Cargo.toml', + 'src/crates/services/services-integrations/Cargo.toml', 'src/crates/services/skin-market-service/Cargo.toml', ].map((path) => ({ path, - reason: 'first-party Reqwest client owners must select the repository TLS backend explicitly', + reason: 'first-party Reqwest consumers must inherit the workspace-owned compatible version', patterns: [ { - regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true,\s*features\s*=\s*\[\s*"rustls"\s*\]\s*\}/m, - message: 'Reqwest client dependency must explicitly enable rustls', + regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true(?:\s*,|\s*\})/m, + message: 'Reqwest dependency must use workspace = true', }, ], })), - { - path: 'src/crates/adapters/ai-adapters/Cargo.toml', - reason: - 'the AI adapter owns Rustls HTTPS clients and the product-supported SOCKS proxy transport', - patterns: [ - { - regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true,\s*features\s*=\s*\[\s*"rustls"\s*,\s*"socks"\s*\]\s*\}/m, - message: 'AI adapter Reqwest dependency must explicitly enable rustls and socks', - }, - ], - }, { path: 'src/crates/services/services-core/src/lib.rs', reason: @@ -3935,6 +3915,14 @@ export const requiredContentRules = [ regex: /"dep:bitfun-ai-adapters"/, message: 'core ai-adapter-runtime feature must explicitly enable the optional dependency', }, + { + regex: /subscription-auth = \["bitfun-ai-adapters\?\/subscription-auth"\]/, + message: 'core subscription-auth modifier must not activate the optional AI adapter runtime by itself', + }, + { + regex: /document-read = \["tool-runtime\?\/document-read"\]/, + message: 'core document-read modifier must not activate the optional tool runtime by itself', + }, { regex: /agent-runtime = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, message: 'core agent-runtime assembly must explicitly opt into AI adapter runtime', @@ -4071,8 +4059,8 @@ export const requiredContentRules = [ message: 'AI client runtime must stay behind ai-adapter-runtime', }, { - regex: /#\[cfg\(feature = "ai-adapter-runtime"\)\]\s*pub mod subscription_auth\b/s, - message: 'AI subscription auth runtime must stay behind ai-adapter-runtime', + regex: /#\[cfg\(all\(feature = "ai-adapter-runtime", feature = "subscription-auth"\)\)\]\s*pub mod subscription_auth\b/s, + message: 'AI subscription auth runtime must require both the adapter and credential owners', }, { regex: /#\[cfg\(feature = "debug-log"\)\]\s*pub mod debug_log\b/s, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 6e19fe7a6..2f4e8d9b3 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -968,45 +968,6 @@ export function runManifestParserSelfTest({ const servicesOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'services-integrations', ); - const workspaceReqwestRule = requiredContentRules.find((rule) => rule.path === 'Cargo.toml'); - const workspaceReqwestRuleText = workspaceReqwestRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - for (const featureName of ['http2', 'json', 'stream', 'multipart', 'query', 'form']) { - if (!workspaceReqwestRuleText.includes(featureName)) { - throw new Error(`workspace Reqwest boundary must allow only reviewed feature ${featureName}`); - } - } - const workspaceReqwestPattern = workspaceReqwestRule?.patterns[0]?.regex; - const reviewedReqwestDeclaration = - 'reqwest = { version = "0.13.4", default-features = false, features = ["http2", "json", "stream", "multipart", "query", "form"] }'; - if (!workspaceReqwestPattern?.test(reviewedReqwestDeclaration)) { - throw new Error('workspace Reqwest boundary must accept the reviewed transport/data profile'); - } - for (const featureName of ['default-tls', 'http3', '__native-tls']) { - const expandedDeclaration = reviewedReqwestDeclaration.replace( - '"form"]', - `"form", "${featureName}"]`, - ); - if (workspaceReqwestPattern.test(expandedDeclaration)) { - throw new Error(`workspace Reqwest boundary must reject TLS-enabling feature ${featureName}`); - } - } - for (const path of [ - 'src/apps/cli/Cargo.toml', - 'src/apps/desktop/Cargo.toml', - 'src/crates/adapters/ai-adapters/Cargo.toml', - 'src/crates/services/miniapp-market-service/Cargo.toml', - 'src/crates/services/skin-market-service/Cargo.toml', - ]) { - const reqwestRule = requiredContentRules.find((rule) => rule.path === path); - const reqwestRuleText = reqwestRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - if (!reqwestRuleText.includes('rustls')) { - throw new Error(`${path} must guard the explicit Reqwest Rustls client dependency`); - } - } const servicesCoreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'services-core', ); diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 7210dd283..2d57b3f0d 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -32,6 +32,8 @@ path = "tests/terminal_process_contracts.rs" # Internal crates bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = [ "agent-runtime", + "document-read", + "subscription-auth", "remote-connect", "deep-research", "lsp", @@ -119,7 +121,7 @@ fs2 = { workspace = true } base64 = { workspace = true } image = { workspace = true } minisign-verify = "0.2" -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["http2", "rustls", "stream"] } sha2 = { workspace = true } tar = { workspace = true } tempfile = "3" diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 7db7f1dd9..a99882a50 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -66,7 +66,7 @@ dark-light = { workspace = true } similar = { workspace = true } ignore = { workspace = true } urlencoding = { workspace = true } -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["http2", "json", "query", "rustls", "stream"] } semver = { workspace = true } zip = { workspace = true } tar = { workspace = true } diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index 6a54278c8..2f8f05037 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -24,7 +24,7 @@ futures = { workspace = true } fs2 = { workspace = true, optional = true } libc = { workspace = true, optional = true } log = { workspace = true } -reqwest = { workspace = true, features = ["rustls", "socks"] } +reqwest = { workspace = true, features = ["http2", "json", "rustls", "socks", "stream"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true, optional = true } @@ -52,6 +52,7 @@ subscription-auth = [ "dep:fs2", "dep:keyring-core", "dep:libc", + "reqwest/form", "dep:sha2", "tokio/fs", "tokio/io-util", diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 8f71839dc..8651359ce 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -90,8 +90,10 @@ SessionManager -> Session -> DialogTurn -> ModelRound `mcp-runtime` layers the Core MCP tool bridge on the Agent lifecycle; and `remote-connect` layers its phone relay on the Agent lifecycle and model catalog. None of these relationships may be hidden in the `agent-runtime` - baseline. `scheduled-jobs` is only the additive dependency/source modifier - used with that baseline; it is not a standalone service profile. + baseline. `scheduled-jobs`, `document-read`, and `subscription-auth` are + additive dependency/source modifiers, not standalone runtime profiles. The + latter two use Cargo weak dependency forwarding so they refine an already + selected tool or adapter owner without activating that owner by themselves. Product-owned managed worktree lifecycle is available only when the Agent lifecycle and Git service owners are both selected; it is not a tool-pack owner. Function Agent adapters use the independent `function-agents` owner; diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index dee0237db..be424303d 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -149,6 +149,8 @@ ts = [ ] product-full = [ "agent-runtime", + "document-read", + "subscription-auth", "model-catalog", "mcp-runtime", "remote-connect", @@ -278,12 +280,15 @@ scheduled-jobs = [ "dep:chrono-tz", "dep:cron", ] +# Additive dependency modifiers. Weak dependency feature references preserve +# the owning runtime boundary instead of activating that runtime by themselves. +document-read = ["tool-runtime?/document-read"] +subscription-auth = ["bitfun-ai-adapters?/subscription-auth"] # Tool groups mirror the provider-neutral groups in bitfun-tool-packs. They # compose concrete service owners but never form a product-shaped umbrella. tools-basic = [ "bitfun-tool-packs/basic", - "tool-runtime/document-read", "workspace-search", ] tools-git = [ @@ -336,8 +341,6 @@ debug-log = [ ] ai-adapter-runtime = [ "dep:bitfun-ai-adapters", - "bitfun-ai-adapters/subscription-auth", - "dep:reqwest", ] product-capabilities = ["dep:bitfun-product-capabilities"] plugin-source = [ diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs index b7c5477a7..fe598a2fe 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs @@ -14,16 +14,22 @@ use log::{debug, warn}; use serde_json::{json, Value}; use std::convert::TryFrom; use std::path::Path; -use std::time::{Duration, Instant}; +#[cfg(feature = "document-read")] +use std::time::Duration; +use std::time::Instant; +use tool_runtime::fs::document::is_supported_document_path; +#[cfg(feature = "document-read")] use tool_runtime::fs::document::{ - convert_document_to_markdown, is_supported_document_path, DocumentConversionError, - MAX_DOCUMENT_INPUT_BYTES, MAX_DOCUMENT_MARKDOWN_BYTES, + convert_document_to_markdown, DocumentConversionError, MAX_DOCUMENT_INPUT_BYTES, + MAX_DOCUMENT_MARKDOWN_BYTES, }; use tool_runtime::fs::read_file::{ build_read_file_presentation, build_remote_read_command, build_remote_tail_read_command, - parse_remote_read_output, parse_remote_tail_read_output, read_file, read_file_bytes_bounded, - read_file_tail, read_text, read_text_tail, ReadFileResult, + parse_remote_read_output, parse_remote_tail_read_output, read_file, read_file_tail, + ReadFileResult, }; +#[cfg(feature = "document-read")] +use tool_runtime::fs::read_file::{read_file_bytes_bounded, read_text, read_text_tail}; pub struct FileReadTool { default_max_lines_to_read: usize, @@ -33,6 +39,7 @@ pub struct FileReadTool { /// Default cap on characters returned by a single Read call (excluding wrapper text). pub const DEFAULT_READ_MAX_TOTAL_CHARS: usize = 64_000; +#[cfg(feature = "document-read")] // anydoc is synchronous, so this bounds the caller's wait rather than terminating the parser. // The worker retains the global conversion permit until it actually exits, keeping failures closed. const DOCUMENT_CONVERSION_TIMEOUT: Duration = Duration::from_secs(30); @@ -300,6 +307,7 @@ impl FileReadTool { Ok(result) } + #[cfg(feature = "document-read")] async fn read_document_window( &self, resolved_path: &str, @@ -410,6 +418,7 @@ impl FileReadTool { )) } + #[cfg(feature = "document-read")] fn document_conversion_error( logical_path: &str, resolved_path: &str, @@ -441,16 +450,29 @@ impl Tool for FileReadTool { } async fn description(&self) -> BitFunResult { + #[cfg(feature = "document-read")] + let document_summary = " Office documents, OpenDocument files, RTF, EPUB, and PDFs are converted locally to GitHub-Flavored Markdown before reading."; + #[cfg(not(feature = "document-read"))] + let document_summary = ""; + #[cfg(feature = "document-read")] + let document_guidance = format!( + r#"- Supported document extensions are .doc, .docx, .docm, .ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm, .xls, .xlsx, .xlsm, .xlsb, .odt, .ods, .odp, .rtf, .epub, .csv, and .pdf. Document input is capped at {} MiB and extracted Markdown at {} MiB. Conversion is offline and never fetches linked resources. +- render defaults to auto. auto converts supported documents but preserves CSV as exact source text for editing compatibility. Use render=markdown to turn CSV into a Markdown table or to content-detect a document with a missing/wrong extension. Use render=source to bypass conversion for a textual document such as CSV or RTF. +- For converted documents, offset, limit, tail, line numbers, and total_lines refer to the extracted Markdown, not source pages or rows. The Markdown is a read-only representation; do not use it as exact source text for Edit. Embedded objects are represented by text, and scanned/image-only PDF pages require OCR. +"#, + MAX_DOCUMENT_INPUT_BYTES / (1024 * 1024), + MAX_DOCUMENT_MARKDOWN_BYTES / (1024 * 1024), + ); + #[cfg(not(feature = "document-read"))] + let document_guidance = ""; + Ok(format!( - r#"Reads a file from the current workspace filesystem. Office documents, OpenDocument files, RTF, EPUB, and PDFs are converted locally to GitHub-Flavored Markdown before reading. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + r#"Reads a file from the current workspace filesystem.{document_summary} If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. Usage: - The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://...` URI returned by another tool. - Do not read host roots or placeholder paths such as `/workspace`. -- Supported document extensions are .doc, .docx, .docm, .ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm, .xls, .xlsx, .xlsm, .xlsb, .odt, .ods, .odp, .rtf, .epub, .csv, and .pdf. Document input is capped at {} MiB and extracted Markdown at {} MiB. Conversion is offline and never fetches linked resources. -- render defaults to auto. auto converts supported documents but preserves CSV as exact source text for editing compatibility. Use render=markdown to turn CSV into a Markdown table or to content-detect a document with a missing/wrong extension. Use render=source to bypass conversion for a textual document such as CSV or RTF. -- For converted documents, offset, limit, tail, line numbers, and total_lines refer to the extracted Markdown, not source pages or rows. The Markdown is a read-only representation; do not use it as exact source text for Edit. Embedded objects are represented by text, and scanned/image-only PDF pages require OCR. -- By default, it reads up to {} lines starting from the beginning of the file. When you plan to Edit a file, prefer this default full read so you see the exact bytes you will need to match. +{document_guidance}- By default, it reads up to {} lines starting from the beginning of the file. When you plan to Edit a file, prefer this default full read so you see the exact bytes you will need to match. - You can optionally specify an offset and limit. offset is a 1-based line number. Use a range only when you already know the target lines; the range must include every line you will copy into Edit `old_string`. - You can set tail=true with limit to read the last N lines. This is useful for command output and logs. Do not combine tail=true with offset. - Any lines longer than {} characters will be truncated. @@ -461,31 +483,25 @@ Usage: - Avoid tiny repeated slices (e.g. 30-100 line chunks). If you need more context, read a larger window that covers the whole block you will edit. - Do not use `limit` with a small value (e.g. < 50) to probe file type or structure. Source files typically begin with copyright headers — a probe read returns no useful code. "#, - MAX_DOCUMENT_INPUT_BYTES / (1024 * 1024), - MAX_DOCUMENT_MARKDOWN_BYTES / (1024 * 1024), - self.default_max_lines_to_read, - self.max_line_chars, - self.max_total_chars + self.default_max_lines_to_read, self.max_line_chars, self.max_total_chars )) } fn short_description(&self) -> String { - "Read text files and extract documents.".to_string() + #[cfg(feature = "document-read")] + return "Read text files and extract documents.".to_string(); + #[cfg(not(feature = "document-read"))] + return "Read text files.".to_string(); } fn input_schema(&self) -> Value { - json!({ + let schema = json!({ "type": "object", "properties": { "file_path": { "type": "string", "description": "The file to read. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun:// URI returned by another tool." }, - "render": { - "type": "string", - "enum": ["auto", "source", "markdown"], - "description": "How to represent the file. auto converts supported documents but preserves CSV source text; source bypasses conversion; markdown forces local anydoc conversion and enables content detection. Defaults to auto." - }, "offset": { "type": "number", "description": "The 1-based line number to start reading from. offset=0 is accepted as offset=1. Only provide if the file is too large to read at once." @@ -501,7 +517,28 @@ Usage: }, "required": ["file_path"], "additionalProperties": false - }) + }); + #[cfg(feature = "document-read")] + let schema = { + let mut schema = schema; + schema["properties"]["render"] = json!({ + "type": "string", + "enum": ["auto", "source", "markdown"], + "description": "How to represent the file. auto converts supported documents but preserves CSV source text; source bypasses conversion; markdown forces local anydoc conversion and enables content detection. Defaults to auto." + }); + schema + }; + #[cfg(not(feature = "document-read"))] + let schema = { + let mut schema = schema; + schema["properties"]["render"] = json!({ + "type": "string", + "enum": ["auto", "source"], + "description": "How to read the file. auto reads ordinary text and reports known document formats as unavailable; source bypasses document detection for text-based formats. Defaults to auto." + }); + schema + }; + schema } fn is_readonly(&self) -> bool { @@ -683,6 +720,13 @@ Usage: ReadRenderMode::Source => false, ReadRenderMode::Markdown => true, }; + #[cfg(not(feature = "document-read"))] + if reads_document_representation { + return Err(BitFunError::tool(format!( + "Document Markdown conversion is not available in this product build: {}. Use a product that includes document-read, or render=source for text-based formats.", + resolved.logical_path + ))); + } let revision_before_read = if reads_document_representation || resolved.uses_remote_workspace_backend() || tail @@ -701,9 +745,10 @@ Usage: )]); } - let (read_file_result, document_metadata) = if reads_document_representation { - let (result, metadata) = self - .read_document_window( + #[cfg(feature = "document-read")] + let document_read = if reads_document_representation { + Some( + self.read_document_window( &resolved.resolved_path, &resolved.logical_path, start_line, @@ -712,7 +757,16 @@ Usage: resolved.uses_remote_workspace_backend(), context, ) - .await?; + .await?, + ) + } else { + None + }; + #[cfg(not(feature = "document-read"))] + let document_read: Option<(ReadFileResult, DocumentReadMetadata)> = None; + + let (read_file_result, document_metadata) = if let Some((result, metadata)) = document_read + { (result, Some(metadata)) } else if resolved.uses_remote_workspace_backend() { if tail { @@ -827,20 +881,27 @@ Usage: #[cfg(test)] mod tests { - use super::{FileReadTool, ReadRenderMode, MAX_DOCUMENT_INPUT_BYTES}; + #[cfg(feature = "document-read")] + use super::MAX_DOCUMENT_INPUT_BYTES; + use super::{FileReadTool, ReadRenderMode}; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; + #[cfg(feature = "document-read")] use async_trait::async_trait; + use bitfun_runtime_ports::ToolRuntimeHandles; + #[cfg(feature = "document-read")] use bitfun_runtime_ports::{ - ToolRuntimeHandles, WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, - WorkspaceFileSystem, WorkspaceServices, WorkspaceShell, + WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, + WorkspaceServices, WorkspaceShell, }; use serde_json::{json, Value}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; + #[cfg(feature = "document-read")] use std::sync::atomic::{AtomicUsize, Ordering}; + #[cfg(feature = "document-read")] use std::sync::Arc; fn local_context(root: PathBuf) -> ToolUseContext { @@ -862,11 +923,13 @@ mod tests { } } + #[cfg(feature = "document-read")] struct FakeRemoteFs { bytes: Vec, bounded_limit: Arc, } + #[cfg(feature = "document-read")] #[async_trait] impl WorkspaceFileSystem for FakeRemoteFs { async fn read_file(&self, _path: &str) -> anyhow::Result> { @@ -907,8 +970,10 @@ mod tests { } } + #[cfg(feature = "document-read")] struct PanicRemoteShell; + #[cfg(feature = "document-read")] #[async_trait] impl WorkspaceShell for PanicRemoteShell { async fn exec_with_options( @@ -920,6 +985,7 @@ mod tests { } } + #[cfg(feature = "document-read")] fn remote_context(bytes: Vec, bounded_limit: Arc) -> ToolUseContext { let root = "/remote/workspace"; let session_identity = @@ -960,12 +1026,79 @@ mod tests { assert!(properties.contains_key("offset")); assert!(properties.contains_key("tail")); + #[cfg(feature = "document-read")] assert_eq!( properties["render"]["enum"], json!(["auto", "source", "markdown"]) ); } + #[cfg(not(feature = "document-read"))] + #[tokio::test] + async fn read_tool_without_document_support_does_not_advertise_conversion() { + let tool = FileReadTool::new(); + let schema = tool.input_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("properties"); + + assert_eq!(properties["render"]["enum"], json!(["auto", "source"])); + assert!(!properties["render"]["description"] + .as_str() + .expect("render description") + .contains("Markdown")); + assert!(!tool + .description() + .await + .expect("description") + .contains("converted locally")); + assert_eq!(tool.short_description(), "Read text files."); + } + + #[cfg(not(feature = "document-read"))] + #[tokio::test] + async fn read_tool_without_document_support_fails_closed_for_document_rendering() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("notes.rtf"), br"{\rtf1\ansi Hello}").expect("write RTF"); + fs::write(dir.path().join("notes.txt"), "plain text").expect("write text"); + let context = local_context(dir.path().to_path_buf()); + let tool = FileReadTool::new(); + + let auto_error = tool + .call_impl(&json!({ "file_path": "notes.rtf" }), &context) + .await + .expect_err("known document path must not fall back to source text"); + assert!(auto_error + .to_string() + .contains("Document Markdown conversion is not available")); + + let markdown_error = tool + .call_impl( + &json!({ "file_path": "notes.txt", "render": "markdown" }), + &context, + ) + .await + .expect_err("forced Markdown conversion must be unavailable"); + assert!(markdown_error + .to_string() + .contains("Document Markdown conversion is not available")); + + let source = tool + .call_impl( + &json!({ "file_path": "notes.rtf", "render": "source" }), + &context, + ) + .await + .expect("explicit source reads remain available"); + let ToolResult::Result { data, .. } = &source[0] else { + panic!("expected result"); + }; + assert!(data["content"] + .as_str() + .is_some_and(|content| content.contains("Hello"))); + } + #[test] fn read_window_start_line_prefers_offset_and_normalizes_zero() { assert_eq!( @@ -1007,6 +1140,7 @@ mod tests { assert!(FileReadTool::read_render_mode(&json!({ "render": 1 })).is_err()); } + #[cfg(feature = "document-read")] #[tokio::test] async fn read_converts_rtf_to_a_markdown_representation() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1041,6 +1175,22 @@ mod tests { .is_some_and(|result| result.contains("from RTF to GitHub-Flavored Markdown"))); } + #[cfg(feature = "document-read")] + #[tokio::test] + async fn document_conversion_failure_does_not_fallback_to_source_bytes() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("broken.pdf"), b"not a PDF").expect("write invalid PDF"); + let context = local_context(dir.path().to_path_buf()); + + let error = FileReadTool::new() + .call_impl(&json!({ "file_path": "broken.pdf" }), &context) + .await + .expect_err("invalid document must not be returned as source text"); + + assert!(error.to_string().contains("Failed to convert document")); + } + + #[cfg(feature = "document-read")] #[tokio::test] async fn csv_auto_preserves_source_while_markdown_render_extracts_a_table() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1088,6 +1238,7 @@ mod tests { .is_some_and(|content| content.contains("| name | value |"))); } + #[cfg(feature = "document-read")] #[tokio::test] async fn remote_document_uses_bounded_file_transfer_and_host_side_conversion() { let bounded_limit = Arc::new(AtomicUsize::new(0)); diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index c2d4c6a5f..ae0ef2824 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -12,13 +12,14 @@ use crate::infrastructure::ai::reasoning_catalog::{ resolve_default_reasoning_preset, }; use crate::infrastructure::ai::{build_stream_options_for_model, AIClient}; +#[cfg(feature = "subscription-auth")] use crate::infrastructure::subscription_auth::{ self, OpenCodePlan as AdapterOpenCodePlan, SubscriptionHttpOptions, SubscriptionProvider as AdapterProvider, }; -use crate::service::config::types::{ - model_runtime_binding_fingerprint, AuthConfig, OpenCodePlan, SubscriptionProvider, -}; +use crate::service::config::types::{model_runtime_binding_fingerprint, AuthConfig}; +#[cfg(feature = "subscription-auth")] +use crate::service::config::types::{OpenCodePlan, SubscriptionProvider}; use crate::service::config::{get_global_config_service, ConfigService}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::AIConfig; @@ -37,8 +38,8 @@ struct CachedAIClient { configuration_fingerprint: String, default_reasoning_preset: Option, client: Arc, - /// Unix seconds when the resolved subscription credential expires; - /// `None` for API-key auth or non-expiring credentials. + /// Unix seconds when the resolved subscription credential expires. + #[cfg(feature = "subscription-auth")] credential_expires_at: Option, } @@ -46,8 +47,10 @@ struct CachedAIClient { /// client is rebuilt so subscription authentication refreshes the token. Kept /// equal to the providers' refresh leeway so the rebuilt client always gets a /// fresh token. +#[cfg(feature = "subscription-auth")] const SUBSCRIPTION_CREDENTIAL_STALE_LEEWAY_SECS: i64 = 5 * 60; +#[cfg(feature = "subscription-auth")] fn now_unix_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -55,6 +58,7 @@ fn now_unix_secs() -> i64 { .unwrap_or(0) } +#[cfg(feature = "subscription-auth")] fn subscription_credential_stale(auth: &AuthConfig, cached: &CachedAIClient) -> bool { if !matches!(auth, AuthConfig::Subscription { .. }) { return false; @@ -64,6 +68,11 @@ fn subscription_credential_stale(auth: &AuthConfig, cached: &CachedAIClient) -> }) } +#[cfg(not(feature = "subscription-auth"))] +fn subscription_credential_stale(auth: &AuthConfig, _cached: &CachedAIClient) -> bool { + matches!(auth, AuthConfig::Subscription { .. }) +} + fn functional_agent_model_selector<'a>( ai_config: &'a crate::service::config::types::AIConfig, func_agent_name: &str, @@ -317,14 +326,15 @@ impl AIClientFactory { } else { None }; - let subscription_options = - SubscriptionHttpOptions::new(proxy_config.clone(), skip_ssl_verify); - let credential_expires_at = apply_subscription_auth_with_options( + let credential_expires_at = apply_configured_auth( &model_config.auth, &mut ai_config, - &subscription_options, + proxy_config.clone(), + skip_ssl_verify, ) .await?; + #[cfg(not(feature = "subscription-auth"))] + let _ = credential_expires_at; let stream_options = build_stream_options_for_model(&global_config.ai, Some(model_config)); let client = apply_default_reasoning_preset( @@ -349,6 +359,7 @@ impl AIClientFactory { configuration_fingerprint, default_reasoning_preset, client: client.clone(), + #[cfg(feature = "subscription-auth")] credential_expires_at, }, ); @@ -434,6 +445,7 @@ pub async fn initialize_global_ai_client_factory() -> BitFunResult<()> { AIClientFactory::initialize_global().await } +#[cfg(feature = "subscription-auth")] fn to_adapter_provider(provider: SubscriptionProvider) -> AdapterProvider { match provider { SubscriptionProvider::Codex => AdapterProvider::Codex, @@ -442,6 +454,7 @@ fn to_adapter_provider(provider: SubscriptionProvider) -> AdapterProvider { } } +#[cfg(feature = "subscription-auth")] fn to_adapter_opencode_plan(plan: OpenCodePlan) -> AdapterOpenCodePlan { match plan { OpenCodePlan::Zen => AdapterOpenCodePlan::Zen, @@ -457,10 +470,49 @@ pub async fn apply_subscription_auth( auth: &AuthConfig, ai_config: &mut AIConfig, ) -> Result> { - apply_subscription_auth_with_options(auth, ai_config, &SubscriptionHttpOptions::default()).await + #[cfg(feature = "subscription-auth")] + return apply_subscription_auth_with_options( + auth, + ai_config, + &SubscriptionHttpOptions::default(), + ) + .await; + + #[cfg(not(feature = "subscription-auth"))] + { + let _ = ai_config; + match auth { + AuthConfig::ApiKey => Ok(None), + AuthConfig::Subscription { .. } => Err(anyhow!( + "Subscription authentication is not available in this product build" + )), + } + } +} + +#[cfg(feature = "subscription-auth")] +async fn apply_configured_auth( + auth: &AuthConfig, + ai_config: &mut AIConfig, + proxy_config: Option, + skip_ssl_verify: bool, +) -> Result> { + let options = SubscriptionHttpOptions::new(proxy_config, skip_ssl_verify); + apply_subscription_auth_with_options(auth, ai_config, &options).await +} + +#[cfg(not(feature = "subscription-auth"))] +async fn apply_configured_auth( + auth: &AuthConfig, + ai_config: &mut AIConfig, + _proxy_config: Option, + _skip_ssl_verify: bool, +) -> Result> { + apply_subscription_auth(auth, ai_config).await } /// Resolves subscription authentication with an explicit transport policy. +#[cfg(feature = "subscription-auth")] pub async fn apply_subscription_auth_with_options( auth: &AuthConfig, ai_config: &mut AIConfig, @@ -527,15 +579,20 @@ pub async fn apply_subscription_auth_with_options( } /// List subscription accounts (Codex / Antigravity / OpenCode). +#[cfg(feature = "subscription-auth")] pub async fn list_subscription_accounts() -> Vec { subscription_auth::list_accounts().await } #[cfg(test)] mod tests { + use super::apply_subscription_auth; + #[cfg(not(feature = "subscription-auth"))] + use crate::service::config::types::SubscriptionProvider; use crate::service::config::types::{ - model_runtime_binding_fingerprint, AIModelConfig, GlobalConfig, + model_runtime_binding_fingerprint, AIModelConfig, AuthConfig, GlobalConfig, }; + use crate::util::types::AIConfig; use bitfun_ai_adapters::{ classify_model_selector, resolve_required_model_selector, ModelSelectorKind, }; @@ -551,6 +608,60 @@ mod tests { } } + fn test_runtime_ai_config() -> AIConfig { + AIConfig { + name: "test".to_string(), + base_url: "https://example.test".to_string(), + request_url: String::new(), + api_key: "unchanged".to_string(), + model: "test-model".to_string(), + format: "openai".to_string(), + context_window: 4096, + max_tokens: None, + temperature: None, + top_p: None, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + custom_request_body: None, + custom_request_body_mode: None, + } + } + + #[cfg(feature = "subscription-auth")] + #[tokio::test] + async fn api_key_auth_remains_a_noop_when_subscription_support_is_compiled() { + let mut config = test_runtime_ai_config(); + + let expires_at = apply_subscription_auth(&AuthConfig::ApiKey, &mut config) + .await + .expect("API-key auth"); + + assert_eq!(expires_at, None); + assert_eq!(config.api_key, "unchanged"); + assert_eq!(config.base_url, "https://example.test"); + } + + #[cfg(not(feature = "subscription-auth"))] + #[tokio::test] + async fn subscription_auth_fails_closed_when_not_compiled() { + let auth = AuthConfig::Subscription { + provider: SubscriptionProvider::Codex, + plan: None, + }; + let mut config = test_runtime_ai_config(); + + let error = apply_subscription_auth(&auth, &mut config) + .await + .expect_err("subscription auth must not degrade to an API-key client"); + + assert!(error + .to_string() + .contains("Subscription authentication is not available")); + assert_eq!(config.api_key, "unchanged"); + } + #[test] fn resolve_model_reference_requires_a_config_id() { let mut config = GlobalConfig::default(); diff --git a/src/crates/assembly/core/src/infrastructure/mod.rs b/src/crates/assembly/core/src/infrastructure/mod.rs index d3ca11ed5..d4161db89 100644 --- a/src/crates/assembly/core/src/infrastructure/mod.rs +++ b/src/crates/assembly/core/src/infrastructure/mod.rs @@ -12,7 +12,7 @@ pub mod events; pub mod filesystem; #[cfg(feature = "local-storage")] pub mod storage; -#[cfg(feature = "ai-adapter-runtime")] +#[cfg(all(feature = "ai-adapter-runtime", feature = "subscription-auth"))] pub mod subscription_auth; #[cfg(feature = "ai-adapter-runtime")] diff --git a/src/crates/execution/tool-execution/src/fs/document.rs b/src/crates/execution/tool-execution/src/fs/document.rs index 62112ec30..0e72985c1 100644 --- a/src/crates/execution/tool-execution/src/fs/document.rs +++ b/src/crates/execution/tool-execution/src/fs/document.rs @@ -1,46 +1,66 @@ -//! Local, provider-neutral document-to-Markdown conversion for the Read tool. +//! Document path recognition and optional provider-neutral Markdown conversion. +#[cfg(feature = "document-read")] use std::collections::VecDeque; +#[cfg(feature = "document-read")] use std::fmt; use std::path::Path; +#[cfg(feature = "document-read")] use std::sync::{Arc, Mutex, OnceLock}; +#[cfg(feature = "document-read")] use anydoc::Format; +#[cfg(feature = "document-read")] use sha2::{Digest, Sha256}; +#[cfg(feature = "document-read")] use tokio::sync::Semaphore; /// Maximum source-document size accepted by the Read tool conversion path. +#[cfg(feature = "document-read")] pub const MAX_DOCUMENT_INPUT_BYTES: usize = 64 * 1024 * 1024; /// Maximum retained Markdown for one conversion and across the in-memory conversion cache. +#[cfg(feature = "document-read")] pub const MAX_DOCUMENT_MARKDOWN_BYTES: usize = 16 * 1024 * 1024; +#[cfg(feature = "document-read")] const MAX_DOCUMENT_CACHE_ENTRIES: usize = 4; +/// Extensions recognized as documents even when conversion support is not compiled. +pub const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &[ + "doc", "docx", "docm", "odt", "pdf", "pptx", "pptm", "ppsx", "ppsm", "ppt", "pps", "pot", + "rtf", "epub", "xlsx", "xlsm", "xlsb", "xls", "ods", "odp", "csv", +]; + /// A document representation that can be paged by the normal Read primitives. +#[cfg(feature = "document-read")] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConvertedDocument { pub markdown: Arc, pub source_format: &'static str, } +#[cfg(feature = "document-read")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct DocumentCacheKey { source_sha256: [u8; 32], format: Format, } +#[cfg(feature = "document-read")] struct DocumentCacheEntry { key: DocumentCacheKey, document: ConvertedDocument, } +#[cfg(feature = "document-read")] #[derive(Default)] struct DocumentCache { entries: VecDeque, retained_markdown_bytes: usize, } +#[cfg(feature = "document-read")] impl DocumentCache { fn get(&mut self, key: DocumentCacheKey) -> Option { let index = self.entries.iter().position(|entry| entry.key == key)?; @@ -74,12 +94,14 @@ impl DocumentCache { } /// Provider-neutral document conversion failure. +#[cfg(feature = "document-read")] #[derive(Debug, Clone, PartialEq, Eq)] pub struct DocumentConversionError { code: &'static str, message: String, } +#[cfg(feature = "document-read")] impl DocumentConversionError { fn new(code: &'static str, message: impl Into) -> Self { Self { @@ -93,21 +115,31 @@ impl DocumentConversionError { } } +#[cfg(feature = "document-read")] impl fmt::Display for DocumentConversionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) } } +#[cfg(feature = "document-read")] impl std::error::Error for DocumentConversionError {} -/// Whether the path extension names a format handled by anydoc. +/// Whether the path extension names a supported document format. pub fn is_supported_document_path(path: &str) -> bool { - Format::from_path(Path::new(path)).is_some() + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + SUPPORTED_DOCUMENT_EXTENSIONS + .iter() + .any(|supported| extension.eq_ignore_ascii_case(supported)) + }) } /// Convert document bytes on the blocking pool. Conversion is serialized process-wide because /// parsers can temporarily retain substantially more decompressed data than the source file. +#[cfg(feature = "document-read")] pub async fn convert_document_to_markdown( bytes: Vec, path_hint: String, @@ -148,11 +180,13 @@ pub async fn convert_document_to_markdown( })? } +#[cfg(feature = "document-read")] fn document_conversion_semaphore() -> &'static Arc { static SEMAPHORE: OnceLock> = OnceLock::new(); SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(1))) } +#[cfg(feature = "document-read")] fn convert_document_to_markdown_sync( bytes: &[u8], path_hint: &str, @@ -201,11 +235,13 @@ fn convert_document_to_markdown_sync( Ok(document) } +#[cfg(feature = "document-read")] fn document_cache() -> &'static Mutex { static CACHE: OnceLock> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(DocumentCache::default())) } +#[cfg(feature = "document-read")] fn format_name(format: Format) -> &'static str { match format { Format::Doc => "doc", @@ -251,6 +287,15 @@ mod tests { assert!(!is_supported_document_path("README.md")); } + #[cfg(feature = "document-read")] + #[test] + fn recognized_extensions_match_anydoc() { + for extension in SUPPORTED_DOCUMENT_EXTENSIONS { + assert!(Format::from_extension(extension).is_some(), "{extension}"); + } + } + + #[cfg(feature = "document-read")] #[test] fn content_detection_takes_precedence_over_a_wrong_extension_hint() { let converted = @@ -261,6 +306,7 @@ mod tests { assert!(converted.markdown.contains("Hello from RTF")); } + #[cfg(feature = "document-read")] #[test] fn csv_uses_the_path_hint_because_it_has_no_content_signature() { let converted = @@ -272,6 +318,7 @@ mod tests { assert!(converted.markdown.contains("| alpha | 1 |")); } + #[cfg(feature = "document-read")] #[test] fn repeated_conversion_reuses_cached_markdown_for_offset_reads() { let first = diff --git a/src/crates/execution/tool-execution/src/fs/mod.rs b/src/crates/execution/tool-execution/src/fs/mod.rs index a51bb0690..f40c7bb80 100644 --- a/src/crates/execution/tool-execution/src/fs/mod.rs +++ b/src/crates/execution/tool-execution/src/fs/mod.rs @@ -1,6 +1,5 @@ pub mod backend; pub mod delete_path; -#[cfg(feature = "document-read")] pub mod document; pub mod edit_file; pub mod list_dir; @@ -65,3 +64,13 @@ pub fn path_has_multiple_hard_links(path: &std::path::Path) -> std::io::Result