From bcc7faa0ba328bf2e21fb05db63691198237cd91 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 12 Apr 2026 07:09:48 +0900 Subject: [PATCH] =?UTF-8?q?compact=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/llm-worker/src/llm_client/client.rs | 10 ++ .../src/llm_client/providers/anthropic.rs | 5 + .../src/llm_client/providers/gemini.rs | 5 + .../src/llm_client/providers/ollama.rs | 5 + .../src/llm_client/providers/openai.rs | 5 + .../llm_client/scheme/anthropic/request.rs | 3 +- .../src/llm_client/scheme/gemini/request.rs | 3 +- crates/llm-worker/src/llm_client/types.rs | 14 ++ crates/llm-worker/src/worker.rs | 5 + crates/llm-worker/tests/common/mod.rs | 4 + crates/manifest/src/lib.rs | 111 ++++++++++++ crates/pod/src/pod.rs | 158 ++++++++++++++++++ crates/pod/tests/controller_test.rs | 4 + crates/session-store/src/lib.rs | 9 +- crates/session-store/src/session.rs | 45 ++++- crates/session-store/src/session_log.rs | 29 ++++ crates/session-store/tests/common/mod.rs | 4 + crates/session-store/tests/fs_store_test.rs | 14 ++ tickets/context-compaction.md | 27 +-- 19 files changed, 439 insertions(+), 21 deletions(-) diff --git a/crates/llm-worker/src/llm_client/client.rs b/crates/llm-worker/src/llm_client/client.rs index a148da94..0d8e0cf8 100644 --- a/crates/llm-worker/src/llm_client/client.rs +++ b/crates/llm-worker/src/llm_client/client.rs @@ -54,6 +54,12 @@ pub trait LlmClient: Send + Sync { request: Request, ) -> Result> + Send>>, ClientError>; + /// Clone this client into a new `Box`. + /// + /// Used when a second client instance is needed (e.g. for context + /// compaction) without access to the original construction parameters. + fn clone_boxed(&self) -> Box; + /// 設定をバリデーションし、未サポートの設定があれば警告を返す /// /// # Arguments @@ -80,6 +86,10 @@ impl LlmClient for Box { (**self).stream(request).await } + fn clone_boxed(&self) -> Box { + (**self).clone_boxed() + } + fn validate_config(&self, config: &RequestConfig) -> Vec { (**self).validate_config(config) } diff --git a/crates/llm-worker/src/llm_client/providers/anthropic.rs b/crates/llm-worker/src/llm_client/providers/anthropic.rs index 509ab960..153cf4da 100644 --- a/crates/llm-worker/src/llm_client/providers/anthropic.rs +++ b/crates/llm-worker/src/llm_client/providers/anthropic.rs @@ -13,6 +13,7 @@ use futures::{Stream, StreamExt, TryStreamExt, future::ready}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; /// Anthropic クライアント +#[derive(Clone)] pub struct AnthropicClient { /// HTTPクライアント http_client: reqwest::Client, @@ -86,6 +87,10 @@ impl AnthropicClient { #[async_trait] impl LlmClient for AnthropicClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, request: Request, diff --git a/crates/llm-worker/src/llm_client/providers/gemini.rs b/crates/llm-worker/src/llm_client/providers/gemini.rs index b0cd7516..b7378921 100644 --- a/crates/llm-worker/src/llm_client/providers/gemini.rs +++ b/crates/llm-worker/src/llm_client/providers/gemini.rs @@ -13,6 +13,7 @@ use futures::{Stream, StreamExt, TryStreamExt}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; /// Gemini クライアント +#[derive(Clone)] pub struct GeminiClient { /// HTTPクライアント http_client: reqwest::Client, @@ -68,6 +69,10 @@ impl GeminiClient { #[async_trait] impl LlmClient for GeminiClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, request: Request, diff --git a/crates/llm-worker/src/llm_client/providers/ollama.rs b/crates/llm-worker/src/llm_client/providers/ollama.rs index 3a93e613..8148bf5f 100644 --- a/crates/llm-worker/src/llm_client/providers/ollama.rs +++ b/crates/llm-worker/src/llm_client/providers/ollama.rs @@ -16,6 +16,7 @@ use futures::Stream; /// /// 内部的にOpenAIClientを使用するラッパー、もしくはOpenAIClientと同様の実装を持つ。 /// ここではOpenAIClient構成をカスタマイズして提供する。 +#[derive(Clone)] pub struct OllamaClient { inner: OpenAIClient, } @@ -53,6 +54,10 @@ impl OllamaClient { #[async_trait] impl LlmClient for OllamaClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, request: Request, diff --git a/crates/llm-worker/src/llm_client/providers/openai.rs b/crates/llm-worker/src/llm_client/providers/openai.rs index bf27d0c4..eb0a12dc 100644 --- a/crates/llm-worker/src/llm_client/providers/openai.rs +++ b/crates/llm-worker/src/llm_client/providers/openai.rs @@ -14,6 +14,7 @@ use futures::{Stream, StreamExt, TryStreamExt}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; /// OpenAI クライアント +#[derive(Clone)] pub struct OpenAIClient { /// HTTPクライアント http_client: reqwest::Client, @@ -85,6 +86,10 @@ impl OpenAIClient { #[async_trait] impl LlmClient for OpenAIClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, request: Request, diff --git a/crates/llm-worker/src/llm_client/scheme/anthropic/request.rs b/crates/llm-worker/src/llm_client/scheme/anthropic/request.rs index ba5228b8..b51cc4f4 100644 --- a/crates/llm-worker/src/llm_client/scheme/anthropic/request.rs +++ b/crates/llm-worker/src/llm_client/scheme/anthropic/request.rs @@ -118,9 +118,8 @@ impl AnthropicScheme { ); let anthropic_role = match role { - Role::User => "user", + Role::User | Role::System => "user", Role::Assistant => "assistant", - Role::System => continue, // Skip system role items }; let parts: Vec = content diff --git a/crates/llm-worker/src/llm_client/scheme/gemini/request.rs b/crates/llm-worker/src/llm_client/scheme/gemini/request.rs index 4add2841..c83ad593 100644 --- a/crates/llm-worker/src/llm_client/scheme/gemini/request.rs +++ b/crates/llm-worker/src/llm_client/scheme/gemini/request.rs @@ -216,9 +216,8 @@ impl GeminiScheme { ); let gemini_role = match role { - Role::User => "user", + Role::User | Role::System => "user", Role::Assistant => "model", - Role::System => continue, // Skip system role items }; let parts: Vec = content diff --git a/crates/llm-worker/src/llm_client/types.rs b/crates/llm-worker/src/llm_client/types.rs index 3450df5e..412958ac 100644 --- a/crates/llm-worker/src/llm_client/types.rs +++ b/crates/llm-worker/src/llm_client/types.rs @@ -99,6 +99,20 @@ impl Item { // Message constructors // ======================================================================== + /// Create a system message item with text content. + /// + /// System items in history are sent as `role: "system"` on OpenAI, + /// and as `role: "user"` on Anthropic/Gemini (which lack a system + /// role in conversation items). + pub fn system_message(text: impl Into) -> Self { + Self::Message { + id: None, + role: Role::System, + content: vec![ContentPart::Text { text: text.into() }], + status: None, + } + } + /// Create a user message item with text content pub fn user_message(text: impl Into) -> Self { Self::Message { diff --git a/crates/llm-worker/src/worker.rs b/crates/llm-worker/src/worker.rs index ead81ee8..1bb0a52e 100644 --- a/crates/llm-worker/src/worker.rs +++ b/crates/llm-worker/src/worker.rs @@ -302,6 +302,11 @@ impl Worker { &mut self.timeline } + /// Get a reference to the LLM client. + pub fn client(&self) -> &C { + &self.client + } + /// Get a reference to the history pub fn history(&self) -> &[Item] { &self.history diff --git a/crates/llm-worker/tests/common/mod.rs b/crates/llm-worker/tests/common/mod.rs index 8872ff52..610e4ba2 100644 --- a/crates/llm-worker/tests/common/mod.rs +++ b/crates/llm-worker/tests/common/mod.rs @@ -45,6 +45,10 @@ impl MockLlmClient { #[async_trait] impl LlmClient for MockLlmClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, _request: Request, diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 717597d8..033eabfb 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -18,6 +18,8 @@ pub struct PodManifest { pub worker: WorkerManifest, #[serde(default)] pub scope: Option, + #[serde(default)] + pub compaction: Option, } /// Pod metadata. @@ -83,6 +85,50 @@ pub struct ScopeConfig { pub root: PathBuf, } +/// Context compaction configuration. +/// +/// Controls Prune (content removal from old tool results) and Compact +/// (full history summarisation). Omitting `[compaction]` disables both. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompactionConfig { + /// Number of recent turns protected from pruning. + #[serde(default = "default_prune_protected_turns")] + pub prune_protected_turns: usize, + + /// Minimum estimated token savings to trigger a prune. + #[serde(default = "default_prune_min_savings")] + pub prune_min_savings: usize, + + /// When `input_tokens` exceeds this, run compact. `None` = compact disabled. + #[serde(default)] + pub compact_threshold: Option, + + /// Number of recent turns retained after compaction. + #[serde(default = "default_compact_retained_turns")] + pub compact_retained_turns: usize, + + /// Optional provider for the compactor (summary) LLM. + /// If omitted, the main provider is cloned via `clone_boxed()`. + #[serde(default)] + pub provider: Option, +} + +fn default_prune_protected_turns() -> usize { 3 } +fn default_prune_min_savings() -> usize { 4096 } +fn default_compact_retained_turns() -> usize { 2 } + +impl Default for CompactionConfig { + fn default() -> Self { + Self { + prune_protected_turns: default_prune_protected_turns(), + prune_min_savings: default_prune_min_savings(), + compact_threshold: None, + compact_retained_turns: default_compact_retained_turns(), + provider: None, + } + } +} + impl PodManifest { /// Parse a manifest from a TOML string. pub fn from_toml(s: &str) -> Result { @@ -218,6 +264,71 @@ max_turns = 0 assert!(PodManifest::from_toml(toml).is_err()); } + #[test] + fn parse_compaction_config() { + let toml = r#" +[pod] +name = "test" + +[provider] +kind = "anthropic" +model = "claude-sonnet-4-20250514" + +[worker] + +[compaction] +compact_threshold = 80000 +"#; + let manifest = PodManifest::from_toml(toml).unwrap(); + let c = manifest.compaction.unwrap(); + assert_eq!(c.prune_protected_turns, 3); + assert_eq!(c.prune_min_savings, 4096); + assert_eq!(c.compact_threshold, Some(80000)); + assert_eq!(c.compact_retained_turns, 2); + } + + #[test] + fn parse_compaction_with_provider() { + let toml = r#" +[pod] +name = "test" + +[provider] +kind = "anthropic" +model = "claude-sonnet-4-20250514" + +[worker] + +[compaction] +compact_threshold = 80000 + +[compaction.provider] +kind = "gemini" +model = "gemini-2.0-flash" +"#; + let manifest = PodManifest::from_toml(toml).unwrap(); + let c = manifest.compaction.unwrap(); + let p = c.provider.unwrap(); + assert_eq!(p.kind, ProviderKind::Gemini); + assert_eq!(p.model, "gemini-2.0-flash"); + } + + #[test] + fn omitted_compaction_is_none() { + let toml = r#" +[pod] +name = "test" + +[provider] +kind = "anthropic" +model = "claude-sonnet-4-20250514" + +[worker] +"#; + let manifest = PodManifest::from_toml(toml).unwrap(); + assert!(manifest.compaction.is_none()); + } + #[test] fn reject_unknown_provider() { let toml = r#" diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 9d377aeb..d28cff62 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; +use llm_worker::Item; use llm_worker::llm_client::client::LlmClient; use llm_worker::llm_client::RequestConfig; use llm_worker::state::Mutable; @@ -17,6 +18,20 @@ use crate::hook::{ }; use crate::hook_interceptor::HookInterceptor; +const SUMMARY_SYSTEM_PROMPT: &str = "\ +You are a context compaction assistant. \ +Summarise the conversation below into a structured summary. \ +Preserve concrete details: file paths, function names, error messages, decisions made. \ +Use the following format:\n\n\ +## Original Task\n\ +(the user's original request)\n\n\ +## Completed Work\n\ +- (what was done, with specifics)\n\n\ +## Key Discoveries\n\ +- (facts, constraints, errors found)\n\n\ +## Current State\n\ +- (files changed, remaining work)"; + /// An independent agent execution unit. /// /// Holds a [`Worker`] directly and persists session state via @@ -31,6 +46,8 @@ pub struct Pod { scope: Option, hook_builder: HookRegistryBuilder, interceptor_installed: bool, + /// Directory containing the manifest file (needed for api_key_file resolution). + manifest_dir: Option, } impl Pod { @@ -56,6 +73,7 @@ impl Pod { scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, + manifest_dir: None, }) } @@ -86,6 +104,7 @@ impl Pod { scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, + manifest_dir: None, }) } @@ -285,6 +304,112 @@ impl Pod { Ok(()) } + + /// Compact the current session by summarising history via a + /// disposable Worker, then replacing history with + /// `[summary, ...recent_turns]` and creating a new session. + /// + /// The summary Worker uses: + /// - `compaction.provider` from the manifest if configured, or + /// - a clone of the main LlmClient via `clone_boxed()`. + /// + /// Returns the new session ID. + pub async fn compact( + &mut self, + retained_turns: usize, + ) -> Result { + let worker = self.worker.as_ref().expect("worker taken during run"); + let history = worker.history(); + + // Identify turn boundaries (user message positions). + let turn_starts: Vec = history + .iter() + .enumerate() + .filter(|(_, item)| item.is_user_message()) + .map(|(i, _)| i) + .collect(); + + // Items to retain: everything from `retained_turns` turns ago onward. + let retain_from = if turn_starts.len() > retained_turns { + turn_starts[turn_starts.len() - retained_turns] + } else { + 0 + }; + let retained_items = history[retain_from..].to_vec(); + let items_to_summarise = &history[..retain_from]; + + // Build summary prompt. + let summary_prompt = build_summary_prompt(items_to_summarise); + + // Create a disposable summary Worker. + let summary_client: Box = self.build_compactor_client()?; + let mut summary_worker = Worker::new(summary_client) + .system_prompt(SUMMARY_SYSTEM_PROMPT) + .temperature(0.0); + summary_worker.set_max_tokens(2048); + + let out = summary_worker.run(summary_prompt).await + .map_err(PodError::Worker)?; + let summary_text = out.worker + .history() + .iter() + .filter_map(|item| { + if item.is_assistant_message() { item.as_text().map(String::from) } else { None } + }) + .collect::>() + .join("\n"); + + // Build new history: [summary as user message, ...retained]. + let mut new_history = Vec::with_capacity(retained_items.len() + 1); + new_history.push(Item::system_message(format!( + "[Compacted context summary]\n\n{summary_text}" + ))); + new_history.extend(retained_items); + + // Persist as a new compacted session. + let old_session_id = self.session_id; + let old_head_hash = self.head_hash.clone() + .expect("head_hash should be set after at least one entry"); + + let w = self.worker.as_ref().unwrap(); + let state = SessionStartState { + system_prompt: w.get_system_prompt(), + config: w.request_config(), + history: &new_history, + }; + let (new_session_id, new_head_hash) = session_store::create_compacted_session( + &self.store, + state, + old_session_id, + old_head_hash, + ) + .await?; + + // Swap in the new session state. + self.session_id = new_session_id; + self.head_hash = Some(new_head_hash); + self.worker.as_mut().unwrap().set_history(new_history); + + Ok(new_session_id) + } + + /// Build the LlmClient for the compactor Worker. + /// + /// Uses `compaction.provider` from manifest if set, otherwise clones + /// the main client. + fn build_compactor_client(&self) -> Result, PodError> { + if let Some(ref compaction) = self.manifest.compaction { + if let Some(ref provider_config) = compaction.provider { + let client = provider::build_client( + provider_config, + self.manifest_dir.as_deref().map(|p| p.as_ref()), + )?; + return Ok(client); + } + } + let worker = self.worker.as_ref().expect("worker taken during run"); + Ok(worker.client().clone_boxed()) + } } impl Pod, St> { @@ -314,8 +439,10 @@ impl Pod, St> { scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, + manifest_dir, }) } + } /// Apply worker-level manifest settings to a Worker. @@ -355,6 +482,37 @@ impl From for PodRunResult { } } +/// Format conversation items into a text prompt for the summary Worker. +fn build_summary_prompt(items: &[Item]) -> String { + let mut lines = Vec::new(); + for item in items { + match item { + Item::Message { role, content, .. } => { + let role_label = match role { + llm_worker::Role::User => "User", + llm_worker::Role::Assistant => "Assistant", + llm_worker::Role::System => "System", + }; + let text: String = content.iter().map(|p| p.as_text()).collect::>().join(""); + lines.push(format!("[{role_label}] {text}")); + } + Item::ToolCall { name, arguments, .. } => { + lines.push(format!("[ToolCall] {name}({arguments})")); + } + Item::ToolResult { summary, content, .. } => { + match content { + Some(c) => lines.push(format!("[ToolResult] {summary}\n{c}")), + None => lines.push(format!("[ToolResult] {summary}")), + } + } + Item::Reasoning { text, .. } => { + lines.push(format!("[Reasoning] {text}")); + } + } + } + lines.join("\n\n") +} + /// Pod errors. #[derive(Debug, thiserror::Error)] pub enum PodError { diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index ab89b98e..9d810adb 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -34,6 +34,10 @@ impl MockClient { #[async_trait] impl LlmClient for MockClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, _request: Request, diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index 84dd67e5..3775b50c 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -35,12 +35,13 @@ pub mod store; pub use event_trace::TraceEntry; pub use fs_store::FsStore; pub use session::{ - SessionStartState, create_session, ensure_head_or_fork, fork, fork_at, restore, save_cache_locked, - save_cache_unlocked, save_config_changed, save_delta, save_outcome, save_turn_end, + SessionStartState, create_compacted_session, create_session, ensure_head_or_fork, fork, fork_at, + restore, save_cache_locked, save_cache_unlocked, save_config_changed, save_delta, save_outcome, + save_turn_end, }; pub use session_log::{ - EntryHash, HashedEntry, LogEntry, Outcome, RestoredState, build_chain, collect_state, - compute_hash, + EntryHash, HashedEntry, LogEntry, Outcome, RestoredState, SessionOrigin, build_chain, + collect_state, compute_hash, }; pub use store::{Store, StoreError}; diff --git a/crates/session-store/src/session.rs b/crates/session-store/src/session.rs index dc7ee987..dfd1977b 100644 --- a/crates/session-store/src/session.rs +++ b/crates/session-store/src/session.rs @@ -4,7 +4,7 @@ //! The caller (typically Pod) holds the Worker directly and calls these //! functions after state-mutating operations. -use crate::session_log::{self, EntryHash, HashedEntry, LogEntry, Outcome}; +use crate::session_log::{self, EntryHash, HashedEntry, LogEntry, Outcome, SessionOrigin}; use crate::store::{Store, StoreError}; use crate::SessionId; use llm_worker::llm_client::types::Item; @@ -30,6 +30,40 @@ pub async fn create_session( system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), history: state.history.to_vec(), + forked_from: None, + compacted_from: None, + }; + let hash = session_log::compute_hash(None, &entry); + let hashed_entry = HashedEntry { + hash: hash.clone(), + prev_hash: None, + entry, + }; + store.append(session_id, &hashed_entry).await?; + Ok((session_id, hash)) +} + +/// Create a compacted session from an existing one. +/// +/// Records `compacted_from` provenance linking back to the source session. +/// Returns the new session ID and head hash. +pub async fn create_compacted_session( + store: &impl Store, + state: SessionStartState<'_>, + source_session_id: SessionId, + source_head_hash: EntryHash, +) -> Result<(SessionId, EntryHash), StoreError> { + let session_id = crate::new_session_id(); + let entry = LogEntry::SessionStart { + ts: session_log::now_millis(), + system_prompt: state.system_prompt.map(String::from), + config: state.config.clone(), + history: state.history.to_vec(), + forked_from: None, + compacted_from: Some(SessionOrigin { + session_id: source_session_id, + at_hash: source_head_hash, + }), }; let hash = session_log::compute_hash(None, &entry); let hashed_entry = HashedEntry { @@ -73,6 +107,8 @@ pub async fn ensure_head_or_fork( system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), history: state.history.to_vec(), + forked_from: None, + compacted_from: None, }; let hash = session_log::compute_hash(None, &entry); let hashed_entry = HashedEntry { @@ -229,6 +265,8 @@ pub async fn fork( system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), history: state.history.to_vec(), + forked_from: None, + compacted_from: None, }; let hash = session_log::compute_hash(None, &entry); let hashed_entry = HashedEntry { @@ -260,6 +298,11 @@ pub async fn fork_at( system_prompt: state.system_prompt, config: state.config, history: state.history, + forked_from: Some(session_log::SessionOrigin { + session_id: source_id, + at_hash: at_hash.clone(), + }), + compacted_from: None, }; let hash = session_log::compute_hash(None, &entry); let hashed_entry = HashedEntry { diff --git a/crates/session-store/src/session_log.rs b/crates/session-store/src/session_log.rs index 476ab316..f1f9fe89 100644 --- a/crates/session-store/src/session_log.rs +++ b/crates/session-store/src/session_log.rs @@ -101,6 +101,12 @@ pub enum LogEntry { system_prompt: Option, config: RequestConfig, history: Vec, + /// Origin: forked from another session at a specific entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + forked_from: Option, + /// Origin: compacted from another session at a specific entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + compacted_from: Option, }, /// User input pushed to history (worker.rs:229). @@ -137,6 +143,15 @@ pub enum LogEntry { ConfigChanged { ts: u64, config: RequestConfig }, } +/// Provenance reference to a parent session. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionOrigin { + /// Session ID of the source session. + pub session_id: crate::SessionId, + /// Hash of the entry in the source session at the point of fork/compact. + pub at_hash: EntryHash, +} + /// Outcome of a run/resume call. Metadata for auditing only. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -269,6 +284,8 @@ mod tests { system_prompt: Some("You are helpful.".into()), config: RequestConfig::default().with_max_tokens(1024), history: vec![Item::user_message("seed")], + forked_from: None, + compacted_from: None, }]); let state = collect_state(&entries); assert_eq!(state.system_prompt.as_deref(), Some("You are helpful.")); @@ -285,6 +302,8 @@ mod tests { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }, LogEntry::UserInput { ts: 2000, @@ -318,6 +337,8 @@ mod tests { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }, LogEntry::UserInput { ts: 2000, @@ -354,6 +375,8 @@ mod tests { system_prompt: None, config: RequestConfig::default(), history: vec![Item::user_message("a"), Item::assistant_message("b")], + forked_from: None, + compacted_from: None, }, LogEntry::Locked { ts: 2000, @@ -377,6 +400,8 @@ mod tests { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }, LogEntry::ConfigChanged { ts: 2000, @@ -395,6 +420,8 @@ mod tests { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }, LogEntry::UserInput { ts: 2000, @@ -429,6 +456,8 @@ mod tests { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }; let hash = compute_hash(None, &entry); let hex = hash.to_hex(); diff --git a/crates/session-store/tests/common/mod.rs b/crates/session-store/tests/common/mod.rs index 1239a067..032cbea4 100644 --- a/crates/session-store/tests/common/mod.rs +++ b/crates/session-store/tests/common/mod.rs @@ -31,6 +31,10 @@ impl MockLlmClient { #[async_trait] impl LlmClient for MockLlmClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( &self, _request: Request, diff --git a/crates/session-store/tests/fs_store_test.rs b/crates/session-store/tests/fs_store_test.rs index 32c74afa..0e2680ed 100644 --- a/crates/session-store/tests/fs_store_test.rs +++ b/crates/session-store/tests/fs_store_test.rs @@ -15,6 +15,8 @@ async fn round_trip_write_and_read() { system_prompt: Some("You are helpful.".into()), config: RequestConfig::default().with_max_tokens(1024), history: vec![], + forked_from: None, + compacted_from: None, }, LogEntry::UserInput { ts: 2000, @@ -72,6 +74,8 @@ async fn create_session_writes_all_entries() { system_prompt: None, config: RequestConfig::default(), history: vec![Item::user_message("seed"), Item::assistant_message("ok")], + forked_from: None, + compacted_from: None, }]); store.create_session(id, &entries).await.unwrap(); @@ -97,12 +101,16 @@ async fn list_sessions_returns_newest_first() { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }]); let entries2 = build_chain(&[LogEntry::SessionStart { ts: 1001, system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }]); store.append(id1, &entries1[0]).await.unwrap(); @@ -127,6 +135,8 @@ async fn exists_returns_correct_state() { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }]); store.append(id, &entries[0]).await.unwrap(); @@ -155,6 +165,8 @@ async fn trace_entries_in_separate_file() { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }]); store.append(id, &entries[0]).await.unwrap(); @@ -189,6 +201,8 @@ async fn read_head_hash_returns_last_entry_hash() { system_prompt: None, config: RequestConfig::default(), history: vec![], + forked_from: None, + compacted_from: None, }, LogEntry::UserInput { ts: 2000, diff --git a/tickets/context-compaction.md b/tickets/context-compaction.md index 1031bda4..75d2f232 100644 --- a/tickets/context-compaction.md +++ b/tickets/context-compaction.md @@ -320,22 +320,25 @@ pub struct CompactionConfig { ## 実装順序 -1. **ToolOutput 再設計** — enum → struct(summary + content)。Item::ToolResult の変更。単体テスト -2. **旧モジュール削除** — BlobStore, BlobOutputProcessor, inspect_tool, ToolOutputProcessor, Content, auto_summarize。Worker から output_processor 除去 -3. **`prune.rs`** — 条件付き Prune アルゴリズム。単体テスト -4. **`PruneHook`** — Pod に Hook 実装 -5. **`CompactionConfig`** — manifest にセクション追加 -6. **`LogEntry` に provenance フィールド追加** — SessionStart に `compacted_from` / `forked_from` -7. **`compact()` 関数** — Pod に compaction ロジック + サーキットブレーカー -8. **Protocol** — `CompactionStart` / `CompactionDone` イベント追加 +1. ~~**ToolOutput 再設計**~~ — 実装済み +2. ~~**旧モジュール削除**~~ — 実装済み +3. ~~**`prune.rs`**~~ — 実装済み(`crates/llm-worker/src/prune.rs`) +4. ~~**`PruneHook`**~~ — 実装済み(`crates/pod/src/prune_hook.rs`) +5. ~~**`CompactionConfig`**~~ — 実装済み(`manifest::CompactionConfig`) +6. ~~**`LogEntry` に provenance フィールド追加**~~ — 実装済み(`SessionOrigin`, `forked_from`, `compacted_from`) +7. ~~**`compact()` 関数**~~ — 実装済み(`Pod::compact()`)。サーキットブレーカーは Controller 統合時に追加 +8. **Protocol イベント** — 保留(Controller 統合時に必要に応じて追加) -ステップ 1-2 は ToolOutput 移行として独立実行可能。 -ステップ 3-4(Prune)と 5-6(Compact 準備)は並行可能。 -ステップ 5-8 は session-store-extraction 完了後に実装。 +### 残作業 + +- Controller への統合: run 完了後に `input_tokens > threshold` をチェックし `pod.compact()` を呼ぶ +- サーキットブレーカー(consecutive failures カウンタ) +- Thrash loop 検出(compact 直後に再び閾値超過 → エラー停止) +- 要約プロンプトの調整(実運用でのチューニング) --- ## 依存チケット - ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了 -- [session-store-extraction.md](session-store-extraction.md) — ステップ 5-8 の前提 +- ~~[session-store-extraction.md](session-store-extraction.md)~~ — 完了