mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(context): global context management module (#504)
* refactor(context): implement layered context pipeline with memory management - Introduced a new context pipeline orchestrator that manages context reduction stages before each provider call, including tool-result budgeting, microcompaction, and session memory management. - Added `ContextGuard` to monitor context utilization and trigger auto-compaction when thresholds are exceeded. - Implemented `microcompact` to replace older tool result payloads with placeholders, preserving API invariants while managing memory effectively. - Created `SessionMemory` to maintain persistent notes across sessions, updated by a background process to avoid impacting performance during user interactions. - Enhanced overall context management to improve memory efficiency and ensure smoother operation of the agent's functionality. * chore(context): add context module and refactor imports - Introduced a new `context` module to centralize context management for agent sessions, enhancing organization and maintainability. - Updated various files to replace references to the deprecated `context_pipeline` with the new `context` module, ensuring consistency across the codebase. - Bumped the version of the `openhuman` package to 0.52.4 in `Cargo.lock` to reflect these changes. * refactor(imports): update prompt module paths to new context structure - Refactored import statements across multiple files to replace references to the deprecated `agent::prompt` module with the new `context::prompt` module. - This change enhances code organization and aligns with the recent restructuring of the context management system. * feat(context): introduce comprehensive context management configuration - Added a new `ContextConfig` struct to manage global context settings, including budget thresholds, summarization triggers, and session-memory extraction parameters. - Implemented environment variable overrides for context management settings, allowing dynamic configuration. - Refactored the configuration loading process to accommodate the new context management structure, ensuring backward compatibility with existing configurations. - Introduced a `Summarizer` trait and default implementation for handling conversation summarization, enhancing the agent's ability to manage context effectively. - Updated related modules to integrate the new context management features, improving overall organization and maintainability of the codebase. * feat(context): implement ContextManager for enhanced session context handling - Introduced a new `ContextManager` struct to manage session context, including prompt assembly, context reduction, and summarization dispatch. - Added methods to `ContextGuard` for tracking input and output token counts, as well as the context window size. - Updated the `mod.rs` files to include the new `manager` module and its exports, improving organization and maintainability of the context management system. - Enhanced overall context management capabilities, allowing for more efficient handling of conversation context during agent interactions. * refactor(context): centralize system prompt assembly for channel runtimes - Moved the system prompt construction logic for channel runtimes into a new `channels_prompt` module within the `context` directory, ensuring all prompt-building code is organized in one location. - Updated various files to replace deprecated references to the old prompt module, enhancing code clarity and maintainability. - Introduced a new `build_system_prompt` function tailored for channel-specific requirements, including tool descriptions and channel-specific preambles, while maintaining byte-stability for cache efficiency. - Refactored related imports and tests to align with the new structure, improving overall organization of the context management system. * style(context): apply rustfmt to context module and integration sites Clippy caught a never-looping `while` in `snap_split_forward` that was really an `if` in disguise — collapse it. Everything else is pure `cargo fmt` cleanup on files touched during the context/ refactor. Full `cargo test --lib` sweep: 2253 tests passing. * delete(config): remove obsolete user configuration file - Deleted the `config.toml` file for user `69ccc8e95692bb0ddd56c10f`, which was no longer needed, streamlining the user configuration management. * refactor(SOUL.md): remove emoji usage guidelines to streamline prompt instructions - Deleted the section on emoji usage to simplify the prompt guidelines, focusing on clarity and direct communication with users. This change aims to enhance the overall effectiveness of the prompts by reducing unnecessary complexity. * refactor(config): enhance environment variable handling for context compaction settings - Improved the parsing and validation of environment variables for `OPENHUMAN_CONTEXT_COMPACTION_TRIGGER_PCT` and `OPENHUMAN_CONTEXT_HARD_LIMIT_PCT`, ensuring that the compaction trigger percentage is strictly less than the hard limit. - Added warnings for invalid percentage values and retained existing configuration values on failure. - Updated the context management logic to handle migration from deprecated fields, ensuring backward compatibility and clearer user guidance. * refactor(context): update tool result budget handling in Agent - Changed the source of the tool result budget to be derived from the ContextManager, ensuring it reflects the resolved `context.tool_result_budget_bytes` with any environment or configuration overrides. - This update removes reliance on the deprecated `agent.tool_result_budget_bytes` field, centralizing budget management for improved consistency across the context management system. * refactor(prompt): enhance subagent rendering options and system prompt construction - Introduced `SubagentRenderOptions` to manage per-definition rendering flags, allowing for more flexible control over the inclusion of identity, safety preamble, and skills catalog in the system prompt. - Updated `render_subagent_system_prompt` to utilize these options, ensuring that the rendering behavior aligns with the specified flags. - Modified `build_system_prompt` to accept an optional `channel_name`, improving clarity in channel capabilities messaging and removing hardcoded references to "Discord". - Adjusted tests to reflect changes in prompt construction, ensuring comprehensive coverage of the new rendering logic. * refactor(context): improve session memory handling in ContextManager - Updated session memory methods in `ContextManager` to use a locked reference for thread safety during extraction state changes. - Introduced a new method `session_memory_handle` to facilitate background task management of session memory extraction. - Adjusted the context statistics to utilize a snapshot of session memory, enhancing data integrity and consistency. * refactor(turn): improve extraction state management in Agent - Updated the extraction state handling to transition to "in-progress" instead of "complete" immediately, allowing for better control during background extraction tasks. - Introduced a shared handle for session memory to manage extraction completion and failure states, ensuring deltas are preserved for retries on failure. - Enhanced logging to provide clearer insights into the extraction process outcomes.
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
//! Layered context-reduction pipeline.
|
||||
//!
|
||||
//! Context summarisation in openhuman is layered, not a single knob.
|
||||
//! Each layer has a specific trigger and invariant:
|
||||
//!
|
||||
//! | Stage | File | When | Cache impact |
|
||||
//! |-------|--------------------------------|--------------------------------|----------------|
|
||||
//! | 1. Tool-result budget | [`tool_result_budget`] | New tool result created | Cache-safe |
|
||||
//! | 2. Snip / trim | `Agent::trim_history` | Message count > max | Cache-safe* |
|
||||
//! | 3. Microcompact | [`microcompact`] | Guard ≥ 90% soft bound | Breaks prefix |
|
||||
//! | 4. Autocompact | `loop_/history.rs` | Microcompact not enough | Breaks prefix |
|
||||
//! | 5. Session memory | [`session_memory`] | Token/turn/tool deltas | Async, free |
|
||||
//!
|
||||
//! \* Trim only drops messages older than the most-recent stable prefix,
|
||||
//! which is often outside the KV-cache anyway.
|
||||
//!
|
||||
//! The orchestrator is [`pipeline::ContextPipeline`], which is owned by
|
||||
//! the `Agent` and called once per turn before each provider hit. Stage
|
||||
//! 1 is applied inline in `Agent::execute_tool_call`, not here.
|
||||
//!
|
||||
//! Stage reference:
|
||||
//! - [`tool_result_budget::apply_tool_result_budget`] — stage 1
|
||||
//! - [`microcompact::microcompact`] — stage 3
|
||||
//! - `PipelineOutcome::AutocompactionRequested` — stage 4 signal
|
||||
//! - [`session_memory::SessionMemoryState`] — stage 5 state tracker
|
||||
|
||||
pub mod microcompact;
|
||||
pub mod pipeline;
|
||||
pub mod session_memory;
|
||||
pub mod tool_result_budget;
|
||||
|
||||
pub use microcompact::{
|
||||
microcompact, MicrocompactStats, CLEARED_PLACEHOLDER, DEFAULT_KEEP_RECENT_TOOL_RESULTS,
|
||||
};
|
||||
pub use pipeline::{ContextPipeline, ContextPipelineConfig, PipelineOutcome};
|
||||
pub use session_memory::{
|
||||
SessionMemoryConfig, SessionMemoryState, ARCHIVIST_EXTRACTION_PROMPT, DEFAULT_MIN_TOKEN_GROWTH,
|
||||
DEFAULT_MIN_TOOL_CALLS, DEFAULT_MIN_TURNS_BETWEEN,
|
||||
};
|
||||
pub use tool_result_budget::{
|
||||
apply_tool_result_budget, BudgetOutcome, DEFAULT_TOOL_RESULT_BUDGET_BYTES,
|
||||
};
|
||||
@@ -29,7 +29,6 @@
|
||||
|
||||
pub(crate) mod archivist;
|
||||
pub(crate) mod builtin_definitions;
|
||||
pub(crate) mod context_guard;
|
||||
mod credentials;
|
||||
pub mod definition;
|
||||
pub(crate) mod definition_loader;
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
//! [`super::turn`]; accessors and run-helpers live in [`super::runtime`].
|
||||
|
||||
use super::types::{Agent, AgentBuilder};
|
||||
use crate::openhuman::agent::context_pipeline::ContextPipeline;
|
||||
use crate::openhuman::agent::dispatcher::{
|
||||
NativeToolDispatcher, ToolDispatcher, XmlToolDispatcher,
|
||||
};
|
||||
use crate::openhuman::agent::host_runtime;
|
||||
use crate::openhuman::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
|
||||
use crate::openhuman::agent::prompt::SystemPromptBuilder;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::config::{Config, ContextConfig};
|
||||
use crate::openhuman::context::prompt::SystemPromptBuilder;
|
||||
use crate::openhuman::context::{ContextManager, ProviderSummarizer};
|
||||
use crate::openhuman::memory::{self, Memory};
|
||||
use crate::openhuman::providers::{self, Provider};
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
@@ -34,6 +34,7 @@ impl AgentBuilder {
|
||||
tool_dispatcher: None,
|
||||
memory_loader: None,
|
||||
config: None,
|
||||
context_config: None,
|
||||
model_name: None,
|
||||
temperature: None,
|
||||
workspace_dir: None,
|
||||
@@ -107,6 +108,15 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the global context-management configuration. Threaded
|
||||
/// into the [`ContextManager`] constructed in [`Self::build`]. If
|
||||
/// not set the manager is constructed with
|
||||
/// [`ContextConfig::default`].
|
||||
pub fn context_config(mut self, context_config: ContextConfig) -> Self {
|
||||
self.context_config = Some(context_config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the model name to use for chat requests.
|
||||
pub fn model_name(mut self, model_name: String) -> Self {
|
||||
self.model_name = Some(model_name);
|
||||
@@ -204,10 +214,37 @@ impl AgentBuilder {
|
||||
!visible_names.is_empty()
|
||||
);
|
||||
|
||||
// Pull the provider out of the builder once. We store it on
|
||||
// the Agent (for normal turn chat calls) and also clone the
|
||||
// Arc into the ProviderSummarizer so the context manager can
|
||||
// dispatch autocompaction through the same provider.
|
||||
let provider = self
|
||||
.provider
|
||||
.ok_or_else(|| anyhow::anyhow!("provider is required"))?;
|
||||
|
||||
let prompt_builder = self
|
||||
.prompt_builder
|
||||
.unwrap_or_else(SystemPromptBuilder::with_defaults);
|
||||
|
||||
let model_name = self
|
||||
.model_name
|
||||
.unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.into());
|
||||
|
||||
// Assemble the per-session ContextManager. The manager owns
|
||||
// the prompt builder, the reduction pipeline, and the
|
||||
// summarizer — every concern that touches "what's in the
|
||||
// model's context window" routes through this single handle.
|
||||
let context_config = self.context_config.unwrap_or_default();
|
||||
let summarizer = Arc::new(ProviderSummarizer::new(provider.clone()));
|
||||
let context = ContextManager::new(
|
||||
&context_config,
|
||||
summarizer,
|
||||
model_name.clone(),
|
||||
prompt_builder,
|
||||
);
|
||||
|
||||
Ok(Agent {
|
||||
provider: self
|
||||
.provider
|
||||
.ok_or_else(|| anyhow::anyhow!("provider is required"))?,
|
||||
provider,
|
||||
tools: Arc::new(tools),
|
||||
tool_specs: Arc::new(tool_specs),
|
||||
visible_tool_specs: Arc::new(visible_tool_specs),
|
||||
@@ -215,9 +252,6 @@ impl AgentBuilder {
|
||||
memory: self
|
||||
.memory
|
||||
.ok_or_else(|| anyhow::anyhow!("memory is required"))?,
|
||||
prompt_builder: self
|
||||
.prompt_builder
|
||||
.unwrap_or_else(SystemPromptBuilder::with_defaults),
|
||||
tool_dispatcher: self
|
||||
.tool_dispatcher
|
||||
.ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?,
|
||||
@@ -225,9 +259,7 @@ impl AgentBuilder {
|
||||
.memory_loader
|
||||
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
|
||||
config: self.config.unwrap_or_default(),
|
||||
model_name: self
|
||||
.model_name
|
||||
.unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.into()),
|
||||
model_name,
|
||||
temperature: self.temperature.unwrap_or(0.7),
|
||||
workspace_dir: self
|
||||
.workspace_dir
|
||||
@@ -242,12 +274,7 @@ impl AgentBuilder {
|
||||
.event_session_id
|
||||
.unwrap_or_else(|| "standalone".to_string()),
|
||||
event_channel: self.event_channel.unwrap_or_else(|| "internal".to_string()),
|
||||
// The context pipeline is intentionally constructed with its
|
||||
// defaults here — its tunables (`tool_result_budget_bytes`,
|
||||
// microcompact thresholds, session-memory knobs) are read from
|
||||
// the per-agent `AgentConfig` at call sites, so there is no
|
||||
// need for a separate fluent setter on the builder today.
|
||||
context_pipeline: ContextPipeline::default(),
|
||||
context,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -439,6 +466,7 @@ impl Agent {
|
||||
))
|
||||
.prompt_builder(prompt_builder)
|
||||
.config(config.agent.clone())
|
||||
.context_config(config.context.clone())
|
||||
.model_name(model_name)
|
||||
.temperature(config.default_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
//! background archivist fork.
|
||||
|
||||
use super::types::Agent;
|
||||
use crate::openhuman::agent::context_pipeline;
|
||||
use crate::openhuman::agent::dispatcher::{ParsedToolCall, ToolExecutionResult};
|
||||
use crate::openhuman::agent::harness;
|
||||
use crate::openhuman::agent::hooks::{self, ToolCallRecord, TurnContext};
|
||||
use crate::openhuman::agent::prompt::{LearnedContextData, PromptContext};
|
||||
use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, PromptTool};
|
||||
use crate::openhuman::context::{ReductionOutcome, ARCHIVIST_EXTRACTION_PROMPT};
|
||||
use crate::openhuman::event_bus::{publish_global, DomainEvent};
|
||||
use crate::openhuman::memory::MemoryCategory;
|
||||
use crate::openhuman::providers::{ChatMessage, ChatRequest, ConversationMessage};
|
||||
@@ -137,7 +137,7 @@ impl Agent {
|
||||
// Bump the session-memory turn counter. Used later by
|
||||
// `should_extract_session_memory` to decide whether to spawn a
|
||||
// background archivist fork at end-of-turn.
|
||||
self.context_pipeline.tick_turn();
|
||||
self.context.tick_turn();
|
||||
|
||||
// Collect tool call records across all iterations for post-turn hooks
|
||||
let mut all_tool_records: Vec<ToolCallRecord> = Vec::new();
|
||||
@@ -150,43 +150,61 @@ impl Agent {
|
||||
self.history.len()
|
||||
);
|
||||
|
||||
// Context pipeline stages 3 & 4: run the reduction
|
||||
// chain before every provider hit. Microcompact fires
|
||||
// when the guard reports we're above the soft threshold
|
||||
// and there are older tool results to clear; otherwise
|
||||
// we log an autocompaction signal (openhuman's
|
||||
// compactor lives in `loop_/history.rs` and operates on
|
||||
// the `ChatMessage` shape, so for now the
|
||||
// `ConversationMessage`-shaped Agent path lets the
|
||||
// signal bubble up as telemetry until a native
|
||||
// summariser lands).
|
||||
let outcome = self.context_pipeline.run_before_call(&mut self.history);
|
||||
// Global context management: run the reduction chain
|
||||
// before every provider hit. Cheap when the guard is
|
||||
// healthy; executes the summarizer LLM call
|
||||
// internally when the pipeline asks for autocompaction
|
||||
// (summarization, microcompact, and the circuit
|
||||
// breaker all live inside [`ContextManager`]).
|
||||
let outcome = self.context.reduce_before_call(&mut self.history).await?;
|
||||
match &outcome {
|
||||
context_pipeline::PipelineOutcome::NoOp => {}
|
||||
context_pipeline::PipelineOutcome::Microcompacted(stats) => {
|
||||
ReductionOutcome::NoOp => {}
|
||||
ReductionOutcome::Microcompacted {
|
||||
envelopes_cleared,
|
||||
entries_cleared,
|
||||
bytes_freed,
|
||||
} => {
|
||||
log::info!(
|
||||
"[agent_loop] context_pipeline microcompact i={} envelopes={} entries={} bytes_freed={}",
|
||||
"[agent_loop] context microcompact i={} envelopes={} entries={} bytes_freed={}",
|
||||
iteration + 1,
|
||||
stats.envelopes_cleared,
|
||||
stats.entries_cleared,
|
||||
stats.bytes_freed
|
||||
envelopes_cleared,
|
||||
entries_cleared,
|
||||
bytes_freed
|
||||
);
|
||||
}
|
||||
context_pipeline::PipelineOutcome::AutocompactionRequested {
|
||||
ReductionOutcome::Summarized(stats) => {
|
||||
log::info!(
|
||||
"[agent_loop] context autocompact summarized i={} messages_removed={} approx_tokens_freed={} summary_chars={}",
|
||||
iteration + 1,
|
||||
stats.messages_removed,
|
||||
stats.approx_tokens_freed,
|
||||
stats.summary_chars
|
||||
);
|
||||
}
|
||||
ReductionOutcome::SummarizationFailed {
|
||||
utilisation_pct,
|
||||
reason,
|
||||
} => {
|
||||
log::warn!(
|
||||
"[agent_loop] context_pipeline autocompaction requested i={} utilisation_pct={}",
|
||||
"[agent_loop] context summarizer failed i={} utilisation_pct={} reason={}",
|
||||
iteration + 1,
|
||||
utilisation_pct,
|
||||
reason
|
||||
);
|
||||
}
|
||||
ReductionOutcome::NotAttempted { utilisation_pct } => {
|
||||
log::warn!(
|
||||
"[agent_loop] context autocompact disabled in config i={} utilisation_pct={}",
|
||||
iteration + 1,
|
||||
utilisation_pct
|
||||
);
|
||||
}
|
||||
context_pipeline::PipelineOutcome::ContextExhausted {
|
||||
ReductionOutcome::Exhausted {
|
||||
utilisation_pct,
|
||||
reason,
|
||||
} => {
|
||||
log::error!(
|
||||
"[agent_loop] context_pipeline context exhausted i={} utilisation_pct={} reason={}",
|
||||
"[agent_loop] context exhausted i={} utilisation_pct={} reason={}",
|
||||
iteration + 1,
|
||||
utilisation_pct,
|
||||
reason
|
||||
@@ -237,11 +255,11 @@ impl Agent {
|
||||
resp.tool_calls.len()
|
||||
);
|
||||
log::debug!("[agent_loop] provider response: {resp:?}");
|
||||
// Feed the context pipeline (guard +
|
||||
// Feed the context manager (guard +
|
||||
// session-memory token accounting). No-op when
|
||||
// the provider doesn't return usage.
|
||||
if let Some(ref usage) = resp.usage {
|
||||
self.context_pipeline.record_usage(usage);
|
||||
self.context.record_usage(usage);
|
||||
}
|
||||
resp
|
||||
}
|
||||
@@ -296,10 +314,10 @@ impl Agent {
|
||||
// `turn_body` so the spawned task can take an owned
|
||||
// parent context without fighting the borrow
|
||||
// checker against `self`. We capture the decision
|
||||
// here and surface it via the pipeline state — the
|
||||
// epilogue (below) reads `should_extract_session_memory()`.
|
||||
self.context_pipeline
|
||||
.record_tool_calls(all_tool_records.len());
|
||||
// here and surface it via the manager's session
|
||||
// state — the epilogue (below) reads
|
||||
// `should_extract_session_memory()`.
|
||||
self.context.record_tool_calls(all_tool_records.len());
|
||||
|
||||
// Fire post-turn hooks (non-blocking)
|
||||
if !self.post_turn_hooks.is_empty() {
|
||||
@@ -429,7 +447,7 @@ impl Agent {
|
||||
// we'll just retry on the next threshold window (a few turns
|
||||
// later), which is the right amount of retry behaviour for a
|
||||
// librarian task that's idempotent across reruns.
|
||||
if result.is_ok() && self.context_pipeline.should_extract_session_memory() {
|
||||
if result.is_ok() && self.context.should_extract_session_memory() {
|
||||
self.spawn_session_memory_extraction();
|
||||
}
|
||||
|
||||
@@ -508,9 +526,13 @@ impl Agent {
|
||||
// *inline* before the result enters history. This is the only
|
||||
// cache-safe reduction stage — the truncated body has never
|
||||
// been sent to the backend so it creates no cache invalidation.
|
||||
let budget_bytes = self.config.tool_result_budget_bytes;
|
||||
// Source the budget from the context manager so it tracks the
|
||||
// resolved `context.tool_result_budget_bytes` (including any
|
||||
// env/config overrides) rather than the deprecated
|
||||
// `agent.tool_result_budget_bytes` field.
|
||||
let budget_bytes = self.context.tool_result_budget_bytes();
|
||||
let (result, budget_outcome) =
|
||||
context_pipeline::apply_tool_result_budget(raw_result, budget_bytes);
|
||||
crate::openhuman::context::apply_tool_result_budget(raw_result, budget_bytes);
|
||||
if budget_outcome.truncated {
|
||||
log::info!(
|
||||
"[agent_loop] tool_result_budget applied name={} original_bytes={} final_bytes={} dropped_bytes={}",
|
||||
@@ -732,16 +754,24 @@ impl Agent {
|
||||
pub(super) fn build_system_prompt(&self, learned: LearnedContextData) -> Result<String> {
|
||||
let tools_slice: &[Box<dyn Tool>] = self.tools.as_slice();
|
||||
let instructions = self.tool_dispatcher.prompt_instructions(tools_slice);
|
||||
// Adapt the owned Box<dyn Tool> slice into the shared PromptTool
|
||||
// shape that every prompt-building call-site uses. Temporary vec
|
||||
// borrows from `tools_slice` and lives for the duration of the
|
||||
// prompt build.
|
||||
let prompt_tools = PromptTool::from_tools(tools_slice);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: &self.workspace_dir,
|
||||
model_name: &self.model_name,
|
||||
tools: tools_slice,
|
||||
tools: &prompt_tools,
|
||||
skills: &self.skills,
|
||||
dispatcher_instructions: &instructions,
|
||||
learned,
|
||||
visible_tool_names: &self.visible_tool_names,
|
||||
};
|
||||
self.prompt_builder.build(&ctx)
|
||||
// Route through the global context manager so every
|
||||
// prompt-building call-site — main agent, sub-agent runner,
|
||||
// channel runtimes — shares one builder configuration.
|
||||
self.context.build_system_prompt(&ctx)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -771,22 +801,23 @@ impl Agent {
|
||||
// consumed by the `with_parent_context` scope above, so this is
|
||||
// a fresh snapshot.
|
||||
let parent_ctx = self.build_parent_execution_context();
|
||||
let extraction_prompt = context_pipeline::ARCHIVIST_EXTRACTION_PROMPT.to_string();
|
||||
let extraction_prompt = ARCHIVIST_EXTRACTION_PROMPT.to_string();
|
||||
|
||||
// Optimistically flip the extraction state to "complete" right
|
||||
// away: we don't need a channel back from the background task
|
||||
// because a failed extraction is idempotent — it will just be
|
||||
// retried after the next threshold crossing. `mark_extraction_complete`
|
||||
// also clears the `extraction_in_progress` flag, so calling it
|
||||
// alone covers both bookkeeping steps.
|
||||
self.context_pipeline
|
||||
.session_memory
|
||||
.mark_extraction_complete();
|
||||
// Flip the extraction state to "in-progress" so future
|
||||
// should_extract checks return false until the archivist
|
||||
// finishes. We then hand a shared handle to the spawned task
|
||||
// so it can mark the extraction complete (resets deltas) on
|
||||
// success, or failed (keeps deltas intact for retry) on error.
|
||||
// This replaces the old optimistic `mark_complete` that
|
||||
// silently dropped the retry window when extractions failed.
|
||||
let stats_snapshot = self.context.stats();
|
||||
self.context.mark_session_memory_started();
|
||||
let sm_handle = self.context.session_memory_handle();
|
||||
|
||||
log::info!(
|
||||
"[session_memory] spawning background archivist extraction (turn={}, tokens={})",
|
||||
self.context_pipeline.session_memory.current_turn,
|
||||
self.context_pipeline.session_memory.total_tokens
|
||||
stats_snapshot.session_memory_current_turn,
|
||||
stats_snapshot.session_memory_total_tokens
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -794,17 +825,31 @@ impl Agent {
|
||||
let fut = harness::run_subagent(&definition, &extraction_prompt, options);
|
||||
let result = harness::with_parent_context(parent_ctx, fut).await;
|
||||
match result {
|
||||
Ok(outcome) => tracing::info!(
|
||||
agent_id = %outcome.agent_id,
|
||||
task_id = %outcome.task_id,
|
||||
iterations = outcome.iterations,
|
||||
output_chars = outcome.output.chars().count(),
|
||||
"[session_memory] archivist extraction completed"
|
||||
),
|
||||
Err(err) => tracing::warn!(
|
||||
error = %err,
|
||||
"[session_memory] archivist extraction failed — will retry after next threshold crossing"
|
||||
),
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
agent_id = %outcome.agent_id,
|
||||
task_id = %outcome.task_id,
|
||||
iterations = outcome.iterations,
|
||||
output_chars = outcome.output.chars().count(),
|
||||
"[session_memory] archivist extraction completed"
|
||||
);
|
||||
if let Ok(mut sm) = sm_handle.lock() {
|
||||
sm.mark_extraction_complete();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"[session_memory] archivist extraction failed — will retry after next threshold crossing"
|
||||
);
|
||||
// Leave the deltas intact so the next threshold
|
||||
// crossing schedules another attempt. Clearing
|
||||
// `extraction_in_progress` lets the retry
|
||||
// actually fire.
|
||||
if let Ok(mut sm) = sm_handle.lock() {
|
||||
sm.mark_extraction_failed();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
//! `impl Agent`/`impl AgentBuilder` can see them without the whole
|
||||
//! crate gaining field access.
|
||||
|
||||
use crate::openhuman::agent::context_pipeline::ContextPipeline;
|
||||
use crate::openhuman::agent::dispatcher::ToolDispatcher;
|
||||
use crate::openhuman::agent::hooks::PostTurnHook;
|
||||
use crate::openhuman::agent::memory_loader::MemoryLoader;
|
||||
use crate::openhuman::agent::prompt::SystemPromptBuilder;
|
||||
use crate::openhuman::context::prompt::SystemPromptBuilder;
|
||||
use crate::openhuman::context::ContextManager;
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::providers::{ConversationMessage, Provider};
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
@@ -39,7 +39,6 @@ pub struct Agent {
|
||||
/// Empty = no filter (all tools visible, backward compat).
|
||||
pub(super) visible_tool_names: std::collections::HashSet<String>,
|
||||
pub(super) memory: Arc<dyn Memory>,
|
||||
pub(super) prompt_builder: SystemPromptBuilder,
|
||||
pub(super) tool_dispatcher: Box<dyn ToolDispatcher>,
|
||||
pub(super) memory_loader: Box<dyn MemoryLoader>,
|
||||
pub(super) config: crate::openhuman::config::AgentConfig,
|
||||
@@ -56,14 +55,15 @@ pub struct Agent {
|
||||
pub(super) learning_enabled: bool,
|
||||
pub(super) event_session_id: String,
|
||||
pub(super) event_channel: String,
|
||||
/// Layered context reduction pipeline (tool-result budget →
|
||||
/// Per-session [`ContextManager`] — owns the system-prompt
|
||||
/// builder, the layered reduction pipeline (tool-result budget →
|
||||
/// microcompact → autocompact signal → session-memory extraction
|
||||
/// trigger). Owned by the agent so its state (token counters,
|
||||
/// session-memory extraction deltas, compaction circuit breaker)
|
||||
/// persists across turns. See
|
||||
/// [`crate::openhuman::agent::context_pipeline`] for the stage
|
||||
/// ordering and cache-safety contract.
|
||||
pub(super) context_pipeline: ContextPipeline,
|
||||
/// trigger), the guard's compaction circuit breaker, and the LLM
|
||||
/// summarizer that runs when the pipeline asks for autocompaction.
|
||||
/// Constructed once at session start so its budget counters and
|
||||
/// session-memory deltas persist across turns. See
|
||||
/// [`crate::openhuman::context`] for the full surface.
|
||||
pub(super) context: ContextManager,
|
||||
}
|
||||
|
||||
/// A builder for creating `Agent` instances with custom configuration.
|
||||
@@ -77,6 +77,10 @@ pub struct AgentBuilder {
|
||||
pub(super) tool_dispatcher: Option<Box<dyn ToolDispatcher>>,
|
||||
pub(super) memory_loader: Option<Box<dyn MemoryLoader>>,
|
||||
pub(super) config: Option<crate::openhuman::config::AgentConfig>,
|
||||
/// Optional [`ContextConfig`] override threaded through from
|
||||
/// `Agent::from_config`. When unset the builder falls back to
|
||||
/// [`crate::openhuman::config::ContextConfig::default`].
|
||||
pub(super) context_config: Option<crate::openhuman::config::ContextConfig>,
|
||||
pub(super) model_name: Option<String>,
|
||||
pub(super) temperature: Option<f64>,
|
||||
pub(super) workspace_dir: Option<std::path::PathBuf>,
|
||||
|
||||
@@ -225,19 +225,32 @@ async fn run_typed_mode(
|
||||
|
||||
// ── Build the narrow system prompt ─────────────────────────────────
|
||||
//
|
||||
// We compose the prompt inline rather than routing through
|
||||
// `SystemPromptBuilder::for_subagent` because the builder requires
|
||||
// a slice of `Box<dyn Tool>` and we only have indices into the
|
||||
// parent's vec (Box isn't Clone, so we can't build an owning
|
||||
// filtered slice cheaply). `render_subagent_system_prompt` mirrors
|
||||
// the builder's output for the narrow case.
|
||||
let system_prompt = render_subagent_system_prompt(
|
||||
// The renderer lives in `context::prompt` alongside the rest of
|
||||
// the system-prompt code so all prompt assembly has one home.
|
||||
// We still use the purpose-built narrow renderer rather than the
|
||||
// general `SystemPromptBuilder::for_subagent` because the builder
|
||||
// requires a slice of `Box<dyn Tool>` and we only have indices
|
||||
// into the parent's vec (Box isn't Clone, so we can't build an
|
||||
// owning filtered slice cheaply).
|
||||
//
|
||||
// Per-definition omit_* flags are threaded through via
|
||||
// `SubagentRenderOptions` — previously the narrow renderer
|
||||
// hard-coded all three as "omit", which silently downgraded
|
||||
// definitions like `code_executor` / `tool_maker` / `skills_agent`
|
||||
// that set `omit_safety_preamble = false`.
|
||||
let render_options =
|
||||
crate::openhuman::context::prompt::SubagentRenderOptions::from_definition_flags(
|
||||
definition.omit_identity,
|
||||
definition.omit_safety_preamble,
|
||||
definition.omit_skills_catalog,
|
||||
);
|
||||
let system_prompt = crate::openhuman::context::prompt::render_subagent_system_prompt(
|
||||
&parent.workspace_dir,
|
||||
&model,
|
||||
&allowed_indices,
|
||||
&parent.all_tools,
|
||||
definition,
|
||||
&archetype_prompt_body,
|
||||
render_options,
|
||||
);
|
||||
|
||||
// ── Build the user message (with optional context prefix) ──────────
|
||||
@@ -609,93 +622,10 @@ fn load_prompt_source(
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the sub-agent's system prompt by hand. We avoid going through
|
||||
/// `SystemPromptBuilder::build` because that requires a slice of
|
||||
/// `Box<dyn Tool>` and we already have indices into the parent's vec
|
||||
/// (which would force an awkward Vec<&Box<dyn Tool>> intermediate).
|
||||
///
|
||||
/// `archetype_body` is the already-loaded archetype markdown — for
|
||||
/// `PromptSource::Inline` this is the inline string, for
|
||||
/// `PromptSource::File` this is the file contents loaded by
|
||||
/// [`load_prompt_source`]. The caller resolves the source exactly
|
||||
/// once and hands the body in, so this renderer works uniformly for
|
||||
/// both definition shapes.
|
||||
///
|
||||
/// # KV cache stability
|
||||
///
|
||||
/// The rendered bytes MUST be a pure function of:
|
||||
/// - the `archetype_body` (archetype role prompt)
|
||||
/// - the filtered tool set (names, descriptions, schemas)
|
||||
/// - the workspace directory
|
||||
/// - the resolved model name
|
||||
///
|
||||
/// Anything that varies across invocations at the *same* call site (e.g.
|
||||
/// `chrono::Local::now()`, hostnames, pids, turn counters) is forbidden
|
||||
/// here. Repeat spawns of the same sub-agent within a session must
|
||||
/// produce byte-identical system prompts so the inference backend's
|
||||
/// automatic prefix caching can reuse the prefill from the previous run.
|
||||
/// Time-of-day information, if a sub-agent needs it, belongs in the user
|
||||
/// message — not the system prompt.
|
||||
fn render_subagent_system_prompt(
|
||||
workspace_dir: &std::path::Path,
|
||||
model_name: &str,
|
||||
allowed_indices: &[usize],
|
||||
parent_tools: &[Box<dyn Tool>],
|
||||
definition: &AgentDefinition,
|
||||
archetype_body: &str,
|
||||
) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut out = String::new();
|
||||
let _ = definition; // reserved for future per-definition rendering flags
|
||||
|
||||
// 1. Archetype role prompt. Works for both `PromptSource::Inline`
|
||||
// and `PromptSource::File` because the caller preloaded the
|
||||
// body via `load_prompt_source`.
|
||||
let trimmed = archetype_body.trim();
|
||||
if !trimmed.is_empty() {
|
||||
out.push_str(trimmed);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
// 2. Filtered tool catalogue. Indices are taken in ascending order
|
||||
// from `allowed_indices`, which itself preserves `parent_tools`
|
||||
// order, so the rendering is deterministic.
|
||||
out.push_str("## Tools\n\n");
|
||||
for &i in allowed_indices {
|
||||
let tool = &parent_tools[i];
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Sub-agent calling-convention preamble. Mirrors the existing
|
||||
// NativeToolDispatcher hint that gets baked into the parent's
|
||||
// prompt — sub-agents need it too.
|
||||
out.push('\n');
|
||||
out.push_str(
|
||||
"Use the provided tools to accomplish the task. Reply with a concise, dense \
|
||||
final answer when you have one — the parent agent will weave it back into the \
|
||||
user-visible response.\n\n",
|
||||
);
|
||||
|
||||
// 4. Workspace so the model knows where it is. Intentionally stable:
|
||||
// no datetime, no hostname, no pid — see the KV-cache note above.
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"## Workspace\n\nWorking directory: `{}`\n",
|
||||
workspace_dir.display()
|
||||
);
|
||||
|
||||
// 5. Runtime banner — model name only. Stable for the lifetime of
|
||||
// this sub-agent's definition.
|
||||
let _ = writeln!(out, "## Runtime\n\nModel: {model_name}");
|
||||
|
||||
out
|
||||
}
|
||||
// Note: the narrow sub-agent prompt renderer lives in
|
||||
// `crate::openhuman::context::prompt::render_subagent_system_prompt`
|
||||
// so every system-prompt-building call-site — main agents, sub-agents,
|
||||
// channel runtimes — shares one module.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
|
||||
@@ -7,11 +7,11 @@ use anyhow::Result;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
use super::context_guard::{ContextCheckResult, ContextGuard};
|
||||
use super::credentials::scrub_credentials;
|
||||
use super::parse::{
|
||||
build_native_assistant_history, find_tool, parse_structured_tool_calls, parse_tool_calls,
|
||||
};
|
||||
use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard};
|
||||
|
||||
/// Minimum characters per chunk when relaying LLM text to a streaming draft.
|
||||
const STREAM_CHUNK_MIN_CHARS: usize = 80;
|
||||
@@ -99,7 +99,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
tracing::warn!(
|
||||
iteration,
|
||||
"[agent_loop] context guard: compaction needed (>{:.0}% full)",
|
||||
super::context_guard::COMPACTION_TRIGGER_THRESHOLD * 100.0
|
||||
crate::openhuman::context::guard::COMPACTION_TRIGGER_THRESHOLD * 100.0
|
||||
);
|
||||
// Compaction is handled by history management upstream;
|
||||
// log and continue so the caller can act on it.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod agents;
|
||||
pub mod context_pipeline;
|
||||
pub mod dispatcher;
|
||||
pub mod error;
|
||||
pub mod harness;
|
||||
@@ -7,7 +6,6 @@ pub mod hooks;
|
||||
pub mod host_runtime;
|
||||
pub mod memory_loader;
|
||||
pub mod multimodal;
|
||||
pub mod prompt;
|
||||
mod schemas;
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_agent_controller_schemas,
|
||||
|
||||
@@ -17,18 +17,6 @@ You are OpenHuman — the user's AI teammate for productivity, research, and tea
|
||||
- Present alternatives and trade-offs when the call isn't obvious — then let the user pick.
|
||||
- Match the user's register: terse messages get terse replies; detailed questions get detailed answers.
|
||||
|
||||
## Emoji
|
||||
|
||||
Use emojis the way a real person texts — sparingly, and only when they add meaning:
|
||||
|
||||
- **One max per message**, or none. Never stack.
|
||||
- **Contextual, not decorative**: 🔥 for something genuinely exciting, 🤔 when actually uncertain, 📊 for data, 🚨 for warnings, ✅ for confirmations. Not 😄 as a mood-setter.
|
||||
- **Never open a sentence with an emoji** — place it at the end of a clause where it punctuates a specific point.
|
||||
- **Mirror the user** — if they use none, use none.
|
||||
- **Skip entirely** in: errors, warnings, serious topics, technical explanations, or responses longer than 3 sentences.
|
||||
- Bad: "Hey! 😄 Let's cook up some magic! 🚀🔥" — decorative, stacked, meaningless.
|
||||
- Good: "Done — your Notion page is updated ✅" or "Three calendars and none agree 🔥"
|
||||
|
||||
## When things go wrong
|
||||
|
||||
- **Tool failure:** try a different approach before escalating. If you're stuck, name what failed and what you'd need to proceed.
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod traits;
|
||||
|
||||
mod commands;
|
||||
pub(crate) mod context;
|
||||
mod prompt;
|
||||
mod routes;
|
||||
mod runtime;
|
||||
|
||||
@@ -59,5 +58,9 @@ pub use whatsapp_web::WhatsAppWebChannel;
|
||||
|
||||
pub use commands::doctor_channels;
|
||||
pub use controllers::{ChannelAuthMode, ChannelDefinition};
|
||||
pub use prompt::build_system_prompt;
|
||||
// Channel system-prompt assembly lives in
|
||||
// `crate::openhuman::context::channels_prompt` alongside the rest of
|
||||
// the prompt-building code. Re-exported here for callers that used the
|
||||
// old `channels::build_system_prompt` path.
|
||||
pub use crate::openhuman::context::channels_prompt::build_system_prompt;
|
||||
pub use runtime::start_channels;
|
||||
|
||||
@@ -19,7 +19,6 @@ use crate::openhuman::channels::linq::LinqChannel;
|
||||
#[cfg(feature = "channel-matrix")]
|
||||
use crate::openhuman::channels::matrix::MatrixChannel;
|
||||
use crate::openhuman::channels::mattermost::MattermostChannel;
|
||||
use crate::openhuman::channels::prompt::build_system_prompt;
|
||||
use crate::openhuman::channels::qq::QQChannel;
|
||||
use crate::openhuman::channels::signal::SignalChannel;
|
||||
use crate::openhuman::channels::slack::SlackChannel;
|
||||
@@ -30,6 +29,7 @@ use crate::openhuman::channels::whatsapp::WhatsAppChannel;
|
||||
use crate::openhuman::channels::whatsapp_web::WhatsAppWebChannel;
|
||||
use crate::openhuman::channels::Channel;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::context::channels_prompt::build_system_prompt;
|
||||
use crate::openhuman::event_bus::{self, DomainEvent, TracingSubscriber, DEFAULT_CAPACITY};
|
||||
use crate::openhuman::memory::{self, Memory};
|
||||
use crate::openhuman::providers::{self, Provider};
|
||||
@@ -189,12 +189,19 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `channel_name = None` on startup: the channel runtime wires up
|
||||
// multiple providers in parallel, so there's no single platform to
|
||||
// name here. The capability block falls back to a platform-agnostic
|
||||
// "messaging bot" phrasing. Per-channel renderers that want a
|
||||
// named capabilities section can call `build_system_prompt` with
|
||||
// `Some(name)` directly.
|
||||
let mut system_prompt = build_system_prompt(
|
||||
&workspace,
|
||||
&model,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
bootstrap_max_chars,
|
||||
None,
|
||||
);
|
||||
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::super::prompt::build_system_prompt;
|
||||
use super::common::make_workspace;
|
||||
use crate::openhuman::context::channels_prompt::build_system_prompt;
|
||||
|
||||
/// `build_system_prompt` loads OpenClaw markdown identity files from the
|
||||
/// workspace and inlines their contents into the Project Context section.
|
||||
#[test]
|
||||
fn openclaw_loads_workspace_markdown_files() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
// Project Context section header is present.
|
||||
assert!(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::super::prompt::{build_system_prompt, BOOTSTRAP_MAX_CHARS};
|
||||
use super::common::make_workspace;
|
||||
use crate::openhuman::context::channels_prompt::{build_system_prompt, BOOTSTRAP_MAX_CHARS};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn prompt_contains_all_sections() {
|
||||
let ws = make_workspace();
|
||||
let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
|
||||
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, Some("Discord"));
|
||||
|
||||
// Section headers
|
||||
assert!(prompt.contains("## Tools"), "missing Tools section");
|
||||
@@ -30,7 +30,7 @@ fn prompt_injects_tools() {
|
||||
("shell", "Run commands"),
|
||||
("memory_recall", "Search memory"),
|
||||
];
|
||||
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, Some("Discord"));
|
||||
|
||||
assert!(prompt.contains("**shell**"));
|
||||
assert!(prompt.contains("Run commands"));
|
||||
@@ -40,7 +40,7 @@ fn prompt_injects_tools() {
|
||||
#[test]
|
||||
fn prompt_injects_safety() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
assert!(prompt.contains("Do not exfiltrate private data"));
|
||||
assert!(prompt.contains("Do not run destructive commands"));
|
||||
@@ -50,7 +50,7 @@ fn prompt_injects_safety() {
|
||||
#[test]
|
||||
fn prompt_injects_workspace_files() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
|
||||
assert!(prompt.contains("Be helpful"), "missing SOUL content");
|
||||
@@ -77,7 +77,7 @@ fn prompt_injects_workspace_files() {
|
||||
fn prompt_missing_file_markers() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Empty workspace — bundled identity files missing should emit markers.
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
assert!(prompt.contains("[File not found: SOUL.md]"));
|
||||
assert!(prompt.contains("[File not found: IDENTITY.md]"));
|
||||
@@ -98,7 +98,7 @@ fn prompt_memory_only_if_exists() {
|
||||
std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity").unwrap();
|
||||
std::fs::write(tmp.path().join("USER.md"), "# User").unwrap();
|
||||
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, Some("Discord"));
|
||||
assert!(
|
||||
!prompt.contains("### MEMORY.md"),
|
||||
"MEMORY.md should not appear when missing"
|
||||
@@ -106,7 +106,7 @@ fn prompt_memory_only_if_exists() {
|
||||
|
||||
// Create MEMORY.md — should appear.
|
||||
std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nLearned bits.").unwrap();
|
||||
let prompt2 = build_system_prompt(tmp.path(), "model", &[], &[], None);
|
||||
let prompt2 = build_system_prompt(tmp.path(), "model", &[], &[], None, Some("Discord"));
|
||||
assert!(
|
||||
prompt2.contains("### MEMORY.md"),
|
||||
"MEMORY.md should appear when present"
|
||||
@@ -126,7 +126,7 @@ fn prompt_no_daily_memory_injection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
// Daily notes should NOT be in the system prompt (on-demand via tools)
|
||||
assert!(
|
||||
@@ -142,7 +142,14 @@ fn prompt_no_daily_memory_injection() {
|
||||
#[test]
|
||||
fn prompt_runtime_metadata() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None);
|
||||
let prompt = build_system_prompt(
|
||||
ws.path(),
|
||||
"claude-sonnet-4",
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
Some("Discord"),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("Model: claude-sonnet-4"));
|
||||
assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
|
||||
@@ -163,7 +170,7 @@ fn prompt_skills_compact_list() {
|
||||
location: None,
|
||||
}];
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, Some("Discord"));
|
||||
|
||||
assert!(prompt.contains("<available_skills>"), "missing skills XML");
|
||||
assert!(prompt.contains("<name>code-review</name>"));
|
||||
@@ -184,7 +191,7 @@ fn prompt_truncation() {
|
||||
let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
|
||||
std::fs::write(ws.path().join("SOUL.md"), &big_content).unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
assert!(
|
||||
prompt.contains("truncated at"),
|
||||
@@ -201,7 +208,7 @@ fn prompt_empty_files_skipped() {
|
||||
let ws = make_workspace();
|
||||
std::fs::write(ws.path().join("USER.md"), "").unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
// Empty file should not produce a header
|
||||
assert!(
|
||||
@@ -230,7 +237,7 @@ fn channel_log_truncation_is_utf8_safe_for_multibyte_text() {
|
||||
#[test]
|
||||
fn prompt_contains_channel_capabilities() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
|
||||
assert!(
|
||||
prompt.contains("## Channel Capabilities"),
|
||||
@@ -249,7 +256,7 @@ fn prompt_contains_channel_capabilities() {
|
||||
#[test]
|
||||
fn prompt_workspace_path() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, Some("Discord"));
|
||||
let workspace_path = ws.path().display().to_string();
|
||||
|
||||
assert!(
|
||||
|
||||
+12
-11
@@ -25,17 +25,18 @@ pub use schema::{
|
||||
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
||||
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
|
||||
AgentConfig, AuditConfig, AutocompleteConfig, AutonomyConfig, BrowserComputerUseConfig,
|
||||
BrowserConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig,
|
||||
CronConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EmbeddingRouteConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig,
|
||||
IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LocalAiConfig, MatrixConfig,
|
||||
MemoryConfig, ModelRouteConfig, MultimodalConfig, ObservabilityConfig, OrchestratorConfig,
|
||||
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig,
|
||||
ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend,
|
||||
SandboxConfig, SchedulerConfig, ScreenIntelligenceConfig, SecretsConfig, SecurityConfig,
|
||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
||||
TelegramConfig, UpdateConfig, VoiceActivationMode, VoiceServerConfig, WebSearchConfig,
|
||||
WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
|
||||
BrowserConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, ContextConfig,
|
||||
CostConfig, CronConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig,
|
||||
DiscordConfig, DockerRuntimeConfig, EmbeddingRouteConfig, HeartbeatConfig, HttpRequestConfig,
|
||||
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig,
|
||||
LocalAiConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
||||
ObservabilityConfig, OrchestratorConfig, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig,
|
||||
ProxyScope, QueryClassificationConfig, ReflectionSource, ReliabilityConfig,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
|
||||
StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig,
|
||||
VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, DEFAULT_MODEL,
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
|
||||
};
|
||||
pub use schema::{
|
||||
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct AgentConfig {
|
||||
}
|
||||
|
||||
fn default_tool_result_budget_bytes() -> usize {
|
||||
crate::openhuman::agent::context_pipeline::DEFAULT_TOOL_RESULT_BUDGET_BYTES
|
||||
crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES
|
||||
}
|
||||
|
||||
fn default_agent_max_tool_iterations() -> usize {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Context management configuration.
|
||||
//!
|
||||
//! Knobs for the global `src/openhuman/context/` module — budget
|
||||
//! thresholds, summarization trigger percentages, microcompact behavior,
|
||||
//! and the session-memory extraction cadence. Wired into the root
|
||||
//! [`super::Config`] as the `context` section; env overrides live in
|
||||
//! [`super::load`].
|
||||
|
||||
use crate::openhuman::context::session_memory::SessionMemoryConfig;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level context-management config. All fields are optional in
|
||||
/// `config.toml` and fall back to the defaults shipped in
|
||||
/// [`ContextConfig::default`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ContextConfig {
|
||||
/// Master switch. When `false`, [`crate::openhuman::context::ContextManager`]
|
||||
/// skips every reduction stage and the summarizer is never invoked.
|
||||
/// Useful for tests and diagnostics; not recommended for production.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Enable stage 3 (microcompact) — clearing older `ToolResults`
|
||||
/// payloads to free tokens before falling back to summarization.
|
||||
#[serde(default = "default_true")]
|
||||
pub microcompact_enabled: bool,
|
||||
|
||||
/// Enable stage 4 (autocompact) — dispatch the summarizer when
|
||||
/// microcompact cannot free enough tokens. Disabling this makes the
|
||||
/// pipeline return `PipelineOutcome::NoOp` at the soft threshold and
|
||||
/// trust the caller to surface the situation via the guard.
|
||||
#[serde(default = "default_true")]
|
||||
pub autocompact_enabled: bool,
|
||||
|
||||
/// Soft compaction trigger as a 0-100 percentage of the model's
|
||||
/// context window. When utilization crosses this, the pipeline runs
|
||||
/// microcompact and (if that doesn't free enough) summarization.
|
||||
/// Defaults to 90 to match the long-standing hardcoded threshold.
|
||||
#[serde(default = "default_compaction_trigger_pct")]
|
||||
pub compaction_trigger_pct: u8,
|
||||
|
||||
/// Hard limit as a 0-100 percentage. Above this and with the
|
||||
/// compaction circuit breaker tripped, the guard returns
|
||||
/// `ContextExhausted` so the agent aborts the turn rather than
|
||||
/// sending an oversized request. Defaults to 95.
|
||||
#[serde(default = "default_hard_limit_pct")]
|
||||
pub hard_limit_pct: u8,
|
||||
|
||||
/// Token budget reserved for the model's output. Subtracted from the
|
||||
/// available budget when deciding how aggressively to reduce the
|
||||
/// prompt. Defaults to 10_000 — large enough for a comfortable
|
||||
/// agentic response without eating too much of the window.
|
||||
#[serde(default = "default_reserve_output_tokens")]
|
||||
pub reserve_output_tokens: u64,
|
||||
|
||||
/// How many of the most-recent `ToolResults` envelopes microcompact
|
||||
/// leaves untouched when it runs. Older envelopes are cleared first.
|
||||
#[serde(default = "default_microcompact_keep_recent")]
|
||||
pub microcompact_keep_recent: usize,
|
||||
|
||||
/// Maximum byte length of a single tool-result body before the
|
||||
/// context pipeline's tool-result budget stage truncates it.
|
||||
/// `0` disables the cap. Applied inline at tool-execution time
|
||||
/// before the result enters history, so it is cache-safe.
|
||||
///
|
||||
/// **Migration note:** this field used to live on
|
||||
/// [`super::AgentConfig::tool_result_budget_bytes`]. It has moved
|
||||
/// here because it is logically a context-reduction knob. A
|
||||
/// compatibility `#[serde(alias)]` on `AgentConfig` keeps existing
|
||||
/// `config.toml` files parsing cleanly during the transition.
|
||||
#[serde(default = "default_tool_result_budget_bytes")]
|
||||
pub tool_result_budget_bytes: usize,
|
||||
|
||||
/// Session-memory extraction thresholds (stage 5 of the pipeline).
|
||||
#[serde(default)]
|
||||
pub session_memory: SessionMemoryConfig,
|
||||
|
||||
/// Override for the model used by the summarizer when autocompaction
|
||||
/// fires. `None` (the default) means "use the caller's current
|
||||
/// model"; set this to a cheaper/faster model to reduce the cost of
|
||||
/// summarization on long sessions.
|
||||
#[serde(default)]
|
||||
pub summarizer_model: Option<String>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_compaction_trigger_pct() -> u8 {
|
||||
90
|
||||
}
|
||||
|
||||
fn default_hard_limit_pct() -> u8 {
|
||||
95
|
||||
}
|
||||
|
||||
fn default_reserve_output_tokens() -> u64 {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn default_microcompact_keep_recent() -> usize {
|
||||
crate::openhuman::context::DEFAULT_KEEP_RECENT_TOOL_RESULTS
|
||||
}
|
||||
|
||||
fn default_tool_result_budget_bytes() -> usize {
|
||||
crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES
|
||||
}
|
||||
|
||||
impl Default for ContextConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_enabled(),
|
||||
microcompact_enabled: default_true(),
|
||||
autocompact_enabled: default_true(),
|
||||
compaction_trigger_pct: default_compaction_trigger_pct(),
|
||||
hard_limit_pct: default_hard_limit_pct(),
|
||||
reserve_output_tokens: default_reserve_output_tokens(),
|
||||
microcompact_keep_recent: default_microcompact_keep_recent(),
|
||||
tool_result_budget_bytes: default_tool_result_budget_bytes(),
|
||||
session_memory: SessionMemoryConfig::default(),
|
||||
summarizer_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -890,6 +890,125 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context management overrides ───────────────────────────────
|
||||
if let Ok(flag) = std::env::var("OPENHUMAN_CONTEXT_ENABLED") {
|
||||
let normalized = flag.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"1" | "true" | "yes" | "on" => self.context.enabled = true,
|
||||
"0" | "false" | "no" | "off" => self.context.enabled = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Ok(flag) = std::env::var("OPENHUMAN_CONTEXT_MICROCOMPACT_ENABLED") {
|
||||
let normalized = flag.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"1" | "true" | "yes" | "on" => self.context.microcompact_enabled = true,
|
||||
"0" | "false" | "no" | "off" => self.context.microcompact_enabled = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Ok(flag) = std::env::var("OPENHUMAN_CONTEXT_AUTOCOMPACT_ENABLED") {
|
||||
let normalized = flag.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"1" | "true" | "yes" | "on" => self.context.autocompact_enabled = true,
|
||||
"0" | "false" | "no" | "off" => self.context.autocompact_enabled = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Parse both percentage env vars into temporaries so we can
|
||||
// enforce the `compaction < hard_limit` invariant before
|
||||
// touching the live config. Each slot is independently
|
||||
// validated (1..=100, rejecting 0 so we never arm the guard at
|
||||
// an always-true trigger) and then cross-validated as a pair.
|
||||
// On any failure we leave the existing values intact and emit a
|
||||
// warning naming the offending env var + value.
|
||||
let compaction_raw = std::env::var("OPENHUMAN_CONTEXT_COMPACTION_TRIGGER_PCT").ok();
|
||||
let hard_limit_raw = std::env::var("OPENHUMAN_CONTEXT_HARD_LIMIT_PCT").ok();
|
||||
|
||||
let parse_pct = |name: &str, raw: &str| -> Option<u8> {
|
||||
match raw.trim().parse::<u8>() {
|
||||
Ok(pct) if (1..=100).contains(&pct) => Some(pct),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
env = %name,
|
||||
value = %raw,
|
||||
"[context:config] invalid percentage — must be integer in 1..=100; ignoring"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let new_compaction = compaction_raw
|
||||
.as_deref()
|
||||
.and_then(|v| parse_pct("OPENHUMAN_CONTEXT_COMPACTION_TRIGGER_PCT", v));
|
||||
let new_hard_limit = hard_limit_raw
|
||||
.as_deref()
|
||||
.and_then(|v| parse_pct("OPENHUMAN_CONTEXT_HARD_LIMIT_PCT", v));
|
||||
|
||||
// Effective pair after applying whichever overrides parsed
|
||||
// cleanly, falling back to the current live values for any
|
||||
// unset side.
|
||||
let effective_compaction = new_compaction.unwrap_or(self.context.compaction_trigger_pct);
|
||||
let effective_hard_limit = new_hard_limit.unwrap_or(self.context.hard_limit_pct);
|
||||
|
||||
if effective_compaction < effective_hard_limit {
|
||||
if let Some(pct) = new_compaction {
|
||||
self.context.compaction_trigger_pct = pct;
|
||||
}
|
||||
if let Some(pct) = new_hard_limit {
|
||||
self.context.hard_limit_pct = pct;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
compaction_trigger_pct = effective_compaction,
|
||||
hard_limit_pct = effective_hard_limit,
|
||||
"[context:config] refusing env overrides — compaction_trigger_pct must be strictly less than hard_limit_pct; leaving existing values unchanged"
|
||||
);
|
||||
}
|
||||
if let Ok(val) = std::env::var("OPENHUMAN_CONTEXT_RESERVE_OUTPUT_TOKENS") {
|
||||
if let Ok(n) = val.trim().parse::<u64>() {
|
||||
self.context.reserve_output_tokens = n;
|
||||
}
|
||||
}
|
||||
if let Ok(val) = std::env::var("OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES") {
|
||||
if let Ok(n) = val.trim().parse::<usize>() {
|
||||
self.context.tool_result_budget_bytes = n;
|
||||
}
|
||||
}
|
||||
if let Ok(model) = std::env::var("OPENHUMAN_CONTEXT_SUMMARIZER_MODEL") {
|
||||
let model = model.trim();
|
||||
if !model.is_empty() {
|
||||
self.context.summarizer_model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: `agent.tool_result_budget_bytes` used to own this
|
||||
// knob before it moved to `context.tool_result_budget_bytes`. If
|
||||
// an existing config.toml sets the old field to a non-default
|
||||
// value and the new field is still at its default AND the env
|
||||
// var is not present, copy the old value forward and emit a
|
||||
// deprecation warning so the user knows to move it. The env var
|
||||
// check is important: without it a user who explicitly sets
|
||||
// `OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES` to the default
|
||||
// value would have their env override silently clobbered by the
|
||||
// agent-field migration.
|
||||
let context_default = crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES;
|
||||
let context_env_set =
|
||||
std::env::var_os("OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES").is_some();
|
||||
if !context_env_set
|
||||
&& self.context.tool_result_budget_bytes == context_default
|
||||
&& self.agent.tool_result_budget_bytes != context_default
|
||||
{
|
||||
tracing::warn!(
|
||||
old = self.agent.tool_result_budget_bytes,
|
||||
"[context:config] `agent.tool_result_budget_bytes` is \
|
||||
deprecated — please move it to \
|
||||
`context.tool_result_budget_bytes` in your config.toml"
|
||||
);
|
||||
self.context.tool_result_budget_bytes = self.agent.tool_result_budget_bytes;
|
||||
}
|
||||
|
||||
if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
|
||||
self.proxy.apply_to_process_env();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod agent;
|
||||
mod autocomplete;
|
||||
mod autonomy;
|
||||
mod channels;
|
||||
mod context;
|
||||
mod defaults;
|
||||
mod dictation;
|
||||
mod heartbeat_cron;
|
||||
@@ -37,6 +38,7 @@ pub use channels::{
|
||||
SandboxBackend, SandboxConfig, SecurityConfig, SignalConfig, SlackConfig, StreamMode,
|
||||
TelegramConfig, WebhookConfig, WhatsAppConfig,
|
||||
};
|
||||
pub use context::ContextConfig;
|
||||
pub use dictation::{DictationActivationMode, DictationConfig};
|
||||
pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
|
||||
pub use identity_cost::{CostConfig, ModelPricing, PeripheralBoardConfig, PeripheralsConfig};
|
||||
|
||||
@@ -57,6 +57,13 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub agent: AgentConfig,
|
||||
|
||||
/// Global context management configuration — budget thresholds,
|
||||
/// summarization trigger, microcompact/autocompact toggles, and the
|
||||
/// session-memory extraction cadence. Consumed by
|
||||
/// [`crate::openhuman::context::ContextManager`].
|
||||
#[serde(default)]
|
||||
pub context: ContextConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub model_routes: Vec<ModelRouteConfig>,
|
||||
|
||||
@@ -158,6 +165,7 @@ impl Default for Config {
|
||||
reliability: ReliabilityConfig::default(),
|
||||
scheduler: SchedulerConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
context: ContextConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
//! System prompt construction for channel interactions.
|
||||
//! System prompt construction for channel runtimes.
|
||||
//!
|
||||
//! Channel runtimes (Discord, Slack, Telegram, …) need a system prompt
|
||||
//! that is shaped differently from the main agent's:
|
||||
//!
|
||||
//! - Tool descriptions come in as `(name, description)` tuples from
|
||||
//! the channel's tool registry, not as `Box<dyn Tool>` instances.
|
||||
//! - The prompt includes channel-specific preambles (the "Your Task"
|
||||
//! action instruction, the "Channel Capabilities" section) that the
|
||||
//! main agent's builder doesn't emit.
|
||||
//! - The datetime block is timezone-only — channel startup happens
|
||||
//! once per process, so we keep the prompt byte-stable within a run
|
||||
//! to maximise prefix-cache hits on the inference backend.
|
||||
//!
|
||||
//! Because the byte layout must not drift during consolidation
|
||||
//! (channel prompts are live in production), this module keeps its
|
||||
//! bespoke [`build_system_prompt`] free function rather than routing
|
||||
//! through [`super::SystemPromptBuilder`]. The file lives here under
|
||||
//! `context/` so every system-prompt-building code path — main
|
||||
//! agents, sub-agents, channel runtimes — has a single home. See the
|
||||
//! `misty-bubbling-bunny` plan file for the roadmap toward a unified
|
||||
//! builder.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -51,6 +72,7 @@ pub fn build_system_prompt(
|
||||
tools: &[(&str, &str)],
|
||||
skills: &[crate::openhuman::skills::Skill],
|
||||
bootstrap_max_chars: Option<usize>,
|
||||
channel_name: Option<&str>,
|
||||
) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut prompt = String::with_capacity(8192);
|
||||
@@ -143,11 +165,34 @@ pub fn build_system_prompt(
|
||||
);
|
||||
|
||||
// ── 8. Channel Capabilities ─────────────────────────────────────
|
||||
//
|
||||
// This block used to hardcode "Discord", which was misleading on
|
||||
// Telegram/Slack/Signal runtimes even though the mechanical wiring
|
||||
// was identical. We now take an optional `channel_name` and render
|
||||
// it into the capability bullets when set, otherwise fall back to a
|
||||
// platform-agnostic "messaging bot" phrasing. Keep the remaining
|
||||
// bullets intact — they're genuinely channel-neutral.
|
||||
prompt.push_str("## Channel Capabilities\n\n");
|
||||
prompt.push_str(
|
||||
"- You are running as a Discord bot. You CAN and do send messages to Discord channels.\n",
|
||||
);
|
||||
prompt.push_str("- When someone messages you on Discord, your response is automatically sent back to Discord.\n");
|
||||
match channel_name {
|
||||
Some(name) => {
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"- You are running as a {name} bot. You CAN and do send messages to {name}."
|
||||
);
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"- When someone messages you on {name}, your response is automatically sent back to {name}."
|
||||
);
|
||||
}
|
||||
None => {
|
||||
prompt.push_str(
|
||||
"- You are running as a messaging bot. You CAN and do send messages to the connected platform.\n",
|
||||
);
|
||||
prompt.push_str(
|
||||
"- When someone messages you, your response is automatically sent back to the same platform.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
prompt.push_str("- You do NOT need to ask permission to respond — just respond directly.\n");
|
||||
prompt.push_str("- NEVER repeat, describe, or echo credentials, tokens, API keys, or secrets in your responses.\n");
|
||||
prompt.push_str("- If a tool output contains credentials, they have already been redacted — do not mention them.\n\n");
|
||||
@@ -145,6 +145,22 @@ impl ContextGuard {
|
||||
pub fn consecutive_failures(&self) -> u8 {
|
||||
self.consecutive_compaction_failures
|
||||
}
|
||||
|
||||
/// Last input-token count seen on a provider response.
|
||||
pub fn last_input_tokens(&self) -> u64 {
|
||||
self.last_input_tokens
|
||||
}
|
||||
|
||||
/// Last output-token count seen on a provider response.
|
||||
pub fn last_output_tokens(&self) -> u64 {
|
||||
self.last_output_tokens
|
||||
}
|
||||
|
||||
/// The currently-known model context window. `0` means unknown —
|
||||
/// the guard runs as a no-op in that case.
|
||||
pub fn context_window(&self) -> u64 {
|
||||
self.context_window
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -0,0 +1,667 @@
|
||||
//! [`ContextManager`] — the single per-session handle agents use to
|
||||
//! manage their prompt and their in-flight conversation context.
|
||||
//!
|
||||
//! # What this owns
|
||||
//!
|
||||
//! 1. **System prompt assembly** — a default [`SystemPromptBuilder`]
|
||||
//! configured once at session start (usually
|
||||
//! `SystemPromptBuilder::with_defaults()`). Callers that need a
|
||||
//! different builder shape — sub-agent archetype sections, channel
|
||||
//! capabilities sections — pass their own via
|
||||
//! [`ContextManager::build_system_prompt_with`].
|
||||
//!
|
||||
//! 2. **Mechanical context reduction** — a [`ContextPipeline`] with its
|
||||
//! guard, microcompact stage, and session-memory tracker.
|
||||
//!
|
||||
//! 3. **LLM summarization dispatch** — an `Arc<dyn Summarizer>` that
|
||||
//! gets called when the pipeline reports
|
||||
//! [`PipelineOutcome::AutocompactionRequested`]. The manager records
|
||||
//! the summarizer outcome on the guard's circuit breaker so
|
||||
//! repeated failures don't loop forever.
|
||||
//!
|
||||
//! # What it doesn't own
|
||||
//!
|
||||
//! The session-memory extraction *task itself* still lives in the
|
||||
//! agent harness (`turn.rs` spawns the archivist sub-agent). The
|
||||
//! manager only owns the *state* that decides whether the trigger
|
||||
//! should fire; it exposes that via
|
||||
//! [`ContextManager::should_extract_session_memory`] so `turn.rs` can
|
||||
//! gate its existing `spawn_subagent` call.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::pipeline::{
|
||||
ContextPipeline, ContextPipelineConfig, PipelineOutcome, SessionMemoryHandle,
|
||||
};
|
||||
use super::prompt::{PromptContext, SystemPromptBuilder};
|
||||
use super::session_memory::SessionMemoryConfig;
|
||||
use super::summarizer::{Summarizer, SummaryStats};
|
||||
use crate::openhuman::config::ContextConfig;
|
||||
use crate::openhuman::providers::{ConversationMessage, UsageInfo};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Outcome of a reduction pass driven by [`ContextManager::reduce_before_call`].
|
||||
///
|
||||
/// This is a slightly wider shape than [`PipelineOutcome`] because the
|
||||
/// manager surfaces the result of the summarizer LLM call as a
|
||||
/// first-class variant — the pipeline alone can only return
|
||||
/// `AutocompactionRequested`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ReductionOutcome {
|
||||
/// No stage fired — budget is healthy and history was untouched.
|
||||
NoOp,
|
||||
/// The pipeline's microcompact stage cleared one or more older
|
||||
/// tool-result envelopes. The history has been mutated in place.
|
||||
Microcompacted {
|
||||
envelopes_cleared: usize,
|
||||
entries_cleared: usize,
|
||||
bytes_freed: usize,
|
||||
},
|
||||
/// The pipeline asked for summarization and the summarizer
|
||||
/// successfully rewrote the head of the history. Contains the
|
||||
/// summarizer's own stats for logging / RPC surfacing.
|
||||
Summarized(SummaryStats),
|
||||
/// The summarizer was asked to run but failed — the guard's
|
||||
/// compaction circuit breaker has been nudged. If this happens
|
||||
/// three times in a row the breaker trips and subsequent calls
|
||||
/// return [`ReductionOutcome::Exhausted`].
|
||||
SummarizationFailed { utilisation_pct: u8, reason: String },
|
||||
/// The circuit breaker is tripped and the context is still above
|
||||
/// the hard limit — the agent turn should abort.
|
||||
Exhausted { utilisation_pct: u8, reason: String },
|
||||
/// Autocompaction was requested but disabled by config. The
|
||||
/// caller is expected to surface this via the guard directly.
|
||||
NotAttempted { utilisation_pct: u8 },
|
||||
}
|
||||
|
||||
/// Read-only snapshot of per-session context state. Returned by
|
||||
/// [`ContextManager::stats`] for observability and the optional
|
||||
/// `context.get_stats` RPC.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextStats {
|
||||
pub utilisation_pct: Option<u8>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub context_window: u64,
|
||||
pub compaction_disabled: bool,
|
||||
pub consecutive_compaction_failures: u8,
|
||||
pub session_memory_total_tokens: u64,
|
||||
pub session_memory_current_turn: u64,
|
||||
pub session_memory_total_tool_calls: u64,
|
||||
}
|
||||
|
||||
/// Per-session context manager. Constructed once by the agent harness
|
||||
/// at session start; lives for the whole lifetime of the `Agent`.
|
||||
pub struct ContextManager {
|
||||
pipeline: ContextPipeline,
|
||||
summarizer: Arc<dyn Summarizer>,
|
||||
/// Model used for the summarization LLM call. Defaults to the
|
||||
/// session's main model; can be overridden via
|
||||
/// [`ContextConfig::summarizer_model`] when the user wants a
|
||||
/// cheaper model for compaction.
|
||||
summarizer_model: String,
|
||||
/// The default system-prompt builder used by
|
||||
/// [`ContextManager::build_system_prompt`]. Held by value so the
|
||||
/// agent's construction-time builder configuration survives the
|
||||
/// move into the manager.
|
||||
default_prompt_builder: SystemPromptBuilder,
|
||||
/// Whether the entire module is enabled. When `false`,
|
||||
/// [`ContextManager::reduce_before_call`] always returns `NoOp`.
|
||||
/// Useful for tests and debugging; see
|
||||
/// [`ContextConfig::enabled`].
|
||||
enabled: bool,
|
||||
/// Per-tool-result byte cap applied inline at tool-execution time.
|
||||
/// Stored on the manager (rather than on the agent directly) so
|
||||
/// every caller that touches "what's in the model's context window"
|
||||
/// reads the same source of truth.
|
||||
tool_result_budget_bytes: usize,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
/// Construct a manager for a session.
|
||||
///
|
||||
/// * `config` — the loaded [`ContextConfig`] section.
|
||||
/// * `summarizer` — typically a [`super::ProviderSummarizer`]
|
||||
/// wrapping the session's provider, but tests pass a mock.
|
||||
/// * `main_model` — the agent's main model; used as the
|
||||
/// summarizer model unless `config.summarizer_model` overrides.
|
||||
/// * `default_prompt_builder` — the builder [`build_system_prompt`]
|
||||
/// calls. For most agents this is `SystemPromptBuilder::with_defaults()`.
|
||||
pub fn new(
|
||||
config: &ContextConfig,
|
||||
summarizer: Arc<dyn Summarizer>,
|
||||
main_model: String,
|
||||
default_prompt_builder: SystemPromptBuilder,
|
||||
) -> Self {
|
||||
// Map ContextConfig into the mechanical pipeline's own config
|
||||
// struct. Session-memory thresholds flow through unchanged.
|
||||
let pipeline_config = ContextPipelineConfig {
|
||||
microcompact_keep_recent: config.microcompact_keep_recent,
|
||||
microcompact_enabled: config.microcompact_enabled,
|
||||
autocompact_enabled: config.autocompact_enabled,
|
||||
session_memory: SessionMemoryConfig {
|
||||
min_token_growth: config.session_memory.min_token_growth,
|
||||
min_tool_calls: config.session_memory.min_tool_calls,
|
||||
min_turns_between: config.session_memory.min_turns_between,
|
||||
},
|
||||
};
|
||||
|
||||
let summarizer_model = config.summarizer_model.clone().unwrap_or(main_model);
|
||||
|
||||
Self {
|
||||
pipeline: ContextPipeline::new(pipeline_config),
|
||||
summarizer,
|
||||
summarizer_model,
|
||||
default_prompt_builder,
|
||||
enabled: config.enabled,
|
||||
tool_result_budget_bytes: config.tool_result_budget_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte budget for an individual tool result before the context
|
||||
/// pipeline's inline truncation stage fires. Agents read this when
|
||||
/// a tool returns to apply the cap before the result enters
|
||||
/// history.
|
||||
pub fn tool_result_budget_bytes(&self) -> usize {
|
||||
self.tool_result_budget_bytes
|
||||
}
|
||||
|
||||
// ─── Budget tracking ──────────────────────────────────────────
|
||||
|
||||
/// Feed the latest provider [`UsageInfo`] into the guard + the
|
||||
/// session-memory state.
|
||||
pub fn record_usage(&mut self, usage: &UsageInfo) {
|
||||
self.pipeline.record_usage(usage);
|
||||
}
|
||||
|
||||
/// Bump the session-memory turn counter (called once per user turn).
|
||||
pub fn tick_turn(&mut self) {
|
||||
self.pipeline.tick_turn();
|
||||
}
|
||||
|
||||
/// Accumulate a turn's tool-call count into the session-memory state.
|
||||
pub fn record_tool_calls(&mut self, n: usize) {
|
||||
self.pipeline.record_tool_calls(n);
|
||||
}
|
||||
|
||||
/// Whether the caller should spawn a background session-memory
|
||||
/// extraction this turn. Delegates to the underlying pipeline
|
||||
/// state; the manager does not spawn the extraction itself.
|
||||
pub fn should_extract_session_memory(&self) -> bool {
|
||||
self.pipeline.should_extract_session_memory()
|
||||
}
|
||||
|
||||
/// Mark a session-memory extraction as started (so repeated
|
||||
/// calls to [`should_extract_session_memory`] return `false` until
|
||||
/// the extraction completes).
|
||||
pub fn mark_session_memory_started(&mut self) {
|
||||
if let Ok(mut sm) = self.pipeline.session_memory.lock() {
|
||||
sm.mark_extraction_started();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a session-memory extraction as complete — resets deltas.
|
||||
pub fn mark_session_memory_complete(&mut self) {
|
||||
if let Ok(mut sm) = self.pipeline.session_memory.lock() {
|
||||
sm.mark_extraction_complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a session-memory extraction as failed — keeps deltas
|
||||
/// intact so the next turn retries.
|
||||
pub fn mark_session_memory_failed(&mut self) {
|
||||
if let Ok(mut sm) = self.pipeline.session_memory.lock() {
|
||||
sm.mark_extraction_failed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone the shared session-memory handle so a detached background
|
||||
/// task (see `turn.rs::spawn_session_memory_extraction`) can mark
|
||||
/// the extraction complete or failed once it finishes. The
|
||||
/// foreground path is expected to call
|
||||
/// [`Self::mark_session_memory_started`] *before* spawning so
|
||||
/// overlapping turns don't fire duplicate extractions while this
|
||||
/// one is in flight.
|
||||
pub fn session_memory_handle(&self) -> SessionMemoryHandle {
|
||||
self.pipeline.session_memory_handle()
|
||||
}
|
||||
|
||||
// ─── Prompt building ───────────────────────────────────────────
|
||||
|
||||
/// Assemble the opening system prompt for a session using the
|
||||
/// manager's default [`SystemPromptBuilder`].
|
||||
pub fn build_system_prompt(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
self.default_prompt_builder.build(ctx)
|
||||
}
|
||||
|
||||
/// Assemble the system prompt via a caller-supplied builder.
|
||||
///
|
||||
/// Sub-agents pass `SystemPromptBuilder::for_subagent(...)` and
|
||||
/// channels pass `with_defaults()` chained with a
|
||||
/// `ChannelCapabilitiesSection`. Either way the builder itself
|
||||
/// lives in [`super::prompt`] — no caller needs to know how
|
||||
/// sections are composed internally.
|
||||
pub fn build_system_prompt_with(
|
||||
&self,
|
||||
builder: &SystemPromptBuilder,
|
||||
ctx: &PromptContext<'_>,
|
||||
) -> Result<String> {
|
||||
builder.build(ctx)
|
||||
}
|
||||
|
||||
// ─── Reduction ─────────────────────────────────────────────────
|
||||
|
||||
/// Run the reduction chain against `history` before a provider
|
||||
/// call. Cheap when the guard is healthy; executes the
|
||||
/// summarization LLM call internally when the pipeline asks for
|
||||
/// autocompaction.
|
||||
///
|
||||
/// This is the single reduction entry point — agents call it once
|
||||
/// before every provider hit and map the returned
|
||||
/// [`ReductionOutcome`] into their own logging / abort logic.
|
||||
pub async fn reduce_before_call(
|
||||
&mut self,
|
||||
history: &mut Vec<ConversationMessage>,
|
||||
) -> Result<ReductionOutcome> {
|
||||
if !self.enabled {
|
||||
return Ok(ReductionOutcome::NoOp);
|
||||
}
|
||||
|
||||
match self.pipeline.run_before_call(history) {
|
||||
PipelineOutcome::NoOp => Ok(ReductionOutcome::NoOp),
|
||||
|
||||
PipelineOutcome::Microcompacted(stats) => Ok(ReductionOutcome::Microcompacted {
|
||||
envelopes_cleared: stats.envelopes_cleared,
|
||||
entries_cleared: stats.entries_cleared,
|
||||
bytes_freed: stats.bytes_freed,
|
||||
}),
|
||||
|
||||
PipelineOutcome::ContextExhausted {
|
||||
utilisation_pct,
|
||||
reason,
|
||||
} => Ok(ReductionOutcome::Exhausted {
|
||||
utilisation_pct,
|
||||
reason,
|
||||
}),
|
||||
|
||||
PipelineOutcome::AutocompactionDisabled { utilisation_pct } => {
|
||||
Ok(ReductionOutcome::NotAttempted { utilisation_pct })
|
||||
}
|
||||
|
||||
PipelineOutcome::AutocompactionRequested { utilisation_pct } => {
|
||||
// Dispatch the summarizer. If it succeeds we reset the
|
||||
// guard's circuit breaker so a prior string of failures
|
||||
// doesn't leave us permanently disabled after a good
|
||||
// run. On failure, we nudge the breaker — three
|
||||
// consecutive failures trip it and we return
|
||||
// `Exhausted` the next time the guard is checked.
|
||||
tracing::info!(
|
||||
utilisation_pct,
|
||||
model = %self.summarizer_model,
|
||||
"[context::manager] dispatching autocompaction summarizer"
|
||||
);
|
||||
match self
|
||||
.summarizer
|
||||
.summarize(history, &self.summarizer_model)
|
||||
.await
|
||||
{
|
||||
Ok(stats) => {
|
||||
self.pipeline.guard.record_compaction_success();
|
||||
Ok(ReductionOutcome::Summarized(stats))
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = e.to_string();
|
||||
tracing::warn!(
|
||||
utilisation_pct,
|
||||
error = %reason,
|
||||
"[context::manager] summarizer failed — nudging circuit breaker"
|
||||
);
|
||||
self.pipeline.guard.record_compaction_failure();
|
||||
Ok(ReductionOutcome::SummarizationFailed {
|
||||
utilisation_pct,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Observability ─────────────────────────────────────────────
|
||||
|
||||
/// Read-only snapshot of the current budget state.
|
||||
pub fn stats(&self) -> ContextStats {
|
||||
let utilisation_pct = self
|
||||
.pipeline
|
||||
.guard
|
||||
.utilization()
|
||||
.map(|u| (u * 100.0).round() as u8);
|
||||
let sm = self.pipeline.session_memory_snapshot();
|
||||
ContextStats {
|
||||
utilisation_pct,
|
||||
input_tokens: self.pipeline.guard.last_input_tokens(),
|
||||
output_tokens: self.pipeline.guard.last_output_tokens(),
|
||||
context_window: self.pipeline.guard.context_window(),
|
||||
compaction_disabled: self.pipeline.guard.is_compaction_disabled(),
|
||||
consecutive_compaction_failures: self.pipeline.guard.consecutive_failures(),
|
||||
session_memory_total_tokens: sm.total_tokens,
|
||||
session_memory_current_turn: sm.current_turn,
|
||||
session_memory_total_tool_calls: sm.total_tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::providers::{ChatMessage, ToolCall, ToolResultMessage};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn user(s: &str) -> ConversationMessage {
|
||||
ConversationMessage::Chat(ChatMessage::user(s))
|
||||
}
|
||||
|
||||
fn call(id: &str) -> ConversationMessage {
|
||||
ConversationMessage::AssistantToolCalls {
|
||||
text: None,
|
||||
tool_calls: vec![ToolCall {
|
||||
id: id.into(),
|
||||
name: "t".into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn result(id: &str, body: &str) -> ConversationMessage {
|
||||
ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: id.into(),
|
||||
content: body.into(),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Mock summarizer that records how many times it was called and
|
||||
/// can be configured to succeed or fail.
|
||||
struct MockSummarizer {
|
||||
calls: Mutex<usize>,
|
||||
should_fail: bool,
|
||||
}
|
||||
|
||||
impl MockSummarizer {
|
||||
fn ok() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: Mutex::new(0),
|
||||
should_fail: false,
|
||||
})
|
||||
}
|
||||
fn failing() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: Mutex::new(0),
|
||||
should_fail: true,
|
||||
})
|
||||
}
|
||||
fn call_count(&self) -> usize {
|
||||
*self.calls.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Summarizer for MockSummarizer {
|
||||
async fn summarize(
|
||||
&self,
|
||||
history: &mut Vec<ConversationMessage>,
|
||||
_model: &str,
|
||||
) -> Result<SummaryStats> {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
if self.should_fail {
|
||||
anyhow::bail!("mock failure");
|
||||
}
|
||||
// Rewrite the history to a single system summary to
|
||||
// simulate a successful reduction.
|
||||
let removed = history.len();
|
||||
history.clear();
|
||||
history.push(ConversationMessage::Chat(ChatMessage::system(
|
||||
"mock summary",
|
||||
)));
|
||||
Ok(SummaryStats {
|
||||
messages_removed: removed,
|
||||
approx_tokens_freed: 1_000,
|
||||
summary_chars: 12,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_with(summarizer: Arc<dyn Summarizer>) -> ContextManager {
|
||||
let config = ContextConfig::default();
|
||||
ContextManager::new(
|
||||
&config,
|
||||
summarizer,
|
||||
"test-model".into(),
|
||||
SystemPromptBuilder::with_defaults(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reduce_returns_noop_when_guard_is_healthy() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
let mut manager = manager_with(summarizer.clone());
|
||||
|
||||
// Low utilisation — guard says ok, pipeline is a no-op.
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 5_000,
|
||||
output_tokens: 500,
|
||||
context_window: 100_000,
|
||||
});
|
||||
|
||||
let mut history = vec![user("hi")];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
|
||||
assert!(matches!(outcome, ReductionOutcome::NoOp));
|
||||
assert_eq!(summarizer.call_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reduce_surfaces_microcompact_without_calling_summarizer() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
let mut manager = manager_with(summarizer.clone());
|
||||
|
||||
// Push utilisation above the 90% soft threshold.
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 92_000,
|
||||
output_tokens: 4_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
|
||||
// Build a history with several older tool-result envelopes
|
||||
// that microcompact can clear — the default keep_recent is
|
||||
// DEFAULT_KEEP_RECENT_TOOL_RESULTS (5), so include at least
|
||||
// 7 pairs so the older ones are eligible.
|
||||
let mut history = vec![
|
||||
call("t1"),
|
||||
result("t1", &"x".repeat(5_000)),
|
||||
call("t2"),
|
||||
result("t2", &"x".repeat(5_000)),
|
||||
call("t3"),
|
||||
result("t3", "a"),
|
||||
call("t4"),
|
||||
result("t4", "b"),
|
||||
call("t5"),
|
||||
result("t5", "c"),
|
||||
call("t6"),
|
||||
result("t6", "d"),
|
||||
call("t7"),
|
||||
result("t7", "e"),
|
||||
];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
|
||||
match outcome {
|
||||
ReductionOutcome::Microcompacted {
|
||||
envelopes_cleared, ..
|
||||
} => {
|
||||
assert!(envelopes_cleared > 0);
|
||||
}
|
||||
other => panic!("expected Microcompacted, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
summarizer.call_count(),
|
||||
0,
|
||||
"microcompact must not invoke summarizer"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reduce_dispatches_summarizer_and_records_success() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
let mut manager = manager_with(summarizer.clone());
|
||||
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 92_000,
|
||||
output_tokens: 4_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
|
||||
// History with no old tool-result envelopes — microcompact
|
||||
// has nothing to clear, so the pipeline signals
|
||||
// AutocompactionRequested and the manager calls the summarizer.
|
||||
let mut history = vec![user("one"), user("two"), user("three")];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
|
||||
match outcome {
|
||||
ReductionOutcome::Summarized(stats) => {
|
||||
assert_eq!(stats.messages_removed, 3);
|
||||
}
|
||||
other => panic!("expected Summarized, got {other:?}"),
|
||||
}
|
||||
assert_eq!(summarizer.call_count(), 1);
|
||||
assert_eq!(
|
||||
history.len(),
|
||||
1,
|
||||
"mock replaced history with a single summary msg"
|
||||
);
|
||||
// Guard breaker should NOT be tripped on success.
|
||||
assert!(!manager.pipeline.guard.is_compaction_disabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarizer_failure_trips_breaker_after_three_tries() {
|
||||
let summarizer = MockSummarizer::failing();
|
||||
let mut manager = manager_with(summarizer);
|
||||
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 92_000,
|
||||
output_tokens: 4_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
|
||||
// Try three times — each call sends the pipeline into
|
||||
// AutocompactionRequested, the mock summarizer fails, and
|
||||
// the breaker nudges forward. The fourth call should report
|
||||
// Exhausted because the breaker is tripped.
|
||||
for _ in 0..3 {
|
||||
let mut history = vec![user("a"), user("b"), user("c")];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
match outcome {
|
||||
ReductionOutcome::SummarizationFailed { .. } => {}
|
||||
other => panic!("expected SummarizationFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(manager.pipeline.guard.is_compaction_disabled());
|
||||
|
||||
// Nudge the guard above the hard limit so the next pipeline
|
||||
// pass returns ContextExhausted.
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 96_000,
|
||||
output_tokens: 2_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
let mut history = vec![user("x")];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
assert!(matches!(outcome, ReductionOutcome::Exhausted { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_autocompact_returns_not_attempted() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
let mut config = ContextConfig::default();
|
||||
// Keep master switch on but disable just the autocompact stage
|
||||
// so the pipeline routes through AutocompactionDisabled instead
|
||||
// of NoOp.
|
||||
config.autocompact_enabled = false;
|
||||
let mut manager = ContextManager::new(
|
||||
&config,
|
||||
summarizer.clone(),
|
||||
"test-model".into(),
|
||||
SystemPromptBuilder::with_defaults(),
|
||||
);
|
||||
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 92_000,
|
||||
output_tokens: 4_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
|
||||
// No old tool-result envelopes — microcompact cannot free
|
||||
// anything, so the pipeline lands in the autocompact branch.
|
||||
let mut history = vec![user("one"), user("two"), user("three")];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
|
||||
match outcome {
|
||||
ReductionOutcome::NotAttempted { utilisation_pct } => {
|
||||
assert!(utilisation_pct >= 90);
|
||||
}
|
||||
other => panic!("expected NotAttempted, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
summarizer.call_count(),
|
||||
0,
|
||||
"summarizer must not run when autocompact is disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_manager_returns_noop() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
let mut config = ContextConfig::default();
|
||||
config.enabled = false;
|
||||
let mut manager = ContextManager::new(
|
||||
&config,
|
||||
summarizer.clone(),
|
||||
"test-model".into(),
|
||||
SystemPromptBuilder::with_defaults(),
|
||||
);
|
||||
|
||||
// High utilisation would normally trigger something.
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 96_000,
|
||||
output_tokens: 2_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
|
||||
let mut history = vec![user("a"), user("b"), user("c")];
|
||||
let outcome = manager.reduce_before_call(&mut history).await.unwrap();
|
||||
assert!(matches!(outcome, ReductionOutcome::NoOp));
|
||||
assert_eq!(summarizer.call_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_reports_snapshot() {
|
||||
let summarizer = MockSummarizer::ok();
|
||||
let mut manager = manager_with(summarizer);
|
||||
manager.record_usage(&UsageInfo {
|
||||
input_tokens: 10_000,
|
||||
output_tokens: 2_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
manager.tick_turn();
|
||||
manager.record_tool_calls(3);
|
||||
|
||||
let s = manager.stats();
|
||||
assert_eq!(s.input_tokens, 10_000);
|
||||
assert_eq!(s.output_tokens, 2_000);
|
||||
assert_eq!(s.context_window, 100_000);
|
||||
assert_eq!(s.utilisation_pct, Some(12));
|
||||
assert_eq!(s.session_memory_total_tokens, 12_000);
|
||||
assert_eq!(s.session_memory_current_turn, 1);
|
||||
assert_eq!(s.session_memory_total_tool_calls, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Global context management for agent sessions.
|
||||
//!
|
||||
//! This module is the single home for everything that shapes what an LLM
|
||||
//! sees during a conversation:
|
||||
//!
|
||||
//! 1. **System prompt assembly** — [`prompt::SystemPromptBuilder`] and its
|
||||
//! composable [`prompt::PromptSection`] trait. Main agents, sub-agents,
|
||||
//! and channels all build their opening system prompts through this
|
||||
//! module; there is no parallel implementation elsewhere in the crate.
|
||||
//!
|
||||
//! 2. **Mechanical history reduction** — the layered [`pipeline`] (tool
|
||||
//! result budget → trim → microcompact → autocompact signal → session
|
||||
//! memory trigger) keeps the in-flight conversation within the
|
||||
//! provider's context window.
|
||||
//!
|
||||
//! 3. **Summarization execution** — when the pipeline asks for
|
||||
//! autocompaction, [`ContextManager`] dispatches the LLM summarization
|
||||
//! call via a [`summarizer::Summarizer`] implementation. Agents do not
|
||||
//! call the provider directly for compaction; they hand their history
|
||||
//! to the manager and get back a reduced history.
|
||||
//!
|
||||
//! Agents hold a single [`ContextManager`] per session. The manager owns
|
||||
//! per-conversation state (budget, circuit breaker, session-memory
|
||||
//! counters) but all of the shared logic — prompt sections, reduction
|
||||
//! stages, the summarizer contract — lives in this module so new agent
|
||||
//! archetypes and delegation tools do not need to re-wire any of it.
|
||||
//!
|
||||
//! Submodules are added incrementally as the `agent/` → `context/`
|
||||
//! migration lands (see plan `misty-bubbling-bunny.md`).
|
||||
|
||||
pub mod channels_prompt;
|
||||
pub mod guard;
|
||||
pub mod manager;
|
||||
pub mod microcompact;
|
||||
pub mod pipeline;
|
||||
pub mod prompt;
|
||||
pub mod session_memory;
|
||||
pub mod summarizer;
|
||||
pub mod tool_result_budget;
|
||||
|
||||
pub use guard::{ContextCheckResult, ContextGuard};
|
||||
pub use manager::{ContextManager, ContextStats, ReductionOutcome};
|
||||
pub use microcompact::{
|
||||
microcompact, MicrocompactStats, CLEARED_PLACEHOLDER, DEFAULT_KEEP_RECENT_TOOL_RESULTS,
|
||||
};
|
||||
pub use pipeline::{ContextPipeline, ContextPipelineConfig, PipelineOutcome};
|
||||
pub use prompt::{
|
||||
ArchetypePromptSection, DateTimeSection, IdentitySection, LearnedContextData, PromptContext,
|
||||
PromptSection, PromptTool, RuntimeSection, SafetySection, SkillsSection, SystemPromptBuilder,
|
||||
ToolsSection, WorkspaceSection,
|
||||
};
|
||||
pub use session_memory::{
|
||||
SessionMemoryConfig, SessionMemoryState, ARCHIVIST_EXTRACTION_PROMPT, DEFAULT_MIN_TOKEN_GROWTH,
|
||||
DEFAULT_MIN_TOOL_CALLS, DEFAULT_MIN_TURNS_BETWEEN,
|
||||
};
|
||||
pub use summarizer::{ProviderSummarizer, Summarizer, SummaryStats};
|
||||
pub use tool_result_budget::{
|
||||
apply_tool_result_budget, BudgetOutcome, DEFAULT_TOOL_RESULT_BUDGET_BYTES,
|
||||
};
|
||||
+73
-19
@@ -21,7 +21,7 @@
|
||||
//! when ready. Keeping the pipeline pure (no LLM calls) means the
|
||||
//! integration tests can exercise every stage without a provider.
|
||||
//! 5. **Session memory** — handled separately by
|
||||
//! [`crate::openhuman::agent::context_pipeline::session_memory`].
|
||||
//! [`crate::openhuman::context::session_memory`].
|
||||
//!
|
||||
//! # Cache contract
|
||||
//!
|
||||
@@ -33,10 +33,19 @@
|
||||
//! firing resets the stable prefix to the new, smaller history so
|
||||
//! subsequent turns hit the cache again.
|
||||
|
||||
use super::guard::{ContextCheckResult, ContextGuard};
|
||||
use super::microcompact::{microcompact, MicrocompactStats, DEFAULT_KEEP_RECENT_TOOL_RESULTS};
|
||||
use super::session_memory::{SessionMemoryConfig, SessionMemoryState};
|
||||
use crate::openhuman::agent::harness::context_guard::{ContextCheckResult, ContextGuard};
|
||||
use crate::openhuman::providers::{ConversationMessage, UsageInfo};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Shared handle to a [`SessionMemoryState`] so both the synchronous
|
||||
/// pipeline path and a detached background archivist task can inspect
|
||||
/// and mutate the same extraction bookkeeping without fighting over
|
||||
/// `&mut self`. The pipeline clones this `Arc` into every task it
|
||||
/// spawns — the `Mutex` lock is only held for microsecond-scale state
|
||||
/// flips, so contention is negligible in practice.
|
||||
pub type SessionMemoryHandle = Arc<Mutex<SessionMemoryState>>;
|
||||
|
||||
/// Pipeline configuration. Defaults are tuned for an `agentic-v1`
|
||||
/// 128k-context run.
|
||||
@@ -87,6 +96,11 @@ pub enum PipelineOutcome {
|
||||
/// The last-known context utilisation as a 0..=100 percentage.
|
||||
utilisation_pct: u8,
|
||||
},
|
||||
/// The guard is above the soft threshold but autocompaction is
|
||||
/// disabled by config, so no summariser will run. Surfaced as a
|
||||
/// distinct variant so the caller can log/observe the situation
|
||||
/// instead of silently falling back to `NoOp`.
|
||||
AutocompactionDisabled { utilisation_pct: u8 },
|
||||
/// The guard's circuit breaker is tripped and the context is still
|
||||
/// above the hard threshold — the caller should abort the turn.
|
||||
ContextExhausted { utilisation_pct: u8, reason: String },
|
||||
@@ -95,11 +109,16 @@ pub enum PipelineOutcome {
|
||||
/// Stateful orchestrator. Owns a [`ContextGuard`] and a
|
||||
/// [`SessionMemoryState`] so a single instance can live on the `Agent`
|
||||
/// across turns without threading state through every call site.
|
||||
///
|
||||
/// `session_memory` is wrapped in a shared handle so a detached
|
||||
/// archivist task spawned from `turn.rs` can mark the extraction as
|
||||
/// complete or failed after the pipeline's synchronous path has
|
||||
/// already released its borrow on `self`.
|
||||
#[derive(Debug)]
|
||||
pub struct ContextPipeline {
|
||||
pub config: ContextPipelineConfig,
|
||||
pub guard: ContextGuard,
|
||||
pub session_memory: SessionMemoryState,
|
||||
pub session_memory: SessionMemoryHandle,
|
||||
}
|
||||
|
||||
impl Default for ContextPipeline {
|
||||
@@ -113,7 +132,7 @@ impl ContextPipeline {
|
||||
Self {
|
||||
config,
|
||||
guard: ContextGuard::new(),
|
||||
session_memory: SessionMemoryState::default(),
|
||||
session_memory: Arc::new(Mutex::new(SessionMemoryState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,26 +140,51 @@ impl ContextPipeline {
|
||||
/// session-memory state.
|
||||
pub fn record_usage(&mut self, usage: &UsageInfo) {
|
||||
self.guard.update_usage(usage);
|
||||
self.session_memory
|
||||
.record_usage(usage.input_tokens + usage.output_tokens);
|
||||
let total = usage.input_tokens + usage.output_tokens;
|
||||
if let Ok(mut sm) = self.session_memory.lock() {
|
||||
sm.record_usage(total);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump the session-memory turn counter. Called once per user turn.
|
||||
pub fn tick_turn(&mut self) {
|
||||
self.session_memory.tick_turn();
|
||||
if let Ok(mut sm) = self.session_memory.lock() {
|
||||
sm.tick_turn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulate a turn's tool-call count into the session-memory
|
||||
/// state. Called once per user turn after tool dispatch settles.
|
||||
pub fn record_tool_calls(&mut self, n: usize) {
|
||||
self.session_memory.record_tool_calls(n);
|
||||
if let Ok(mut sm) = self.session_memory.lock() {
|
||||
sm.record_tool_calls(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Should the caller spawn a background session-memory extraction
|
||||
/// this turn?
|
||||
pub fn should_extract_session_memory(&self) -> bool {
|
||||
self.session_memory
|
||||
.should_extract(&self.config.session_memory)
|
||||
.lock()
|
||||
.map(|sm| sm.should_extract(&self.config.session_memory))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read-only snapshot of the session-memory bookkeeping for
|
||||
/// observability / [`crate::openhuman::context::ContextStats`].
|
||||
pub fn session_memory_snapshot(&self) -> SessionMemoryState {
|
||||
self.session_memory
|
||||
.lock()
|
||||
.map(|sm| sm.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Share a clone of the session-memory handle. The caller takes
|
||||
/// ownership of the `Arc` and can move it into a detached
|
||||
/// background task to update the extraction state when the task
|
||||
/// finishes. See `turn.rs::spawn_session_memory_extraction`.
|
||||
pub fn session_memory_handle(&self) -> SessionMemoryHandle {
|
||||
Arc::clone(&self.session_memory)
|
||||
}
|
||||
|
||||
/// Run the reduction chain against `history` in place. Safe to call
|
||||
@@ -171,13 +215,16 @@ impl ContextPipeline {
|
||||
// Stage 4: if microcompact didn't free anything (no old
|
||||
// tool results to clear), signal autocompaction to the
|
||||
// caller. The pipeline deliberately does not issue the
|
||||
// LLM call itself.
|
||||
// LLM call itself. When autocompact is disabled we
|
||||
// still surface the situation as a distinct variant so
|
||||
// the manager can log/observe it rather than silently
|
||||
// dropping back to `NoOp`.
|
||||
let pct = self
|
||||
.guard
|
||||
.utilization()
|
||||
.map(|u| (u * 100.0).round() as u8)
|
||||
.unwrap_or(0);
|
||||
if self.config.autocompact_enabled {
|
||||
let pct = self
|
||||
.guard
|
||||
.utilization()
|
||||
.map(|u| (u * 100.0).round() as u8)
|
||||
.unwrap_or(0);
|
||||
tracing::info!(
|
||||
utilisation_pct = pct,
|
||||
"[context_pipeline] autocompaction requested"
|
||||
@@ -187,7 +234,13 @@ impl ContextPipeline {
|
||||
};
|
||||
}
|
||||
|
||||
PipelineOutcome::NoOp
|
||||
tracing::warn!(
|
||||
utilisation_pct = pct,
|
||||
"[context_pipeline] above soft threshold but autocompact disabled"
|
||||
);
|
||||
PipelineOutcome::AutocompactionDisabled {
|
||||
utilisation_pct: pct,
|
||||
}
|
||||
}
|
||||
ContextCheckResult::ContextExhausted {
|
||||
utilization_pct,
|
||||
@@ -378,7 +431,7 @@ mod tests {
|
||||
output_tokens: 2_000,
|
||||
context_window: 100_000,
|
||||
});
|
||||
assert_eq!(pipeline.session_memory.total_tokens, 12_000);
|
||||
assert_eq!(pipeline.session_memory_snapshot().total_tokens, 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -386,7 +439,8 @@ mod tests {
|
||||
let mut pipeline = ContextPipeline::default();
|
||||
pipeline.tick_turn();
|
||||
pipeline.record_tool_calls(5);
|
||||
assert_eq!(pipeline.session_memory.current_turn, 1);
|
||||
assert_eq!(pipeline.session_memory.total_tool_calls, 5);
|
||||
let snap = pipeline.session_memory_snapshot();
|
||||
assert_eq!(snap.current_turn, 1);
|
||||
assert_eq!(snap.total_tool_calls, 5);
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,64 @@ pub struct LearnedContextData {
|
||||
pub user_profile: Vec<String>,
|
||||
}
|
||||
|
||||
/// A lightweight tool descriptor for prompt rendering.
|
||||
///
|
||||
/// Shared shape so every call-site that builds a system prompt — main
|
||||
/// agents (which own `Box<dyn Tool>`), sub-agents, and channel runtimes
|
||||
/// (which only have `(name, description)` tuples from their tool
|
||||
/// registries) — can feed the same [`ToolsSection`] implementation
|
||||
/// instead of each writing its own. Callers adapt their own tool
|
||||
/// representation into a `Vec<PromptTool<'_>>` at the PromptContext
|
||||
/// construction site via a one-line `.iter().map(...).collect()` or via
|
||||
/// [`PromptTool::from_tools`].
|
||||
///
|
||||
/// `parameters_schema` is optional because channel runtimes don't have
|
||||
/// full JSON schemas at prompt-build time; the tools section renders
|
||||
/// the schema line only when it's present.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptTool<'a> {
|
||||
pub name: &'a str,
|
||||
pub description: &'a str,
|
||||
pub parameters_schema: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> PromptTool<'a> {
|
||||
pub fn new(name: &'a str, description: &'a str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
parameters_schema: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_schema(name: &'a str, description: &'a str, parameters_schema: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
parameters_schema: Some(parameters_schema),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapt a `Box<dyn Tool>` slice into a `Vec<PromptTool<'_>>`. The
|
||||
/// returned vector borrows names and descriptions from the original
|
||||
/// tools, so it must not outlive them. Main-agent call-sites use
|
||||
/// this one-liner to build the slice passed into [`PromptContext::tools`].
|
||||
pub fn from_tools(tools: &'a [Box<dyn Tool>]) -> Vec<PromptTool<'a>> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| PromptTool {
|
||||
name: t.name(),
|
||||
description: t.description(),
|
||||
parameters_schema: Some(t.parameters_schema().to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PromptContext<'a> {
|
||||
pub workspace_dir: &'a Path,
|
||||
pub model_name: &'a str,
|
||||
pub tools: &'a [Box<dyn Tool>],
|
||||
pub tools: &'a [PromptTool<'a>],
|
||||
pub skills: &'a [Skill],
|
||||
pub dispatcher_instructions: &'a str,
|
||||
/// Pre-fetched learned context (empty when learning is disabled).
|
||||
@@ -211,16 +265,18 @@ impl PromptSection for ToolsSection {
|
||||
let has_filter = !ctx.visible_tool_names.is_empty();
|
||||
for tool in ctx.tools {
|
||||
// Skip tools not in the visible set when a filter is active.
|
||||
if has_filter && !ctx.visible_tool_names.contains(tool.name()) {
|
||||
if has_filter && !ctx.visible_tool_names.contains(tool.name) {
|
||||
continue;
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
tool.parameters_schema()
|
||||
);
|
||||
if let Some(schema) = &tool.parameters_schema {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name, tool.description, schema
|
||||
);
|
||||
} else {
|
||||
let _ = writeln!(out, "- **{}**: {}", tool.name, tool.description);
|
||||
}
|
||||
}
|
||||
if !ctx.dispatcher_instructions.is_empty() {
|
||||
out.push('\n');
|
||||
@@ -324,6 +380,201 @@ fn is_dynamic_section(name: &str) -> bool {
|
||||
matches!(name, "workspace" | "datetime" | "runtime")
|
||||
}
|
||||
|
||||
/// Per-definition rendering flags passed into
|
||||
/// [`render_subagent_system_prompt`]. Mirrors the `omit_*` fields on
|
||||
/// [`crate::openhuman::agent::harness::definition::AgentDefinition`] so
|
||||
/// the runner can thread each definition's preferences through without
|
||||
/// growing the function signature.
|
||||
///
|
||||
/// KV-cache-stable as long as the flags are read from a definition that
|
||||
/// does not change mid-session.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SubagentRenderOptions {
|
||||
/// When `false`, include the standard `## Safety` block. Mirrors
|
||||
/// `AgentDefinition::omit_safety_preamble`. Defaults to `false`
|
||||
/// (omit) because the narrow sub-agent renderer historically
|
||||
/// skipped this section entirely.
|
||||
pub include_safety_preamble: bool,
|
||||
/// When `false`, skip the identity/project-context dump. Mirrors
|
||||
/// `AgentDefinition::omit_identity`. Defaults to `false`; setting
|
||||
/// this to `true` is uncommon because sub-agents usually run
|
||||
/// narrow and the identity block pushes too many tokens.
|
||||
pub include_identity: bool,
|
||||
/// When `false`, skip the skills catalogue. Mirrors
|
||||
/// `AgentDefinition::omit_skills_catalog`. Defaults to `false`
|
||||
/// for the same reason as `include_identity`.
|
||||
pub include_skills_catalog: bool,
|
||||
}
|
||||
|
||||
impl SubagentRenderOptions {
|
||||
/// Build the narrow default (every section off) — matches the
|
||||
/// historical behaviour of the purpose-built renderer before the
|
||||
/// flags were threaded through.
|
||||
pub fn narrow() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Construct from the per-definition flags, inverting them into the
|
||||
/// positive-sense `include_*` shape used by the renderer.
|
||||
pub fn from_definition_flags(
|
||||
omit_identity: bool,
|
||||
omit_safety_preamble: bool,
|
||||
omit_skills_catalog: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
include_identity: !omit_identity,
|
||||
include_safety_preamble: !omit_safety_preamble,
|
||||
include_skills_catalog: !omit_skills_catalog,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a narrow, KV-cache-stable system prompt for a typed sub-agent.
|
||||
///
|
||||
/// This is a purpose-built alternative to
|
||||
/// [`SystemPromptBuilder::for_subagent`] for call sites that only have
|
||||
/// indices into the parent's `&[Box<dyn Tool>]` vec (so they can't
|
||||
/// cheaply build a filtered owning slice for `ToolsSection`). The
|
||||
/// output mirrors what `for_subagent` would emit with the matching
|
||||
/// `omit_*` flags, plus a sub-agent-specific calling-convention
|
||||
/// preamble and a model-only runtime banner.
|
||||
///
|
||||
/// `archetype_body` is the already-loaded archetype markdown — for
|
||||
/// `PromptSource::Inline` this is the inline string, for
|
||||
/// `PromptSource::File` this is the file contents loaded by the caller.
|
||||
/// Callers resolve the source exactly once and hand the body in, so
|
||||
/// this renderer works uniformly for both definition shapes.
|
||||
///
|
||||
/// `options` carries the per-definition rendering flags (safety, etc.)
|
||||
/// inverted into positive-sense `include_*` form.
|
||||
/// [`SubagentRenderOptions::narrow`] preserves the historical behaviour.
|
||||
///
|
||||
/// # KV cache stability
|
||||
///
|
||||
/// The rendered bytes MUST be a pure function of:
|
||||
/// - the `archetype_body` (archetype role prompt)
|
||||
/// - the filtered tool set (names, descriptions, schemas)
|
||||
/// - the workspace directory
|
||||
/// - the resolved model name
|
||||
/// - the `options` (all static per definition)
|
||||
///
|
||||
/// Anything that varies across invocations at the *same* call site
|
||||
/// (e.g. `chrono::Local::now()`, hostnames, pids, turn counters) is
|
||||
/// forbidden here. Repeat spawns of the same sub-agent within a session
|
||||
/// must produce byte-identical system prompts so the inference
|
||||
/// backend's automatic prefix caching can reuse the prefill from the
|
||||
/// previous run. Time-of-day information, if a sub-agent needs it,
|
||||
/// belongs in the user message — not the system prompt.
|
||||
pub fn render_subagent_system_prompt(
|
||||
workspace_dir: &Path,
|
||||
model_name: &str,
|
||||
allowed_indices: &[usize],
|
||||
parent_tools: &[Box<dyn Tool>],
|
||||
archetype_body: &str,
|
||||
options: SubagentRenderOptions,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
// 1. Archetype role prompt. Works for both `PromptSource::Inline`
|
||||
// and `PromptSource::File` because the caller preloaded the
|
||||
// body via `load_prompt_source`.
|
||||
let trimmed = archetype_body.trim();
|
||||
if !trimmed.is_empty() {
|
||||
out.push_str(trimmed);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
// 1b. Optional identity block. Off by default; turned on when the
|
||||
// definition sets `omit_identity = false`. Renders the same
|
||||
// OpenClaw bootstrap files the main agent loads, keeping the
|
||||
// byte layout stable across repeat spawns of the same
|
||||
// definition within a session.
|
||||
if options.include_identity {
|
||||
out.push_str("## Project Context\n\n");
|
||||
out.push_str(
|
||||
"The following workspace files define your identity, behavior, and context.\n\n",
|
||||
);
|
||||
for file in &["SOUL.md", "IDENTITY.md", "USER.md"] {
|
||||
inject_workspace_file(&mut out, workspace_dir, file);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Filtered tool catalogue. Indices are taken in ascending order
|
||||
// from `allowed_indices`, which itself preserves `parent_tools`
|
||||
// order, so the rendering is deterministic. We use `.get(i)`
|
||||
// defensively even though the current caller (subagent_runner)
|
||||
// only produces in-range indices — a future caller that derives
|
||||
// indices from a different source must not be able to panic this
|
||||
// renderer with a stale index.
|
||||
out.push_str("## Tools\n\n");
|
||||
for &i in allowed_indices {
|
||||
let Some(tool) = parent_tools.get(i) else {
|
||||
tracing::warn!(
|
||||
index = i,
|
||||
tool_count = parent_tools.len(),
|
||||
"[context::prompt] dropping out-of-range tool index in subagent render"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Sub-agent calling-convention preamble. Mirrors the existing
|
||||
// NativeToolDispatcher hint that gets baked into the parent's
|
||||
// prompt — sub-agents need it too.
|
||||
out.push('\n');
|
||||
out.push_str(
|
||||
"Use the provided tools to accomplish the task. Reply with a concise, dense \
|
||||
final answer when you have one — the parent agent will weave it back into the \
|
||||
user-visible response.\n\n",
|
||||
);
|
||||
|
||||
// 3b. Optional safety preamble. Definitions that do work with real
|
||||
// side-effects (code_executor, tool_maker, skills_agent) set
|
||||
// `omit_safety_preamble = false` so the narrow renderer used to
|
||||
// silently drop that instruction — we now honour the flag.
|
||||
// Byte-identical to `SafetySection::build`.
|
||||
if options.include_safety_preamble {
|
||||
out.push_str(
|
||||
"## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.\n\n",
|
||||
);
|
||||
}
|
||||
|
||||
// 3c. Optional skills catalogue. Off by default because sub-agents
|
||||
// usually skip skills entirely. Kept here so a custom
|
||||
// definition can opt in without falling back to the general
|
||||
// builder. The renderer intentionally takes no `skills` slice
|
||||
// — the caller would have to extend this helper before
|
||||
// enabling this flag for real, which keeps the common (narrow)
|
||||
// path free of extra arguments.
|
||||
if options.include_skills_catalog {
|
||||
out.push_str("## Available Skills\n\n");
|
||||
out.push_str(
|
||||
"Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Workspace so the model knows where it is. Intentionally stable:
|
||||
// no datetime, no hostname, no pid — see the KV-cache note above.
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"## Workspace\n\nWorking directory: `{}`\n",
|
||||
workspace_dir.display()
|
||||
);
|
||||
|
||||
// 5. Runtime banner — model name only. Stable for the lifetime of
|
||||
// this sub-agent's definition.
|
||||
let _ = writeln!(out, "## Runtime\n\nModel: {model_name}");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Ensure the workspace file is up-to-date with the compiled-in default.
|
||||
///
|
||||
/// On first install the file doesn't exist → write it. On subsequent runs
|
||||
@@ -406,10 +657,14 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &s
|
||||
}
|
||||
|
||||
fn default_workspace_file_content(filename: &str) -> &'static str {
|
||||
// The bundled identity files live at `src/openhuman/agent/prompts/`
|
||||
// (owned by the `agent/` tree because they describe agent identity).
|
||||
// This module is under `src/openhuman/context/`, so the relative path
|
||||
// walks up one level and back into `agent/prompts/`.
|
||||
match filename {
|
||||
"SOUL.md" => include_str!("prompts/SOUL.md"),
|
||||
"IDENTITY.md" => include_str!("prompts/IDENTITY.md"),
|
||||
"USER.md" => include_str!("prompts/USER.md"),
|
||||
"SOUL.md" => include_str!("../agent/prompts/SOUL.md"),
|
||||
"IDENTITY.md" => include_str!("../agent/prompts/IDENTITY.md"),
|
||||
"USER.md" => include_str!("../agent/prompts/USER.md"),
|
||||
"HEARTBEAT.md" => {
|
||||
"# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n"
|
||||
}
|
||||
@@ -454,10 +709,11 @@ mod tests {
|
||||
#[test]
|
||||
fn prompt_builder_assembles_sections() {
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
tools: &tools,
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
dispatcher_instructions: "instr",
|
||||
learned: LearnedContextData::default(),
|
||||
@@ -476,10 +732,11 @@ mod tests {
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
|
||||
let tools: Vec<Box<dyn Tool>> = vec![];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: &workspace,
|
||||
model_name: "test-model",
|
||||
tools: &tools,
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
@@ -507,10 +764,11 @@ mod tests {
|
||||
#[test]
|
||||
fn datetime_section_includes_timestamp_and_timezone() {
|
||||
let tools: Vec<Box<dyn Tool>> = vec![];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
tools: &tools,
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
dispatcher_instructions: "instr",
|
||||
learned: LearnedContextData::default(),
|
||||
+19
-1
@@ -31,13 +31,31 @@ pub const DEFAULT_MIN_TOOL_CALLS: u64 = 8;
|
||||
pub const DEFAULT_MIN_TURNS_BETWEEN: u64 = 4;
|
||||
|
||||
/// Tunable thresholds for session-memory extraction.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
///
|
||||
/// Serializable so it can be embedded directly into the top-level
|
||||
/// [`crate::openhuman::config::ContextConfig`] config section.
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SessionMemoryConfig {
|
||||
#[serde(default = "default_min_token_growth")]
|
||||
pub min_token_growth: u64,
|
||||
#[serde(default = "default_min_tool_calls")]
|
||||
pub min_tool_calls: u64,
|
||||
#[serde(default = "default_min_turns_between")]
|
||||
pub min_turns_between: u64,
|
||||
}
|
||||
|
||||
fn default_min_token_growth() -> u64 {
|
||||
DEFAULT_MIN_TOKEN_GROWTH
|
||||
}
|
||||
|
||||
fn default_min_tool_calls() -> u64 {
|
||||
DEFAULT_MIN_TOOL_CALLS
|
||||
}
|
||||
|
||||
fn default_min_turns_between() -> u64 {
|
||||
DEFAULT_MIN_TURNS_BETWEEN
|
||||
}
|
||||
|
||||
impl Default for SessionMemoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -0,0 +1,545 @@
|
||||
//! LLM-backed conversation summarization.
|
||||
//!
|
||||
//! The context [`super::ContextPipeline`] is deliberately pure — when
|
||||
//! it decides the agent history is over budget and can't be rescued by
|
||||
//! cheap stages (microcompact, tool-result budget), it returns
|
||||
//! [`super::PipelineOutcome::AutocompactionRequested`] and trusts the
|
||||
//! caller to dispatch an LLM summarization.
|
||||
//!
|
||||
//! This module owns that dispatch. [`Summarizer`] is the async trait
|
||||
//! [`super::ContextManager`] calls on behalf of agents; the default
|
||||
//! implementation [`ProviderSummarizer`] wraps an `Arc<dyn Provider>`
|
||||
//! and executes a single chat completion against the same provider the
|
||||
//! agent uses for its normal turns. Tests pass a mock implementation
|
||||
//! so `ContextManager::reduce_before_call` can be exercised without
|
||||
//! touching the network.
|
||||
//!
|
||||
//! ## Reduction strategy
|
||||
//!
|
||||
//! The summarizer keeps the `keep_recent` most-recent messages
|
||||
//! untouched (so the model still has fresh context for its next turn),
|
||||
//! replays the older head of the conversation as a plain-text
|
||||
//! transcript, asks the LLM to compress it into a dense note, and
|
||||
//! replaces the head with a single `system` [`ConversationMessage`]
|
||||
//! holding that note. The API invariant
|
||||
//! (`AssistantToolCalls` ↔ `ToolResults`) is preserved because we
|
||||
//! never split a pair across the head/tail boundary — if the
|
||||
//! boundary lands mid-pair we push it forward until it sits between
|
||||
//! complete turns.
|
||||
|
||||
use super::microcompact::MicrocompactStats;
|
||||
use crate::openhuman::providers::{ChatMessage, ConversationMessage, Provider};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Default number of most-recent messages preserved verbatim by the
|
||||
/// summarizer. Anything older gets collapsed into the summary note.
|
||||
pub const DEFAULT_KEEP_RECENT: usize = 10;
|
||||
|
||||
/// Default temperature for summarization calls. Low-ish so the same
|
||||
/// history produces stable summaries across retries.
|
||||
pub const DEFAULT_SUMMARIZER_TEMPERATURE: f64 = 0.2;
|
||||
|
||||
/// The system prompt pinned to every summarization call. Intentionally
|
||||
/// short so it burns as few tokens as possible on a call whose whole
|
||||
/// purpose is to *free* tokens.
|
||||
pub const SUMMARIZER_SYSTEM_PROMPT: &str =
|
||||
"You are a conversation summarizer. Your job is to take \
|
||||
a chronological history of a conversation between a user and an AI assistant (including any tool \
|
||||
calls and their results) and produce a compact, information-dense summary that preserves: \
|
||||
(1) the user's goals and constraints, (2) decisions made so far, (3) important facts discovered \
|
||||
via tool calls, (4) open questions or pending work. Do NOT preserve verbatim quotes, greetings, \
|
||||
small talk, or redundant acknowledgements. Return ONLY the summary text — no preamble, no \
|
||||
closing remarks.";
|
||||
|
||||
/// Outcome of a single summarization pass.
|
||||
///
|
||||
/// Returned by [`Summarizer::summarize`] so callers — chiefly
|
||||
/// [`super::ContextManager`] — can log, telemeter, and feed the result
|
||||
/// back into the compaction circuit breaker on the [`super::ContextGuard`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SummaryStats {
|
||||
/// How many entries were removed from the head of the history and
|
||||
/// replaced with the summary message.
|
||||
pub messages_removed: usize,
|
||||
/// Character-heuristic estimate of freed tokens (input transcript
|
||||
/// bytes minus summary bytes, divided by 4). Rough but stable and
|
||||
/// free.
|
||||
pub approx_tokens_freed: u64,
|
||||
/// Total character length of the summary message that replaced the
|
||||
/// head. Useful for detecting degenerate "summarizer kept every
|
||||
/// word" responses.
|
||||
pub summary_chars: usize,
|
||||
}
|
||||
|
||||
impl SummaryStats {
|
||||
/// Helper to turn a [`MicrocompactStats`] into a [`SummaryStats`]
|
||||
/// shaped value when reporting the union through
|
||||
/// [`super::ReductionOutcome`]. Currently unused but included so
|
||||
/// the types compose cleanly if a caller ever wants a uniform
|
||||
/// stats payload.
|
||||
#[doc(hidden)]
|
||||
pub fn from_microcompact(stats: &MicrocompactStats) -> Self {
|
||||
Self {
|
||||
messages_removed: stats.entries_cleared,
|
||||
approx_tokens_freed: (stats.bytes_freed as u64).div_ceil(4),
|
||||
summary_chars: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for anything that can summarize an agent conversation history
|
||||
/// in place.
|
||||
///
|
||||
/// Implementations must not partially mutate `history` on failure —
|
||||
/// either the full rewrite succeeds and the function returns `Ok`, or
|
||||
/// `history` is untouched and the error bubbles up. This contract
|
||||
/// lets [`super::ContextManager`] treat failures as "nothing happened"
|
||||
/// when it records the result on its compaction circuit breaker.
|
||||
#[async_trait]
|
||||
pub trait Summarizer: Send + Sync {
|
||||
async fn summarize(
|
||||
&self,
|
||||
history: &mut Vec<ConversationMessage>,
|
||||
model: &str,
|
||||
) -> Result<SummaryStats>;
|
||||
}
|
||||
|
||||
/// Default summarizer that wraps an `Arc<dyn Provider>`.
|
||||
///
|
||||
/// Instantiated once per [`super::ContextManager`] — usually by the
|
||||
/// agent harness at session start — so every summarization inside a
|
||||
/// session hits the same provider/model. A cheaper `summarizer_model`
|
||||
/// can be threaded through the caller's
|
||||
/// [`crate::openhuman::config::ContextConfig`] if summarization on
|
||||
/// the main model gets expensive; [`super::ContextManager::new`] is
|
||||
/// responsible for choosing which model string to pass in.
|
||||
pub struct ProviderSummarizer {
|
||||
provider: Arc<dyn Provider>,
|
||||
keep_recent: usize,
|
||||
temperature: f64,
|
||||
}
|
||||
|
||||
impl ProviderSummarizer {
|
||||
/// Construct a summarizer around `provider` with default tunables.
|
||||
pub fn new(provider: Arc<dyn Provider>) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
keep_recent: DEFAULT_KEEP_RECENT,
|
||||
temperature: DEFAULT_SUMMARIZER_TEMPERATURE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override how many messages are preserved verbatim at the tail.
|
||||
pub fn with_keep_recent(mut self, n: usize) -> Self {
|
||||
self.keep_recent = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the temperature used for the summarization chat call.
|
||||
pub fn with_temperature(mut self, t: f64) -> Self {
|
||||
self.temperature = t;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Summarizer for ProviderSummarizer {
|
||||
async fn summarize(
|
||||
&self,
|
||||
history: &mut Vec<ConversationMessage>,
|
||||
model: &str,
|
||||
) -> Result<SummaryStats> {
|
||||
let total = history.len();
|
||||
if total <= self.keep_recent {
|
||||
tracing::debug!(
|
||||
total,
|
||||
keep_recent = self.keep_recent,
|
||||
"[context::summarizer] nothing to summarize — history below keep_recent"
|
||||
);
|
||||
return Ok(SummaryStats::default());
|
||||
}
|
||||
|
||||
// Head = everything before the preserved tail. Snap the split
|
||||
// forward so we never break an AssistantToolCalls ↔ ToolResults
|
||||
// pair. If an `AssistantToolCalls` sits at the proposed split
|
||||
// point, walk forward until we're past its matching
|
||||
// `ToolResults` envelope (or until the tail would collapse to
|
||||
// zero, in which case there's nothing to summarize).
|
||||
let head_len = snap_split_forward(history, total - self.keep_recent);
|
||||
if head_len == 0 {
|
||||
return Ok(SummaryStats::default());
|
||||
}
|
||||
|
||||
// Build the plain-text transcript the summarizer reads.
|
||||
let transcript = render_transcript(&history[..head_len]);
|
||||
let approx_input_bytes = transcript.len();
|
||||
|
||||
// Summarization chat call — one turn, no tools, fixed system.
|
||||
let messages = vec![
|
||||
ChatMessage::system(SUMMARIZER_SYSTEM_PROMPT),
|
||||
ChatMessage::user(format!(
|
||||
"Summarize this conversation history for continuation. Focus on goals, \
|
||||
decisions, facts, and pending work.\n\n--- BEGIN HISTORY ---\n{transcript}\n\
|
||||
--- END HISTORY ---"
|
||||
)),
|
||||
];
|
||||
|
||||
tracing::info!(
|
||||
model,
|
||||
head_messages = head_len,
|
||||
tail_preserved = total - head_len,
|
||||
approx_input_bytes,
|
||||
"[context::summarizer] dispatching autocompaction summary"
|
||||
);
|
||||
|
||||
let response = self
|
||||
.provider
|
||||
.chat_with_history(&messages, model, self.temperature)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "[context::summarizer] provider call failed");
|
||||
e
|
||||
})?;
|
||||
|
||||
let summary = response.trim();
|
||||
if summary.is_empty() {
|
||||
anyhow::bail!("summarizer returned empty response");
|
||||
}
|
||||
|
||||
let summary_body =
|
||||
format!("[auto-compacted] Summary of {head_len} earlier messages:\n\n{summary}");
|
||||
let summary_chars = summary_body.len();
|
||||
let approx_tokens_freed = (approx_input_bytes as u64)
|
||||
.saturating_sub(summary_chars as u64)
|
||||
.div_ceil(4);
|
||||
|
||||
// Replace the head in place. Drain the tail, clear the vec,
|
||||
// push the summary, and put the tail back. No partial mutation
|
||||
// on error paths — everything above returned early.
|
||||
let tail: Vec<ConversationMessage> = history.drain(head_len..).collect();
|
||||
history.clear();
|
||||
history.push(ConversationMessage::Chat(ChatMessage::system(summary_body)));
|
||||
history.extend(tail);
|
||||
|
||||
tracing::info!(
|
||||
messages_removed = head_len,
|
||||
approx_tokens_freed,
|
||||
summary_chars,
|
||||
"[context::summarizer] autocompaction complete"
|
||||
);
|
||||
|
||||
Ok(SummaryStats {
|
||||
messages_removed: head_len,
|
||||
approx_tokens_freed,
|
||||
summary_chars,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap the proposed split point forward until it sits on a clean
|
||||
/// turn boundary (i.e. not mid-way through an
|
||||
/// `AssistantToolCalls` → `ToolResults` pair). Returns the adjusted
|
||||
/// head length. Returns 0 when the adjustment would consume the entire
|
||||
/// history, meaning there is nothing we can safely summarize without
|
||||
/// breaking the API invariant.
|
||||
fn snap_split_forward(history: &[ConversationMessage], proposed_head: usize) -> usize {
|
||||
let mut head = proposed_head.min(history.len());
|
||||
// If the message immediately *before* the split is an
|
||||
// AssistantToolCalls and the message *at* the split is its
|
||||
// matching ToolResults, advance past the pair so we don't break
|
||||
// the API invariant mid-pair. Any other shape (no prev, prev not
|
||||
// a tool call, or tool call without a matching result right after)
|
||||
// leaves the split where it was.
|
||||
if head > 0
|
||||
&& head < history.len()
|
||||
&& matches!(
|
||||
&history[head - 1],
|
||||
ConversationMessage::AssistantToolCalls { .. }
|
||||
)
|
||||
&& matches!(&history[head], ConversationMessage::ToolResults(_))
|
||||
{
|
||||
head += 1;
|
||||
}
|
||||
// Don't consume the whole history — there'd be no tail to preserve.
|
||||
if head >= history.len() {
|
||||
0
|
||||
} else {
|
||||
head
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a slice of `ConversationMessage` as a plain-text transcript
|
||||
/// for the summarizer prompt. Format is intentionally simple — the
|
||||
/// summarizer reads it as-is.
|
||||
fn render_transcript(msgs: &[ConversationMessage]) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, msg) in msgs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
match msg {
|
||||
ConversationMessage::Chat(m) => {
|
||||
let _ = writeln!(&mut out, "[{i}] {}: {}", m.role, m.content);
|
||||
}
|
||||
ConversationMessage::AssistantToolCalls { text, tool_calls } => {
|
||||
if let Some(t) = text.as_deref() {
|
||||
if !t.is_empty() {
|
||||
let _ = writeln!(&mut out, "[{i}] assistant: {t}");
|
||||
}
|
||||
}
|
||||
for tc in tool_calls {
|
||||
let _ = writeln!(
|
||||
&mut out,
|
||||
"[{i}] assistant tool_call: {}({})",
|
||||
tc.name, tc.arguments
|
||||
);
|
||||
}
|
||||
}
|
||||
ConversationMessage::ToolResults(results) => {
|
||||
for r in results {
|
||||
let _ = writeln!(
|
||||
&mut out,
|
||||
"[{i}] tool_result({}): {}",
|
||||
r.tool_call_id, r.content
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::providers::{ChatResponse, ToolCall, ToolResultMessage};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn user(text: &str) -> ConversationMessage {
|
||||
ConversationMessage::Chat(ChatMessage::user(text))
|
||||
}
|
||||
|
||||
fn assistant(text: &str) -> ConversationMessage {
|
||||
ConversationMessage::Chat(ChatMessage::assistant(text))
|
||||
}
|
||||
|
||||
fn call(id: &str) -> ConversationMessage {
|
||||
ConversationMessage::AssistantToolCalls {
|
||||
text: None,
|
||||
tool_calls: vec![ToolCall {
|
||||
id: id.into(),
|
||||
name: "t".into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn result(id: &str, body: &str) -> ConversationMessage {
|
||||
ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: id.into(),
|
||||
content: body.into(),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Minimal Provider that returns a pinned reply for every call.
|
||||
/// Records how many times `chat_with_history` fired so tests can
|
||||
/// assert the summarizer skipped the provider round-trip when it
|
||||
/// should have.
|
||||
struct StubProvider {
|
||||
reply: String,
|
||||
calls: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl StubProvider {
|
||||
fn new(reply: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: reply.into(),
|
||||
calls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
fn call_count(&self) -> usize {
|
||||
*self.calls.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StubProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: crate::openhuman::providers::ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
Ok(ChatResponse {
|
||||
text: Some(self.reply.clone()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_when_history_below_keep_recent() {
|
||||
let provider = Arc::new(StubProvider::new("IRRELEVANT"));
|
||||
let summarizer = ProviderSummarizer::new(provider.clone()).with_keep_recent(10);
|
||||
|
||||
let mut history = vec![user("hi"), assistant("hello")];
|
||||
let stats = summarizer
|
||||
.summarize(&mut history, "test-model")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stats.messages_removed, 0);
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(provider.call_count(), 0, "must not call provider on no-op");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarizes_long_history_and_replaces_head() {
|
||||
let provider = Arc::new(StubProvider::new("SUMMARY_BODY"));
|
||||
let summarizer = ProviderSummarizer::new(provider.clone()).with_keep_recent(2);
|
||||
|
||||
// 6 older messages + 2 tail = 8 total; head should collapse to 1
|
||||
// system message, tail of 2 preserved.
|
||||
let mut history = vec![
|
||||
user("q1"),
|
||||
assistant("a1"),
|
||||
user("q2"),
|
||||
assistant("a2"),
|
||||
user("q3"),
|
||||
assistant("a3"),
|
||||
user("q4-tail"),
|
||||
assistant("a4-tail"),
|
||||
];
|
||||
|
||||
let stats = summarizer
|
||||
.summarize(&mut history, "test-model")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stats.messages_removed, 6);
|
||||
assert_eq!(history.len(), 3, "1 summary + 2 tail");
|
||||
assert_eq!(provider.call_count(), 1);
|
||||
|
||||
// First message must be a system summary containing the stub reply.
|
||||
match &history[0] {
|
||||
ConversationMessage::Chat(m) => {
|
||||
assert_eq!(m.role, "system");
|
||||
assert!(m.content.contains("SUMMARY_BODY"));
|
||||
assert!(m.content.contains("[auto-compacted]"));
|
||||
}
|
||||
other => panic!("expected system summary, got {other:?}"),
|
||||
}
|
||||
// Tail preserved verbatim.
|
||||
match &history[1] {
|
||||
ConversationMessage::Chat(m) => assert_eq!(m.content, "q4-tail"),
|
||||
_ => panic!(),
|
||||
}
|
||||
match &history[2] {
|
||||
ConversationMessage::Chat(m) => assert_eq!(m.content, "a4-tail"),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snaps_split_past_tool_result_pair() {
|
||||
// Proposed head = 3 would land between `call("t1")` and its
|
||||
// matching `result("t1")` — the snap should push it to 4 so
|
||||
// the AssistantToolCalls ↔ ToolResults pair stays together.
|
||||
let provider = Arc::new(StubProvider::new("SUMMARY"));
|
||||
let summarizer = ProviderSummarizer::new(provider.clone()).with_keep_recent(2);
|
||||
|
||||
let mut history = vec![
|
||||
user("q"),
|
||||
assistant("ack"),
|
||||
call("t1"),
|
||||
result("t1", "r1"),
|
||||
user("tail-q"),
|
||||
assistant("tail-a"),
|
||||
];
|
||||
|
||||
let _ = summarizer
|
||||
.summarize(&mut history, "test-model")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Expect 1 summary + 2-tail + maybe nothing between. Because
|
||||
// the head was snapped to 4, the resulting history is:
|
||||
// [system-summary, user("tail-q"), assistant("tail-a")]
|
||||
assert_eq!(history.len(), 3);
|
||||
match &history[0] {
|
||||
ConversationMessage::Chat(m) => {
|
||||
assert_eq!(m.role, "system");
|
||||
assert!(m.content.contains("SUMMARY"));
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_summary_errors_and_leaves_history_untouched() {
|
||||
let provider = Arc::new(StubProvider::new(" \n\t "));
|
||||
let summarizer = ProviderSummarizer::new(provider).with_keep_recent(1);
|
||||
|
||||
let mut history = vec![user("q1"), assistant("a1"), user("q2-tail")];
|
||||
let before = history.clone();
|
||||
|
||||
let err = summarizer
|
||||
.summarize(&mut history, "test-model")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("empty"));
|
||||
|
||||
// History must be untouched on error.
|
||||
assert_eq!(history.len(), before.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_renders_all_message_variants() {
|
||||
let msgs = vec![
|
||||
user("hello"),
|
||||
assistant("hi"),
|
||||
ConversationMessage::AssistantToolCalls {
|
||||
text: Some("let me check".into()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: r#"{"cmd":"ls"}"#.into(),
|
||||
}],
|
||||
},
|
||||
result("1", "file.txt"),
|
||||
];
|
||||
let rendered = render_transcript(&msgs);
|
||||
assert!(rendered.contains("user: hello"));
|
||||
assert!(rendered.contains("assistant: hi"));
|
||||
assert!(rendered.contains("assistant: let me check"));
|
||||
assert!(rendered.contains("assistant tool_call: shell("));
|
||||
assert!(rendered.contains("tool_result(1): file.txt"));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! These sections read pre-fetched data from `PromptContext.learned` — no async
|
||||
//! or blocking I/O happens during prompt building.
|
||||
|
||||
use crate::openhuman::agent::prompt::{PromptContext, PromptSection};
|
||||
use crate::openhuman::context::prompt::{PromptContext, PromptSection};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Injects recent observations and patterns from the learning subsystem.
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod billing;
|
||||
pub mod channels;
|
||||
pub mod composio;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod cost;
|
||||
pub mod credentials;
|
||||
pub mod cron;
|
||||
|
||||
Reference in New Issue
Block a user