From 5c34b03952d14d79431ffd57110b78bb89bd24cd Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 2 Jun 2026 15:00:18 +0800 Subject: [PATCH] refactor(agent-runtime): own prompt cache and registry facts --- docs/plans/core-decomposition-completed.md | 16 +- docs/plans/core-decomposition-plan.md | 105 +---- scripts/check-core-boundaries.mjs | 242 +++++++++- src/apps/desktop/src/api/subagent_api.rs | 7 +- src/crates/agent-runtime/AGENTS.md | 13 +- src/crates/agent-runtime/src/agents.rs | 78 ++++ src/crates/agent-runtime/src/lib.rs | 1 + src/crates/agent-runtime/src/prompt_cache.rs | 437 ++++++++++++++++++ .../tests/agent_registry_contracts.rs | 72 ++- .../tests/prompt_cache_contracts.rs | 75 +++ src/crates/core/AGENTS-CN.md | 17 +- src/crates/core/AGENTS.md | 11 +- src/crates/core/src/agentic/agents/mod.rs | 48 +- .../agentic/agents/registry/availability.rs | 22 +- .../src/agentic/agents/registry/custom.rs | 6 +- .../core/src/agentic/agents/registry/query.rs | 34 +- .../core/src/agentic/agents/registry/tests.rs | 16 +- .../core/src/agentic/agents/registry/types.rs | 36 +- .../core/src/agentic/session/prompt_cache.rs | 435 +---------------- 19 files changed, 1015 insertions(+), 656 deletions(-) create mode 100644 src/crates/agent-runtime/src/prompt_cache.rs create mode 100644 src/crates/agent-runtime/tests/prompt_cache_contracts.rs diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 7730bf9f0..419ed0320 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -57,9 +57,10 @@ - `bitfun-agent-runtime` 已建立为可独立构建的 Agent Runtime SDK owner crate,当前承接 scheduler/background delivery 纯决策,thread goal runtime 的 turn accounting、goal mutation、continuation plan 和 tool response assembly, subagent query scope / visibility / availability 决策,以及 round-boundary yield / injection state 和 - turn-outcome queue policy;prompt-loop 的 user-context policy 和 tool / skill / subagent listing reminder - ordering 已归入该 crate;finish-reason label、session-state event label 和 turn-outcome event fact 也已由 - `bitfun-agent-runtime` 承接,core 只保留旧路径 re-export 或 concrete adapter。 + turn-outcome queue policy;prompt-loop 的 user-context policy、tool / skill / subagent listing reminder + ordering、prompt cache policy / identity / DTO / scope key / in-memory store、shared mode profile / context policy、 + mode / subagent source presentation facts 已归入该 crate;finish-reason label、session-state event label 和 + turn-outcome event fact 也已由 `bitfun-agent-runtime` 承接,core 只保留旧路径 re-export 或 concrete adapter。 - persisted thread goal 的 portable DTO、status、continuation plan 和 tool response contract 已归入 `bitfun-runtime-ports`;`get_goal` / `create_goal` / `update_goal` 已进入产品 tool registry。 - `bitfun-harness` 已建立为可独立构建的 Harness contract crate,当前承接 workflow descriptor、legacy route @@ -68,8 +69,9 @@ 明确未完成: -- `bitfun-agent-runtime` 不代表 session manager、concrete prompt assembly、concrete agent definition loading、scheduler 生命周期、 - event delivery 或 post-turn hook 已迁移;当前 event 迁移只覆盖无副作用的 wire label / fact 映射。 +- `bitfun-agent-runtime` 不代表 session manager、session persistence / prompt-cache cold restore、concrete prompt assembly、 + concrete agent definition loading、custom subagent file IO、scheduler 生命周期、event delivery、permission `Tool` handler + 或 post-turn hook 已迁移;当前 event 迁移只覆盖无副作用的 wire label / fact 映射。 - thread goal 的 metadata store、token subscriber、scheduler delivery adapter 和 goal `Tool` handler 仍在 `bitfun-core`;runtime 决策已经归属 `bitfun-agent-runtime`,后续不应再把它误归入普通 concrete tool IO。 - `bitfun-harness` 不代表 Deep Review、DeepResearch、MiniApp 的 concrete workflow execution 已迁移;PR4 provider @@ -107,10 +109,10 @@ - `product-full` 是完整产品能力保护开关。 - 构建脚本和 installer 相关脚本不作为 core 拆解的一部分修改。 - boundary check 覆盖已外移 owner 的旧路径 facade-only / 禁止回流状态。 -- tool manifest、`GetToolSpec`、execution admission gate、MiniApp storage layout adapter、product-domain pure helper、remote workspace search fallback、MCP config / catalog / dynamic manifest 等已有 focused baseline。 +- tool manifest、`GetToolSpec`、execution admission gate、MiniApp storage layout adapter、product-domain pure helper、remote workspace search fallback、MCP config / catalog / dynamic manifest、agent-runtime prompt cache 与 agent registry source/profile facts 等已有 focused baseline。 ## 3. 当前剩余结论 - 低风险准备项已经完成,不再新增零散小 PR。 -- 后续只按高风险 owner 主题推进:Agent Runtime 剩余的 registry/scheduler lifecycle、Product-Domain Runtime、Tool Runtime 剩余主体、Feature / Build-Benefit Evaluation,以及经过单独保护的 Harness execution / Product Capability pack 迁移。 +- 后续只按三段大 PR 推进:PR-B 的 Product-Domain + Tool Runtime owner closure,以及 PR-C 的 Harness / Capability / Build-Benefit closure;如要继续移动 Agent Runtime concrete scheduler、event delivery、permission handler 或 post-turn hook,必须先补等价保护并明确纳入后续大 PR,不能拆成零散 helper PR。 - 缺陷修复、行为变更、冗余清理、三方库升级和构建脚本调整必须独立评估,不能伪装成 core decomposition 剩余里程碑。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index e2cd2934c..c81f18caa 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -68,98 +68,25 @@ workspace build 证明没有行为或 feature 影响。 ## 4. 后续迁移队列 -后续迁移固定收敛为 7 个大块 PR。每个 PR 都必须先补保护,再迁移 owner,最后回看文档和边界;如果发现必须改变功能语义,需要在 PR 中单独说明原因、影响范围和回滚边界。 +后续迁移收敛为 PR-A / PR-B / PR-C 三个大型 PR。每个 PR 必须迁移真实 owner 逻辑,并同时包含旧路径兼容、focused tests、boundary check 和提交前对抗性审核。只新增抽象、只补 facade 或只增加 guard 不满足准出要求。 | PR | 主题 | 完整范围 | 不允许混入 | 合入门禁 | |---|---|---|---|---| -| PR1 | Product Assembly / Runtime Services Foundation | 创建 `bitfun-runtime-services`,补 `RuntimeServicesBuilder`、typed provider registration、capability availability、Remote ports、fake provider 和 boundary check 入口 | 具体 remote runtime、tool IO、product-domain IO、default feature 调整 | provider 注册路径可测试,Remote ports 不暴露 SSH / relay concrete handle,新增 crate 不依赖 `bitfun-core` | -| PR2 | Service / Agent Remote Runtime Owner | 在 remote connection、remote workspace、remote FS / terminal projection、workspace-root / persistence、`ImageContextData`、remote-SSH / relay provider 中完成一个完整 owner 主题的 port、provider、旧路径兼容和行为等价验证 | tool runtime、product-domain runtime、feature matrix、产品命令或 UI 行为变更 | remote/session/file/image/terminal/scheduler 行为等价,产品 surface 不变 | -| PR3 | Agent Runtime SDK Owner | 拆分 mode-scoped subagent visibility、agent registry facts、queue policy decision、scheduler submit/cancel facts 和 background delivery 边界;concrete scheduler 生命周期按保护程度逐步外移 | remote provider、tool IO、product-domain IO、默认 feature 调整 | subagent 可见性、queue/preempt/cancel、background reply、DeepResearch hook 等价 | -| PR4 | Harness / Product Capability Boundary | 建立 Harness provider contract,让 Deep Review、DeepResearch、MiniApp 等 workflow 通过 provider 注册,不侵入 Agent Runtime SDK | concrete service IO、tool IO、surface 命令语义变更 | 至少两个 workflow 可通过 provider contract 表达,旧路径兼容 | -| PR5 | Product-Domain Runtime Owner | MiniApp filesystem IO / worker / host / builtin seed 或 function-agent Git/AI 中完成一个完整 owner 主题,建立最小 port/provider 和 core adapter | tool runtime、service/agent runtime、surface 行为变更 | MiniApp/function-agent focused regression,PathManager/process/Git/AI 边界清晰 | -| PR6 | Tool Runtime Owner | 继续收敛 tool runtime owner:已完成 deterministic execution admission gate、`GetToolSpecTool` product runtime owner closure、manifest/catalog/snapshot owner closure;本阶段迁移 workspace service contract 与 collapsed unlock observation owner,core pipeline 只保留执行编排和旧路径兼容 | service/agent runtime、product-domain runtime、feature matrix、产品行为变更、全量 concrete tool IO 搬迁 | tool pipeline focused tests、product runtime focused tests、manifest facade parity tests、`bitfun-agent-tools` / `bitfun-runtime-ports` contract tests、boundary check | -| PR7 | Feature / Build-Benefit Evaluation | 评估 feature matrix、dependency profile、no-default 编译面和构建收益数据,确认是否具备收敛默认 feature 的条件 | runtime owner 迁移、default feature 副作用、构建脚本变更 | cargo metadata / cargo tree 证据,产品入口完整能力不变 | - -### 4.1 PR1 具体实施计划 - -PR1 是后续高风险迁移的前置门禁,目标是提供可测试的 typed assembly 基础,而不是移动任何既有业务行为。 - -1. 新建 `bitfun-runtime-services` crate,并加入 workspace。 -2. 在 `bitfun-runtime-ports` 中补齐 Runtime Services 所需的轻量 port trait 和 Remote port trait;这些 trait 只能描述能力和请求边界,不携带 SSH、relay、Tauri、process、filesystem manager 等 concrete handle。 -3. 在 `bitfun-runtime-services` 中实现 `RuntimeServices`、`RuntimeServicesBuilder`、capability availability、typed unsupported error 和 provider registry。 -4. 提供 `test_support` fake provider,覆盖本地 mandatory service、optional remote service 和 unsupported capability 三类注入路径。 -5. 更新 `scripts/check-core-boundaries.mjs`,把 `bitfun-runtime-services` 纳入 no-core dependency 和轻量依赖边界检查。 -6. 更新仓库入口文档中的模块索引,说明 `bitfun-runtime-services` 仍使用 core decomposition guardrails。 -7. 运行 focused tests、边界检查和最小 Rust 验证;提交前从第三方视角检查是否出现 service locator、全局 mutable registry、反向依赖或功能语义漂移。 - -PR1 不迁移任何 concrete service owner,因此预期不会修改产品行为、默认能力集合、权限语义、工具曝光、事件语义、session 生命周期或构建脚本。 - -### 4.2 PR2 + PR3 合并实施计划 - -本次 PR 合并推进 PR2 和 PR3,但仍按两个 owner 主题顺序实施,避免把 remote provider、agent scheduler -和产品 surface 行为混在同一个迁移步骤中。若实现过程中发现必须改变用户可见行为、默认 feature、权限语义或构建形态, -应暂停并在 PR 描述中单独说明设计偏移原因、影响范围和回滚边界。 - -#### 4.2.1 PR2:Service / Agent Remote Runtime Owner - -目标是在不搬动 concrete SSH / relay / terminal / session restore 实现的前提下,把 remote workspace 与 projection -的稳定接口归入 `bitfun-runtime-ports`,并保留 `bitfun-services-integrations::remote_connect` 旧路径 re-export。 - -1. 在 `bitfun-runtime-ports` 中承接 remote workspace facts、remote session metadata、remote workspace file projection DTO - 和 `RemoteWorkspacePort` / `RemoteProjectionPort` owner trait。 -2. 在 `bitfun-services-integrations::remote_connect` 中删除重复 owner 定义,改为 re-export 新 owner crate 的类型和 trait, - 保持现有调用方 import 路径兼容。 -3. 让 core 侧 remote workspace / file adapter 继续作为具体 provider,实现新的 stable port;workspace-root、 - persistence、session restore、terminal pre-warm 和 scheduler submit 仍保留在 `bitfun-core`。 -4. 补充 focused tests,覆盖 remote workspace / file projection 类型通过旧路径与新 owner 路径保持等价,以及 - `RuntimeServicesBuilder` 能注册带方法的 remote workspace / projection provider。 -5. 更新 boundary check,防止 remote owner contract 回流到 `bitfun-core` 或 concrete service crate。 - -#### 4.2.2 PR3:Agent Runtime SDK Owner - -本阶段把 `bitfun-agent-runtime` 从单一 scheduler helper 扩展为有真实 owner 的 Agent Runtime SDK 基线。 -已承接范围包括 scheduler/background delivery 纯决策,thread goal 的 turn accounting、goal mutation、 -continuation / budget-limit / objective-updated plan、tool response assembly 和 skip/retry/usage-limit policy, -以及 subagent query scope、visibility / availability、round-boundary yield / injection state、turn-outcome -queue policy、finish-reason label、session-state event label 和 turn-outcome event fact,并已承接 -user-context policy 与 listing reminder ordering 这类 prompt-loop 纯事实。 - -仍不外移 concrete scheduler 生命周期:core 继续负责 running-turn injection delivery、submit、turn id、metadata、session -manager、metadata store、token subscriber、scheduler delivery adapter、goal `Tool` handler、concrete prompt assembly、 -concrete agent definition loading、custom subagent file IO / config adapter、event delivery 和 post-turn hook。 - -后续 Agent Runtime SDK 工作不得再把 thread goal runtime 当作普通 concrete tool IO;继续推进时应聚焦 -agent definition registry loading、permission coordination、event delivery / post-turn hook、prompt module / -prompt cache contract 和 concrete scheduler lifecycle 的受保护迁移。prompt-loop 纯事实与 runtime event -facts 已归入 `bitfun-agent-runtime`,不得再回流到 core。 - -#### 4.2.3 本次 PR 验收 - -- 不修改产品命令、UI、默认 feature、release / fast build 脚本或产品能力集合。 -- 不新增反向依赖、无类型 service locator、全局 mutable registry 或重复 runtime materialization。 -- 必须通过 remote / runtime owner focused tests、boundary check、repo hygiene 和最小 Rust 编译验证。 -- 提交前从第三方视角审查功能偏移、性能劣化、跨产品形态遗漏、文档与代码不一致,并修复发现的问题。 - -### 4.3 PR4:Harness / Product Capability Boundary 实施计划 - -PR4 的目标是建立 Harness contract 和迁移期 provider 注册边界,而不是外移 Deep Review、DeepResearch -或 MiniApp 的具体执行逻辑。 - -1. 新建 `bitfun-harness` crate,并加入 workspace;该 crate 只承接 provider-neutral workflow、capability、 - plan、step、outcome、error 和 registry contract。 -2. 提供 descriptor-only `HarnessProvider`,支持 legacy-facade route plan;`execute` 在 PR4 阶段必须返回 - typed unsupported,避免形成“执行已迁移”的错觉。 -3. 在 `bitfun-core::agentic::harness` 注册 Deep Review、DeepResearch、MiniApp 三个 legacy-facade provider, - 只表达现有 workflow 的归属和 route,不改变产品命令、session、tool、service IO 或 UI 语义。 -4. 将 `bitfun-harness` 纳入 boundary check,禁止依赖 `bitfun-core`、具体 service crate、product-domain - implementation、AI adapter、transport、Tauri、Git/MCP/image/WebSocket 等 concrete runtime 依赖。 -5. 补充 `bitfun-harness` focused tests 和 core registry 兼容测试,证明至少两个以上 workflow 可以通过 - provider contract 表达,且 concrete execution 仍停留在旧路径。 -6. 更新架构、计划和 AGENTS 文档,明确 PR4 只完成 contract / registry boundary;执行迁移、product - command registry、capability pack 和 service/tool orchestration 仍属于后续 PR。 - -PR4 不迁移 concrete service IO、tool IO、surface command 语义、session manager、scheduler 生命周期或构建 feature。 -如后续要让 Harness 实际执行 workflow,必须在独立 PR 中补行为等价测试和回滚边界。 +| PR-A | Agent Runtime SDK Owner Closure | 承接 prompt cache policy / identity / DTO / in-memory store、shared mode profile / context policy、mode / subagent source presentation facts,并保持 core agent registry 与 session manager 旧路径兼容 | concrete scheduler 生命周期、event emitter、post-turn hook、permission `Tool` handler、custom subagent file IO、产品命令或默认 feature 变更 | `bitfun-agent-runtime` 独立测试、core agents / prompt-cache focused tests、boundary check、repo hygiene、`cargo check -p bitfun-core --features product-full` | +| PR-B | Product-Domain + Tool Runtime Owner Closure | 在 MiniApp worker / host / builtin asset、function-agent Git / AI、ToolUseContext concrete handles、product registry materialization、collapsed unlock persistence 与具体 IO tools 中迁移完整 owner 主题 | Agent Runtime scheduler / event 行为、Harness execution、feature matrix、UI 或产品语义变更 | MiniApp/function-agent/tool pipeline focused regressions,runtime/service port 边界清晰,产品 surface 不变 | +| PR-C | Harness / Capability / Build-Benefit Closure | 推进 Harness execution / Product Capability pack / service-tool orchestration,并评估 feature matrix、dependency profile、no-default 编译面、构建收益和可选 crate 目录分组 | runtime owner 主体迁移、默认 feature 副作用、未验证的构建脚本调整 | Harness workflow 等价、capability pack 注册可测、cargo metadata / cargo tree 证据,产品入口完整能力不变 | + +### 4.1 PR-A 实施状态 + +PR-A 只迁移无 IO、无副作用、可由 contract test 证明等价的 Agent Runtime SDK owner 事实。当前分支已经覆盖: + +1. `bitfun-agent-runtime::prompt_cache` 承接 prompt cache schema、policy、identity、DTO、scope key 和 in-memory store。 +2. `bitfun-agent-runtime::agents` 承接 shared coding mode profile、mode presentation rank、shared user-context policy、`SubAgentSource` DTO、source kind 与 presentation rank。 +3. `bitfun-core` 保留 `agentic::session::prompt_cache`、agent mode module 和 registry DTO 旧路径 re-export;session persistence、clone/cold restore、agent definition loading、custom subagent file IO、scheduler lifecycle 和 event delivery 仍在 core。 +4. `scripts/check-core-boundaries.mjs` 已增加 prompt cache、shared mode profile/context、mode/source presentation 和 `SubAgentSource` 的防回流规则。 +5. focused tests 覆盖 runtime contracts、core agent registry、prompt cache restore/clone/invalidation 和 boundary check。 + +不继续纳入 PR-A 的内容:concrete scheduler 生命周期、event delivery / post-turn hook、permission coordination 的 `Tool` handler 和 custom subagent file IO。这些路径直接连接事件发送、调度执行或文件/配置 IO,若迁移必须在 PR-B/PR-C 前单独补可观测行为等价保护,不能作为“纯 owner 事实迁移”处理。 ## 5. 每类 PR 的保护重点 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 0e2dfa34f..7475b75c9 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -733,6 +733,106 @@ const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/agents/mod.rs', + patterns: [ + { + regex: /\bpub const SHARED_CODING_MODE_PROMPT_TEMPLATE\b/, + message: + 'core agent mode module must not own shared coding-mode prompt facts; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub const SHARED_CODING_MODE_CONFIG_PROFILE_ID\b/, + message: + 'core agent mode module must not own shared coding-mode config profile facts; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub const SHARED_CODING_MODE_IDS\b/, + message: + 'core agent mode module must not own shared coding-mode membership facts; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn resolve_mode_config_profile_id\b/, + message: + 'core agent mode module must not own mode config profile resolution; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn mode_config_profile_member_mode_ids\b/, + message: + 'core agent mode module must not own mode config profile membership; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn mode_config_profile_label\b/, + message: + 'core agent mode module must not own mode config profile labels; use bitfun-agent-runtime agents', + }, + { + regex: /\bpub fn shared_coding_mode_user_context_policy\b/, + message: + 'core agent mode module must not own shared coding-mode context policy; use bitfun-agent-runtime agents', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/query.rs', + patterns: [ + { + regex: /"agentic"\s*=>\s*0[\s\S]*"Cowork"\s*=>\s*1/, + message: + 'core agent registry query must not own mode presentation order; use bitfun-agent-runtime agents', + }, + { + regex: /\bfn subagent_source_rank\b/, + message: + 'core agent registry query must not own subagent source presentation rank; use bitfun-agent-runtime agents', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/types.rs', + patterns: [ + { + regex: /\bpub enum SubAgentSource\b/, + message: + 'core agent registry must not own subagent source DTOs; use bitfun-agent-runtime agents', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/session/prompt_cache.rs', + patterns: [ + { + regex: /\bpub const PROMPT_CACHE_SCHEMA_VERSION\b/, + message: + 'core prompt cache must not own prompt-cache schema facts; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub struct PromptCachePolicy\b/, + message: + 'core prompt cache must not own prompt-cache policy; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub struct SessionPromptCache\b/, + message: + 'core prompt cache must not own prompt-cache DTOs; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub enum PromptCacheScope\b/, + message: + 'core prompt cache must not own prompt-cache invalidation scope; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub struct SessionPromptCacheStore\b/, + message: + 'core prompt cache must not own in-memory prompt-cache store; use bitfun-agent-runtime prompt_cache', + }, + { + regex: /\bpub enum PromptCacheLookup\b/, + message: + 'core prompt cache must not own prompt-cache lookup outcomes; use bitfun-agent-runtime prompt_cache', + }, + ], + }, { path: 'src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs', patterns: [ @@ -2347,6 +2447,95 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/agent-runtime/src/prompt_cache.rs', + reason: + 'agent-runtime must own prompt-cache policy, identities, DTOs, scope keys, and in-memory runtime store', + patterns: [ + { + regex: /\bpub const PROMPT_CACHE_SCHEMA_VERSION\b/, + message: 'missing agent-runtime prompt-cache schema fact', + }, + { + regex: /\bpub struct PromptCachePolicy\b/, + message: 'missing agent-runtime prompt-cache policy', + }, + { + regex: /\bpub fn prompt_cache_scope_key\b/, + message: 'missing agent-runtime prompt-cache scope-key helper', + }, + { + regex: /\bpub struct SessionPromptCacheStore\b/, + message: 'missing agent-runtime in-memory prompt-cache store', + }, + { + regex: /\bpub enum PromptCacheLookup\b/, + message: 'missing agent-runtime prompt-cache lookup contract', + }, + ], + }, + { + path: 'src/crates/agent-runtime/tests/prompt_cache_contracts.rs', + reason: + 'agent-runtime prompt-cache owner must keep behavior-equivalence contracts for cache identity, expiry, invalidation, and scope-key shape', + patterns: [ + { + regex: /\bprompt_cache_policy_keeps_existing_default_persistence_ttl\b/, + message: 'missing prompt-cache default TTL regression', + }, + { + regex: /\bprompt_cache_lookup_preserves_identity_and_expiry_semantics\b/, + message: 'missing prompt-cache identity/expiry regression', + }, + { + regex: /\bprompt_cache_scope_key_preserves_legacy_mode_switch_shape\b/, + message: 'missing prompt-cache scope-key shape regression', + }, + ], + }, + { + path: 'src/crates/agent-runtime/src/agents.rs', + reason: + 'agent-runtime must own shared mode config profile facts that are runtime-visible and product-neutral', + patterns: [ + { + regex: /\bpub const SHARED_CODING_MODE_PROMPT_TEMPLATE\b/, + message: 'missing shared coding-mode prompt template fact', + }, + { + regex: /\bpub const SHARED_CODING_MODE_CONFIG_PROFILE_ID\b/, + message: 'missing shared coding-mode config profile id', + }, + { + regex: /\bpub fn resolve_mode_config_profile_id\b/, + message: 'missing mode config profile resolver', + }, + { + regex: /\bpub fn mode_config_profile_member_mode_ids\b/, + message: 'missing mode config profile member lookup', + }, + { + regex: /\bpub fn mode_presentation_rank\b/, + message: 'missing mode presentation rank', + }, + { + regex: /\bpub fn shared_coding_mode_user_context_policy\b/, + message: 'missing shared coding-mode user-context policy', + }, + { + regex: /\bpub enum SubAgentSource\b/, + message: 'missing subagent source DTO', + }, + { + regex: /\bpub const fn subagent_source_kind\b/, + message: 'missing subagent source runtime-kind mapping', + }, + { + regex: /\bpub const fn subagent_source_presentation_rank\b/, + message: 'missing subagent source presentation rank', + }, + ], + }, { path: 'src/crates/agent-runtime/tests/prompt_contracts.rs', reason: @@ -2415,6 +2604,28 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/session/prompt_cache.rs', + reason: + 'core prompt_cache path must stay a compatibility facade over agent-runtime', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::prompt_cache::\*;/, + message: 'missing agent-runtime prompt-cache compatibility re-export', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/mod.rs', + reason: + 'core agent mode module must keep old import paths while agent-runtime owns shared mode profile facts', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::agents::\{[\s\S]*mode_presentation_rank[\s\S]*resolve_mode_config_profile_id[\s\S]*shared_coding_mode_user_context_policy[\s\S]*SHARED_CODING_MODE_PROMPT_TEMPLATE[\s\S]*\};/, + message: 'missing agent-runtime shared mode profile compatibility re-export', + }, + ], + }, { path: 'src/crates/services-core/src/filesystem/mod.rs', reason: @@ -4847,7 +5058,7 @@ const requiredContentRules = [ 'core agent registry must preserve legacy DTO fields while bitfun-agent-runtime owns query scope and availability reason contracts', patterns: [ { - regex: /pub use bitfun_agent_runtime::agents::\{[\s\S]*SubagentListScope[\s\S]*SubagentOverrideState[\s\S]*SubagentQueryContext[\s\S]*SubagentStateReason[\s\S]*\};/, + regex: /pub use bitfun_agent_runtime::agents::\{[\s\S]*SubAgentSource[\s\S]*SubagentListScope[\s\S]*SubagentOverrideState[\s\S]*SubagentQueryContext[\s\S]*SubagentStateReason[\s\S]*\};/, message: 'missing agent-runtime subagent registry contract re-export', }, { @@ -7404,6 +7615,14 @@ function runManifestParserSelfTest() { 'resolve_subagent_availability', 'SubagentOverrideLayers', 'SubagentStateReason', + 'SHARED_CODING_MODE_CONFIG_PROFILE_ID', + 'resolve_mode_config_profile_id', + 'mode_config_profile_member_mode_ids', + 'mode_presentation_rank', + 'shared_coding_mode_user_context_policy', + 'SubAgentSource', + 'subagent_source_kind', + 'subagent_source_presentation_rank', ], }, { @@ -7412,6 +7631,9 @@ function runManifestParserSelfTest() { 'visibility_policy_supports_public_restricted_hidden_and_denied_parents', 'availability_preserves_builtin_project_and_user_override_layering', 'default_enabled_uses_visibility_only_for_builtin_subagents', + 'shared_coding_modes_resolve_to_the_same_config_profile', + 'subagent_source_contract_preserves_runtime_kind_and_presentation_order', + 'mode_presentation_and_shared_context_policy_match_existing_mode_contract', ], }, { @@ -7472,6 +7694,24 @@ function runManifestParserSelfTest() { 'PrependedPromptReminders', ], }, + { + path: 'src/crates/agent-runtime/src/prompt_cache.rs', + contracts: [ + 'PROMPT_CACHE_SCHEMA_VERSION', + 'PromptCachePolicy', + 'prompt_cache_scope_key', + 'SessionPromptCacheStore', + 'PromptCacheLookup', + ], + }, + { + path: 'src/crates/agent-runtime/tests/prompt_cache_contracts.rs', + contracts: [ + 'prompt_cache_policy_keeps_existing_default_persistence_ttl', + 'prompt_cache_lookup_preserves_identity_and_expiry_semantics', + 'prompt_cache_scope_key_preserves_legacy_mode_switch_shape', + ], + }, { path: 'src/crates/agent-runtime/tests/prompt_contracts.rs', contracts: [ diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 425b12e7d..8691fb169 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -2,8 +2,9 @@ use crate::api::app_state::AppState; use bitfun_core::agentic::agents::{ - AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentDetail, - CustomSubagentKind, SubAgentSource, SubagentListScope, SubagentQueryContext, + subagent_source_from_custom_kind, AgentCategory, AgentInfo, CustomSubagent, + CustomSubagentConfig, CustomSubagentDetail, CustomSubagentKind, SubAgentSource, + SubagentListScope, SubagentQueryContext, }; use log::warn; use serde::{Deserialize, Serialize}; @@ -366,7 +367,7 @@ pub async fn create_subagent( state.agent_registry.register_agent( Arc::new(subagent), AgentCategory::SubAgent, - Some(SubAgentSource::from_custom_kind(kind)), + Some(subagent_source_from_custom_kind(kind)), Some(custom_config), ); diff --git a/src/crates/agent-runtime/AGENTS.md b/src/crates/agent-runtime/AGENTS.md index 14d1f3970..8409063b3 100644 --- a/src/crates/agent-runtime/AGENTS.md +++ b/src/crates/agent-runtime/AGENTS.md @@ -15,12 +15,13 @@ and tested without `bitfun-core`. - Prefer pure facts and decisions first: queue policy, background delivery, thread-goal accounting/mutation/continuation decisions, cancellation routing, runtime event facts, registry visibility/availability, round-boundary - yield/injection state, turn-outcome queue decisions, prompt-loop user-context - policy, prompt listing reminder ordering, finish-reason labels, - session-state event labels, and turn-outcome event facts. -- Keep concrete prompt assembly, workspace context IO, prompt cache - coordination, and dynamic environment collection outside this crate until a - reviewed migration proves behavior equivalence. + yield/injection state, turn-outcome queue decisions, registry source/profile + facts, prompt-loop user-context policy, prompt listing reminder ordering, + prompt-cache policy/identity/store, finish-reason labels, session-state event + labels, and turn-outcome event facts. +- Keep concrete prompt assembly, workspace context IO, prompt-cache persistence + wiring, dynamic environment collection, and concrete agent definition loading + outside this crate until a reviewed migration proves behavior equivalence. - Add focused tests before moving any runtime decision into this crate. ## Verification diff --git a/src/crates/agent-runtime/src/agents.rs b/src/crates/agent-runtime/src/agents.rs index 1a347bdaa..cbd6197b9 100644 --- a/src/crates/agent-runtime/src/agents.rs +++ b/src/crates/agent-runtime/src/agents.rs @@ -1,9 +1,60 @@ //! Agent and subagent registry owner decisions. +use crate::prompt::UserContextPolicy; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::collections::HashSet; use std::path::Path; +pub const SHARED_CODING_MODE_PROMPT_TEMPLATE: &str = "agentic_mode"; +pub const SHARED_CODING_MODE_CONFIG_PROFILE_ID: &str = "coding_shared"; +pub const SHARED_CODING_MODE_CONFIG_PROFILE_LABEL: &str = "Coding Shared"; +pub const SHARED_CODING_MODE_IDS: &[&str] = &["agentic", "Plan", "debug", "Multitask"]; + +pub fn resolve_mode_config_profile_id<'a>(mode_id: &'a str) -> Cow<'a, str> { + match mode_id.trim() { + "agentic" | "Plan" | "debug" | "Multitask" => { + Cow::Borrowed(SHARED_CODING_MODE_CONFIG_PROFILE_ID) + } + _ => Cow::Borrowed(mode_id), + } +} + +pub fn mode_config_profile_member_mode_ids(profile_id: &str) -> &'static [&'static str] { + match profile_id.trim() { + SHARED_CODING_MODE_CONFIG_PROFILE_ID => SHARED_CODING_MODE_IDS, + _ => &[], + } +} + +pub fn mode_config_profile_label(profile_id: &str) -> Option<&'static str> { + match profile_id.trim() { + SHARED_CODING_MODE_CONFIG_PROFILE_ID => Some(SHARED_CODING_MODE_CONFIG_PROFILE_LABEL), + _ => None, + } +} + +pub fn mode_presentation_rank(mode_id: &str) -> u8 { + match mode_id { + "agentic" => 0, + "Cowork" => 1, + "Plan" => 2, + "debug" => 3, + "Multitask" => 4, + "DeepResearch" => 5, + "Team" => 6, + _ => 99, + } +} + +pub fn shared_coding_mode_user_context_policy() -> UserContextPolicy { + UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() + .with_project_layout() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SubagentListScope { TaskVisible, @@ -147,6 +198,33 @@ pub enum SubagentSourceKind { Unspecified, } +/// Subagent source shown to product surfaces and registry-management APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SubAgentSource { + Builtin, + Project, + User, +} + +pub const fn subagent_source_kind(source: Option) -> SubagentSourceKind { + match source { + Some(SubAgentSource::Builtin) => SubagentSourceKind::Builtin, + Some(SubAgentSource::Project) => SubagentSourceKind::Project, + Some(SubAgentSource::User) => SubagentSourceKind::User, + None => SubagentSourceKind::Unspecified, + } +} + +pub const fn subagent_source_presentation_rank(source: Option) -> u8 { + match source { + Some(SubAgentSource::Builtin) => 0, + Some(SubAgentSource::Project) => 1, + Some(SubAgentSource::User) => 2, + None => 3, + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SubagentOverrideState { diff --git a/src/crates/agent-runtime/src/lib.rs b/src/crates/agent-runtime/src/lib.rs index 880827e3a..d6ff1cf48 100644 --- a/src/crates/agent-runtime/src/lib.rs +++ b/src/crates/agent-runtime/src/lib.rs @@ -6,5 +6,6 @@ pub mod agents; pub mod events; pub mod prompt; +pub mod prompt_cache; pub mod scheduler; pub mod thread_goal; diff --git a/src/crates/agent-runtime/src/prompt_cache.rs b/src/crates/agent-runtime/src/prompt_cache.rs new file mode 100644 index 000000000..8b92531ef --- /dev/null +++ b/src/crates/agent-runtime/src/prompt_cache.rs @@ -0,0 +1,437 @@ +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const PROMPT_CACHE_SCHEMA_VERSION: u32 = 1; +pub const DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL: Duration = Duration::from_secs(60 * 60 * 24); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptCachePolicy { + pub cache_ttl: Option, + pub persistence_ttl: Option, +} + +impl Default for PromptCachePolicy { + fn default() -> Self { + Self { + cache_ttl: None, + persistence_ttl: Some(DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SystemPromptCacheIdentity { + pub scope_key: String, +} + +impl SystemPromptCacheIdentity { + pub fn new(scope_key: impl Into) -> Self { + Self { + scope_key: scope_key.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserContextCacheIdentity { + pub scope_key: String, +} + +impl UserContextCacheIdentity { + pub fn new(scope_key: impl Into) -> Self { + Self { + scope_key: scope_key.into(), + } + } +} + +pub fn prompt_cache_scope_key( + system_prompt: &SystemPromptCacheIdentity, + user_context: &UserContextCacheIdentity, +) -> String { + format!("{}||{}", system_prompt.scope_key, user_context.scope_key) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedPromptText { + pub content: String, + pub created_at_ms: u64, +} + +impl CachedPromptText { + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + created_at_ms: current_time_ms(), + } + } + + pub fn is_expired(&self, ttl: Option, now_ms: u64) -> bool { + ttl.is_some_and(|ttl| { + let ttl_ms = ttl.as_millis().try_into().unwrap_or(u64::MAX); + now_ms.saturating_sub(self.created_at_ms) >= ttl_ms + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedSystemPrompt { + #[serde(flatten)] + pub text: CachedPromptText, + pub identity: SystemPromptCacheIdentity, +} + +impl CachedSystemPrompt { + pub fn new(identity: SystemPromptCacheIdentity, content: impl Into) -> Self { + Self { + text: CachedPromptText::new(content), + identity, + } + } + + pub fn is_usable( + &self, + identity: &SystemPromptCacheIdentity, + ttl: Option, + now_ms: u64, + ) -> bool { + self.identity == *identity && !self.text.is_expired(ttl, now_ms) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedUserContext { + #[serde(flatten)] + pub text: CachedPromptText, + pub identity: UserContextCacheIdentity, +} + +impl CachedUserContext { + pub fn new(identity: UserContextCacheIdentity, content: impl Into) -> Self { + Self { + text: CachedPromptText::new(content), + identity, + } + } + + pub fn is_usable( + &self, + identity: &UserContextCacheIdentity, + ttl: Option, + now_ms: u64, + ) -> bool { + self.identity == *identity && !self.text.is_expired(ttl, now_ms) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPromptCache { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_context: Option, +} + +impl SessionPromptCache { + pub fn apply_persistence_ttl(&mut self, ttl: Option) -> bool { + let now_ms = current_time_ms(); + let mut changed = false; + + if self + .system_prompt + .as_ref() + .is_some_and(|entry| entry.text.is_expired(ttl, now_ms)) + { + self.system_prompt = None; + changed = true; + } + + if self + .user_context + .as_ref() + .is_some_and(|entry| entry.text.is_expired(ttl, now_ms)) + { + self.user_context = None; + changed = true; + } + + changed + } + + pub fn is_empty(&self) -> bool { + self.system_prompt.is_none() && self.user_context.is_none() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptCacheScope { + SystemPrompt, + UserContext, + All, +} + +impl PromptCacheScope { + fn clears_system_prompt(self) -> bool { + matches!(self, Self::SystemPrompt | Self::All) + } + + fn clears_user_context(self) -> bool { + matches!(self, Self::UserContext | Self::All) + } +} + +pub struct SessionPromptCacheStore { + session_caches: Arc>, +} + +pub enum PromptCacheLookup { + Hit(String), + Miss, + Expired, +} + +impl Default for SessionPromptCacheStore { + fn default() -> Self { + Self::new() + } +} + +impl SessionPromptCacheStore { + pub fn new() -> Self { + Self { + session_caches: Arc::new(DashMap::new()), + } + } + + pub fn create_session(&self, session_id: &str) { + self.session_caches + .entry(session_id.to_string()) + .or_default(); + } + + pub fn has_session(&self, session_id: &str) -> bool { + self.session_caches.contains_key(session_id) + } + + pub fn replace_cache(&self, session_id: &str, cache: SessionPromptCache) { + self.session_caches.insert(session_id.to_string(), cache); + } + + pub fn get_cache(&self, session_id: &str) -> Option { + self.session_caches + .get(session_id) + .map(|cache| cache.clone()) + } + + pub fn lookup_system_prompt( + &self, + session_id: &str, + identity: &SystemPromptCacheIdentity, + ttl: Option, + ) -> PromptCacheLookup { + let now_ms = current_time_ms(); + let cached_entry = self + .session_caches + .get(session_id) + .and_then(|cache| cache.system_prompt.clone()); + + match cached_entry { + Some(entry) if entry.is_usable(identity, ttl, now_ms) => { + PromptCacheLookup::Hit(entry.text.content) + } + Some(entry) if entry.text.is_expired(ttl, now_ms) => { + self.invalidate(session_id, PromptCacheScope::SystemPrompt); + PromptCacheLookup::Expired + } + _ => PromptCacheLookup::Miss, + } + } + + pub fn lookup_user_context( + &self, + session_id: &str, + identity: &UserContextCacheIdentity, + ttl: Option, + ) -> PromptCacheLookup { + let now_ms = current_time_ms(); + let cached_entry = self + .session_caches + .get(session_id) + .and_then(|cache| cache.user_context.clone()); + + match cached_entry { + Some(entry) if entry.is_usable(identity, ttl, now_ms) => { + PromptCacheLookup::Hit(entry.text.content) + } + Some(entry) if entry.text.is_expired(ttl, now_ms) => { + self.invalidate(session_id, PromptCacheScope::UserContext); + PromptCacheLookup::Expired + } + Some(_) => PromptCacheLookup::Miss, + None => PromptCacheLookup::Miss, + } + } + + pub fn set_system_prompt(&self, session_id: &str, entry: CachedSystemPrompt) { + if let Some(mut cache) = self.session_caches.get_mut(session_id) { + cache.system_prompt = Some(entry); + } else { + self.session_caches.insert( + session_id.to_string(), + SessionPromptCache { + system_prompt: Some(entry), + user_context: None, + }, + ); + } + } + + pub fn set_user_context(&self, session_id: &str, entry: CachedUserContext) { + if let Some(mut cache) = self.session_caches.get_mut(session_id) { + cache.user_context = Some(entry); + } else { + self.session_caches.insert( + session_id.to_string(), + SessionPromptCache { + system_prompt: None, + user_context: Some(entry), + }, + ); + } + } + + pub fn invalidate(&self, session_id: &str, scope: PromptCacheScope) -> bool { + let Some(mut cache) = self.session_caches.get_mut(session_id) else { + return false; + }; + + let mut changed = false; + if scope.clears_system_prompt() && cache.system_prompt.take().is_some() { + changed = true; + } + if scope.clears_user_context() && cache.user_context.take().is_some() { + changed = true; + } + changed + } + + pub fn delete_session(&self, session_id: &str) { + self.session_caches.remove(session_id); + } +} + +fn current_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::{ + CachedSystemPrompt, CachedUserContext, PromptCacheLookup, PromptCachePolicy, + PromptCacheScope, SessionPromptCacheStore, SystemPromptCacheIdentity, + UserContextCacheIdentity, DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL, + }; + use std::time::Duration; + + #[test] + fn default_prompt_cache_policy_uses_one_day_persistence_ttl() { + let policy = PromptCachePolicy::default(); + + assert_eq!(policy.cache_ttl, None); + assert_eq!( + policy.persistence_ttl, + Some(DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL) + ); + } + + #[test] + fn system_prompt_cache_requires_matching_identity() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + store.set_system_prompt( + "session-1", + CachedSystemPrompt::new( + SystemPromptCacheIdentity::new("template:agentic_mode"), + "prompt-a", + ), + ); + + assert_eq!( + match store.lookup_system_prompt( + "session-1", + &SystemPromptCacheIdentity::new("template:agentic_mode"), + None, + ) { + PromptCacheLookup::Hit(value) => Some(value), + _ => None, + }, + Some("prompt-a".to_string()) + ); + assert!(matches!( + store.lookup_system_prompt( + "session-1", + &SystemPromptCacheIdentity::new("template:debug_mode"), + None, + ), + PromptCacheLookup::Miss + )); + } + + #[test] + fn expired_user_context_is_evicted_on_read() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + store.set_user_context( + "session-1", + CachedUserContext::new( + UserContextCacheIdentity::new("workspace_context|workspace_instructions"), + "stale context", + ), + ); + + assert!(matches!( + store.lookup_user_context( + "session-1", + &UserContextCacheIdentity::new("workspace_context|workspace_instructions"), + Some(Duration::from_millis(0)), + ), + PromptCacheLookup::Expired + )); + assert!(store + .get_cache("session-1") + .expect("session cache") + .user_context + .is_none()); + } + + #[test] + fn invalidate_scope_can_clear_all_cached_prompt_parts() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + store.set_system_prompt( + "session-1", + CachedSystemPrompt::new( + SystemPromptCacheIdentity::new("template:agentic_mode"), + "prompt-a", + ), + ); + store.set_user_context( + "session-1", + CachedUserContext::new( + UserContextCacheIdentity::new("workspace_context"), + "context", + ), + ); + + assert!(store.invalidate("session-1", PromptCacheScope::All)); + + let cache = store.get_cache("session-1").expect("session cache"); + assert!(cache.system_prompt.is_none()); + assert!(cache.user_context.is_none()); + } +} diff --git a/src/crates/agent-runtime/tests/agent_registry_contracts.rs b/src/crates/agent-runtime/tests/agent_registry_contracts.rs index e4a81c43c..110eb09ab 100644 --- a/src/crates/agent-runtime/tests/agent_registry_contracts.rs +++ b/src/crates/agent-runtime/tests/agent_registry_contracts.rs @@ -1,7 +1,11 @@ use bitfun_agent_runtime::agents::{ - resolve_subagent_availability, resolve_subagent_default_enabled, BuiltinSubagentExposure, + mode_config_profile_label, mode_config_profile_member_mode_ids, mode_presentation_rank, + resolve_mode_config_profile_id, resolve_subagent_availability, + resolve_subagent_default_enabled, shared_coding_mode_user_context_policy, subagent_source_kind, + subagent_source_presentation_rank, BuiltinSubagentExposure, SubAgentSource, SubagentOverrideLayers, SubagentOverrideState, SubagentSourceKind, SubagentStateReason, - SubagentVisibilityPolicy, + SubagentVisibilityPolicy, SHARED_CODING_MODE_CONFIG_PROFILE_ID, + SHARED_CODING_MODE_CONFIG_PROFILE_LABEL, SHARED_CODING_MODE_IDS, }; #[test] @@ -96,3 +100,67 @@ fn default_enabled_uses_visibility_only_for_builtin_subagents() { Some("agentic") )); } + +#[test] +fn shared_coding_modes_resolve_to_the_same_config_profile() { + for mode_id in SHARED_CODING_MODE_IDS { + assert_eq!( + resolve_mode_config_profile_id(mode_id).as_ref(), + SHARED_CODING_MODE_CONFIG_PROFILE_ID + ); + } + + assert_eq!(resolve_mode_config_profile_id("Cowork").as_ref(), "Cowork"); + assert_eq!( + mode_config_profile_member_mode_ids(SHARED_CODING_MODE_CONFIG_PROFILE_ID), + SHARED_CODING_MODE_IDS + ); + assert_eq!( + mode_config_profile_label(SHARED_CODING_MODE_CONFIG_PROFILE_ID), + Some(SHARED_CODING_MODE_CONFIG_PROFILE_LABEL) + ); +} + +#[test] +fn subagent_source_contract_preserves_runtime_kind_and_presentation_order() { + assert_eq!( + subagent_source_kind(Some(SubAgentSource::Builtin)), + SubagentSourceKind::Builtin + ); + assert_eq!( + subagent_source_kind(Some(SubAgentSource::Project)), + SubagentSourceKind::Project + ); + assert_eq!( + subagent_source_kind(Some(SubAgentSource::User)), + SubagentSourceKind::User + ); + assert_eq!(subagent_source_kind(None), SubagentSourceKind::Unspecified); + + assert_eq!( + subagent_source_presentation_rank(Some(SubAgentSource::Builtin)), + 0 + ); + assert_eq!( + subagent_source_presentation_rank(Some(SubAgentSource::Project)), + 1 + ); + assert_eq!( + subagent_source_presentation_rank(Some(SubAgentSource::User)), + 2 + ); + assert_eq!(subagent_source_presentation_rank(None), 3); +} + +#[test] +fn mode_presentation_and_shared_context_policy_match_existing_mode_contract() { + assert_eq!(mode_presentation_rank("agentic"), 0); + assert_eq!(mode_presentation_rank("Cowork"), 1); + assert_eq!(mode_presentation_rank("Team"), 6); + assert_eq!(mode_presentation_rank("unknown"), 99); + + assert_eq!( + shared_coding_mode_user_context_policy().cache_scope_key(), + "workspace_context|workspace_instructions|workspace_memory_files|project_layout" + ); +} diff --git a/src/crates/agent-runtime/tests/prompt_cache_contracts.rs b/src/crates/agent-runtime/tests/prompt_cache_contracts.rs new file mode 100644 index 000000000..b5cf47acd --- /dev/null +++ b/src/crates/agent-runtime/tests/prompt_cache_contracts.rs @@ -0,0 +1,75 @@ +use bitfun_agent_runtime::prompt_cache::{ + prompt_cache_scope_key, CachedSystemPrompt, CachedUserContext, PromptCacheLookup, + PromptCachePolicy, PromptCacheScope, SessionPromptCacheStore, SystemPromptCacheIdentity, + UserContextCacheIdentity, DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL, +}; +use std::time::Duration; + +#[test] +fn prompt_cache_policy_keeps_existing_default_persistence_ttl() { + let policy = PromptCachePolicy::default(); + + assert_eq!(policy.cache_ttl, None); + assert_eq!( + policy.persistence_ttl, + Some(DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL) + ); +} + +#[test] +fn prompt_cache_lookup_preserves_identity_and_expiry_semantics() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + store.set_system_prompt( + "session-1", + CachedSystemPrompt::new( + SystemPromptCacheIdentity::new("template:agentic_mode"), + "system prompt", + ), + ); + store.set_user_context( + "session-1", + CachedUserContext::new( + UserContextCacheIdentity::new("workspace_context"), + "user context", + ), + ); + + assert!(matches!( + store.lookup_system_prompt( + "session-1", + &SystemPromptCacheIdentity::new("template:debug_mode"), + None + ), + PromptCacheLookup::Miss + )); + assert!(matches!( + store.lookup_user_context( + "session-1", + &UserContextCacheIdentity::new("workspace_context"), + Some(Duration::from_millis(0)) + ), + PromptCacheLookup::Expired + )); + assert!(store + .get_cache("session-1") + .expect("session cache") + .user_context + .is_none()); + + assert!(store.invalidate("session-1", PromptCacheScope::All)); + let cache = store.get_cache("session-1").expect("session cache"); + assert!(cache.system_prompt.is_none()); + assert!(cache.user_context.is_none()); +} + +#[test] +fn prompt_cache_scope_key_preserves_legacy_mode_switch_shape() { + assert_eq!( + prompt_cache_scope_key( + &SystemPromptCacheIdentity::new("template:agentic_mode"), + &UserContextCacheIdentity::new("workspace_context|workspace_instructions"), + ), + "template:agentic_mode||workspace_context|workspace_instructions" + ); +} diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index 292b18769..1cd983c8a 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -39,15 +39,16 @@ SessionManager → Session → DialogTurn → ModelRound 归属 `bitfun-agent-runtime`。core 只保留 session metadata store、token subscriber、scheduler delivery adapter、event emission,以及 `get_goal` / `create_goal` / `update_goal` 的 `Tool` handler。 -- Subagent query scope、visibility/availability decision、round-boundary - yield/injection state 和 turn-outcome queue decision 归属 - `bitfun-agent-runtime`。core 保留 concrete agent definition loading、 - custom subagent file IO/config adapter、desktop API wiring、concrete - scheduler lifecycle、submit execution 和 event delivery。 +- Subagent query scope、visibility/availability decision、registry source/profile + fact、mode/subagent presentation fact、round-boundary yield/injection state 和 + turn-outcome queue decision 归属 `bitfun-agent-runtime`。core 保留 concrete + agent definition loading、custom subagent file IO/config adapter、desktop API + wiring、concrete scheduler lifecycle、submit execution 和 event delivery。 - Prompt-loop 的 user-context policy 和 tool / skill / subagent listing - reminder ordering 归属 `bitfun-agent-runtime`。core 保留具体 prompt - assembly、workspace / remote / project-layout context IO、language/config - lookup、prompt cache 协调和旧路径兼容 re-export。 + reminder ordering,以及 prompt-cache policy、identity、DTO、scope-key fact 和 + in-memory store 归属 `bitfun-agent-runtime`。core 保留具体 prompt assembly、 + workspace / remote / project-layout context IO、language/config lookup、 + prompt-cache persistence/restore 协调和旧路径兼容 re-export。 - Finish-reason label、session-state event label 和 turn-outcome event fact 归属 `bitfun-agent-runtime`。core 保留具体 event routing、event emission、 session state storage 和旧路径兼容 re-export。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index ab949da2e..b37b299ce 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -45,15 +45,18 @@ SessionManager → Session → DialogTurn → ModelRound response assembly belong in `bitfun-agent-runtime`. Core keeps only the session metadata store, token subscriber, scheduler delivery adapter, event emission, and `get_goal` / `create_goal` / `update_goal` `Tool` handlers. -- Subagent query scope, visibility/availability decisions, round-boundary - yield/injection state, and turn-outcome queue decisions belong in +- Subagent query scope, visibility/availability decisions, registry source/profile + facts, mode/subagent presentation facts, round-boundary yield/injection state, + and turn-outcome queue decisions belong in `bitfun-agent-runtime`. Core keeps concrete agent definition loading, custom subagent file IO/config adapters, desktop API wiring, concrete scheduler lifecycle, submit execution, and event delivery. - Prompt-loop user-context policy and tool/skill/subagent listing reminder - ordering belong in `bitfun-agent-runtime`. Core keeps concrete prompt + ordering, plus prompt-cache policy, identity, DTOs, scope-key facts, and + in-memory store belong in `bitfun-agent-runtime`. Core keeps concrete prompt assembly, workspace / remote / project-layout context IO, language/config - lookup, prompt cache coordination, and old-path compatibility re-exports. + lookup, prompt-cache persistence/restore coordination, and old-path + compatibility re-exports. - Finish-reason labels, session-state event labels, and turn-outcome event facts belong in `bitfun-agent-runtime`. Core keeps concrete event routing, event emission, diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index b2dd86515..b43807fd4 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -15,6 +15,12 @@ use crate::agentic::tools::framework::ToolExposure; use crate::agentic::WorkspaceBinding; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +pub use bitfun_agent_runtime::agents::{ + mode_config_profile_label, mode_config_profile_member_mode_ids, mode_presentation_rank, + resolve_mode_config_profile_id, shared_coding_mode_user_context_policy, + SHARED_CODING_MODE_CONFIG_PROFILE_ID, SHARED_CODING_MODE_CONFIG_PROFILE_LABEL, + SHARED_CODING_MODE_IDS, SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ @@ -37,15 +43,15 @@ pub use prompt_builder::{ }; pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; pub use registry::types::{ - AgentCategory, AgentInfo, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, - SubagentListScope, SubagentQueryContext, SubagentStateReason, + subagent_source_from_custom_kind, AgentCategory, AgentInfo, AgentToolPolicy, + CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, + SubagentStateReason, }; pub use registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, }; pub use registry::{get_agent_registry, AgentRegistry, CustomSubagentDetail}; use std::any::Any; -use std::borrow::Cow; // Include embedded prompts generated at compile time include!(concat!(env!("OUT_DIR"), "/embedded_agents_prompt.rs")); @@ -55,34 +61,6 @@ pub type AgentToolPolicyOverrides = IndexMap; static EMPTY_AGENT_TOOL_POLICY_OVERRIDES: std::sync::LazyLock = std::sync::LazyLock::new(AgentToolPolicyOverrides::default); -pub const SHARED_CODING_MODE_PROMPT_TEMPLATE: &str = "agentic_mode"; -pub const SHARED_CODING_MODE_CONFIG_PROFILE_ID: &str = "coding_shared"; -pub const SHARED_CODING_MODE_CONFIG_PROFILE_LABEL: &str = "Coding Shared"; -pub const SHARED_CODING_MODE_IDS: &[&str] = &["agentic", "Plan", "debug", "Multitask"]; - -pub fn resolve_mode_config_profile_id<'a>(mode_id: &'a str) -> Cow<'a, str> { - match mode_id.trim() { - "agentic" | "Plan" | "debug" | "Multitask" => { - Cow::Borrowed(SHARED_CODING_MODE_CONFIG_PROFILE_ID) - } - _ => Cow::Borrowed(mode_id), - } -} - -pub fn mode_config_profile_member_mode_ids(profile_id: &str) -> &'static [&'static str] { - match profile_id.trim() { - SHARED_CODING_MODE_CONFIG_PROFILE_ID => SHARED_CODING_MODE_IDS, - _ => &[], - } -} - -pub fn mode_config_profile_label(profile_id: &str) -> Option<&'static str> { - match profile_id.trim() { - SHARED_CODING_MODE_CONFIG_PROFILE_ID => Some(SHARED_CODING_MODE_CONFIG_PROFILE_LABEL), - _ => None, - } -} - pub fn shared_coding_mode_tools() -> Vec { vec![ "Task".to_string(), @@ -111,14 +89,6 @@ pub fn shared_coding_mode_tools() -> Vec { ] } -pub fn shared_coding_mode_user_context_policy() -> UserContextPolicy { - UserContextPolicy::empty() - .with_workspace_context() - .with_workspace_instructions() - .with_workspace_memory_files() - .with_project_layout() -} - /// Agent trait defining the interface for all agents #[async_trait] pub trait Agent: Send + Sync + 'static { diff --git a/src/crates/core/src/agentic/agents/registry/availability.rs b/src/crates/core/src/agentic/agents/registry/availability.rs index bc510e82a..2c0c1a08a 100644 --- a/src/crates/core/src/agentic/agents/registry/availability.rs +++ b/src/crates/core/src/agentic/agents/registry/availability.rs @@ -4,8 +4,9 @@ use crate::service::config::types::{ AgentSubagentOverrideConfig, AgentSubagentOverrideState, ParentSubagentOverrideConfig, }; use bitfun_agent_runtime::agents::{ - resolve_subagent_availability, resolve_subagent_default_enabled, ResolvedSubagentAvailability, - SubagentOverrideLayers as ResolvedOverrideLayers, SubagentOverrideState, SubagentSourceKind, + resolve_subagent_availability, resolve_subagent_default_enabled, subagent_source_kind, + ResolvedSubagentAvailability, SubagentOverrideLayers as ResolvedOverrideLayers, + SubagentOverrideState, }; use std::collections::HashMap; @@ -16,15 +17,6 @@ fn to_runtime_override_state(state: AgentSubagentOverrideState) -> SubagentOverr } } -fn source_kind(source: Option) -> SubagentSourceKind { - match source { - Some(SubAgentSource::Builtin) => SubagentSourceKind::Builtin, - Some(SubAgentSource::Project) => SubagentSourceKind::Project, - Some(SubAgentSource::User) => SubagentSourceKind::User, - None => SubagentSourceKind::Unspecified, - } -} - pub fn normalize_parent_agent_id(parent_agent_type: Option<&str>) -> Option { parent_agent_type .map(str::trim) @@ -51,7 +43,7 @@ pub fn subagent_override_for_parent( pub fn resolve_default_enabled(entry: &AgentEntry, parent_agent_type: Option<&str>) -> bool { resolve_subagent_default_enabled( - source_kind(entry.subagent_source), + subagent_source_kind(entry.subagent_source), &entry.visibility_policy, parent_agent_type, ) @@ -98,7 +90,11 @@ pub fn resolve_availability( let default_enabled = resolve_default_enabled(entry, parent_agent_type); let layers = resolve_override_layers(entry, parent_agent_type, project_overrides, user_overrides); - resolve_subagent_availability(source_kind(entry.subagent_source), default_enabled, layers) + resolve_subagent_availability( + subagent_source_kind(entry.subagent_source), + default_enabled, + layers, + ) } pub fn prune_override_config( diff --git a/src/crates/core/src/agentic/agents/registry/custom.rs b/src/crates/core/src/agentic/agents/registry/custom.rs index a2f322b75..db4ab8b40 100644 --- a/src/crates/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/core/src/agentic/agents/registry/custom.rs @@ -9,7 +9,9 @@ use super::{AgentRegistry, CustomSubagentDetail}; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::types::subagent_key_for; use crate::agentic::agents::registry::visibility::SubagentVisibilityPolicy; -use crate::agentic::agents::{Agent, AgentCategory, CustomSubagentConfig, SubAgentSource}; +use crate::agentic::agents::{ + subagent_source_from_custom_kind, Agent, AgentCategory, CustomSubagentConfig, SubAgentSource, +}; use crate::agentic::tools::{get_all_registered_tool_names, get_readonly_registered_tool_names}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::mode_config_canonicalizer::persist_agent_profile_from_value; @@ -37,7 +39,7 @@ impl AgentRegistry { let mut project_entries = HashMap::new(); for mut sub in custom { let id = sub.id().to_string(); - let source = SubAgentSource::from_custom_kind(sub.kind); + let source = subagent_source_from_custom_kind(sub.kind); // validate and correct tools and model Self::validate_custom_subagent(&mut sub, &valid_tools, &readonly_tools, &valid_models); // create CustomSubagentConfig cache configuration information diff --git a/src/crates/core/src/agentic/agents/registry/query.rs b/src/crates/core/src/agentic/agents/registry/query.rs index 598b65803..1308ba65d 100644 --- a/src/crates/core/src/agentic/agents/registry/query.rs +++ b/src/crates/core/src/agentic/agents/registry/query.rs @@ -6,28 +6,20 @@ use super::support::{ use super::AgentRegistry; use crate::agentic::agents::registry::types::{is_review_agent_entry, AgentEntry}; use crate::agentic::agents::{ - resolve_mode_config_profile_id, AgentCategory, AgentInfo, AgentToolPolicy, SubagentListScope, - SubagentQueryContext, + mode_presentation_rank, resolve_mode_config_profile_id, AgentCategory, AgentInfo, + AgentToolPolicy, SubagentListScope, SubagentQueryContext, }; use crate::agentic::tools::get_all_registered_tool_names; use crate::service::config::mode_config_canonicalizer::resolve_effective_tools; +use bitfun_agent_runtime::agents::subagent_source_presentation_rank; use std::collections::HashSet; use std::path::Path; impl AgentRegistry { - fn subagent_source_rank(source: Option) -> u8 { - match source { - Some(crate::agentic::agents::SubAgentSource::Builtin) => 0, - Some(crate::agentic::agents::SubAgentSource::Project) => 1, - Some(crate::agentic::agents::SubAgentSource::User) => 2, - None => 3, - } - } - fn sort_subagents_for_presentation(mut result: Vec) -> Vec { result.sort_by(|a, b| { - Self::subagent_source_rank(a.subagent_source) - .cmp(&Self::subagent_source_rank(b.subagent_source)) + subagent_source_presentation_rank(a.subagent_source) + .cmp(&subagent_source_presentation_rank(b.subagent_source)) .then_with(|| a.id.to_lowercase().cmp(&b.id.to_lowercase())) .then_with(|| a.id.cmp(&b.id)) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) @@ -113,21 +105,7 @@ impl AgentRegistry { .map(AgentInfo::from_agent_entry) .collect(); drop(map); - result.sort_by(|a, b| { - let order = |id: &str| -> u8 { - match id { - "agentic" => 0, - "Cowork" => 1, - "Plan" => 2, - "debug" => 3, - "Multitask" => 4, - "DeepResearch" => 5, - "Team" => 6, - _ => 99, - } - }; - order(&a.id).cmp(&order(&b.id)) - }); + result.sort_by(|a, b| mode_presentation_rank(&a.id).cmp(&mode_presentation_rank(&b.id))); result } diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index fa363fb04..5c82d09c4 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -3,8 +3,8 @@ use super::AgentRegistry; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::registry::types::{ - AgentCategory, AgentEntry, CustomSubagentConfig, SubAgentSource, SubagentListScope, - SubagentOverrideState, SubagentQueryContext, + subagent_source_from_custom_kind, AgentCategory, AgentEntry, CustomSubagentConfig, + SubAgentSource, SubagentListScope, SubagentOverrideState, SubagentQueryContext, }; use crate::agentic::agents::registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, @@ -87,6 +87,18 @@ fn top_level_modes_default_to_auto() { } } +#[test] +fn custom_subagent_kind_maps_to_registry_source() { + assert_eq!( + subagent_source_from_custom_kind(CustomSubagentKind::Project), + SubAgentSource::Project + ); + assert_eq!( + subagent_source_from_custom_kind(CustomSubagentKind::User), + SubAgentSource::User + ); +} + #[tokio::test] async fn computer_use_is_builtin_subagent_not_mode() { let registry = AgentRegistry::new(); diff --git a/src/crates/core/src/agentic/agents/registry/types.rs b/src/crates/core/src/agentic/agents/registry/types.rs index 44152edfc..2793430d8 100644 --- a/src/crates/core/src/agentic/agents/registry/types.rs +++ b/src/crates/core/src/agentic/agents/registry/types.rs @@ -12,29 +12,13 @@ use crate::agentic::deep_review_policy::{ REVIEW_JUDGE_AGENT_TYPE, }; pub use bitfun_agent_runtime::agents::{ - SubagentListScope, SubagentOverrideState, SubagentQueryContext, SubagentStateReason, + SubAgentSource, SubagentListScope, SubagentOverrideState, SubagentQueryContext, + SubagentStateReason, }; +use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; use serde::{Deserialize, Serialize}; use std::sync::Arc; -/// subagent source (builtin / project / user), used for frontend display -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SubAgentSource { - Builtin, - Project, - User, -} - -impl SubAgentSource { - pub fn from_custom_kind(kind: CustomSubagentKind) -> Self { - match kind { - CustomSubagentKind::Project => SubAgentSource::Project, - CustomSubagentKind::User => SubAgentSource::User, - } - } -} - /// mutable configuration for custom subagent (model will change, path/kind can be obtained by downcast) #[derive(Clone, Debug)] pub struct CustomSubagentConfig { @@ -117,6 +101,13 @@ fn default_true() -> bool { true } +pub fn subagent_source_from_custom_kind(kind: CustomSubagentKind) -> SubAgentSource { + match kind { + CustomSubagentKind::Project => SubAgentSource::Project, + CustomSubagentKind::User => SubAgentSource::User, + } +} + pub fn subagent_key_for(source: Option, agent: &dyn Agent) -> Option { let source = source?; let slot = match source { @@ -191,10 +182,9 @@ impl AgentInfo { is_review: is_review_agent_entry(entry), tool_count: default_tools.len(), default_tools, - prompt_cache_scope_key: format!( - "{}||{}", - agent.system_prompt_cache_identity(None).scope_key, - agent.user_context_cache_identity().scope_key + prompt_cache_scope_key: prompt_cache_scope_key( + &agent.system_prompt_cache_identity(None), + &agent.user_context_cache_identity(), ), config_profile_id, config_profile_label, diff --git a/src/crates/core/src/agentic/session/prompt_cache.rs b/src/crates/core/src/agentic/session/prompt_cache.rs index df72bdcf4..722af9f40 100644 --- a/src/crates/core/src/agentic/session/prompt_cache.rs +++ b/src/crates/core/src/agentic/session/prompt_cache.rs @@ -1,430 +1,7 @@ -use dashmap::DashMap; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +//! Prompt cache compatibility facade. +//! +//! `bitfun-agent-runtime` owns prompt-cache identities, policy, DTOs, and +//! in-memory runtime store. Core keeps this module for old import paths and +//! concrete session persistence wiring. -pub const PROMPT_CACHE_SCHEMA_VERSION: u32 = 1; -pub const DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL: Duration = Duration::from_secs(60 * 60 * 24); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PromptCachePolicy { - pub cache_ttl: Option, - pub persistence_ttl: Option, -} - -impl Default for PromptCachePolicy { - fn default() -> Self { - Self { - cache_ttl: None, - persistence_ttl: Some(DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SystemPromptCacheIdentity { - pub scope_key: String, -} - -impl SystemPromptCacheIdentity { - pub fn new(scope_key: impl Into) -> Self { - Self { - scope_key: scope_key.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserContextCacheIdentity { - pub scope_key: String, -} - -impl UserContextCacheIdentity { - pub fn new(scope_key: impl Into) -> Self { - Self { - scope_key: scope_key.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CachedPromptText { - pub content: String, - pub created_at_ms: u64, -} - -impl CachedPromptText { - pub fn new(content: impl Into) -> Self { - Self { - content: content.into(), - created_at_ms: current_time_ms(), - } - } - - pub fn is_expired(&self, ttl: Option, now_ms: u64) -> bool { - ttl.is_some_and(|ttl| { - let ttl_ms = ttl.as_millis().try_into().unwrap_or(u64::MAX); - now_ms.saturating_sub(self.created_at_ms) >= ttl_ms - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CachedSystemPrompt { - #[serde(flatten)] - pub text: CachedPromptText, - pub identity: SystemPromptCacheIdentity, -} - -impl CachedSystemPrompt { - pub fn new(identity: SystemPromptCacheIdentity, content: impl Into) -> Self { - Self { - text: CachedPromptText::new(content), - identity, - } - } - - pub fn is_usable( - &self, - identity: &SystemPromptCacheIdentity, - ttl: Option, - now_ms: u64, - ) -> bool { - self.identity == *identity && !self.text.is_expired(ttl, now_ms) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CachedUserContext { - #[serde(flatten)] - pub text: CachedPromptText, - pub identity: UserContextCacheIdentity, -} - -impl CachedUserContext { - pub fn new(identity: UserContextCacheIdentity, content: impl Into) -> Self { - Self { - text: CachedPromptText::new(content), - identity, - } - } - - pub fn is_usable( - &self, - identity: &UserContextCacheIdentity, - ttl: Option, - now_ms: u64, - ) -> bool { - self.identity == *identity && !self.text.is_expired(ttl, now_ms) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SessionPromptCache { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub system_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_context: Option, -} - -impl SessionPromptCache { - pub fn apply_persistence_ttl(&mut self, ttl: Option) -> bool { - let now_ms = current_time_ms(); - let mut changed = false; - - if self - .system_prompt - .as_ref() - .is_some_and(|entry| entry.text.is_expired(ttl, now_ms)) - { - self.system_prompt = None; - changed = true; - } - - if self - .user_context - .as_ref() - .is_some_and(|entry| entry.text.is_expired(ttl, now_ms)) - { - self.user_context = None; - changed = true; - } - - changed - } - - pub fn is_empty(&self) -> bool { - self.system_prompt.is_none() && self.user_context.is_none() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PromptCacheScope { - SystemPrompt, - UserContext, - All, -} - -impl PromptCacheScope { - fn clears_system_prompt(self) -> bool { - matches!(self, Self::SystemPrompt | Self::All) - } - - fn clears_user_context(self) -> bool { - matches!(self, Self::UserContext | Self::All) - } -} - -pub struct SessionPromptCacheStore { - session_caches: Arc>, -} - -pub enum PromptCacheLookup { - Hit(String), - Miss, - Expired, -} - -impl Default for SessionPromptCacheStore { - fn default() -> Self { - Self::new() - } -} - -impl SessionPromptCacheStore { - pub fn new() -> Self { - Self { - session_caches: Arc::new(DashMap::new()), - } - } - - pub fn create_session(&self, session_id: &str) { - self.session_caches - .entry(session_id.to_string()) - .or_default(); - } - - pub fn has_session(&self, session_id: &str) -> bool { - self.session_caches.contains_key(session_id) - } - - pub fn replace_cache(&self, session_id: &str, cache: SessionPromptCache) { - self.session_caches.insert(session_id.to_string(), cache); - } - - pub fn get_cache(&self, session_id: &str) -> Option { - self.session_caches - .get(session_id) - .map(|cache| cache.clone()) - } - - pub fn lookup_system_prompt( - &self, - session_id: &str, - identity: &SystemPromptCacheIdentity, - ttl: Option, - ) -> PromptCacheLookup { - let now_ms = current_time_ms(); - let cached_entry = self - .session_caches - .get(session_id) - .and_then(|cache| cache.system_prompt.clone()); - - match cached_entry { - Some(entry) if entry.is_usable(identity, ttl, now_ms) => { - PromptCacheLookup::Hit(entry.text.content) - } - Some(entry) if entry.text.is_expired(ttl, now_ms) => { - self.invalidate(session_id, PromptCacheScope::SystemPrompt); - PromptCacheLookup::Expired - } - _ => PromptCacheLookup::Miss, - } - } - - pub fn lookup_user_context( - &self, - session_id: &str, - identity: &UserContextCacheIdentity, - ttl: Option, - ) -> PromptCacheLookup { - let now_ms = current_time_ms(); - let cached_entry = self - .session_caches - .get(session_id) - .and_then(|cache| cache.user_context.clone()); - - match cached_entry { - Some(entry) if entry.is_usable(identity, ttl, now_ms) => { - PromptCacheLookup::Hit(entry.text.content) - } - Some(entry) if entry.text.is_expired(ttl, now_ms) => { - self.invalidate(session_id, PromptCacheScope::UserContext); - PromptCacheLookup::Expired - } - Some(_) => PromptCacheLookup::Miss, - None => PromptCacheLookup::Miss, - } - } - - pub fn set_system_prompt(&self, session_id: &str, entry: CachedSystemPrompt) { - if let Some(mut cache) = self.session_caches.get_mut(session_id) { - cache.system_prompt = Some(entry); - } else { - self.session_caches.insert( - session_id.to_string(), - SessionPromptCache { - system_prompt: Some(entry), - user_context: None, - }, - ); - } - } - - pub fn set_user_context(&self, session_id: &str, entry: CachedUserContext) { - if let Some(mut cache) = self.session_caches.get_mut(session_id) { - cache.user_context = Some(entry); - } else { - self.session_caches.insert( - session_id.to_string(), - SessionPromptCache { - system_prompt: None, - user_context: Some(entry), - }, - ); - } - } - - pub fn invalidate(&self, session_id: &str, scope: PromptCacheScope) -> bool { - let Some(mut cache) = self.session_caches.get_mut(session_id) else { - return false; - }; - - let mut changed = false; - if scope.clears_system_prompt() && cache.system_prompt.take().is_some() { - changed = true; - } - if scope.clears_user_context() && cache.user_context.take().is_some() { - changed = true; - } - changed - } - - pub fn delete_session(&self, session_id: &str) { - self.session_caches.remove(session_id); - } -} - -fn current_time_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -#[cfg(test)] -mod tests { - use super::{ - CachedSystemPrompt, CachedUserContext, PromptCacheLookup, PromptCachePolicy, - PromptCacheScope, SessionPromptCacheStore, SystemPromptCacheIdentity, - UserContextCacheIdentity, DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL, - }; - use std::time::Duration; - - #[test] - fn default_prompt_cache_policy_uses_one_day_persistence_ttl() { - let policy = PromptCachePolicy::default(); - - assert_eq!(policy.cache_ttl, None); - assert_eq!( - policy.persistence_ttl, - Some(DEFAULT_PROMPT_CACHE_PERSISTENCE_TTL) - ); - } - - #[test] - fn system_prompt_cache_requires_matching_identity() { - let store = SessionPromptCacheStore::new(); - store.create_session("session-1"); - store.set_system_prompt( - "session-1", - CachedSystemPrompt::new( - SystemPromptCacheIdentity::new("template:agentic_mode"), - "prompt-a", - ), - ); - - assert_eq!( - match store.lookup_system_prompt( - "session-1", - &SystemPromptCacheIdentity::new("template:agentic_mode"), - None, - ) { - PromptCacheLookup::Hit(value) => Some(value), - _ => None, - }, - Some("prompt-a".to_string()) - ); - assert!(matches!( - store.lookup_system_prompt( - "session-1", - &SystemPromptCacheIdentity::new("template:debug_mode"), - None, - ), - PromptCacheLookup::Miss - )); - } - - #[test] - fn expired_user_context_is_evicted_on_read() { - let store = SessionPromptCacheStore::new(); - store.create_session("session-1"); - store.set_user_context( - "session-1", - CachedUserContext::new( - UserContextCacheIdentity::new("workspace_context|workspace_instructions"), - "stale context", - ), - ); - - assert!(matches!( - store.lookup_user_context( - "session-1", - &UserContextCacheIdentity::new("workspace_context|workspace_instructions"), - Some(Duration::from_millis(0)), - ), - PromptCacheLookup::Expired - )); - assert!(store - .get_cache("session-1") - .expect("session cache") - .user_context - .is_none()); - } - - #[test] - fn invalidate_scope_can_clear_all_cached_prompt_parts() { - let store = SessionPromptCacheStore::new(); - store.create_session("session-1"); - store.set_system_prompt( - "session-1", - CachedSystemPrompt::new( - SystemPromptCacheIdentity::new("template:agentic_mode"), - "prompt-a", - ), - ); - store.set_user_context( - "session-1", - CachedUserContext::new( - UserContextCacheIdentity::new("workspace_context"), - "context", - ), - ); - - assert!(store.invalidate("session-1", PromptCacheScope::All)); - - let cache = store.get_cache("session-1").expect("session cache"); - assert!(cache.system_prompt.is_none()); - assert!(cache.user_context.is_none()); - } -} +pub use bitfun_agent_runtime::prompt_cache::*;