agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call) and the ChatSessionHandler. Everything that shaped a Value — projection, recovery, compaction mechanics, the LLM loop, message building — lives in agent-loop or behind a loop_adapters trait. agent-loop: - projection/ (mod + media): stored history -> wire messages, the one place provider divergence lives; well-formedness contract, DTL injections (append-only), media parts. LinearAssembler is now a Projection + ProjectionHooks config, not its own implementation - recovery.rs: reap interrupted batches -> resolve the deepest frame's non-terminal calls (Running by policy + RestartHint, AwaitingHuman re-asked) -> un-wedge finished children -> cascade up, every frame on its own agent (B3) - compaction.rs: split point (never assistant+tool group), transcript, SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row - manager: resolve_pending (gate skipped, real ToolContext, then continue incl. sub-agent); start_loop used by recovery; LiveInput - delegate: AsyncExecutor + StoreSink for mode:async (durable cron row, result delivered back into the parent conversation) - kernel/context/store: support the above (TurnScope via Extensions, frame lookups, aligned result-text semantics) skald-core: - loop_adapters: UserLoopRuntime (D12 - one LoopManager per user), TurnScope (per-turn state in the Extensions type-map; no scope is denied), projection_cfg/media_source/tool_digest (Skald's projection knobs without owning projection code), async_task (CronExecutor + DurableSink) - session/handler: stripped to mod.rs + kernel_turn.rs + config.rs + interface_tools.rs + media.rs; deleted agent_dispatch, approval, dispatch, emitter, gate, llm_call, llm_loop, message_builder, messages, outcome, resume - compactor.rs: policy only (threshold, model pick, CompactionEvent); mechanics are the crate's CLAUDE.md updated (recovery, compaction, sub-agents, approval gate, projection sections now describe the crate-owned flow).
This commit is contained in:
@@ -1,61 +1,52 @@
|
||||
//! Context compaction — reduces LLM context size by summarising old messages.
|
||||
//!
|
||||
//! # Responsibility
|
||||
//! [`ContextCompactor`] is a stateless service (all state lives in the DB).
|
||||
//! It is shared via `Arc` across all [`ChatSessionHandler`]s.
|
||||
//! [`ContextCompactor`] is Skald's **policy**: when to compact (the token
|
||||
//! threshold, the ephemeral guard), which model summarises, and telling the
|
||||
//! rest of the app it happened. The mechanics — split point, transcript,
|
||||
//! prompt, the summariser call, the saved row — are the library's
|
||||
//! (`agent_loop::compaction`), so a compaction is the same operation whether
|
||||
//! Skald or another host triggers it.
|
||||
//!
|
||||
//! It is triggered **at the start of a turn** when the previous turn's
|
||||
//! `input_tokens` exceeds the configured threshold (Opzione C from the design
|
||||
//! doc), or manually via `force_compact`. Ephemeral sessions (cron, tic)
|
||||
//! It is a stateless service (all state lives in the DB), shared via `Arc`
|
||||
//! across every [`ChatSessionHandler`](crate::session::handler). Triggered at
|
||||
//! the **start of a turn** when the previous turn's `input_tokens` exceeded the
|
||||
//! threshold, or manually via `force_compact`. Ephemeral sessions (cron, tic)
|
||||
//! are always skipped.
|
||||
//!
|
||||
//! # Compaction flow
|
||||
//! ```text
|
||||
//! handle_message()
|
||||
//! └─► ContextCompactor::try_compact(pool, stack_id, last_input_tokens)
|
||||
//! │
|
||||
//! ├─ guard: tokens < threshold → return Ok(false)
|
||||
//! ├─ guard: is_ephemeral → return Ok(false)
|
||||
//! │
|
||||
//! └─► do_compact(pool, session_id, stack_id, effective_tokens)
|
||||
//! ├─ load latest summary (if any)
|
||||
//! ├─ load raw messages since last summary boundary
|
||||
//! │ (or all messages if no prior summary)
|
||||
//! ├─ split: to_summarise = messages[0 .. len - keep_recent]
|
||||
//! │ to_keep_raw = messages[len - keep_recent ..]
|
||||
//! ├─ if to_summarise is empty → return Ok(false)
|
||||
//! ├─ build compaction prompt (system hard-coded + user = conversation text)
|
||||
//! ├─ call LLM (no tools, strength-based AUTO selection)
|
||||
//! ├─ save summary to chat_summaries
|
||||
//! └─ publish BusEvent::CompactionDone
|
||||
//!
|
||||
//! force_compact() skips the threshold guard and calls do_compact() directly.
|
||||
//! └─► ContextCompactor::try_compact(manager, …, last_input_tokens)
|
||||
//! ├─ guard: is_ephemeral → Ok(false)
|
||||
//! ├─ guard: tokens (or estimate) < threshold → Ok(false)
|
||||
//! └─► manager.new_compaction(conv, frame).run()
|
||||
//! ├─ split at the keep_recent boundary, on a user/agent message
|
||||
//! ├─ summarise (one call, no tools)
|
||||
//! ├─ save the summary row
|
||||
//! └─ hooks.on_compacted → DTL re-anchor (loop_adapters::hooks)
|
||||
//! ```
|
||||
//!
|
||||
//! # build_openai_messages after compaction
|
||||
//! ```text
|
||||
//! latest_summary = chat_summaries::latest_for_stack(pool, stack_id)
|
||||
//! if let Some(s) = latest_summary:
|
||||
//! inject <summary>…</summary> after system prompt
|
||||
//! load messages with id > s.covers_up_to_message_id
|
||||
//! else:
|
||||
//! load all messages (current behaviour)
|
||||
//! apply max_history_messages drain as safety floor (only when compaction is disabled)
|
||||
//! ```
|
||||
//! The next turn needs nothing from this: the assembler reads the latest
|
||||
//! summary from the store and projects it in front of the surviving messages.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::compaction::{CompactionMode, should_compact};
|
||||
use agent_loop::manager::LoopManager;
|
||||
use agent_loop::model::ModelHint;
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use core_api::{ConfigProperty, ConfigSet, PropertyType};
|
||||
|
||||
use crate::chat_event_bus::{ChatEventBus, CompactionEvent};
|
||||
use crate::config::CompactionConfig;
|
||||
use crate::config_store::GlobalConfigManager;
|
||||
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
|
||||
use crate::db::chat_history;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::selector::SkaldSelector;
|
||||
|
||||
/// Registry `config` key holding the name of the LLM model to use for
|
||||
/// compaction summaries. Set from the Settings page (instance-wide); empty /
|
||||
@@ -82,105 +73,14 @@ pub fn config_set() -> ConfigSet {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compaction constants (ported from Hermes context_compressor.py) ──────────
|
||||
//
|
||||
// SUMMARY_PREFIX — prepended to every stored summary when injected as context.
|
||||
// Tells the LLM this is historical reference, not live instructions.
|
||||
// SUMMARIZER_PREAMBLE — system/user-message preamble for the summarisation LLM call.
|
||||
// SUMMARY_TEMPLATE — structured section template the LLM must follow.
|
||||
// ── The summariser's wording ─────────────────────────────────────────────────
|
||||
|
||||
/// Prefix prepended to the summary content when it is injected into the
|
||||
/// message array as context for the main agent. Exposed as `pub` so that
|
||||
/// `build_openai_messages` can use the same wording.
|
||||
pub const SUMMARY_PREFIX: &str = "\
|
||||
[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \
|
||||
into the summary below. This is a handoff from a previous context \
|
||||
window — treat it as background reference, NOT as active instructions. \
|
||||
Do NOT answer questions or fulfill requests mentioned in this summary; \
|
||||
they were already addressed. \
|
||||
Your current task is identified in the '## Active Task' section of the \
|
||||
summary — resume exactly from there. \
|
||||
Your system prompt and any injected memory files are ALWAYS authoritative \
|
||||
— never deprioritize them due to this compaction note. \
|
||||
Respond ONLY to the latest user message that appears AFTER this summary. \
|
||||
The current session state (files, config, etc.) may reflect work \
|
||||
described here — avoid repeating it:";
|
||||
/// Prefix prepended to a stored summary when it is projected back into the
|
||||
/// context. Re-exported from the library, which owns the wording along with the
|
||||
/// preamble and the section template: the assembler on the other side of the
|
||||
/// projection reads the same constant, so the two can never drift.
|
||||
pub use agent_loop::compaction::SUMMARY_PREFIX;
|
||||
|
||||
/// Preamble shared by both first-compaction and iterative-update prompts.
|
||||
/// Wording is deliberately plain to avoid content-filter false positives.
|
||||
const SUMMARIZER_PREAMBLE: &str = "\
|
||||
You are a summarization agent creating a context checkpoint. \
|
||||
Treat the conversation turns below as source material for a \
|
||||
compact record of prior work. \
|
||||
Produce only the structured summary; do not add a greeting, \
|
||||
preamble, or prefix. \
|
||||
Write the summary in the same language the user was using in the \
|
||||
conversation — do not translate or switch to English. \
|
||||
NEVER include API keys, tokens, passwords, secrets, credentials, \
|
||||
or connection strings in the summary — replace any that appear \
|
||||
with [REDACTED]. Note that the user may have had credentials present, \
|
||||
but do not preserve their values.";
|
||||
|
||||
/// Structured section template the summariser must fill in.
|
||||
const SUMMARY_TEMPLATE: &str = "\
|
||||
## Active Task
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or \
|
||||
task assignment verbatim — the exact words they used. If multiple tasks \
|
||||
were requested and only some are done, list only the ones NOT yet completed. \
|
||||
Continuation should pick up exactly here. Example: \
|
||||
\"User asked: 'Now refactor the auth module to use JWT instead of sessions'\" \
|
||||
If no outstanding task exists, write \"None.\"]
|
||||
|
||||
## Goal
|
||||
[What the user is trying to accomplish overall]
|
||||
|
||||
## Constraints & Preferences
|
||||
[User preferences, coding style, constraints, important decisions]
|
||||
|
||||
## Completed Actions
|
||||
[Numbered list of concrete actions taken — include tool used, target, and outcome.
|
||||
Format each as: N. ACTION target — outcome [tool: name]
|
||||
Example:
|
||||
1. READ config.rs:45 — found == should be != [tool: read_file]
|
||||
2. EDIT config.rs:45 — changed == to != [tool: write_file]
|
||||
3. BUILD `cargo build` — succeeded, 0 errors [tool: execute_cmd]
|
||||
Be specific with file paths, commands, line numbers, and results.]
|
||||
|
||||
## Active State
|
||||
[Current working state — include:
|
||||
- Working directory and branch (if applicable)
|
||||
- Modified/created files with brief note on each
|
||||
- Build/test status
|
||||
- Any running processes or servers
|
||||
- Environment details that matter]
|
||||
|
||||
## In Progress
|
||||
[Work currently underway — what was being done when compaction fired]
|
||||
|
||||
## Blocked
|
||||
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
|
||||
|
||||
## Key Decisions
|
||||
[Important technical decisions and WHY they were made]
|
||||
|
||||
## Resolved Questions
|
||||
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
|
||||
|
||||
## Pending User Asks
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write \"None.\"]
|
||||
|
||||
## Relevant Files
|
||||
[Files read, modified, or created — with brief note on each]
|
||||
|
||||
## Remaining Work
|
||||
[What remains to be done — framed as context, not instructions]
|
||||
|
||||
## Critical Context
|
||||
[Any specific values, error messages, configuration details, or data that would \
|
||||
be lost without explicit preservation. NEVER include API keys, tokens, passwords, \
|
||||
or credentials — write [REDACTED] instead.]
|
||||
|
||||
Write only the summary body. Do not include any preamble or prefix.";
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -211,6 +111,7 @@ impl ContextCompactor {
|
||||
/// Returns `true` if a new summary was written, `false` if skipped.
|
||||
pub async fn try_compact(
|
||||
&self,
|
||||
manager: &Arc<LoopManager>,
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
stack_id: i64,
|
||||
@@ -221,17 +122,12 @@ impl ContextCompactor {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let effective_tokens = if last_input_tokens > 0 {
|
||||
last_input_tokens
|
||||
} else {
|
||||
let est = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
|
||||
debug!(stack_id, estimate = est, "compactor: no usage data, using char estimate");
|
||||
est
|
||||
};
|
||||
|
||||
if effective_tokens < self.config.threshold_tokens {
|
||||
// A provider that reported no usage leaves only the character estimate.
|
||||
let estimated = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
|
||||
if !should_compact(Some(last_input_tokens), estimated, self.config.threshold_tokens) {
|
||||
return Ok(false);
|
||||
}
|
||||
let effective_tokens = if last_input_tokens > 0 { last_input_tokens } else { estimated };
|
||||
|
||||
info!(
|
||||
stack_id,
|
||||
@@ -240,7 +136,7 @@ impl ContextCompactor {
|
||||
"compactor: threshold exceeded, starting compaction"
|
||||
);
|
||||
|
||||
self.do_compact(pool, session_id, stack_id, effective_tokens).await
|
||||
self.do_compact(manager, session_id, stack_id, effective_tokens).await
|
||||
}
|
||||
|
||||
/// Force compaction regardless of the token threshold.
|
||||
@@ -249,6 +145,7 @@ impl ContextCompactor {
|
||||
/// Returns `true` if a new summary was written, `false` if skipped.
|
||||
pub async fn force_compact(
|
||||
&self,
|
||||
manager: &Arc<LoopManager>,
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
stack_id: i64,
|
||||
@@ -265,309 +162,70 @@ impl ContextCompactor {
|
||||
"compactor: manual compaction triggered"
|
||||
);
|
||||
|
||||
self.do_compact(pool, session_id, stack_id, effective_tokens).await
|
||||
self.do_compact(manager, session_id, stack_id, effective_tokens).await
|
||||
}
|
||||
|
||||
/// Core compaction logic shared by `try_compact` and `force_compact`.
|
||||
/// Loads messages, splits at the keep_recent boundary, calls the summariser
|
||||
/// LLM, persists the summary, and publishes a `CompactionDone` event.
|
||||
/// Runs the library's compaction on the frame with Skald's model policy,
|
||||
/// then publishes the result on the app's event bus.
|
||||
///
|
||||
/// Model: the instance-wide Settings pick (`compaction_model`) wins; empty,
|
||||
/// unset, or naming a model that no longer exists all degrade to AUTO
|
||||
/// selection by `compaction.strength` from config.yml.
|
||||
async fn do_compact(
|
||||
&self,
|
||||
pool: &SqlitePool,
|
||||
manager: &Arc<LoopManager>,
|
||||
session_id: i64,
|
||||
stack_id: i64,
|
||||
effective_tokens: u32,
|
||||
) -> anyhow::Result<bool> {
|
||||
let prior_summary = chat_summaries::latest_for_stack(pool, stack_id).await?;
|
||||
let hint = self.model_hint().await;
|
||||
let conv = SqliteHistory::conversation(session_id);
|
||||
|
||||
let messages = match &prior_summary {
|
||||
Some(s) => chat_history::for_stack_since(pool, stack_id, s.covers_up_to_message_id).await?,
|
||||
None => chat_history::for_stack(pool, stack_id).await?,
|
||||
};
|
||||
|
||||
let keep = self.config.keep_recent;
|
||||
|
||||
if messages.len() <= keep {
|
||||
debug!(
|
||||
stack_id,
|
||||
messages = messages.len(),
|
||||
keep,
|
||||
"compactor: not enough messages to summarise beyond keep_recent, skipping"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let raw_split = messages.len() - keep;
|
||||
let split = (0..=raw_split)
|
||||
.rev()
|
||||
.find(|&i| {
|
||||
i == 0 || matches!(
|
||||
messages[i].role,
|
||||
chat_history::Role::User | chat_history::Role::Agent
|
||||
)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if split == 0 {
|
||||
debug!(stack_id, "compactor: no suitable split point found, skipping");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let to_summarise = &messages[..split];
|
||||
let last_covered_id = to_summarise.last().expect("to_summarise is non-empty").id;
|
||||
|
||||
let conversation_text = self
|
||||
.format_for_summary(pool, to_summarise, prior_summary.as_ref().map(|s| s.content.as_str()))
|
||||
let outcome = manager
|
||||
.new_compaction(conv, agent_loop::ids::FrameId(stack_id))
|
||||
.mode(CompactionMode::Auto { keep_tail: self.config.keep_recent })
|
||||
// Strength is Skald's, captured here (D14): a pin bypasses it.
|
||||
.selector(Arc::new(SkaldSelector::new(
|
||||
Arc::clone(&self.llm_manager),
|
||||
self.config.strength,
|
||||
)))
|
||||
.model(hint)
|
||||
.log(json!({ "session_id": session_id, "stack_id": stack_id }))
|
||||
.run()
|
||||
.await?;
|
||||
|
||||
// Model for the summary call: the instance-wide Settings pick
|
||||
// (`compaction_model`) wins; empty/unset falls back to AUTO selection by
|
||||
// `compaction.strength` from config.yml. A configured model that no
|
||||
// longer exists (renamed/deleted) degrades to the same AUTO path.
|
||||
let configured = self.config_store.get(COMPACTION_MODEL_KEY).await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let (client_name, llm) = match configured {
|
||||
Some(name) => match self.llm_manager.resolve(Some(&name), None).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(model = %name, error = %e, "compactor: configured compaction model unavailable, falling back to AUTO selection");
|
||||
self.llm_manager.resolve(None, self.config.strength).await?
|
||||
}
|
||||
},
|
||||
None => self.llm_manager.resolve(None, self.config.strength).await?,
|
||||
};
|
||||
|
||||
info!(
|
||||
stack_id,
|
||||
client = %client_name,
|
||||
messages_covered = to_summarise.len(),
|
||||
last_covered_id,
|
||||
"compactor: calling LLM for summary"
|
||||
);
|
||||
|
||||
let messages_payload = vec![
|
||||
json!({ "role": "user", "content": conversation_text }),
|
||||
];
|
||||
|
||||
let request = agent_loop::model::ModelRequest {
|
||||
messages: messages_payload,
|
||||
tools: Vec::new(),
|
||||
model: llm.model.clone(),
|
||||
max_tokens: None,
|
||||
temperature: Some(0.3),
|
||||
request_id: uuid::Uuid::new_v4().to_string(),
|
||||
conversation: agent_loop::ids::ConversationId::new(format!("session:{session_id}")),
|
||||
frame: agent_loop::ids::FrameId(stack_id),
|
||||
extras: serde_json::Value::Null,
|
||||
log: Some(json!({ "session_id": session_id, "stack_id": stack_id })),
|
||||
};
|
||||
|
||||
let resp = llm.client.complete(&request, None).await
|
||||
.map_err(|e| {
|
||||
warn!(stack_id, error = %e, "compactor: LLM call failed");
|
||||
e
|
||||
})?;
|
||||
|
||||
let summary_text = match resp {
|
||||
agent_loop::model::ModelResponse::Message { content, .. } => content,
|
||||
agent_loop::model::ModelResponse::ToolCalls { content, .. } => {
|
||||
warn!(stack_id, "compactor: unexpected tool calls in summary response, using content");
|
||||
content
|
||||
}
|
||||
};
|
||||
|
||||
if summary_text.trim().is_empty() {
|
||||
warn!(stack_id, "compactor: LLM returned empty summary, skipping save");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let summary_id = chat_summaries::save(pool, stack_id, &summary_text, last_covered_id).await?;
|
||||
|
||||
// DTL: activations pinned to a message that was just compacted away would
|
||||
// otherwise lose their render position (the Kimi `system`+`tools` block).
|
||||
// Re-anchor them onto the first surviving message. Best-effort — a failure
|
||||
// only means the model may re-activate a tool after compaction.
|
||||
let first_surviving_id = messages[split].id;
|
||||
if let Err(e) = crate::db::activated_tools::reanchor_compacted(
|
||||
pool, stack_id, last_covered_id, first_surviving_id,
|
||||
).await {
|
||||
warn!(stack_id, error = %e, "compactor: failed to re-anchor DTL activations");
|
||||
}
|
||||
|
||||
info!(
|
||||
stack_id,
|
||||
summary_id,
|
||||
last_covered_id,
|
||||
"compactor: summary saved"
|
||||
);
|
||||
let Some(outcome) = outcome else { return Ok(false) };
|
||||
|
||||
self.event_bus.compaction_done(CompactionEvent {
|
||||
session_id,
|
||||
stack_id,
|
||||
summary_id,
|
||||
covers_up_to_message_id: last_covered_id,
|
||||
triggered_by_tokens: effective_tokens,
|
||||
summary_id: outcome.summary_id.get(),
|
||||
covers_up_to_message_id: outcome.covered_up_to.get(),
|
||||
triggered_by_tokens: effective_tokens,
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
/// The summariser's model pin, or `ModelHint::default()` (AUTO) when none is
|
||||
/// configured or the configured one is gone.
|
||||
async fn model_hint(&self) -> ModelHint {
|
||||
let configured = self
|
||||
.config_store
|
||||
.get(COMPACTION_MODEL_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let Some(name) = configured else { return ModelHint::default() };
|
||||
|
||||
/// Builds the full prompt for the summarisation LLM call (Hermes-style).
|
||||
///
|
||||
/// Returns a single string intended to be sent as a `user` message.
|
||||
/// The preamble, conversation transcript, and structured template are all
|
||||
/// concatenated, matching how Hermes' `_generate_summary` works.
|
||||
///
|
||||
/// * First compaction — `prior_summary` is `None`.
|
||||
/// * Subsequent compaction — `prior_summary` contains the previous summary body
|
||||
/// (without `SUMMARY_PREFIX`) so the LLM can produce an updated, non-nested summary.
|
||||
async fn format_for_summary(
|
||||
&self,
|
||||
pool: &SqlitePool,
|
||||
messages: &[chat_history::ChatMessage],
|
||||
prior_summary: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
let transcript = self.serialize_for_summary(pool, messages).await?;
|
||||
|
||||
let prompt = if let Some(prev) = prior_summary {
|
||||
format!(
|
||||
"{SUMMARIZER_PREAMBLE}\n\n\
|
||||
You are updating a context compaction summary. A previous compaction produced \
|
||||
the summary below. New conversation turns have occurred since then and need \
|
||||
to be incorporated.\n\n\
|
||||
PREVIOUS SUMMARY:\n{prev}\n\n\
|
||||
NEW TURNS TO INCORPORATE:\n{transcript}\n\n\
|
||||
Update the summary using this exact structure. PRESERVE all existing information \
|
||||
that is still relevant. ADD new completed actions to the numbered list (continue \
|
||||
numbering). Move items from \"In Progress\" to \"Completed Actions\" when done. \
|
||||
Move answered questions to \"Resolved Questions\". Update \"Active State\" to \
|
||||
reflect current state. Remove information only if it is clearly obsolete. \
|
||||
CRITICAL: Update \"## Active Task\" to reflect the user's most recent unfulfilled \
|
||||
request — this is the most important field for task continuity.\n\n\
|
||||
{SUMMARY_TEMPLATE}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{SUMMARIZER_PREAMBLE}\n\n\
|
||||
Create a structured checkpoint summary for the conversation after earlier turns \
|
||||
are compacted. The summary should preserve enough detail for continuity without \
|
||||
re-reading the original turns.\n\n\
|
||||
TURNS TO SUMMARIZE:\n{transcript}\n\n\
|
||||
Use this exact structure:\n\n\
|
||||
{SUMMARY_TEMPLATE}"
|
||||
)
|
||||
};
|
||||
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
/// Serialises conversation messages into Hermes-style labeled text for the summariser.
|
||||
///
|
||||
/// Format:
|
||||
/// ```text
|
||||
/// [USER]: text…
|
||||
///
|
||||
/// [ASSISTANT]: text…
|
||||
/// [Tool calls:
|
||||
/// tool_name(args…)
|
||||
/// ]
|
||||
///
|
||||
/// [TOOL RESULT tc_N]: result…
|
||||
/// ```
|
||||
///
|
||||
/// Long content is truncated with a head+tail strategy (preserving the start and
|
||||
/// end of the text) rather than a simple prefix cut.
|
||||
async fn serialize_for_summary(
|
||||
&self,
|
||||
pool: &SqlitePool,
|
||||
messages: &[chat_history::ChatMessage],
|
||||
) -> anyhow::Result<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
match msg.role {
|
||||
chat_history::Role::User | chat_history::Role::Agent => {
|
||||
let content = truncate_head_tail(msg.content.trim(), 6000, 1500);
|
||||
parts.push(format!("[USER]: {content}"));
|
||||
}
|
||||
chat_history::Role::Assistant => {
|
||||
let mut content = truncate_head_tail(msg.content.trim(), 6000, 1500);
|
||||
|
||||
let tool_calls = chat_llm_tools::for_message(pool, msg.id).await?;
|
||||
|
||||
if !tool_calls.is_empty() {
|
||||
let tc_lines: String = tool_calls
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let args = tc.arguments.as_deref()
|
||||
.map(|a| truncate(a, 1200))
|
||||
.unwrap_or_default();
|
||||
format!(" {}({})", tc.name, args)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
content.push_str(&format!("\n[Tool calls:\n{tc_lines}\n]"));
|
||||
}
|
||||
|
||||
parts.push(format!("[ASSISTANT]: {content}"));
|
||||
|
||||
// Tool results as separate labeled entries — mirrors Hermes'
|
||||
// `[TOOL RESULT {call_id}]` entries in the serialised transcript.
|
||||
for tc in &tool_calls {
|
||||
let result = match tc.status.as_str() {
|
||||
"done" => tc.result.as_deref()
|
||||
.map(|r| truncate_head_tail(r, 4000, 1500))
|
||||
.unwrap_or_default(),
|
||||
_ => "(failed or interrupted)".to_string(),
|
||||
};
|
||||
parts.push(format!("[TOOL RESULT tc_{}]: {result}", tc.id));
|
||||
}
|
||||
}
|
||||
match self.llm_manager.resolve(Some(&name), None).await {
|
||||
Ok((resolved, _)) => ModelHint::name(resolved),
|
||||
Err(e) => {
|
||||
warn!(model = %name, error = %e,
|
||||
"compactor: configured compaction model unavailable, falling back to AUTO");
|
||||
ModelHint::default()
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parts.join("\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max_chars`, appending "…" if truncated.
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let s = s.trim();
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
let end = s.char_indices()
|
||||
.nth(max_chars)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(s.len());
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the first `head_chars` and last `tail_chars` of a string, inserting
|
||||
/// `\n...[truncated]...\n` in the middle when the string is longer than their sum.
|
||||
///
|
||||
/// Mirrors Hermes' `_CONTENT_HEAD` + `_CONTENT_TAIL` strategy so the summariser
|
||||
/// always sees both the beginning context and the ending result of verbose outputs.
|
||||
fn truncate_head_tail(s: &str, head_chars: usize, tail_chars: usize) -> String {
|
||||
let s = s.trim();
|
||||
let char_count = s.chars().count();
|
||||
let total = head_chars + tail_chars;
|
||||
if char_count <= total {
|
||||
return s.to_string();
|
||||
}
|
||||
let head_end = s.char_indices()
|
||||
.nth(head_chars)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(s.len());
|
||||
let tail_start = s.char_indices()
|
||||
.nth(char_count - tail_chars)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
format!("{}\n...[truncated]...\n{}", &s[..head_end], &s[tail_start..])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user