diff --git a/path=/Users/cardinal/.openhuman/users/69ccc8e95692bb0ddd56c10f/config.toml b/path=/Users/cardinal/.openhuman/users/69ccc8e95692bb0ddd56c10f/config.toml deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/openhuman/agent/context_pipeline/mod.rs b/src/openhuman/agent/context_pipeline/mod.rs deleted file mode 100644 index 44a9af1c7..000000000 --- a/src/openhuman/agent/context_pipeline/mod.rs +++ /dev/null @@ -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, -}; diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index 97e2cd53a..b84cf3ca1 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -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; diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index 04eaf5a1e..7b99e6046 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -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()) diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 804533122..335ac8b2e 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -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 = 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 { let tools_slice: &[Box] = self.tools.as_slice(); let instructions = self.tool_dispatcher.prompt_instructions(tools_slice); + // Adapt the owned Box 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(); + } + } } }); } diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index d04157cfd..8f7d83064 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -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, pub(super) memory: Arc, - pub(super) prompt_builder: SystemPromptBuilder, pub(super) tool_dispatcher: Box, pub(super) memory_loader: Box, 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>, pub(super) memory_loader: Option>, pub(super) config: Option, + /// 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, pub(super) model_name: Option, pub(super) temperature: Option, pub(super) workspace_dir: Option, diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs index c379d7fd0..9982832b9 100644 --- a/src/openhuman/agent/harness/subagent_runner.rs +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -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` 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` 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` and we already have indices into the parent's vec -/// (which would force an awkward Vec<&Box> 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], - 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 diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs index fae8e9b97..4f45a3093 100644 --- a/src/openhuman/agent/harness/tool_loop.rs +++ b/src/openhuman/agent/harness/tool_loop.rs @@ -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. diff --git a/src/openhuman/agent/mod.rs b/src/openhuman/agent/mod.rs index 3b9181260..2845df679 100644 --- a/src/openhuman/agent/mod.rs +++ b/src/openhuman/agent/mod.rs @@ -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, diff --git a/src/openhuman/agent/prompts/SOUL.md b/src/openhuman/agent/prompts/SOUL.md index 316f48d50..4c877e660 100644 --- a/src/openhuman/agent/prompts/SOUL.md +++ b/src/openhuman/agent/prompts/SOUL.md @@ -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. diff --git a/src/openhuman/channels/mod.rs b/src/openhuman/channels/mod.rs index 1be9e4d91..73dfd19da 100644 --- a/src/openhuman/channels/mod.rs +++ b/src/openhuman/channels/mod.rs @@ -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; diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 24914f595..983cdd78c 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -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())); diff --git a/src/openhuman/channels/tests/identity.rs b/src/openhuman/channels/tests/identity.rs index b494b3958..849dd5dc2 100644 --- a/src/openhuman/channels/tests/identity.rs +++ b/src/openhuman/channels/tests/identity.rs @@ -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!( diff --git a/src/openhuman/channels/tests/prompt.rs b/src/openhuman/channels/tests/prompt.rs index 6707181c0..fc65705ab 100644 --- a/src/openhuman/channels/tests/prompt.rs +++ b/src/openhuman/channels/tests/prompt.rs @@ -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(""), "missing skills XML"); assert!(prompt.contains("code-review")); @@ -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!( diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 3c3d2b13a..8f74a8814 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -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, diff --git a/src/openhuman/config/schema/agent.rs b/src/openhuman/config/schema/agent.rs index 1f23e0e60..45c6474f2 100644 --- a/src/openhuman/config/schema/agent.rs +++ b/src/openhuman/config/schema/agent.rs @@ -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 { diff --git a/src/openhuman/config/schema/context.rs b/src/openhuman/config/schema/context.rs new file mode 100644 index 000000000..8e8b3320b --- /dev/null +++ b/src/openhuman/config/schema/context.rs @@ -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, +} + +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, + } + } +} diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 195b83f9f..b3b993e28 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -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 { + match raw.trim().parse::() { + 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::() { + 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::() { + 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(); } diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 585947abc..624966e47 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -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}; diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index e8f198473..e2309a675 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -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, @@ -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(), diff --git a/src/openhuman/channels/prompt.rs b/src/openhuman/context/channels_prompt.rs similarity index 78% rename from src/openhuman/channels/prompt.rs rename to src/openhuman/context/channels_prompt.rs index 45783dfc6..1040dec2a 100644 --- a/src/openhuman/channels/prompt.rs +++ b/src/openhuman/context/channels_prompt.rs @@ -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` 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, + 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"); diff --git a/src/openhuman/agent/harness/context_guard.rs b/src/openhuman/context/guard.rs similarity index 93% rename from src/openhuman/agent/harness/context_guard.rs rename to src/openhuman/context/guard.rs index 988cbfa30..c809ef786 100644 --- a/src/openhuman/agent/harness/context_guard.rs +++ b/src/openhuman/context/guard.rs @@ -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)] diff --git a/src/openhuman/context/manager.rs b/src/openhuman/context/manager.rs new file mode 100644 index 000000000..95bf0e0bc --- /dev/null +++ b/src/openhuman/context/manager.rs @@ -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` 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, + 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, + /// 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, + 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 { + 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 { + 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, + ) -> Result { + 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, + should_fail: bool, + } + + impl MockSummarizer { + fn ok() -> Arc { + Arc::new(Self { + calls: Mutex::new(0), + should_fail: false, + }) + } + fn failing() -> Arc { + 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, + _model: &str, + ) -> Result { + *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) -> 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); + } +} diff --git a/src/openhuman/agent/context_pipeline/microcompact.rs b/src/openhuman/context/microcompact.rs similarity index 100% rename from src/openhuman/agent/context_pipeline/microcompact.rs rename to src/openhuman/context/microcompact.rs diff --git a/src/openhuman/context/mod.rs b/src/openhuman/context/mod.rs new file mode 100644 index 000000000..926e67e18 --- /dev/null +++ b/src/openhuman/context/mod.rs @@ -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, +}; diff --git a/src/openhuman/agent/context_pipeline/pipeline.rs b/src/openhuman/context/pipeline.rs similarity index 80% rename from src/openhuman/agent/context_pipeline/pipeline.rs rename to src/openhuman/context/pipeline.rs index 85f8a620c..e3225f97e 100644 --- a/src/openhuman/agent/context_pipeline/pipeline.rs +++ b/src/openhuman/context/pipeline.rs @@ -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>; /// 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); } } diff --git a/src/openhuman/agent/prompt.rs b/src/openhuman/context/prompt.rs similarity index 60% rename from src/openhuman/agent/prompt.rs rename to src/openhuman/context/prompt.rs index 24f2215a3..53a5ee8d9 100644 --- a/src/openhuman/agent/prompt.rs +++ b/src/openhuman/context/prompt.rs @@ -18,10 +18,64 @@ pub struct LearnedContextData { pub user_profile: Vec, } +/// A lightweight tool descriptor for prompt rendering. +/// +/// Shared shape so every call-site that builds a system prompt — main +/// agents (which own `Box`), 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>` 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, +} + +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` slice into a `Vec>`. 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]) -> Vec> { + 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], + 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]` 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], + 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> = 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> = 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> = 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(), diff --git a/src/openhuman/agent/context_pipeline/session_memory.rs b/src/openhuman/context/session_memory.rs similarity index 94% rename from src/openhuman/agent/context_pipeline/session_memory.rs rename to src/openhuman/context/session_memory.rs index 8f4a9fb01..6403da75c 100644 --- a/src/openhuman/agent/context_pipeline/session_memory.rs +++ b/src/openhuman/context/session_memory.rs @@ -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 { diff --git a/src/openhuman/context/summarizer.rs b/src/openhuman/context/summarizer.rs new file mode 100644 index 000000000..ca36ac558 --- /dev/null +++ b/src/openhuman/context/summarizer.rs @@ -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` +//! 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, + model: &str, + ) -> Result; +} + +/// Default summarizer that wraps an `Arc`. +/// +/// 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, + keep_recent: usize, + temperature: f64, +} + +impl ProviderSummarizer { + /// Construct a summarizer around `provider` with default tunables. + pub fn new(provider: Arc) -> 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, + model: &str, + ) -> Result { + 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 = 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, + } + + impl StubProvider { + fn new(reply: impl Into) -> 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 { + *self.calls.lock().unwrap() += 1; + Ok(self.reply.clone()) + } + + async fn chat_with_history( + &self, + _messages: &[ChatMessage], + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + *self.calls.lock().unwrap() += 1; + Ok(self.reply.clone()) + } + + async fn chat( + &self, + _request: crate::openhuman::providers::ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + *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")); + } +} diff --git a/src/openhuman/agent/context_pipeline/tool_result_budget.rs b/src/openhuman/context/tool_result_budget.rs similarity index 100% rename from src/openhuman/agent/context_pipeline/tool_result_budget.rs rename to src/openhuman/context/tool_result_budget.rs diff --git a/src/openhuman/learning/prompt_sections.rs b/src/openhuman/learning/prompt_sections.rs index 61719644e..f3f143373 100644 --- a/src/openhuman/learning/prompt_sections.rs +++ b/src/openhuman/learning/prompt_sections.rs @@ -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. diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index c3f595e8a..fa0e651ed 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -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;