From 183542175bb15a386383d7a2722f9f55a850366e Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Tue, 2 Jun 2026 16:24:11 +0800 Subject: [PATCH] fix: improve cache baseline reuse across derived agent sessions - persist skill-agent baseline override snapshots under session snapshots and load them during request assembly - seed forked subagents and /btw child sessions with split listing baselines so prompt/listing prefix reuse and later listing diffs use the correct baselines - copy skill-agent baseline override snapshots during session branching and rebuild override state when listing baselines are rebuilt - allow direct persisted metadata lookups for hidden sessions and persist subagent sessionKind in frontend session metadata - add regression coverage for override persistence, branch copying, and /btw baseline seeding - document model cache reuse and the cache-friendly message structure --- .../cache-friendly-message-structure.md | 326 ++++++++++++++++++ .../architecture/model-request-cache-reuse.md | 38 +- src/apps/desktop/src/api/session_api.rs | 4 +- .../src/agentic/coordination/coordinator.rs | 162 +++++++++ .../src/agentic/execution/execution_engine.rs | 14 +- .../core/src/agentic/persistence/manager.rs | 56 ++- .../src/agentic/persistence/session_branch.rs | 74 ++++ .../src/agentic/session/session_manager.rs | 300 +++++++++++++++- .../flow-chat-manager/EventHandlerModule.ts | 1 - .../flow_chat/utils/sessionMetadata.test.ts | 26 ++ .../src/flow_chat/utils/sessionMetadata.ts | 2 + 11 files changed, 990 insertions(+), 13 deletions(-) create mode 100644 docs/architecture/cache-friendly-message-structure.md diff --git a/docs/architecture/cache-friendly-message-structure.md b/docs/architecture/cache-friendly-message-structure.md new file mode 100644 index 000000000..2b8d31e76 --- /dev/null +++ b/docs/architecture/cache-friendly-message-structure.md @@ -0,0 +1,326 @@ +# Cache-Friendly Message Structure In BitFun + +This note explains the cache-friendly request shape BitFun tries to preserve +for long-running agent sessions, where each layer is stored, and which kinds +of changes tend to preserve or break provider-side prefix cache reuse. + +The implementation is mostly in `src/crates/core/src/agentic/`. + +## Request Shape + +BitFun's model requests are intentionally assembled in a stable order: + +1. `system prompt` +2. `tool definitions` +3. `collapsed tool listing` +4. `skill listing` +5. `agent listing` +6. `user context` +7. `conversation history` + +Two details matter here: + +- `tool definitions` are request-attached provider payload, not a chat message +- `collapsed tool listing`, `skill listing`, `agent listing`, and `user context` + are prepended reminders injected immediately before the first non-system + conversation message +- `tool definitions` and `collapsed tool listing` are currently rebuilt every + turn; they are not restored from a dedicated session-level cache artifact + +Relevant code: + +- request assembly: + `src/crates/core/src/agentic/execution/execution_engine.rs` +- prepended reminder ordering: + `src/crates/agent-runtime/src/prompt.rs` +- tool manifest resolution: + `src/crates/core/src/agentic/tools/manifest_resolver.rs` + +In practice the model-visible layout looks like this: + +```text +system prompt message +tool definitions payload +collapsed tool listing reminder +skill listing reminder +agent listing reminder +user context reminder +conversation history messages +``` + +The reminder order is fixed on purpose: + +1. collapsed tool listing +2. skill listing +3. agent listing +4. user context + +That fixed order is part of the cache strategy. + +## Layer By Layer + +### 1. System prompt + +What it is: + +- the agent/mode system prompt template rendered into the first system message + +How it is reused: + +- cached per session in `SessionPromptCache.system_prompt` +- keyed by `SystemPromptCacheIdentity` + +Where it is persisted: + +- `prompt_cache.json` + +What usually preserves reuse: + +- staying on a compatible prompt template / prompt-cache scope +- cloning prompt cache into derived sessions instead of rebuilding from scratch + +What usually breaks reuse: + +- changing to a different prompt template or incompatible mode +- explicit prompt-cache invalidation +- context compression, which intentionally resets the prompt cache after + rewriting history + +Relevant code: + +- cache model: + `src/crates/agent-runtime/src/prompt_cache.rs` +- cache lifecycle: + `src/crates/core/src/agentic/session/session_manager.rs` + +The old core path `src/crates/core/src/agentic/session/prompt_cache.rs` is now +a compatibility facade that re-exports the owner types from `bitfun-agent-runtime`. + +### 2. Tool definitions + +What it is: + +- the callable tool schema payload attached to the provider request + +How it is reused: + +- not stored in `prompt_cache.json` +- resolved at turn start from the effective tool manifest +- reused within the turn's model rounds, but not persisted as a session cache + +Where it is persisted: + +- nowhere as a dedicated session cache artifact + +What usually preserves reuse: + +- keeping the effective tool manifest stable across turns + +What usually breaks reuse: + +- adding, removing, or materially changing callable tools +- switching to an agent/profile with a different effective tool set + +Why this layer is different: + +- unlike prompt-visible descriptive listings, callable tools must exist in the + actual request payload, so new tool availability cannot be represented by a + reminder alone + +### 3. Collapsed tool listing + +What it is: + +- a prompt-visible reminder describing collapsed tools + +How it is reused: + +- rebuilt each turn/request from the current prompt-builder context +- ordered before skill/agent listings to keep the reminder prefix stable + +Where it is persisted: + +- nowhere as a dedicated snapshot or cache file + +What usually preserves reuse: + +- keeping the collapsed-tool surface stable + +What usually breaks reuse: + +- changing which tools are collapsed or how that collapsed listing renders + +### 4. Skill listing and agent listing + +What it is: + +- prompt-visible reminders for available skills and visible subagents/agents + +How it is reused: + +- first turn saves a full `TurnSkillAgentSnapshot` +- later turns diff the latest prior snapshot against the current snapshot +- only diff reminders are injected when the listing changes +- full listing reminder rebuild reads a baseline snapshot first, then renders + from that baseline instead of recomputing the historical prefix from scratch + +Where it is persisted: + +- per-turn snapshots: + `snapshots/skill-agent-0000.json`, `snapshots/skill-agent-0001.json`, ... +- optional fork baseline override: + `snapshots/skill-agent-baseline-override.json` + +Why there is an override file: + +- forked child sessions sometimes need two different baselines at once +- the child's prompt/full-listing prefix may need to stay aligned with the + parent's original listing baseline +- the child's later diff calculations should still use the child's own fork-time + baseline + +So BitFun can keep: + +- `skill-agent-baseline-override.json` as the prompt/full-listing baseline +- child `turn-0 skill-agent snapshot` as the diff baseline for later child turns + +What usually preserves reuse: + +- appending listing diffs instead of rebuilding full listing every turn +- seeding derived sessions from parent baselines instead of starting with a new + full listing + +What usually breaks reuse: + +- losing the baseline snapshot when creating a derived session +- forcing full-listing rebuilds too often + +Special rewrite path: + +- when BitFun rebuilds the listing baseline to the latest snapshot, it also + removes old skill/agent diff reminders from live context and from pre-rebuild + persisted context snapshots +- this is the main non-compression exception to the normal append-only message + pattern + +Relevant code: + +- snapshot model and diffing: + `src/crates/core/src/agentic/skill_agent_snapshot.rs` +- sparse snapshot store: + `src/crates/core/src/agentic/session/turn_skill_agent_snapshot_store.rs` +- baseline rebuild and cleanup: + `src/crates/core/src/agentic/session/session_manager.rs` + +### 5. User context + +What it is: + +- workspace context, workspace instructions, memory files, project layout, and + other user-context sections selected by the current policy + +How it is reused: + +- cached per session in `SessionPromptCache.user_context` +- keyed by `UserContextCacheIdentity` + +Where it is persisted: + +- `prompt_cache.json` + +What usually preserves reuse: + +- keeping the same user-context policy scope + +What usually breaks reuse: + +- changing to a different user-context policy scope +- explicit prompt-cache invalidation +- context compression, which resets prompt cache after rewriting history + +### 6. Conversation history + +What it is: + +- the actual user / assistant / tool / internal-reminder message stream sent as + conversation context after the prepended reminders + +How it is reused: + +- normal session flow is append-only: new turns extend history instead of + rewriting already-sent prefixes +- derived sessions reuse captured parent history instead of reconstructing it + +Where it is persisted: + +- per-turn records: + `turns/turn-0000.json`, `turns/turn-0001.json`, ... +- context snapshots: + `snapshots/context-0000.json`, `snapshots/context-0001.json`, ... + +Important exceptions: + +- listing baseline rebuild removes old skill/agent diff reminders from live + context and older persisted context snapshots +- context compression intentionally rewrites conversation history into a shorter + summary form, then invalidates prompt cache and starts a new stable prefix + +## Session Storage Layout + +Persisted session artifacts live under `.bitfun/sessions/{session_id}/` +(or the session's effective storage mirror for remote workspaces). + +The cache-relevant files are: + +```text +session.json +metadata.json +prompt_cache.json +turns/ + turn-0000.json + turn-0001.json +snapshots/ + context-0000.json + context-0001.json + skill-agent-0000.json + skill-agent-0001.json + skill-agent-baseline-override.json # only when a session needs it +``` + +## Developer Rules Of Thumb + +If you want to preserve prefix cache reuse: + +1. Keep the system prompt identity stable. +2. Keep the user-context policy scope stable. +3. Avoid changing the effective tool manifest unless the feature really needs + it. +4. Treat skill/agent listings as snapshot + diff state, not as text to rebuild + from scratch every turn. +5. When creating derived sessions, clone prompt cache and seed listing baselines + from the parent session. +6. Prefer append-only history updates; do not rewrite already-sent prefixes in + normal turn flow. + +If you intentionally need to reset or rewrite the prefix: + +1. Do it explicitly, like compression does. +2. Rebuild the listing baseline if the old diff chain is no longer the right + baseline. +3. Invalidate prompt cache when the old cached prefix no longer matches the + rewritten history. + +## Implementation Map + +- request assembly: + `src/crates/core/src/agentic/execution/execution_engine.rs` +- reminder ordering and prompt builder helpers: + `src/crates/agent-runtime/src/prompt.rs` +- prompt cache model: + `src/crates/agent-runtime/src/prompt_cache.rs` +- prompt cache lifecycle and listing baseline rebuild: + `src/crates/core/src/agentic/session/session_manager.rs` +- listing snapshots and diffs: + `src/crates/core/src/agentic/skill_agent_snapshot.rs` +- session persistence paths: + `src/crates/core/src/agentic/persistence/manager.rs` diff --git a/docs/architecture/model-request-cache-reuse.md b/docs/architecture/model-request-cache-reuse.md index 7cc902543..6e1b85128 100644 --- a/docs/architecture/model-request-cache-reuse.md +++ b/docs/architecture/model-request-cache-reuse.md @@ -32,7 +32,11 @@ Instead, it separates two relatively stable layers: - the user-context reminder The cache model lives in -`src/crates/core/src/agentic/session/prompt_cache.rs`. +`src/crates/agent-runtime/src/prompt_cache.rs`. + +Core still exposes `src/crates/core/src/agentic/session/prompt_cache.rs`, but +that file is now a compatibility facade that re-exports the owner types from +`bitfun-agent-runtime`. Key details: @@ -85,6 +89,7 @@ text: - the branched turns up to the selected turn - persisted turn context snapshots - persisted skill/subagent listing snapshots +- persisted `skill-agent-baseline-override.json` when the source session has one - the source session's prompt cache - compression state and lineage metadata @@ -116,6 +121,7 @@ The child session is initialized from a captured parent context snapshot: - same workspace and model config - inherited message context - cloned session-level prompt cache +- seeded skill/agent listing baselines derived from the parent session That gives the side thread an already-built conversational prefix. @@ -123,6 +129,8 @@ In other words, `/btw` now reuses both: - the parent message context snapshot - the parent session's prompt cache via `SessionManager::clone_prompt_cache(...)` +- the parent session's full skill/agent listing baseline via + `SessionManager::seed_forked_skill_agent_listing_baselines(...)` ### `fork_context` subagents @@ -144,6 +152,17 @@ When `Task` is called with `fork_context=true`, BitFun: workspace, remote metadata, and model selection - clones the parent session's prompt cache into the child session via `SessionManager::clone_prompt_cache(...)` +- seeds a fork-aware skill/agent listing baseline split via + `SessionManager::seed_forked_skill_agent_listing_baselines(...)` + +That seed step intentionally keeps two different baselines: + +- the parent's turn-0 skill/agent snapshot is preserved as a prompt/listing + baseline override so the child can reuse the same full-listing prefix on its + first request +- the parent's latest snapshot at fork time becomes the child's own turn-0 + snapshot so later child turns diff against the fork-time surface, not forever + against the parent's original turn-0 baseline The `Task` tool also forbids fields that would make the fork drift away from the inherited prefix, including `subagent_type`, `workspace_path`, `model_id`, @@ -166,7 +185,7 @@ They are intentionally configured to share the same stable prompt base. Relevant code: - shared constants and tests: - `src/crates/core/src/agentic/agents/mod.rs` + `src/crates/agent-runtime/src/agents.rs` - mode definitions: `src/crates/core/src/agentic/agents/definitions/modes/{agentic,plan,debug,multitask}.rs` @@ -217,10 +236,15 @@ Relevant code: `src/crates/core/src/agentic/skill_agent_snapshot.rs` - sparse snapshot store: `src/crates/core/src/agentic/session/turn_skill_agent_snapshot_store.rs` +- fork baseline override persistence: + `snapshots/skill-agent-baseline-override.json` via + `src/crates/core/src/agentic/persistence/manager.rs` - turn-time diff injection: `ConversationCoordinator::wrap_user_input(...)` - baseline reminder reuse: `ExecutionEngine::build_cached_prepended_prompt_reminders(...)` +- reminder ordering owner: + `src/crates/agent-runtime/src/prompt.rs` The strategy is: @@ -232,6 +256,12 @@ The strategy is: This keeps the base cached prefix stable while still letting the model see fresh capability changes. +Forked child sessions add one extra wrinkle: prompt/listing reuse and later diff +correctness do not always want the same baseline. In those cases BitFun can keep +the child's turn-0 snapshot as the diff baseline while reading the separate +baseline-override snapshot first when rebuilding the full skill/agent listing +reminder. + What gets updated dynamically: - skills @@ -451,7 +481,7 @@ The most important implementation choices are: ## Implementation Map - Prompt cache model: - `src/crates/core/src/agentic/session/prompt_cache.rs` + `src/crates/agent-runtime/src/prompt_cache.rs` - Prompt cache lifecycle: `src/crates/core/src/agentic/session/session_manager.rs` - Request assembly and cache hits: @@ -464,7 +494,7 @@ The most important implementation choices are: `src/crates/core/src/agentic/coordination/coordinator.rs` and `src/apps/desktop/src/api/btw_api.rs` - Shared coding-mode identities: - `src/crates/core/src/agentic/agents/mod.rs` + `src/crates/agent-runtime/src/agents.rs` - Dynamic skill/agent listing snapshots: `src/crates/core/src/agentic/skill_agent_snapshot.rs` - Provider cache telemetry: diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 5a46ee814..2065220cb 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -433,7 +433,9 @@ pub async fn load_persisted_session_metadata( .await .map_err(|e| format!("Failed to load persisted session metadata: {}", e))?; - Ok(metadata.filter(|metadata| !metadata.should_hide_from_user_lists())) + // Direct metadata lookups are used by persistence flows that must be able + // to read hidden subagent sessions without list-level visibility filtering. + Ok(metadata) } #[tauri::command] diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index f036f83d5..3ace704bd 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -4211,6 +4211,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Forked prompt cache into subagent session: source_session_id={}, session_id={}, copied={}", source_session_id, session_id, copied ); + self.session_manager + .seed_forked_skill_agent_listing_baselines(source_session_id, &session_id) + .await; } self.session_manager .replace_context_messages(&session_id, initial_messages.clone()) @@ -4968,6 +4971,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Forked prompt cache into /btw child session: parent_session_id={}, child_session_id={}, copied={}", parent_session_id, child_session.session_id, copied ); + self.session_manager + .seed_forked_skill_agent_listing_baselines( + parent_session_id, + &child_session.session_id, + ) + .await; self.session_manager .replace_context_messages(&child_session.session_id, snapshot.messages) @@ -5785,8 +5794,70 @@ mod tests { normalize_subagent_max_concurrency, resolve_agent_submission_turn_id, ConversationCoordinator, }; + use crate::agentic::core::SessionConfig; + use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; + use crate::agentic::execution::{ + ExecutionEngine, ExecutionEngineConfig, RoundExecutor, StreamProcessor, + }; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + compression::{CompressionConfig, ContextCompressor}, + PromptCachePolicy, SessionContextStore, SessionManager, SessionManagerConfig, + SystemPromptCacheIdentity, UserContextCacheIdentity, + }; + use crate::agentic::skill_agent_snapshot::SkillSnapshotEntry; + use crate::agentic::tools::registry::ToolRegistry; + use crate::agentic::tools::{ToolPipeline, ToolStateManager}; + use crate::agentic::TurnSkillAgentSnapshot; + use crate::infrastructure::PathManager; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use bitfun_runtime_ports::{AgentSubmissionRequest, AgentSubmissionSource}; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::RwLock as TokioRwLock; + + fn test_coordinator() -> (ConversationCoordinator, Arc) { + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ), + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue, + Arc::new(EventRouter::new()), + ); + + (coordinator, session_manager) + } #[test] fn conversation_coordinator_exposes_remote_runtime_ports() { @@ -5959,4 +6030,95 @@ mod tests { assert_eq!(config.remote_ssh_host.as_deref(), Some("remote-host")); assert_eq!(config.model_id.as_deref(), Some("model-fast")); } + + #[tokio::test] + async fn hidden_btw_session_seeds_forked_listing_baselines() { + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-btw-baseline-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + let parent_session = session_manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("parent session should be created"); + session_manager + .replace_context_messages( + &parent_session.session_id, + vec![crate::agentic::core::Message::user("parent context".to_string())], + ) + .await; + + let system_prompt_identity = SystemPromptCacheIdentity::new("template:agentic_mode"); + let user_context_identity = UserContextCacheIdentity::new("workspace_context"); + session_manager + .remember_system_prompt( + &parent_session.session_id, + system_prompt_identity.clone(), + "cached system prompt".to_string(), + ) + .await; + session_manager + .remember_user_context( + &parent_session.session_id, + user_context_identity.clone(), + "cached user context".to_string(), + ) + .await; + + let baseline_snapshot = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "interactive-debug".to_string(), + description: "debug helper".to_string(), + location: "C:/Users/wsp/.codex/skills/interactive-debug".to_string(), + }], + subagents: Vec::new(), + }; + session_manager + .remember_turn_skill_agent_snapshot( + &parent_session.session_id, + 0, + baseline_snapshot.clone(), + ) + .await; + + let child_session = coordinator + .ensure_hidden_btw_session(&parent_session.session_id, "btw-child", None) + .await + .expect("btw child session should be created"); + + assert_eq!(child_session.kind, crate::agentic::core::SessionKind::EphemeralChild); + assert_eq!( + session_manager + .cached_system_prompt(&child_session.session_id, &system_prompt_identity) + .await, + Some("cached system prompt".to_string()) + ); + assert_eq!( + session_manager + .cached_user_context(&child_session.session_id, &user_context_identity) + .await, + Some("cached user context".to_string()) + ); + assert_eq!( + session_manager + .skill_agent_baseline_override_snapshot(&child_session.session_id) + .await, + Some(baseline_snapshot.clone()) + ); + assert_eq!( + session_manager + .turn_skill_agent_snapshot(&child_session.session_id, 0) + .await, + Some(baseline_snapshot) + ); + } } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 0099ebc60..f366c4532 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -601,14 +601,22 @@ impl ExecutionEngine { }; let prompt_builder = PromptBuilder::new(prompt_context); - let baseline_tool_sections = self + let baseline_snapshot = if let Some(snapshot) = self .session_manager - .turn_skill_agent_snapshot(session_id, 0) + .skill_agent_baseline_override_snapshot(session_id) .await + { + Some(snapshot) + } else { + self.session_manager + .turn_skill_agent_snapshot(session_id, 0) + .await + }; + let baseline_tool_sections = baseline_snapshot .map(|snapshot| build_skill_agent_tool_listing_sections_from_snapshot(&snapshot)); if baseline_tool_sections.is_none() { warn!( - "First-turn skill-agent snapshot unavailable while building prepended reminders: session_id={}", + "Listing reminder baseline snapshot unavailable while building prepended reminders: session_id={}", session_id ); } diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index c903af4a1..edbcf263c 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -106,6 +106,13 @@ struct StoredTurnSkillAgentSnapshotFile { snapshot: TurnSkillAgentSnapshot, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredSkillAgentBaselineOverrideFile { + schema_version: u32, + session_id: String, + snapshot: TurnSkillAgentSnapshot, +} + #[derive(Debug, Default)] struct ContextSnapshotPayloadStats { tool_result_count: usize, @@ -469,6 +476,20 @@ impl PersistenceManager { .join(format!("skill-agent-{:04}.json", turn_index)) } + fn skill_agent_baseline_override_path( + &self, + workspace_path: &Path, + session_id: &str, + ) -> PathBuf { + // Forked subagents need two different "baselines": + // - turn-0 skill-agent snapshot remains the child's own diff baseline for later turns + // - this override preserves the parent's turn-0 listing baseline so prompt/listing + // reminders can keep the same prefix/cache baseline after forking + // Full listing reminder assembly reads this file before falling back to turn 0. + self.snapshots_dir(workspace_path, session_id) + .join("skill-agent-baseline-override.json") + } + fn transcript_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { self.artifacts_dir(workspace_path, session_id) .join("transcript.txt") @@ -2200,6 +2221,39 @@ impl PersistenceManager { Ok(()) } + pub async fn save_skill_agent_baseline_override_snapshot( + &self, + workspace_path: &Path, + session_id: &str, + snapshot: &TurnSkillAgentSnapshot, + ) -> BitFunResult<()> { + self.ensure_runtime_for_write(workspace_path).await?; + self.ensure_snapshots_dir(workspace_path, session_id).await?; + + self.write_json_atomic( + &self.skill_agent_baseline_override_path(workspace_path, session_id), + &StoredSkillAgentBaselineOverrideFile { + schema_version: SESSION_STORAGE_SCHEMA_VERSION, + session_id: session_id.to_string(), + snapshot: snapshot.clone(), + }, + ) + .await + } + + pub async fn load_skill_agent_baseline_override_snapshot( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult> { + let stored = self + .read_json_optional::( + &self.skill_agent_baseline_override_path(workspace_path, session_id), + ) + .await?; + Ok(stored.map(|value| value.snapshot)) + } + pub async fn delete_turn_context_snapshots_from( &self, workspace_path: &Path, @@ -2251,7 +2305,6 @@ impl PersistenceManager { self.ensure_runtime_for_write(workspace_path).await?; self.ensure_session_dir(workspace_path, &session.session_id) .await?; - let existing_metadata = self .load_session_metadata(workspace_path, &session.session_id) .await?; @@ -2565,7 +2618,6 @@ impl PersistenceManager { .ok_or_else(|| { BitFunError::NotFound(format!("Session metadata not found: {}", turn.session_id)) })?; - self.ensure_turns_dir(workspace_path, &turn.session_id) .await?; diff --git a/src/crates/core/src/agentic/persistence/session_branch.rs b/src/crates/core/src/agentic/persistence/session_branch.rs index 94c489751..f83fa4f29 100644 --- a/src/crates/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/core/src/agentic/persistence/session_branch.rs @@ -186,6 +186,20 @@ impl PersistenceManager { self.save_prompt_cache(workspace_path, &target_session_id, cache) .await?; } + if let Some(snapshot) = self + .load_skill_agent_baseline_override_snapshot( + workspace_path, + &request.source_session_id, + ) + .await? + { + self.save_skill_agent_baseline_override_snapshot( + workspace_path, + &target_session_id, + &snapshot, + ) + .await?; + } let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -257,6 +271,7 @@ impl PersistenceManager { mod tests { use super::{PersistenceManager, SessionBranchRequest}; use crate::agentic::core::{Message, Session, SessionKind}; + use crate::agentic::skill_agent_snapshot::{SkillSnapshotEntry, TurnSkillAgentSnapshot}; use crate::agentic::session::{ CachedSystemPrompt, CachedUserContext, SessionPromptCache, SystemPromptCacheIdentity, UserContextCacheIdentity, @@ -485,4 +500,63 @@ mod tests { }) ); } + + #[tokio::test] + async fn branch_session_copies_skill_agent_baseline_override_snapshot() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + + let mut source_session = Session::new( + "Source Title".to_string(), + "agentic".to_string(), + Default::default(), + ); + source_session.kind = SessionKind::Standard; + manager + .save_session(workspace.path(), &source_session) + .await + .expect("source session should save"); + + let turn_0 = build_turn(&source_session.session_id, "turn-0", 0, "first"); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("turn 0 should save"); + + let baseline_override = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "interactive-debug".to_string(), + description: "debug helper".to_string(), + location: "/skills/interactive-debug".to_string(), + }], + subagents: Vec::new(), + }; + manager + .save_skill_agent_baseline_override_snapshot( + workspace.path(), + &source_session.session_id, + &baseline_override, + ) + .await + .expect("baseline override should save"); + + let result = manager + .branch_session( + workspace.path(), + &SessionBranchRequest { + source_session_id: source_session.session_id.clone(), + source_turn_id: "turn-0".to_string(), + }, + ) + .await + .expect("branch should succeed"); + + let branched_override = manager + .load_skill_agent_baseline_override_snapshot(workspace.path(), &result.session_id) + .await + .expect("branched override should load") + .expect("branched override should exist"); + assert_eq!(branched_override, baseline_override); + } } diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 47b2bf8c6..cc612509a 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -106,6 +106,7 @@ pub struct SessionManager { context_store: Arc, prompt_cache_store: Arc, turn_skill_agent_snapshot_store: Arc, + skill_agent_baseline_override_snapshot_store: Arc>, file_read_state_store: Arc, evidence_ledger: Arc, persistence_manager: Arc, @@ -822,6 +823,7 @@ impl SessionManager { context_store, prompt_cache_store: Arc::new(SessionPromptCacheStore::new()), turn_skill_agent_snapshot_store: Arc::new(TurnSkillAgentSnapshotStore::new()), + skill_agent_baseline_override_snapshot_store: Arc::new(DashMap::new()), file_read_state_store: Arc::new(FileReadStateStore::new()), evidence_ledger: Arc::new(SessionEvidenceLedger::new()), persistence_manager, @@ -1010,6 +1012,8 @@ impl SessionManager { let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); let turn_skill_agent_snapshot_store = self.turn_skill_agent_snapshot_store.clone(); + let skill_agent_baseline_override_snapshot_store = + self.skill_agent_baseline_override_snapshot_store.clone(); let file_read_state_store = self.file_read_state_store.clone(); let evidence_ledger = self.evidence_ledger.clone(); let persistence_manager = self.persistence_manager.clone(); @@ -1032,6 +1036,7 @@ impl SessionManager { context_store, prompt_cache_store, turn_skill_agent_snapshot_store, + skill_agent_baseline_override_snapshot_store, file_read_state_store, evidence_ledger, persistence_manager, @@ -1472,6 +1477,117 @@ impl SessionManager { } } + pub async fn remember_skill_agent_baseline_override_snapshot( + &self, + session_id: &str, + snapshot: TurnSkillAgentSnapshot, + ) { + self.skill_agent_baseline_override_snapshot_store + .insert(session_id.to_string(), snapshot.clone()); + + if !self.should_persist_session_id(session_id) { + return; + } + + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { + debug!( + "Skipping listing reminder baseline override persistence because workspace path is unavailable: session_id={}", + session_id + ); + return; + }; + + match self + .persistence_manager + .save_skill_agent_baseline_override_snapshot( + &workspace_path, + session_id, + &snapshot, + ) + .await + { + Err(error) => { + warn!( + "Failed to persist listing reminder baseline override snapshot: session_id={}, workspace_path={}, error={}", + session_id, + workspace_path.display(), + error + ); + } + Ok(()) => {} + } + } + + pub async fn skill_agent_baseline_override_snapshot( + &self, + session_id: &str, + ) -> Option { + if let Some(snapshot) = self + .skill_agent_baseline_override_snapshot_store + .get(session_id) + .map(|value| value.clone()) + { + return Some(snapshot); + } + + if !self.should_persist_session_id(session_id) { + return None; + } + + let workspace_path = self.effective_session_workspace_path(session_id).await?; + let snapshot = match self + .persistence_manager + .load_skill_agent_baseline_override_snapshot(&workspace_path, session_id) + .await + { + Ok(snapshot) => snapshot, + Err(error) => { + warn!( + "Failed to load listing reminder baseline override snapshot: session_id={}, workspace_path={}, error={}", + session_id, + workspace_path.display(), + error + ); + return None; + } + }; + let snapshot = snapshot?; + self.skill_agent_baseline_override_snapshot_store + .insert(session_id.to_string(), snapshot.clone()); + Some(snapshot) + } + + pub async fn seed_forked_skill_agent_listing_baselines( + &self, + parent_session_id: &str, + child_session_id: &str, + ) { + // Forked children need two different baselines at the same time: + // - the parent's turn-0 snapshot stays as the prompt/listing baseline so the child's + // first request can reuse the same full skill/agent listing prefix + // - the parent's latest snapshot becomes the child's own turn-0 snapshot so later child + // turns diff against the fork-time surface instead of diffing forever against the + // parent's original turn-0 baseline + let prompt_listing_baseline = self.turn_skill_agent_snapshot(parent_session_id, 0).await; + if let Some(snapshot) = prompt_listing_baseline.clone() { + self.remember_skill_agent_baseline_override_snapshot(child_session_id, snapshot) + .await; + } + + let latest_parent_snapshot = match self.get_turn_count(parent_session_id).checked_sub(1) { + Some(turn_index) => self + .latest_turn_skill_agent_snapshot_at_or_before(parent_session_id, turn_index) + .await + .map(|(_, snapshot)| snapshot), + None => None, + }; + + if let Some(snapshot) = latest_parent_snapshot.or(prompt_listing_baseline) { + self.remember_turn_skill_agent_snapshot(child_session_id, 0, snapshot) + .await; + } + } + pub async fn rebuild_skill_agent_listing_baseline_to_latest(&self, session_id: &str) -> bool { let Some(turn_index) = self .sessions @@ -1488,6 +1604,18 @@ impl SessionManager { return false; }; + if self + .skill_agent_baseline_override_snapshot(session_id) + .await + .is_some() + { + self.remember_skill_agent_baseline_override_snapshot( + session_id, + latest_snapshot.clone(), + ) + .await; + } + self.recover_first_turn_skill_agent_snapshot(session_id, latest_snapshot) .await; self.persist_listing_baseline_rebuild_turn_index_best_effort(session_id, turn_index) @@ -2111,6 +2239,8 @@ impl SessionManager { self.prompt_cache_store.delete_session(session_id); self.turn_skill_agent_snapshot_store .delete_session(session_id); + self.skill_agent_baseline_override_snapshot_store + .remove(session_id); self.file_read_state_store.delete_session(session_id); debug!( "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", @@ -2608,6 +2738,8 @@ impl SessionManager { self.prompt_cache_store.delete_session(session_id); self.turn_skill_agent_snapshot_store .delete_session(session_id); + self.skill_agent_baseline_override_snapshot_store + .remove(session_id); self.file_read_state_store.delete_session(session_id); } @@ -2919,7 +3051,6 @@ impl SessionManager { })? } }; - metadata.custom_metadata = Some(match (metadata.custom_metadata.take(), patch) { ( Some(serde_json::Value::Object(mut existing)), @@ -2933,7 +3064,8 @@ impl SessionManager { (_, value) => value, }); - self.persistence_manager + self + .persistence_manager .save_session_metadata(&workspace_path, &metadata) .await } @@ -4340,6 +4472,8 @@ impl SessionManager { let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); let turn_skill_agent_snapshot_store = self.turn_skill_agent_snapshot_store.clone(); + let skill_agent_baseline_override_snapshot_store = + self.skill_agent_baseline_override_snapshot_store.clone(); let file_read_state_store = self.file_read_state_store.clone(); tokio::spawn(async move { @@ -4399,6 +4533,7 @@ impl SessionManager { context_store.delete_session(&candidate.session_id); prompt_cache_store.delete_session(&candidate.session_id); turn_skill_agent_snapshot_store.delete_session(&candidate.session_id); + skill_agent_baseline_override_snapshot_store.remove(&candidate.session_id); file_read_state_store.delete_session(&candidate.session_id); } } @@ -4459,6 +4594,7 @@ mod tests { PromptCachePolicy, PromptCacheScope, SessionContextStore, SystemPromptCacheIdentity, UserContextCacheIdentity, }; + use crate::agentic::skill_agent_snapshot::{SkillSnapshotEntry, TurnSkillAgentSnapshot}; use crate::infrastructure::PathManager; use crate::service::config::types::{ AIConfig as ServiceAIConfig, AIModelConfig as ServiceAIModelConfig, @@ -6188,6 +6324,166 @@ mod tests { ); } + #[tokio::test] + async fn skill_agent_baseline_override_snapshot_persists_across_session_restore() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Listing baseline".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should be created"); + let baseline = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-a".to_string(), + description: "desc-a".to_string(), + location: "/skills/a".to_string(), + }], + ..Default::default() + }; + + manager + .remember_skill_agent_baseline_override_snapshot(&session.session_id, baseline.clone()) + .await; + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata load should succeed") + .expect("metadata should exist"); + assert_eq!(metadata.custom_metadata, None); + assert_eq!( + persistence_manager + .load_skill_agent_baseline_override_snapshot( + workspace.path(), + &session.session_id, + ) + .await + .expect("override snapshot load should succeed"), + Some(baseline.clone()) + ); + + let restored_manager = test_manager(persistence_manager); + restored_manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("session should restore"); + + assert_eq!( + restored_manager + .skill_agent_baseline_override_snapshot(&session.session_id) + .await, + Some(baseline) + ); + } + + #[tokio::test] + async fn seed_forked_skill_agent_listing_baselines_splits_prompt_and_diff_baselines() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager.clone()); + let parent = manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("parent session should create"); + let child = manager + .create_session( + "Child".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("child session should create"); + let prompt_baseline = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-parent-turn-0".to_string(), + description: "desc-0".to_string(), + location: "/skills/turn-0".to_string(), + }], + ..Default::default() + }; + let latest_baseline = TurnSkillAgentSnapshot { + skills: vec![SkillSnapshotEntry { + name: "skill-parent-latest".to_string(), + description: "desc-latest".to_string(), + location: "/skills/latest".to_string(), + }], + ..Default::default() + }; + + manager + .remember_turn_skill_agent_snapshot(&parent.session_id, 0, prompt_baseline.clone()) + .await; + manager + .remember_turn_skill_agent_snapshot(&parent.session_id, 2, latest_baseline.clone()) + .await; + { + let mut parent_session = manager + .sessions + .get_mut(&parent.session_id) + .expect("parent session should remain in memory"); + parent_session.dialog_turn_ids = vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string(), + ]; + } + + manager + .seed_forked_skill_agent_listing_baselines(&parent.session_id, &child.session_id) + .await; + + assert_eq!( + manager + .skill_agent_baseline_override_snapshot(&child.session_id) + .await, + Some(prompt_baseline.clone()) + ); + assert_eq!( + manager + .turn_skill_agent_snapshot(&child.session_id, 0) + .await, + Some(latest_baseline.clone()) + ); + + let restored_manager = test_manager(persistence_manager); + restored_manager + .restore_session(workspace.path(), &child.session_id) + .await + .expect("child session should restore"); + assert_eq!( + restored_manager + .skill_agent_baseline_override_snapshot(&child.session_id) + .await, + Some(prompt_baseline) + ); + assert_eq!( + restored_manager + .turn_skill_agent_snapshot(&child.session_id, 0) + .await, + Some(latest_baseline) + ); + } + #[tokio::test] async fn prompt_cache_invalidation_removes_persisted_entries() { let workspace = TestWorkspace::new(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index cfad8cc19..2c71493c1 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -385,7 +385,6 @@ function ensureSubagentSession( const parentTurnIndex = parentSession ?.dialogTurns .findIndex(turn => turn.id === parentInfo.dialogTurnId); - store.addExternalSession( subagentSessionId, buildSubagentSessionTitleWithType(parentInfo, explicitSubagentType), diff --git a/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts b/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts index 1b9901d7c..778a3a687 100644 --- a/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts @@ -440,6 +440,7 @@ describe('sessionMetadata', () => { }); expect(metadata.tags).toEqual(['subagent']); + expect(metadata.sessionKind).toBe('subagent'); expect(metadata.relationship).toMatchObject({ kind: 'subagent', parentSessionId: 'parent-1', @@ -478,6 +479,31 @@ describe('sessionMetadata', () => { }); }); + it('persists subagent sessionKind without existing metadata', () => { + const session = createSession({ + sessionId: 'subagent-child-2', + sessionKind: 'subagent', + parentSessionId: 'parent-2', + parentToolCallId: 'tool-call-8', + subagentType: 'Explore', + btwOrigin: { + parentSessionId: 'parent-2', + parentDialogTurnId: 'turn-2', + parentTurnIndex: 2, + }, + }); + + const metadata = buildSessionMetadata(session); + + expect(metadata.sessionKind).toBe('subagent'); + expect(metadata.relationship).toMatchObject({ + kind: 'subagent', + parentSessionId: 'parent-2', + parentToolCallId: 'tool-call-8', + subagentType: 'Explore', + }); + }); + it('prefers structured relationship metadata over legacy custom metadata when both exist', () => { const metadata: SessionMetadata = { sessionId: 'review-child-relationship', diff --git a/src/web-ui/src/flow_chat/utils/sessionMetadata.ts b/src/web-ui/src/flow_chat/utils/sessionMetadata.ts index 25e5d4462..50cd9eca9 100644 --- a/src/web-ui/src/flow_chat/utils/sessionMetadata.ts +++ b/src/web-ui/src/flow_chat/utils/sessionMetadata.ts @@ -364,6 +364,7 @@ export function buildSessionMetadata( ): SessionMetadata { const stats = calculateSessionStats(session); const sessionKind = normalizeSessionKind(session.sessionKind); + const persistedSessionKind = sessionKind === 'subagent' ? 'subagent' : 'standard'; return { ...existingMetadata, @@ -389,6 +390,7 @@ export function buildSessionMetadata( stats.toolCallCount, existingMetadata?.toolCallCount ?? 0 ), + sessionKind: persistedSessionKind, status: 'active', snapshotSessionId: existingMetadata?.snapshotSessionId, tags: buildSessionTags(sessionKind, existingMetadata?.tags),