compactの実装

This commit is contained in:
Keisuke Hirata 2026-04-12 07:09:48 +09:00
parent 47c59a416e
commit bcc7faa0ba
19 changed files with 439 additions and 21 deletions

View File

@ -54,6 +54,12 @@ pub trait LlmClient: Send + Sync {
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError>;
/// Clone this client into a new `Box<dyn LlmClient>`.
///
/// 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<dyn LlmClient>;
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
///
/// # Arguments
@ -80,6 +86,10 @@ impl LlmClient for Box<dyn LlmClient> {
(**self).stream(request).await
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
(**self).clone_boxed()
}
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
(**self).validate_config(config)
}

View File

@ -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<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,

View File

@ -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<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,

View File

@ -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<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,

View File

@ -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<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,

View File

@ -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<AnthropicContentPart> = content

View File

@ -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<GeminiPart> = content

View File

@ -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<String>) -> 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<String>) -> Self {
Self::Message {

View File

@ -302,6 +302,11 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
&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

View File

@ -45,6 +45,10 @@ impl MockLlmClient {
#[async_trait]
impl LlmClient for MockLlmClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,

View File

@ -18,6 +18,8 @@ pub struct PodManifest {
pub worker: WorkerManifest,
#[serde(default)]
pub scope: Option<ScopeConfig>,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
}
/// 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<u64>,
/// 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<ProviderConfig>,
}
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<Self, toml::de::Error> {
@ -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#"

View File

@ -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<C: LlmClient, St: Store> {
scope: Option<Scope>,
hook_builder: HookRegistryBuilder,
interceptor_installed: bool,
/// Directory containing the manifest file (needed for api_key_file resolution).
manifest_dir: Option<PathBuf>,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@ -56,6 +73,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
manifest_dir: None,
})
}
@ -86,6 +104,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
manifest_dir: None,
})
}
@ -285,6 +304,112 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
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<SessionId, PodError> {
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<usize> = 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<dyn LlmClient> = 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::<Vec<_>>()
.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<Box<dyn LlmClient>, 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<St: Store> Pod<Box<dyn LlmClient>, St> {
@ -314,8 +439,10 @@ impl<St: Store> Pod<Box<dyn LlmClient>, 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<WorkerResult> 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::<Vec<_>>().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 {

View File

@ -34,6 +34,10 @@ impl MockClient {
#[async_trait]
impl LlmClient for MockClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,

View File

@ -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};

View File

@ -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 {

View File

@ -101,6 +101,12 @@ pub enum LogEntry {
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<Item>,
/// Origin: forked from another session at a specific entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SessionOrigin>,
/// Origin: compacted from another session at a specific entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SessionOrigin>,
},
/// 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();

View File

@ -31,6 +31,10 @@ impl MockLlmClient {
#[async_trait]
impl LlmClient for MockLlmClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,

View File

@ -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,

View File

@ -320,22 +320,25 @@ pub struct CompactionConfig {
## 実装順序
1. **ToolOutput 再設計** — enum → structsummary + 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-4Pruneと 5-6Compact 準備)は並行可能。
ステップ 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)~~ — 完了