compactの実装
This commit is contained in:
parent
47c59a416e
commit
bcc7faa0ba
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)~~ — 完了
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user