M6: auto-compaction

Adds harness-core::engine::compact, a Compactor triggered when a session approaches its context limit, and threads the overflow trigger through the engine loop so a session driven past the limit compacts and continues correctly.
This commit is contained in:
2026-07-10 16:20:30 +02:00
parent dbc676332a
commit 5fd1698d47
6 changed files with 520 additions and 17 deletions
+1
View File
@@ -12,6 +12,7 @@ harness-mcp = { workspace = true }
harness-lsp = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
dirs = { workspace = true }
thiserror = { workspace = true }
+73 -1
View File
@@ -14,8 +14,9 @@ use async_trait::async_trait;
use harness_core::agent::{AgentDef, AgentRegistry};
use harness_core::config::{self, Config, ConfigError};
use harness_core::engine::jobs::{JobBoard, JobState, LaunchSpec};
use harness_core::engine::{run_session, RunConfig, StepContext};
use harness_core::engine::{run_session, Compactor, RunConfig, StepContext};
use harness_core::event::{AppEvent, EventBus, RunOutcome};
use harness_core::llm::{Initiator, LlmEvent, LlmRequest, Provider, ProviderError, WireMessage};
use harness_core::lsp::DiagnosticsSource;
use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset};
use harness_core::store::{Store, StoreError};
@@ -164,6 +165,9 @@ struct EngineInner {
skills_prompt: Option<String>,
/// Slash commands by name, expanded into the user message by the TUI input layer.
commands: HashMap<String, config::CommandDef>,
/// Auto-compaction backend (small model). `None` when no `small_model` is configured or
/// its provider isn't registered — compaction is then disabled.
compactor: Option<Arc<dyn Compactor>>,
cwd: PathBuf,
data_dir: PathBuf,
runs: Mutex<HashMap<SessionId, RunHandle>>,
@@ -255,6 +259,19 @@ impl EngineHandle {
// `init` warms the cache in the background for the next launch.
let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path());
// Auto-compaction backend: the configured `small_model` (provider/model) if its provider
// is registered. `None` disables compaction (the engine then runs with full history).
let compactor: Option<Arc<dyn Compactor>> = config
.small_model
.as_deref()
.and_then(|m| m.split_once('/'))
.and_then(|(provider_id, model_id)| {
providers
.get(provider_id)
.map(|provider| SmallModelCompactor::new(provider, model_id.to_string()))
})
.map(|c| Arc::new(c) as Arc<dyn Compactor>);
// LSP pool: built-ins present on PATH, plus any config-declared servers.
let lsp_servers: Vec<harness_lsp::ServerConfig> = config
.lsp
@@ -282,6 +299,7 @@ impl EngineHandle {
diagnostics,
skills_prompt,
commands,
compactor,
cwd,
data_dir,
runs: Mutex::new(HashMap::new()),
@@ -427,6 +445,7 @@ impl EngineHandle {
job_board: Some(board),
context_reporter: None, // root session has no parent board to report to
diagnostics: self.inner.diagnostics.clone(),
compactor: self.inner.compactor.clone(),
};
let run_config = RunConfig {
agent_name: agent.name.clone(),
@@ -440,6 +459,12 @@ impl EngineHandle {
reminder_turn_start: self.inner.reminder("turn_start"),
reminder_after_file_tool: self.inner.reminder("after_file_tool"),
skills_prompt: self.inner.skills_prompt.clone(),
context_limit: self
.inner
.catalog
.get(provider_id, model_id)
.map(|i| i.context_limit)
.unwrap_or(0),
};
// Reserve the run slot *before* spawning. If we inserted after spawning, a run that
@@ -734,6 +759,7 @@ impl EngineInner {
job_board: Some(board),
context_reporter: reporter,
diagnostics: self.diagnostics.clone(),
compactor: self.compactor.clone(),
}
}
}
@@ -754,6 +780,44 @@ impl ContextReporter for BoardReporter {
}
}
/// Auto-compaction backend: summarizes a conversation with the configured `small_model`.
struct SmallModelCompactor {
provider: Arc<dyn Provider>,
model_id: String,
}
impl SmallModelCompactor {
fn new(provider: Arc<dyn Provider>, model_id: String) -> Self {
Self { provider, model_id }
}
}
#[async_trait]
impl Compactor for SmallModelCompactor {
async fn summarize(&self, messages: &[WireMessage]) -> Result<String, ProviderError> {
use futures::StreamExt;
let req = LlmRequest {
model: self.model_id.clone(),
system: vec![harness_core::engine::compact::SUMMARY_SYSTEM_PROMPT.to_string()],
messages: messages.to_vec(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
reasoning: None,
initiator: Initiator::Agent,
};
let mut stream = self.provider.stream(req, CancellationToken::new()).await?;
let mut summary = String::new();
while let Some(event) = stream.next().await {
if let LlmEvent::TextDelta { text, .. } = event? {
summary.push_str(&text);
}
}
Ok(summary)
}
}
#[async_trait]
impl SubagentSpawner for EngineInner {
async fn spawn(&self, req: SpawnRequest) -> Result<SpawnOutcome, SpawnError> {
@@ -853,6 +917,14 @@ impl SubagentSpawner for EngineInner {
reminder_turn_start: self.reminder("turn_start"),
reminder_after_file_tool: self.reminder("after_file_tool"),
skills_prompt: self.skills_prompt.clone(),
context_limit: self
.catalog
.get(
&child_session.model.provider_id,
&child_session.model.model_id,
)
.map(|i| i.context_limit)
.unwrap_or(0),
};
// Register the launch on the parent board (also makes foreground results reusable).