diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index ed425d5cc..6700f682e 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -836,6 +836,19 @@ pub async fn bootstrap_skill_runtime() { // from both jsonrpc and repl paths) cannot double-subscribe. register_domain_subscribers(); + // --- Sub-agent definition registry bootstrap --- + // Loads built-in archetype definitions plus any custom TOML files + // under `/agents/*.toml`. Idempotent — safe to call from + // both jsonrpc and repl paths. + if let Err(err) = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global( + &base_dir.join("workspace"), + ) { + log::warn!( + "[runtime] AgentDefinitionRegistry::init_global failed: {err} — \ + spawn_subagent will be unavailable until restart" + ); + } + // --- Socket manager bootstrap --- let socket_mgr = Arc::new(SocketManager::new()); set_global_socket_manager(socket_mgr.clone()); diff --git a/src/openhuman/agent/agent.rs b/src/openhuman/agent/agent.rs deleted file mode 100644 index 608e3ddb1..000000000 --- a/src/openhuman/agent/agent.rs +++ /dev/null @@ -1,1353 +0,0 @@ -//! Core agent implementation for the OpenHuman platform. -//! -//! This module provides the `Agent` struct, which orchestrates the interaction -//! between the AI provider, available tools, memory systems, and the user. -//! It handles the agent's "turn" logic, including tool execution and history -//! management. - -use super::dispatcher::{ - NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher, -}; -use super::error::AgentError; -use super::hooks::{self, sanitize_tool_output, PostTurnHook, ToolCallRecord, TurnContext}; -use super::memory_loader::{DefaultMemoryLoader, MemoryLoader}; -use super::prompt::{PromptContext, SystemPromptBuilder}; -use crate::openhuman::agent::host_runtime; -use crate::openhuman::config::Config; -use crate::openhuman::event_bus::{publish_global, DomainEvent}; -use crate::openhuman::memory::{self, Memory, MemoryCategory}; -use crate::openhuman::providers::{ - self, ChatMessage, ChatRequest, ConversationMessage, Provider, ToolCall, -}; -use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::{self, Tool, ToolSpec}; -use crate::openhuman::util::truncate_with_ellipsis; -use anyhow::Result; -use std::io::Write as IoWrite; -use std::sync::Arc; - -/// An autonomous or semi-autonomous AI agent. -/// -/// The `Agent` is the central component that manages conversation state, -/// executes tools based on model requests, and interacts with the memory system -/// to maintain context across turns. -pub struct Agent { - provider: Box, - tools: Vec>, - tool_specs: Vec, - memory: Arc, - prompt_builder: SystemPromptBuilder, - tool_dispatcher: Box, - memory_loader: Box, - config: crate::openhuman::config::AgentConfig, - model_name: String, - temperature: f64, - workspace_dir: std::path::PathBuf, - identity_config: crate::openhuman::config::IdentityConfig, - skills: Vec, - auto_save: bool, - history: Vec, - classification_config: crate::openhuman::config::QueryClassificationConfig, - available_hints: Vec, - post_turn_hooks: Vec>, - learning_enabled: bool, - event_session_id: String, - event_channel: String, -} - -/// A builder for creating `Agent` instances with custom configuration. -pub struct AgentBuilder { - provider: Option>, - tools: Option>>, - memory: Option>, - prompt_builder: Option, - tool_dispatcher: Option>, - memory_loader: Option>, - config: Option, - model_name: Option, - temperature: Option, - workspace_dir: Option, - identity_config: Option, - skills: Option>, - auto_save: Option, - classification_config: Option, - available_hints: Option>, - post_turn_hooks: Vec>, - learning_enabled: bool, - event_session_id: Option, - event_channel: Option, -} - -impl Default for AgentBuilder { - fn default() -> Self { - Self::new() - } -} - -impl AgentBuilder { - /// Creates a new `AgentBuilder` with default values. - pub fn new() -> Self { - Self { - provider: None, - tools: None, - memory: None, - prompt_builder: None, - tool_dispatcher: None, - memory_loader: None, - config: None, - model_name: None, - temperature: None, - workspace_dir: None, - identity_config: None, - skills: None, - auto_save: None, - classification_config: None, - available_hints: None, - post_turn_hooks: Vec::new(), - learning_enabled: false, - event_session_id: None, - event_channel: None, - } - } - - /// Sets the AI provider for the agent. - pub fn provider(mut self, provider: Box) -> Self { - self.provider = Some(provider); - self - } - - /// Sets the available tools for the agent. - pub fn tools(mut self, tools: Vec>) -> Self { - self.tools = Some(tools); - self - } - - /// Sets the memory system for the agent. - pub fn memory(mut self, memory: Arc) -> Self { - self.memory = Some(memory); - self - } - - /// Sets the system prompt builder for the agent. - pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self { - self.prompt_builder = Some(prompt_builder); - self - } - - /// Sets the tool dispatcher for the agent. - pub fn tool_dispatcher(mut self, tool_dispatcher: Box) -> Self { - self.tool_dispatcher = Some(tool_dispatcher); - self - } - - /// Sets the memory loader for the agent. - pub fn memory_loader(mut self, memory_loader: Box) -> Self { - self.memory_loader = Some(memory_loader); - self - } - - /// Sets the agent configuration. - pub fn config(mut self, config: crate::openhuman::config::AgentConfig) -> Self { - self.config = Some(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); - self - } - - /// Sets the temperature for chat requests. - pub fn temperature(mut self, temperature: f64) -> Self { - self.temperature = Some(temperature); - self - } - - /// Sets the workspace directory for the agent. - pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self { - self.workspace_dir = Some(workspace_dir); - self - } - - /// Sets the identity configuration for the agent. - pub fn identity_config( - mut self, - identity_config: crate::openhuman::config::IdentityConfig, - ) -> Self { - self.identity_config = Some(identity_config); - self - } - - /// Sets the skills available to the agent. - pub fn skills(mut self, skills: Vec) -> Self { - self.skills = Some(skills); - self - } - - /// Enables or disables automatic saving of conversation history to memory. - pub fn auto_save(mut self, auto_save: bool) -> Self { - self.auto_save = Some(auto_save); - self - } - - /// Sets the query classification configuration. - pub fn classification_config( - mut self, - classification_config: crate::openhuman::config::QueryClassificationConfig, - ) -> Self { - self.classification_config = Some(classification_config); - self - } - - /// Sets the available model hints for auto-classification. - pub fn available_hints(mut self, available_hints: Vec) -> Self { - self.available_hints = Some(available_hints); - self - } - - /// Sets the post-turn hooks to be executed after each turn. - pub fn post_turn_hooks(mut self, hooks: Vec>) -> Self { - self.post_turn_hooks = hooks; - self - } - - /// Enables or disables learning features. - pub fn learning_enabled(mut self, enabled: bool) -> Self { - self.learning_enabled = enabled; - self - } - - pub fn event_context( - mut self, - session_id: impl Into, - channel: impl Into, - ) -> Self { - self.event_session_id = Some(session_id.into()); - self.event_channel = Some(channel.into()); - self - } - - /// Validates the configuration and builds the `Agent` instance. - pub fn build(self) -> Result { - let tools = self - .tools - .ok_or_else(|| anyhow::anyhow!("tools are required"))?; - let tool_specs = tools.iter().map(|tool| tool.spec()).collect(); - - Ok(Agent { - provider: self - .provider - .ok_or_else(|| anyhow::anyhow!("provider is required"))?, - tools, - tool_specs, - 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"))?, - memory_loader: self - .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()), - temperature: self.temperature.unwrap_or(0.7), - workspace_dir: self - .workspace_dir - .unwrap_or_else(|| std::path::PathBuf::from(".")), - identity_config: self.identity_config.unwrap_or_default(), - skills: self.skills.unwrap_or_default(), - auto_save: self.auto_save.unwrap_or(false), - history: Vec::new(), - classification_config: self.classification_config.unwrap_or_default(), - available_hints: self.available_hints.unwrap_or_default(), - post_turn_hooks: self.post_turn_hooks, - learning_enabled: self.learning_enabled, - event_session_id: self - .event_session_id - .unwrap_or_else(|| "standalone".to_string()), - event_channel: self.event_channel.unwrap_or_else(|| "internal".to_string()), - }) - } -} - -impl Agent { - const EVENT_ERROR_MAX_CHARS: usize = 256; - - fn event_session_id(&self) -> &str { - &self.event_session_id - } - - fn event_channel(&self) -> &str { - &self.event_channel - } - - fn count_iterations(messages: &[ConversationMessage]) -> usize { - messages - .iter() - .filter(|message| matches!(message, ConversationMessage::AssistantToolCalls { .. })) - .count() - + 1 - } - - fn conversation_message_eq(left: &ConversationMessage, right: &ConversationMessage) -> bool { - serde_json::to_string(left).ok() == serde_json::to_string(right).ok() - } - - fn message_slice_eq(left: &[ConversationMessage], right: &[ConversationMessage]) -> bool { - left.len() == right.len() - && left - .iter() - .zip(right.iter()) - .all(|(left, right)| Self::conversation_message_eq(left, right)) - } - - fn new_entries_for_turn<'a>( - history_snapshot: &[ConversationMessage], - current_history: &'a [ConversationMessage], - ) -> &'a [ConversationMessage] { - let common_prefix_len = history_snapshot - .iter() - .zip(current_history.iter()) - .take_while(|(left, right)| Self::conversation_message_eq(left, right)) - .count(); - - if common_prefix_len == history_snapshot.len() { - return ¤t_history[common_prefix_len..]; - } - - let max_overlap = history_snapshot.len().min(current_history.len()); - for overlap in (0..=max_overlap).rev() { - let snapshot_suffix = &history_snapshot[history_snapshot.len() - overlap..]; - let current_prefix = ¤t_history[..overlap]; - if Self::message_slice_eq(snapshot_suffix, current_prefix) { - return ¤t_history[overlap..]; - } - } - - current_history - } - - fn sanitize_event_error_message(err: &anyhow::Error) -> String { - let kind = match err.downcast_ref::() { - Some(AgentError::ProviderError { .. }) => Some("provider_error"), - Some(AgentError::ContextLimitExceeded { .. }) => Some("context_limit_exceeded"), - Some(AgentError::ToolExecutionError { .. }) => Some("tool_execution_error"), - Some(AgentError::CostBudgetExceeded { .. }) => Some("cost_budget_exceeded"), - Some(AgentError::MaxIterationsExceeded { .. }) => Some("max_iterations_exceeded"), - Some(AgentError::CompactionFailed { .. }) => Some("compaction_failed"), - Some(AgentError::PermissionDenied { .. }) => Some("permission_denied"), - Some(AgentError::Other(_)) | None => None, - }; - - if let Some(kind) = kind { - return kind.to_string(); - } - - let scrubbed = providers::sanitize_api_error(&err.to_string()) - .replace(['\n', '\r', '\t'], " ") - .split_whitespace() - .collect::>() - .join(" "); - truncate_with_ellipsis(&scrubbed, Self::EVENT_ERROR_MAX_CHARS) - } - - /// Injects unique IDs into tool calls that are missing them. - /// - /// This is necessary for some tool dispatchers to correctly track and - /// associate results. - fn with_fallback_tool_call_ids( - mut parsed_calls: Vec, - iteration: usize, - ) -> Vec { - for (idx, call) in parsed_calls.iter_mut().enumerate() { - if call.tool_call_id.is_none() { - call.tool_call_id = Some(format!("parsed-{}-{}", iteration + 1, idx + 1)); - } - } - parsed_calls - } - - /// Converts parsed tool calls into the provider-standard `ToolCall` format. - /// - /// If the provider response already contains native tool calls, they are - /// returned as-is. - fn persisted_tool_calls_for_history( - response: &crate::openhuman::providers::ChatResponse, - parsed_calls: &[ParsedToolCall], - iteration: usize, - ) -> Vec { - if !response.tool_calls.is_empty() { - return response.tool_calls.clone(); - } - - parsed_calls - .iter() - .enumerate() - .map(|(idx, call)| ToolCall { - id: call - .tool_call_id - .clone() - .unwrap_or_else(|| format!("parsed-{}-{}", iteration + 1, idx + 1)), - name: call.name.clone(), - arguments: call.arguments.to_string(), - }) - .collect() - } - - /// Returns a new `AgentBuilder`. - pub fn builder() -> AgentBuilder { - AgentBuilder::new() - } - - /// Returns the current conversation history. - pub fn history(&self) -> &[ConversationMessage] { - &self.history - } - - pub fn set_event_context(&mut self, session_id: impl Into, channel: impl Into) { - self.event_session_id = session_id.into(); - self.event_channel = channel.into(); - } - - /// Clears the agent's conversation history. - pub fn clear_history(&mut self) { - self.history.clear(); - } - - /// Creates an `Agent` instance from a global configuration. - /// - /// This is the primary way to initialize an agent with all system - /// integrations (memory, tools, skills, etc.) configured. - pub fn from_config(config: &Config) -> Result { - let runtime: Arc = - Arc::from(host_runtime::create_runtime(&config.runtime)?); - let security = Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - )); - - let memory: Arc = Arc::from(memory::create_memory_with_storage_and_routes( - &config.memory, - &config.embedding_routes, - Some(&config.storage.provider.config), - &config.workspace_dir, - config.api_key.as_deref(), - )?); - - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() - } else { - None - }; - let composio_entity_id = if config.composio.enabled { - Some(config.composio.entity_id.as_str()) - } else { - None - }; - - let mut tools = tools::all_tools_with_runtime( - Arc::new(config.clone()), - &security, - runtime, - memory.clone(), - composio_key, - composio_entity_id, - &config.browser, - &config.http_request, - &config.workspace_dir, - &config.agents, - config.api_key.as_deref(), - config, - ); - - // Bridge skill tools (Notion, Gmail, etc.) from the QuickJS runtime - // into the agent's tool registry so the LLM can call them. - let skill_tools = tools::skill_bridge::collect_skill_tools(); - if !skill_tools.is_empty() { - log::info!( - "[agent] Injecting {} skill tool(s) into agent registry", - skill_tools.len() - ); - tools.extend(skill_tools); - } - - let model_name = config - .default_model - .as_deref() - .unwrap_or(crate::openhuman::config::DEFAULT_MODEL) - .to_string(); - - let provider_runtime_options = providers::ProviderRuntimeOptions { - auth_profile_override: None, - openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from), - secrets_encrypt: config.secrets.encrypt, - reasoning_enabled: config.runtime.reasoning_enabled, - }; - - let provider: Box = providers::create_routed_provider_with_options( - config.api_key.as_deref(), - config.api_url.as_deref(), - &config.reliability, - &config.model_routes, - &model_name, - &provider_runtime_options, - )?; - - let dispatcher_choice = config.agent.tool_dispatcher.as_str(); - let tool_dispatcher: Box = match dispatcher_choice { - "native" => Box::new(NativeToolDispatcher), - "xml" => Box::new(XmlToolDispatcher), - _ if provider.supports_native_tools() => Box::new(NativeToolDispatcher), - _ => Box::new(XmlToolDispatcher), - }; - - let available_hints: Vec = - config.model_routes.iter().map(|r| r.hint.clone()).collect(); - - // Build prompt builder, optionally with learning sections - let mut prompt_builder = SystemPromptBuilder::with_defaults(); - if config.learning.enabled { - prompt_builder = prompt_builder - .add_section(Box::new( - crate::openhuman::learning::LearnedContextSection::new(memory.clone()), - )) - .add_section(Box::new( - crate::openhuman::learning::UserProfileSection::new(memory.clone()), - )); - log::info!("[learning] prompt sections registered (learned_context, user_profile)"); - } - - // Build post-turn hooks when learning is enabled - let mut post_turn_hooks: Vec> = Vec::new(); - if config.learning.enabled { - let full_config = Arc::new(config.clone()); - - if config.learning.reflection_enabled { - // For cloud reflection, wrap the provider in an Arc. - // For local, no provider needed. - let reflection_provider: Option> = - if config.learning.reflection_source - == crate::openhuman::config::ReflectionSource::Cloud - { - Some(Arc::from(providers::create_routed_provider( - config.api_key.as_deref(), - config.api_url.as_deref(), - &config.reliability, - &config.model_routes, - &model_name, - )?)) - } else { - None - }; - post_turn_hooks.push(Arc::new(crate::openhuman::learning::ReflectionHook::new( - config.learning.clone(), - full_config.clone(), - memory.clone(), - reflection_provider, - ))); - log::info!( - "[learning] reflection hook registered (source={:?})", - config.learning.reflection_source - ); - } - - if config.learning.user_profile_enabled { - post_turn_hooks.push(Arc::new(crate::openhuman::learning::UserProfileHook::new( - config.learning.clone(), - memory.clone(), - ))); - log::info!("[learning] user_profile hook registered"); - } - - if config.learning.tool_tracking_enabled { - post_turn_hooks.push(Arc::new(crate::openhuman::learning::ToolTrackerHook::new( - config.learning.clone(), - memory.clone(), - ))); - log::info!("[learning] tool_tracker hook registered"); - } - } - - Agent::builder() - .provider(provider) - .tools(tools) - .memory(memory) - .tool_dispatcher(tool_dispatcher) - .memory_loader(Box::new( - DefaultMemoryLoader::new(5, config.memory.min_relevance_score) - .with_max_chars(config.agent.max_memory_context_chars), - )) - .prompt_builder(prompt_builder) - .config(config.agent.clone()) - .model_name(model_name) - .temperature(config.default_temperature) - .workspace_dir(config.workspace_dir.clone()) - .classification_config(config.query_classification.clone()) - .available_hints(available_hints) - .identity_config(config.identity.clone()) - .skills(crate::openhuman::skills::load_skills(&config.workspace_dir)) - .auto_save(config.memory.auto_save) - .post_turn_hooks(post_turn_hooks) - .learning_enabled(config.learning.enabled) - .build() - } - - /// Truncates the conversation history to the configured maximum message count. - /// - /// System messages are always preserved. Older non-system messages are - /// dropped first. - fn trim_history(&mut self) { - let max = self.config.max_history_messages; - if self.history.len() <= max { - return; - } - - let mut system_messages = Vec::new(); - let mut other_messages = Vec::new(); - - for msg in self.history.drain(..) { - match &msg { - ConversationMessage::Chat(chat) if chat.role == "system" => { - system_messages.push(msg); - } - _ => other_messages.push(msg), - } - } - - if other_messages.len() > max { - let drop_count = other_messages.len() - max; - other_messages.drain(0..drop_count); - } - - self.history = system_messages; - self.history.extend(other_messages); - } - - /// Pre-fetches learned context data from memory (observations, patterns, user profile). - /// - /// This is an async, non-blocking operation that populates the context - /// for the system prompt. - async fn fetch_learned_context(&self) -> crate::openhuman::agent::prompt::LearnedContextData { - use crate::openhuman::agent::prompt::LearnedContextData; - - if !self.learning_enabled { - return LearnedContextData::default(); - } - - let obs_entries = self - .memory - .list( - Some(&MemoryCategory::Custom("learning_observations".into())), - None, - ) - .await - .unwrap_or_default(); - - let pat_entries = self - .memory - .list( - Some(&MemoryCategory::Custom("learning_patterns".into())), - None, - ) - .await - .unwrap_or_default(); - - let profile_entries = self - .memory - .list(Some(&MemoryCategory::Custom("user_profile".into())), None) - .await - .unwrap_or_default(); - - LearnedContextData { - observations: obs_entries - .iter() - .rev() - .take(5) - .map(|e| sanitize_learned_entry(&e.content)) - .collect(), - patterns: pat_entries - .iter() - .take(3) - .map(|e| sanitize_learned_entry(&e.content)) - .collect(), - user_profile: profile_entries - .iter() - .take(20) - .map(|e| sanitize_learned_entry(&e.content)) - .collect(), - } - } - - /// Builds the system prompt for the current turn, including tool - /// instructions and learned context. - fn build_system_prompt( - &self, - learned: crate::openhuman::agent::prompt::LearnedContextData, - ) -> Result { - let instructions = self.tool_dispatcher.prompt_instructions(&self.tools); - let ctx = PromptContext { - workspace_dir: &self.workspace_dir, - model_name: &self.model_name, - tools: &self.tools, - skills: &self.skills, - identity_config: Some(&self.identity_config), - dispatcher_instructions: &instructions, - learned, - }; - self.prompt_builder.build(&ctx) - } - - /// Sanitize tool output to prevent PII/secrets in learning data. - /// Returns a safe metadata string: tool type, status, and error class. - fn sanitize_tool_output(raw_output: &str, tool_name: &str, success: bool) -> String { - if success { - // For successful calls, return a structured summary without raw data - let char_count = raw_output.chars().count(); - format!( - "tool={} status=success output_length={}", - tool_name, char_count - ) - } else { - // For errors, classify the error type without exposing details - let error_class = if raw_output.contains("permission") || raw_output.contains("denied") - { - "permission_error" - } else if raw_output.contains("not found") || raw_output.contains("404") { - "not_found" - } else if raw_output.contains("timeout") || raw_output.contains("timed out") { - "timeout" - } else if raw_output.contains("network") || raw_output.contains("connection") { - "network_error" - } else if raw_output.contains("invalid") || raw_output.contains("parse") { - "validation_error" - } else { - "unknown_error" - }; - format!("tool={} status=error class={}", tool_name, error_class) - } - } - - /// Executes a single tool call and returns the result and execution record. - async fn execute_tool_call( - &self, - call: &ParsedToolCall, - ) -> (ToolExecutionResult, ToolCallRecord) { - let started = std::time::Instant::now(); - publish_global(DomainEvent::ToolExecutionStarted { - tool_name: call.name.clone(), - session_id: self.event_session_id().to_string(), - }); - log::info!("[agent_loop] tool start name={}", call.name); - let (result, success) = - if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) { - match tool.execute(call.arguments.clone()).await { - Ok(r) => { - if !r.is_error { - (r.output(), true) - } else { - (format!("Error: {}", r.output()), false) - } - } - Err(e) => (format!("Error executing {}: {e}", call.name), false), - } - } else { - (format!("Unknown tool: {}", call.name), false) - }; - let elapsed_ms = started.elapsed().as_millis() as u64; - publish_global(DomainEvent::ToolExecutionCompleted { - tool_name: call.name.clone(), - session_id: self.event_session_id().to_string(), - success, - elapsed_ms, - }); - log::info!( - "[agent_loop] tool finish name={} elapsed_ms={} output_chars={} success={}", - call.name, - elapsed_ms, - result.chars().count(), - success - ); - - let output_summary = sanitize_tool_output(&result, &call.name, success); - - let record = ToolCallRecord { - name: call.name.clone(), - arguments: call.arguments.clone(), - success, - output_summary, - duration_ms: elapsed_ms, - }; - - let exec_result = ToolExecutionResult { - name: call.name.clone(), - output: result, - success, - tool_call_id: call.tool_call_id.clone(), - }; - - (exec_result, record) - } - - /// Executes multiple tool calls in sequence. - async fn execute_tools( - &self, - calls: &[ParsedToolCall], - ) -> (Vec, Vec) { - let mut results = Vec::with_capacity(calls.len()); - let mut records = Vec::with_capacity(calls.len()); - for call in calls { - let (exec_result, record) = self.execute_tool_call(call).await; - results.push(exec_result); - records.push(record); - } - (results, records) - } - - /// Classifies the user message to determine if a specific model hint should be used. - fn classify_model(&self, user_message: &str) -> String { - if let Some(hint) = super::classifier::classify(&self.classification_config, user_message) { - if self.available_hints.contains(&hint) { - tracing::info!(hint = hint.as_str(), "Auto-classified query"); - return format!("hint:{hint}"); - } - } - self.model_name.clone() - } - - /// Performs a single interaction "turn" with the agent. - /// - /// This is the core logic that takes user input, manages the history, - /// calls the LLM, handles tool calls (up to `max_tool_iterations`), - /// and returns the final assistant response. - pub async fn turn(&mut self, user_message: &str) -> Result { - let turn_started = std::time::Instant::now(); - log::info!( - "[agent_loop] turn start message_chars={} history_len={} max_tool_iterations={}", - user_message.chars().count(), - self.history.len(), - self.config.max_tool_iterations - ); - // Pre-fetch learned context async before building the system prompt - let learned = self.fetch_learned_context().await; - - if self.history.is_empty() { - let system_prompt = self.build_system_prompt(learned)?; - log::info!( - "[agent_loop] system prompt built chars={} content=\n{}", - system_prompt.chars().count(), - system_prompt - ); - self.history - .push(ConversationMessage::Chat(ChatMessage::system( - system_prompt, - ))); - } else if self.learning_enabled { - // Rebuild system prompt on subsequent turns to include newly learned context - let system_prompt = self.build_system_prompt(learned)?; - if let Some(pos) = self.history.iter().position( - |msg| matches!(msg, ConversationMessage::Chat(chat) if chat.role == "system"), - ) { - self.history[pos] = ConversationMessage::Chat(ChatMessage::system(system_prompt)); - log::debug!("[agent_loop] system prompt refreshed with learned context"); - } - } - - if self.auto_save { - let _ = self - .memory - .store("user_msg", user_message, MemoryCategory::Conversation, None) - .await; - } - - let context = self - .memory_loader - .load_context(self.memory.as_ref(), user_message) - .await - .unwrap_or_default(); - - let enriched = if context.is_empty() { - user_message.to_string() - } else { - format!("{context}{user_message}") - }; - - self.history - .push(ConversationMessage::Chat(ChatMessage::user(enriched))); - - let effective_model = self.classify_model(user_message); - log::info!("[agent_loop] model selected model={}", effective_model); - - // Collect tool call records across all iterations for post-turn hooks - let mut all_tool_records: Vec = Vec::new(); - - for iteration in 0..self.config.max_tool_iterations { - log::info!( - "[agent_loop] iteration start i={} history_len={}", - iteration + 1, - self.history.len() - ); - let messages = self.tool_dispatcher.to_provider_messages(&self.history); - log::info!( - "[agent_loop] provider request i={} messages={} send_tool_specs={}", - iteration + 1, - messages.len(), - self.tool_dispatcher.should_send_tool_specs() - ); - let provider_started = std::time::Instant::now(); - let response = match self - .provider - .chat( - ChatRequest { - messages: &messages, - tools: if self.tool_dispatcher.should_send_tool_specs() { - Some(&self.tool_specs) - } else { - None - }, - system_prompt_cache_boundary: None, - }, - &effective_model, - self.temperature, - ) - .await - { - Ok(resp) => { - log::info!( - "[agent_loop] provider response i={} elapsed_ms={} text_chars={} native_tool_calls={}", - iteration + 1, - provider_started.elapsed().as_millis(), - resp.text.as_ref().map_or(0, |t| t.chars().count()), - resp.tool_calls.len() - ); - log::debug!("[agent_loop] provider response: {resp:?}"); - resp - } - Err(err) => return Err(err), - }; - - let (text, calls) = self.tool_dispatcher.parse_response(&response); - let calls = Self::with_fallback_tool_call_ids(calls, iteration); - log::info!( - "[agent_loop] parsed response i={} parsed_text_chars={} parsed_tool_calls={}", - iteration + 1, - text.chars().count(), - calls.len() - ); - if calls.is_empty() { - let final_text = if text.is_empty() { - response.text.unwrap_or_default() - } else { - text - }; - log::info!( - "[agent_loop] final response i={} final_chars={}", - iteration + 1, - final_text.chars().count() - ); - - self.history - .push(ConversationMessage::Chat(ChatMessage::assistant( - final_text.clone(), - ))); - self.trim_history(); - - if self.auto_save { - let summary = truncate_with_ellipsis(&final_text, 100); - let _ = self - .memory - .store("assistant_resp", &summary, MemoryCategory::Daily, None) - .await; - } - - // Fire post-turn hooks (non-blocking) - if !self.post_turn_hooks.is_empty() { - let ctx = TurnContext { - user_message: user_message.to_string(), - assistant_response: final_text.clone(), - tool_calls: all_tool_records, - turn_duration_ms: turn_started.elapsed().as_millis() as u64, - session_id: None, - iteration_count: iteration + 1, - }; - hooks::fire_hooks(&self.post_turn_hooks, ctx); - } - - return Ok(final_text); - } - - if !text.is_empty() { - log::info!( - "[agent_loop] assistant pre-tool text i={} chars={}", - iteration + 1, - text.chars().count() - ); - self.history - .push(ConversationMessage::Chat(ChatMessage::assistant( - text.clone(), - ))); - print!("{text}"); - let _ = std::io::stdout().flush(); - } - let tool_names: Vec<&str> = calls.iter().map(|call| call.name.as_str()).collect(); - log::info!( - "[agent_loop] executing tools i={} names={:?}", - iteration + 1, - tool_names - ); - let persisted_tool_calls = - Self::persisted_tool_calls_for_history(&response, &calls, iteration); - log::info!( - "[agent_loop] persisting assistant tool calls i={} persisted_tool_calls={} parsed_tool_calls={}", - iteration + 1, - persisted_tool_calls.len(), - calls.len() - ); - self.history.push(ConversationMessage::AssistantToolCalls { - text: if text.is_empty() { - None - } else { - Some(text.clone()) - }, - tool_calls: persisted_tool_calls, - }); - - let (results, records) = self.execute_tools(&calls).await; - all_tool_records.extend(records); - log::info!( - "[agent_loop] tool results complete i={} result_count={}", - iteration + 1, - results.len() - ); - let formatted = self.tool_dispatcher.format_results(&results); - self.history.push(formatted); - self.trim_history(); - log::info!( - "[agent_loop] iteration end i={} history_len={}", - iteration + 1, - self.history.len() - ); - } - - log::warn!( - "[agent_loop] exceeded maximum tool iterations max={}", - self.config.max_tool_iterations - ); - anyhow::bail!( - "Agent exceeded maximum tool iterations ({})", - self.config.max_tool_iterations - ) - } - - /// Runs a single turn with the given message and returns the response. - pub async fn run_single(&mut self, message: &str) -> Result { - let history_snapshot = self.history.clone(); - publish_global(DomainEvent::AgentTurnStarted { - session_id: self.event_session_id().to_string(), - channel: self.event_channel().to_string(), - }); - - match self.turn(message).await { - Ok(response) => { - let new_entries = Self::new_entries_for_turn(&history_snapshot, &self.history); - publish_global(DomainEvent::AgentTurnCompleted { - session_id: self.event_session_id().to_string(), - text_chars: response.chars().count(), - iterations: Self::count_iterations(new_entries), - }); - Ok(response) - } - Err(err) => { - let sanitized_message = Self::sanitize_event_error_message(&err); - publish_global(DomainEvent::AgentError { - session_id: self.event_session_id().to_string(), - message: sanitized_message, - recoverable: false, - }); - Err(err) - } - } - } - - /// Runs an interactive CLI loop, reading from standard input and printing to standard output. - pub async fn run_interactive(&mut self) -> Result<()> { - println!("🦀 OpenHuman Interactive Mode"); - println!("Type /quit to exit.\n"); - - let (tx, mut rx) = tokio::sync::mpsc::channel(32); - let cli = crate::openhuman::channels::CliChannel::new(); - - let listen_handle = tokio::spawn(async move { - let _ = crate::openhuman::channels::Channel::listen(&cli, tx).await; - }); - - while let Some(msg) = rx.recv().await { - let response = match self.turn(&msg.content).await { - Ok(resp) => resp, - Err(e) => { - eprintln!("\nError: {e}\n"); - continue; - } - }; - println!("\n{response}\n"); - } - - listen_handle.abort(); - Ok(()) - } -} - -/// Convenience entry point to run an agent with the given configuration and message. -pub async fn run( - config: Config, - message: Option, - model_override: Option, - temperature: f64, -) -> Result<()> { - let mut effective_config = config; - if let Some(m) = model_override { - effective_config.default_model = Some(m); - } - effective_config.default_temperature = temperature; - - let mut agent = Agent::from_config(&effective_config)?; - - if let Some(msg) = message { - let response = agent.run_single(&msg).await?; - println!("{response}"); - } else { - agent.run_interactive().await?; - } - - Ok(()) -} - -/// Sanitize a learned memory entry before injecting into the system prompt. -/// Strips raw data, limits length, and removes potential secrets. -fn sanitize_learned_entry(content: &str) -> String { - let trimmed = content.trim(); - if trimmed.is_empty() { - return String::new(); - } - // Truncate to a safe length - let max_len = 200; - let sanitized: String = trimmed.chars().take(max_len).collect(); - // Strip anything that looks like a secret/token - if sanitized.contains("Bearer ") - || sanitized.contains("sk-") - || sanitized.contains("ghp_") - || sanitized.contains("-----BEGIN") - { - return "[redacted: potential secret]".to_string(); - } - sanitized -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use parking_lot::Mutex; - - struct MockProvider { - responses: Mutex>, - } - - #[async_trait] - impl Provider for MockProvider { - async fn chat_with_system( - &self, - _system_prompt: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> Result { - Ok("ok".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> Result { - let mut guard = self.responses.lock(); - if guard.is_empty() { - return Ok(crate::openhuman::providers::ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - }); - } - Ok(guard.remove(0)) - } - } - - struct MockTool; - - #[async_trait] - impl Tool for MockTool { - fn name(&self) -> &str { - "echo" - } - - fn description(&self) -> &str { - "echo" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - - async fn execute( - &self, - _args: serde_json::Value, - ) -> Result { - Ok(crate::openhuman::tools::ToolResult::success("tool-out")) - } - } - - #[tokio::test] - async fn turn_without_tools_returns_text() { - let workspace = tempfile::TempDir::new().expect("temp workspace"); - let workspace_path = workspace.path().to_path_buf(); - - let provider = Box::new(MockProvider { - responses: Mutex::new(vec![crate::openhuman::providers::ChatResponse { - text: Some("hello".into()), - tool_calls: vec![], - usage: None, - }]), - }); - - let memory_cfg = crate::openhuman::config::MemoryConfig { - backend: "none".into(), - ..crate::openhuman::config::MemoryConfig::default() - }; - let mem: Arc = Arc::from( - crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), - ); - - let mut agent = Agent::builder() - .provider(provider) - .tools(vec![Box::new(MockTool)]) - .memory(mem) - .tool_dispatcher(Box::new(XmlToolDispatcher)) - .workspace_dir(workspace_path) - .build() - .unwrap(); - - let response = agent.turn("hi").await.unwrap(); - assert_eq!(response, "hello"); - } - - #[tokio::test] - async fn turn_with_native_dispatcher_handles_tool_results_variant() { - let workspace = tempfile::TempDir::new().expect("temp workspace"); - let workspace_path = workspace.path().to_path_buf(); - - let provider = Box::new(MockProvider { - responses: Mutex::new(vec![ - crate::openhuman::providers::ChatResponse { - text: Some(String::new()), - tool_calls: vec![crate::openhuman::providers::ToolCall { - id: "tc1".into(), - name: "echo".into(), - arguments: "{}".into(), - }], - usage: None, - }, - crate::openhuman::providers::ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - }, - ]), - }); - - let memory_cfg = crate::openhuman::config::MemoryConfig { - backend: "none".into(), - ..crate::openhuman::config::MemoryConfig::default() - }; - let mem: Arc = Arc::from( - crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), - ); - - let mut agent = Agent::builder() - .provider(provider) - .tools(vec![Box::new(MockTool)]) - .memory(mem) - .tool_dispatcher(Box::new(NativeToolDispatcher)) - .workspace_dir(workspace_path) - .build() - .unwrap(); - - let response = agent.turn("hi").await.unwrap(); - assert_eq!(response, "done"); - assert!(agent - .history() - .iter() - .any(|msg| matches!(msg, ConversationMessage::ToolResults(_)))); - } - - #[tokio::test] - async fn turn_with_native_dispatcher_persists_fallback_tool_calls() { - let workspace = tempfile::TempDir::new().expect("temp workspace"); - let workspace_path = workspace.path().to_path_buf(); - - let provider = Box::new(MockProvider { - responses: Mutex::new(vec![ - crate::openhuman::providers::ChatResponse { - text: Some( - "Checking...\n{\"name\":\"echo\",\"arguments\":{}}" - .into(), - ), - tool_calls: vec![], - usage: None, - }, - crate::openhuman::providers::ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - }, - ]), - }); - - let memory_cfg = crate::openhuman::config::MemoryConfig { - backend: "none".into(), - ..crate::openhuman::config::MemoryConfig::default() - }; - let mem: Arc = Arc::from( - crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), - ); - - let mut agent = Agent::builder() - .provider(provider) - .tools(vec![Box::new(MockTool)]) - .memory(mem) - .tool_dispatcher(Box::new(NativeToolDispatcher)) - .workspace_dir(workspace_path) - .build() - .unwrap(); - - let response = agent.turn("hi").await.unwrap(); - assert_eq!(response, "done"); - - let persisted_calls = agent - .history() - .iter() - .find_map(|msg| match msg { - ConversationMessage::AssistantToolCalls { tool_calls, .. } => Some(tool_calls), - _ => None, - }) - .expect("assistant tool calls should be persisted"); - assert_eq!(persisted_calls.len(), 1); - assert_eq!(persisted_calls[0].name, "echo"); - } -} diff --git a/src/openhuman/agent/agent/builder.rs b/src/openhuman/agent/agent/builder.rs new file mode 100644 index 000000000..4b84f824a --- /dev/null +++ b/src/openhuman/agent/agent/builder.rs @@ -0,0 +1,431 @@ +//! `AgentBuilder` fluent API and the `Agent::from_config` factory. +//! +//! Everything in this file is about *constructing* an `Agent` — the +//! builder setters, the `build()` validator, and the `from_config()` +//! factory that wires together the real provider / memory / tool +//! registry from a loaded [`Config`]. Per-turn behaviour lives in +//! [`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::memory::{self, Memory}; +use crate::openhuman::providers::{self, Provider}; +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::{self, Tool, ToolSpec}; +use anyhow::Result; +use std::sync::Arc; + +impl AgentBuilder { + /// Creates a new `AgentBuilder` with default values. + pub fn new() -> Self { + Self { + provider: None, + tools: None, + memory: None, + prompt_builder: None, + tool_dispatcher: None, + memory_loader: None, + config: None, + model_name: None, + temperature: None, + workspace_dir: None, + identity_config: None, + skills: None, + auto_save: None, + classification_config: None, + available_hints: None, + post_turn_hooks: Vec::new(), + learning_enabled: false, + event_session_id: None, + event_channel: None, + } + } + + /// Sets the AI provider for the agent. + /// + /// Accepts a `Box` for backward compatibility but stores + /// the provider as an `Arc` internally so sub-agents spawned from this + /// agent (via `spawn_subagent`) can share the same instance. + pub fn provider(mut self, provider: Box) -> Self { + self.provider = Some(Arc::from(provider)); + self + } + + /// Sets the AI provider from an existing `Arc`. Use this when sharing + /// a provider instance across multiple agents. + pub fn provider_arc(mut self, provider: Arc) -> Self { + self.provider = Some(provider); + self + } + + /// Sets the available tools for the agent. + pub fn tools(mut self, tools: Vec>) -> Self { + self.tools = Some(tools); + self + } + + /// Sets the memory system for the agent. + pub fn memory(mut self, memory: Arc) -> Self { + self.memory = Some(memory); + self + } + + /// Sets the system prompt builder for the agent. + pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self { + self.prompt_builder = Some(prompt_builder); + self + } + + /// Sets the tool dispatcher for the agent. + pub fn tool_dispatcher(mut self, tool_dispatcher: Box) -> Self { + self.tool_dispatcher = Some(tool_dispatcher); + self + } + + /// Sets the memory loader for the agent. + pub fn memory_loader(mut self, memory_loader: Box) -> Self { + self.memory_loader = Some(memory_loader); + self + } + + /// Sets the agent configuration. + pub fn config(mut self, config: crate::openhuman::config::AgentConfig) -> Self { + self.config = Some(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); + self + } + + /// Sets the temperature for chat requests. + pub fn temperature(mut self, temperature: f64) -> Self { + self.temperature = Some(temperature); + self + } + + /// Sets the workspace directory for the agent. + pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self { + self.workspace_dir = Some(workspace_dir); + self + } + + /// Sets the identity configuration for the agent. + pub fn identity_config( + mut self, + identity_config: crate::openhuman::config::IdentityConfig, + ) -> Self { + self.identity_config = Some(identity_config); + self + } + + /// Sets the skills available to the agent. + pub fn skills(mut self, skills: Vec) -> Self { + self.skills = Some(skills); + self + } + + /// Enables or disables automatic saving of conversation history to memory. + pub fn auto_save(mut self, auto_save: bool) -> Self { + self.auto_save = Some(auto_save); + self + } + + /// Sets the query classification configuration. + pub fn classification_config( + mut self, + classification_config: crate::openhuman::config::QueryClassificationConfig, + ) -> Self { + self.classification_config = Some(classification_config); + self + } + + /// Sets the available model hints for auto-classification. + pub fn available_hints(mut self, available_hints: Vec) -> Self { + self.available_hints = Some(available_hints); + self + } + + /// Sets the post-turn hooks to be executed after each turn. + pub fn post_turn_hooks( + mut self, + hooks: Vec>, + ) -> Self { + self.post_turn_hooks = hooks; + self + } + + /// Enables or disables learning features. + pub fn learning_enabled(mut self, enabled: bool) -> Self { + self.learning_enabled = enabled; + self + } + + /// Sets the event-bus `session_id` and `channel` used to tag + /// `DomainEvent`s emitted by this agent. + /// + /// - `session_id` groups all events for a single user / conversation so + /// downstream subscribers can correlate turns, tool calls, and errors. + /// - `channel` labels the source or stream the events originated from + /// (e.g. `"cli"`, `"telegram"`, `"rpc"`) — useful when multiple front + /// ends share the same subscriber pipeline. + /// + /// Both parameters are converted into owned `String`s and stored in + /// `event_session_id` / `event_channel` respectively. + pub fn event_context( + mut self, + session_id: impl Into, + channel: impl Into, + ) -> Self { + self.event_session_id = Some(session_id.into()); + self.event_channel = Some(channel.into()); + self + } + + /// Validates the configuration and builds the `Agent` instance. + pub fn build(self) -> Result { + let tools = self + .tools + .ok_or_else(|| anyhow::anyhow!("tools are required"))?; + let tool_specs: Vec = tools.iter().map(|tool| tool.spec()).collect(); + + Ok(Agent { + provider: self + .provider + .ok_or_else(|| anyhow::anyhow!("provider is required"))?, + tools: Arc::new(tools), + tool_specs: Arc::new(tool_specs), + 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"))?, + memory_loader: self + .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()), + temperature: self.temperature.unwrap_or(0.7), + workspace_dir: self + .workspace_dir + .unwrap_or_else(|| std::path::PathBuf::from(".")), + identity_config: self.identity_config.unwrap_or_default(), + skills: self.skills.unwrap_or_default(), + auto_save: self.auto_save.unwrap_or(false), + history: Vec::new(), + classification_config: self.classification_config.unwrap_or_default(), + available_hints: self.available_hints.unwrap_or_default(), + post_turn_hooks: self.post_turn_hooks, + learning_enabled: self.learning_enabled, + event_session_id: self + .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(), + }) + } +} + +impl Agent { + /// Creates an `Agent` instance from a global configuration. + /// + /// This is the primary way to initialize an agent with all system + /// integrations (memory, tools, skills, etc.) configured. + pub fn from_config(config: &Config) -> Result { + let runtime: Arc = + Arc::from(host_runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + + let memory: Arc = Arc::from(memory::create_memory_with_storage_and_routes( + &config.memory, + &config.embedding_routes, + Some(&config.storage.provider.config), + &config.workspace_dir, + config.api_key.as_deref(), + )?); + + let composio_key = if config.composio.enabled { + config.composio.api_key.as_deref() + } else { + None + }; + let composio_entity_id = if config.composio.enabled { + Some(config.composio.entity_id.as_str()) + } else { + None + }; + + let mut tools = tools::all_tools_with_runtime( + Arc::new(config.clone()), + &security, + runtime, + memory.clone(), + composio_key, + composio_entity_id, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + config, + ); + + // Bridge skill tools (Notion, Gmail, etc.) from the QuickJS runtime + // into the agent's tool registry so the LLM can call them. + let skill_tools = tools::skill_bridge::collect_skill_tools(); + if !skill_tools.is_empty() { + log::info!( + "[agent] Injecting {} skill tool(s) into agent registry", + skill_tools.len() + ); + tools.extend(skill_tools); + } + + let model_name = config + .default_model + .as_deref() + .unwrap_or(crate::openhuman::config::DEFAULT_MODEL) + .to_string(); + + let provider_runtime_options = providers::ProviderRuntimeOptions { + auth_profile_override: None, + openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from), + secrets_encrypt: config.secrets.encrypt, + reasoning_enabled: config.runtime.reasoning_enabled, + }; + + let provider: Box = providers::create_routed_provider_with_options( + config.api_key.as_deref(), + config.api_url.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + &provider_runtime_options, + )?; + + let dispatcher_choice = config.agent.tool_dispatcher.as_str(); + let tool_dispatcher: Box = match dispatcher_choice { + "native" => Box::new(NativeToolDispatcher), + "xml" => Box::new(XmlToolDispatcher), + _ if provider.supports_native_tools() => Box::new(NativeToolDispatcher), + _ => Box::new(XmlToolDispatcher), + }; + + let available_hints: Vec = + config.model_routes.iter().map(|r| r.hint.clone()).collect(); + + // Build prompt builder, optionally with learning sections + let mut prompt_builder = SystemPromptBuilder::with_defaults(); + if config.learning.enabled { + prompt_builder = prompt_builder + .add_section(Box::new( + crate::openhuman::learning::LearnedContextSection::new(memory.clone()), + )) + .add_section(Box::new( + crate::openhuman::learning::UserProfileSection::new(memory.clone()), + )); + log::info!("[learning] prompt sections registered (learned_context, user_profile)"); + } + + // Build post-turn hooks when learning is enabled + let mut post_turn_hooks: Vec> = + Vec::new(); + if config.learning.enabled { + if config.learning.reflection_enabled { + // Only the reflection hook needs an owned snapshot of the + // full config, so create the `Arc` lazily inside this + // branch instead of paying for the clone whenever + // `learning.enabled` is true. + let full_config = Arc::new(config.clone()); + // For cloud reflection, wrap the provider in an Arc. + // For local, no provider needed. + let reflection_provider: Option> = + if config.learning.reflection_source + == crate::openhuman::config::ReflectionSource::Cloud + { + Some(Arc::from(providers::create_routed_provider( + config.api_key.as_deref(), + config.api_url.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + )?)) + } else { + None + }; + post_turn_hooks.push(Arc::new(crate::openhuman::learning::ReflectionHook::new( + config.learning.clone(), + full_config.clone(), + memory.clone(), + reflection_provider, + ))); + log::info!( + "[learning] reflection hook registered (source={:?})", + config.learning.reflection_source + ); + } + + if config.learning.user_profile_enabled { + post_turn_hooks.push(Arc::new(crate::openhuman::learning::UserProfileHook::new( + config.learning.clone(), + memory.clone(), + ))); + log::info!("[learning] user_profile hook registered"); + } + + if config.learning.tool_tracking_enabled { + post_turn_hooks.push(Arc::new(crate::openhuman::learning::ToolTrackerHook::new( + config.learning.clone(), + memory.clone(), + ))); + log::info!("[learning] tool_tracker hook registered"); + } + } + + Agent::builder() + .provider(provider) + .tools(tools) + .memory(memory) + .tool_dispatcher(tool_dispatcher) + .memory_loader(Box::new( + DefaultMemoryLoader::new(5, config.memory.min_relevance_score) + .with_max_chars(config.agent.max_memory_context_chars), + )) + .prompt_builder(prompt_builder) + .config(config.agent.clone()) + .model_name(model_name) + .temperature(config.default_temperature) + .workspace_dir(config.workspace_dir.clone()) + .classification_config(config.query_classification.clone()) + .available_hints(available_hints) + .identity_config(config.identity.clone()) + .skills(crate::openhuman::skills::load_skills(&config.workspace_dir)) + .auto_save(config.memory.auto_save) + .post_turn_hooks(post_turn_hooks) + .learning_enabled(config.learning.enabled) + .build() + } +} diff --git a/src/openhuman/agent/agent/mod.rs b/src/openhuman/agent/agent/mod.rs new file mode 100644 index 000000000..4f23144b4 --- /dev/null +++ b/src/openhuman/agent/agent/mod.rs @@ -0,0 +1,61 @@ +//! Core agent implementation for the OpenHuman platform. +//! +//! This module provides the [`Agent`] struct, which orchestrates the +//! interaction between the AI provider, available tools, memory +//! systems, and the user. It handles the agent's "turn" logic, +//! including tool execution and history management. +//! +//! # File layout +//! +//! This module used to be a single 2000-line `agent.rs` file. It's now +//! split into focused children so each file has a clear role: +//! +//! | File | Role | +//! |---------------|------------------------------------------------------------------| +//! | [`types`] | `Agent` and `AgentBuilder` struct definitions (no logic). | +//! | [`builder`] | `AgentBuilder` fluent API + `Agent::from_config` factory. | +//! | [`turn`] | The `turn()` lifecycle, tool dispatch, context-pipeline wiring. | +//! | [`runtime`] | Public accessors, `run_single` / `run_interactive`, helpers. | +//! | `tests` | Integration tests (private). | +//! +//! External callers should import [`Agent`] and [`AgentBuilder`] from +//! this module (or from `crate::openhuman::agent`, which re-exports +//! them). The child files are an implementation detail. + +mod builder; +mod runtime; +mod turn; +mod types; + +#[cfg(test)] +mod tests; + +pub use types::{Agent, AgentBuilder}; + +use crate::openhuman::config::Config; +use anyhow::Result; + +/// Convenience entry point to run an agent with the given configuration and message. +pub async fn run( + config: Config, + message: Option, + model_override: Option, + temperature: f64, +) -> Result<()> { + let mut effective_config = config; + if let Some(m) = model_override { + effective_config.default_model = Some(m); + } + effective_config.default_temperature = temperature; + + let mut agent = Agent::from_config(&effective_config)?; + + if let Some(msg) = message { + let response = agent.run_single(&msg).await?; + println!("{response}"); + } else { + agent.run_interactive().await?; + } + + Ok(()) +} diff --git a/src/openhuman/agent/agent/runtime.rs b/src/openhuman/agent/agent/runtime.rs new file mode 100644 index 000000000..d9b5d0d0c --- /dev/null +++ b/src/openhuman/agent/agent/runtime.rs @@ -0,0 +1,308 @@ +//! Public accessors, `run_single` / `run_interactive` CLI helpers, and +//! assorted per-turn static helpers (id-fallback injection, event-error +//! sanitisation, history diffing). +//! +//! These used to live alongside the turn loop in `agent.rs`. Splitting +//! them out keeps `turn.rs` focused on the interaction lifecycle and +//! makes it obvious which methods are cheap getters vs which actually +//! drive the model. + +use super::types::{Agent, AgentBuilder}; +use crate::openhuman::agent::dispatcher::ParsedToolCall; +use crate::openhuman::agent::error::AgentError; +use crate::openhuman::event_bus::{publish_global, DomainEvent}; +use crate::openhuman::memory::Memory; +use crate::openhuman::providers::{self, ConversationMessage, Provider, ToolCall}; +use crate::openhuman::tools::{Tool, ToolSpec}; +use crate::openhuman::util::truncate_with_ellipsis; +use anyhow::Result; +use std::sync::Arc; + +impl Agent { + const EVENT_ERROR_MAX_CHARS: usize = 256; + + // ───────────────────────────────────────────────────────────────── + // Small accessors used by `run_single` + `turn` + sub-agent runner + // ───────────────────────────────────────────────────────────────── + + pub(super) fn event_session_id(&self) -> &str { + &self.event_session_id + } + + pub(super) fn event_channel(&self) -> &str { + &self.event_channel + } + + /// Returns a new `AgentBuilder`. + pub fn builder() -> AgentBuilder { + AgentBuilder::new() + } + + /// Borrow the agent's provider as an `Arc`. Used by the sub-agent + /// runner to share the parent's provider instance with spawned + /// sub-agents (so they share connection pools, retry budgets, and + /// rate-limit state). + pub fn provider_arc(&self) -> Arc { + Arc::clone(&self.provider) + } + + /// Borrow the agent's tools as a slice. Used by the sub-agent runner + /// to filter the parent's tool registry per-archetype. + pub fn tools(&self) -> &[Box] { + self.tools.as_slice() + } + + /// Clone the agent's tools `Arc` for sharing with sub-agents. + pub fn tools_arc(&self) -> Arc>> { + Arc::clone(&self.tools) + } + + /// Borrow the agent's tool specs (pre-serialised). Captured at + /// turn-start so sub-agents can pass byte-identical schemas to the + /// provider for prefix-cache reuse. + pub fn tool_specs(&self) -> &[ToolSpec] { + self.tool_specs.as_slice() + } + + /// Clone the agent's tool specs `Arc` for sharing with sub-agents. + pub fn tool_specs_arc(&self) -> Arc> { + Arc::clone(&self.tool_specs) + } + + /// Borrow the agent's memory backing store as an `Arc`. + pub fn memory_arc(&self) -> Arc { + Arc::clone(&self.memory) + } + + /// The agent's working directory. + pub fn workspace_dir(&self) -> &std::path::Path { + &self.workspace_dir + } + + /// The agent's currently-configured model name (before per-turn + /// auto-classification). + pub fn model_name(&self) -> &str { + &self.model_name + } + + /// The agent's currently-configured temperature. + pub fn temperature(&self) -> f64 { + self.temperature + } + + /// The agent's loaded skills, if any. + pub fn skills(&self) -> &[crate::openhuman::skills::Skill] { + &self.skills + } + + /// The agent's identity config (used by sub-agent prompt building + /// when `omit_identity = false`). + pub fn identity_config(&self) -> &crate::openhuman::config::IdentityConfig { + &self.identity_config + } + + /// The agent's runtime config snapshot. + pub fn agent_config(&self) -> &crate::openhuman::config::AgentConfig { + &self.config + } + + /// Returns the current conversation history. + pub fn history(&self) -> &[ConversationMessage] { + &self.history + } + + pub fn set_event_context(&mut self, session_id: impl Into, channel: impl Into) { + self.event_session_id = session_id.into(); + self.event_channel = channel.into(); + } + + /// Clears the agent's conversation history. + pub fn clear_history(&mut self) { + self.history.clear(); + } + + // ───────────────────────────────────────────────────────────────── + // Static helpers for turn parsing + telemetry + // ───────────────────────────────────────────────────────────────── + + pub(super) fn count_iterations(messages: &[ConversationMessage]) -> usize { + messages + .iter() + .filter(|message| matches!(message, ConversationMessage::AssistantToolCalls { .. })) + .count() + + 1 + } + + fn conversation_message_eq(left: &ConversationMessage, right: &ConversationMessage) -> bool { + serde_json::to_string(left).ok() == serde_json::to_string(right).ok() + } + + fn message_slice_eq(left: &[ConversationMessage], right: &[ConversationMessage]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| Self::conversation_message_eq(left, right)) + } + + pub(super) fn new_entries_for_turn<'a>( + history_snapshot: &[ConversationMessage], + current_history: &'a [ConversationMessage], + ) -> &'a [ConversationMessage] { + let common_prefix_len = history_snapshot + .iter() + .zip(current_history.iter()) + .take_while(|(left, right)| Self::conversation_message_eq(left, right)) + .count(); + + if common_prefix_len == history_snapshot.len() { + return ¤t_history[common_prefix_len..]; + } + + let max_overlap = history_snapshot.len().min(current_history.len()); + for overlap in (0..=max_overlap).rev() { + let snapshot_suffix = &history_snapshot[history_snapshot.len() - overlap..]; + let current_prefix = ¤t_history[..overlap]; + if Self::message_slice_eq(snapshot_suffix, current_prefix) { + return ¤t_history[overlap..]; + } + } + + current_history + } + + pub(super) fn sanitize_event_error_message(err: &anyhow::Error) -> String { + let kind = match err.downcast_ref::() { + Some(AgentError::ProviderError { .. }) => Some("provider_error"), + Some(AgentError::ContextLimitExceeded { .. }) => Some("context_limit_exceeded"), + Some(AgentError::ToolExecutionError { .. }) => Some("tool_execution_error"), + Some(AgentError::CostBudgetExceeded { .. }) => Some("cost_budget_exceeded"), + Some(AgentError::MaxIterationsExceeded { .. }) => Some("max_iterations_exceeded"), + Some(AgentError::CompactionFailed { .. }) => Some("compaction_failed"), + Some(AgentError::PermissionDenied { .. }) => Some("permission_denied"), + Some(AgentError::Other(_)) | None => None, + }; + + if let Some(kind) = kind { + return kind.to_string(); + } + + let scrubbed = providers::sanitize_api_error(&err.to_string()) + .replace(['\n', '\r', '\t'], " ") + .split_whitespace() + .collect::>() + .join(" "); + truncate_with_ellipsis(&scrubbed, Self::EVENT_ERROR_MAX_CHARS) + } + + /// Injects unique IDs into tool calls that are missing them. + /// + /// This is necessary for some tool dispatchers to correctly track and + /// associate results. + pub(super) fn with_fallback_tool_call_ids( + mut parsed_calls: Vec, + iteration: usize, + ) -> Vec { + for (idx, call) in parsed_calls.iter_mut().enumerate() { + if call.tool_call_id.is_none() { + call.tool_call_id = Some(format!("parsed-{}-{}", iteration + 1, idx + 1)); + } + } + parsed_calls + } + + /// Converts parsed tool calls into the provider-standard `ToolCall` format. + /// + /// If the provider response already contains native tool calls, they are + /// returned as-is. + pub(super) fn persisted_tool_calls_for_history( + response: &crate::openhuman::providers::ChatResponse, + parsed_calls: &[ParsedToolCall], + iteration: usize, + ) -> Vec { + if !response.tool_calls.is_empty() { + return response.tool_calls.clone(); + } + + parsed_calls + .iter() + .enumerate() + .map(|(idx, call)| ToolCall { + id: call + .tool_call_id + .clone() + .unwrap_or_else(|| format!("parsed-{}-{}", iteration + 1, idx + 1)), + name: call.name.clone(), + arguments: call.arguments.to_string(), + }) + .collect() + } + + // ───────────────────────────────────────────────────────────────── + // Run helpers — single-shot and interactive loops + // ───────────────────────────────────────────────────────────────── + + /// Runs a single turn with the given message and returns the response. + pub async fn run_single(&mut self, message: &str) -> Result { + let history_snapshot = self.history.clone(); + publish_global(DomainEvent::AgentTurnStarted { + session_id: self.event_session_id().to_string(), + channel: self.event_channel().to_string(), + }); + + match self.turn(message).await { + Ok(response) => { + let new_entries = Self::new_entries_for_turn(&history_snapshot, &self.history); + publish_global(DomainEvent::AgentTurnCompleted { + session_id: self.event_session_id().to_string(), + text_chars: response.chars().count(), + iterations: Self::count_iterations(new_entries), + }); + Ok(response) + } + Err(err) => { + let sanitized_message = Self::sanitize_event_error_message(&err); + publish_global(DomainEvent::AgentError { + session_id: self.event_session_id().to_string(), + message: sanitized_message, + recoverable: false, + }); + Err(err) + } + } + } + + /// Runs an interactive CLI loop, reading from standard input and printing to standard output. + /// + /// Each incoming message is dispatched through [`Agent::run_single`] so + /// the unified lifecycle events (`AgentTurnStarted`, `AgentTurnCompleted`, + /// `AgentError`) and error sanitisation run for interactive turns just + /// like they do for one-shot invocations. + pub async fn run_interactive(&mut self) -> Result<()> { + println!("🦀 OpenHuman Interactive Mode"); + println!("Type /quit to exit.\n"); + + let (tx, mut rx) = tokio::sync::mpsc::channel(32); + let cli = crate::openhuman::channels::CliChannel::new(); + + let listen_handle = tokio::spawn(async move { + let _ = crate::openhuman::channels::Channel::listen(&cli, tx).await; + }); + + while let Some(msg) = rx.recv().await { + match self.run_single(&msg.content).await { + Ok(response) => println!("\n{response}\n"), + Err(e) => { + // `run_single` already publishes `AgentError` and + // sanitises the payload; surface a concise line here + // for the CLI user and continue the loop. + eprintln!("\nError: {e}\n"); + continue; + } + } + } + + listen_handle.abort(); + Ok(()) + } +} diff --git a/src/openhuman/agent/agent/tests.rs b/src/openhuman/agent/agent/tests.rs new file mode 100644 index 000000000..058f19ae9 --- /dev/null +++ b/src/openhuman/agent/agent/tests.rs @@ -0,0 +1,582 @@ +//! `Agent` unit + integration tests. +//! +//! All tests exercise the agent through its public surface only (no +//! private-field access), which is why they live in a sibling file +//! rather than inline with one of the impl blocks. Shared fakes +//! (`MockProvider`, `RecordingProvider`, `MockTool`) are defined here. + +use super::types::{Agent, AgentBuilder}; +use crate::openhuman::agent::dispatcher::{NativeToolDispatcher, XmlToolDispatcher}; +use crate::openhuman::memory::Memory; +use crate::openhuman::providers::{ChatRequest, ConversationMessage, Provider}; +use crate::openhuman::tools::Tool; +use anyhow::Result; +use async_trait::async_trait; +use parking_lot::Mutex; +use std::sync::Arc; + +struct MockProvider { + responses: Mutex>, +} + +#[async_trait] +impl Provider for MockProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> Result { + Ok("ok".into()) + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> Result { + let mut guard = self.responses.lock(); + if guard.is_empty() { + return Ok(crate::openhuman::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + usage: None, + }); + } + Ok(guard.remove(0)) + } +} + +/// Provider that records the system prompt bytes and model name of +/// every `chat()` call. Used by KV-cache stability tests — anything +/// that varies between turns (timestamps, re-rendered memory context, +/// flipped model hints) will show up as a diff between captures. +#[derive(Default)] +struct RecordingProvider { + captures: Mutex>, + responses: Mutex>, +} + +#[derive(Clone)] +struct CapturedCall { + system_prompt: Option, + model: String, +} + +#[async_trait] +impl Provider for RecordingProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> Result { + Ok("ok".into()) + } + + async fn chat( + &self, + request: ChatRequest<'_>, + model: &str, + _temperature: f64, + ) -> Result { + let system_prompt = request + .messages + .iter() + .find(|m| m.role == "system") + .map(|m| m.content.clone()); + self.captures.lock().push(CapturedCall { + system_prompt, + model: model.to_string(), + }); + + let mut guard = self.responses.lock(); + if guard.is_empty() { + return Ok(crate::openhuman::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + usage: None, + }); + } + Ok(guard.remove(0)) + } +} + +struct MockTool; + +#[async_trait] +impl Tool for MockTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "echo" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute( + &self, + _args: serde_json::Value, + ) -> Result { + Ok(crate::openhuman::tools::ToolResult::success("tool-out")) + } +} + +// silence clippy — `AgentBuilder` is imported so tests can reference +// it in doc examples / type assertions if needed. +#[allow(dead_code)] +fn _assert_builder_is_exported() -> AgentBuilder { + Agent::builder() +} + +#[tokio::test] +async fn turn_without_tools_returns_text() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![crate::openhuman::providers::ChatResponse { + text: Some("hello".into()), + tool_calls: vec![], + usage: None, + }]), + }); + + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), + ); + + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .tool_dispatcher(Box::new(XmlToolDispatcher)) + .workspace_dir(workspace_path) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "hello"); +} + +#[tokio::test] +async fn turn_with_native_dispatcher_handles_tool_results_variant() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![ + crate::openhuman::providers::ChatResponse { + text: Some(String::new()), + tool_calls: vec![crate::openhuman::providers::ToolCall { + id: "tc1".into(), + name: "echo".into(), + arguments: "{}".into(), + }], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + usage: None, + }, + ]), + }); + + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), + ); + + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(workspace_path) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "done"); + assert!(agent + .history() + .iter() + .any(|msg| matches!(msg, ConversationMessage::ToolResults(_)))); +} + +#[tokio::test] +async fn turn_with_native_dispatcher_persists_fallback_tool_calls() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![ + crate::openhuman::providers::ChatResponse { + text: Some( + "Checking...\n{\"name\":\"echo\",\"arguments\":{}}" + .into(), + ), + tool_calls: vec![], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("done".into()), + tool_calls: vec![], + usage: None, + }, + ]), + }); + + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), + ); + + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(workspace_path) + .build() + .unwrap(); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "done"); + + let persisted_calls = agent + .history() + .iter() + .find_map(|msg| match msg { + ConversationMessage::AssistantToolCalls { tool_calls, .. } => Some(tool_calls), + _ => None, + }) + .expect("assistant tool calls should be persisted"); + assert_eq!(persisted_calls.len(), 1); + assert_eq!(persisted_calls[0].name, "echo"); +} + +/// End-to-end: parent Agent issues a `spawn_subagent` tool call, the +/// runner dispatches a built-in sub-agent (`researcher`) using the +/// same MockProvider, and the parent's next turn folds the sub-agent's +/// text output into the final response. +/// +/// This is the highest-level test that exercises: +/// - Agent::turn → execute_tool_call → SpawnSubagentTool::execute +/// - PARENT_CONTEXT task-local visibility +/// - AgentDefinitionRegistry::global lookup +/// - run_subagent → run_inner_loop with the parent's provider +/// - Result returned as a ToolResult and threaded back into history +#[tokio::test] +async fn turn_dispatches_spawn_subagent_through_full_path() { + use crate::openhuman::agent::harness::AgentDefinitionRegistry; + use crate::openhuman::tools::SpawnSubagentTool; + + // Idempotent — other tests may have already initialised it. + AgentDefinitionRegistry::init_global_builtins().unwrap(); + + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + // Scripted responses, in the exact order MockProvider will see them: + // 1. Parent turn iter 0 — emit a spawn_subagent tool call. + // 2. Sub-agent (researcher) iter 0 — return final text "X is Y". + // 3. Parent turn iter 1 — fold sub-agent result into "Based on the research, X is Y." + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![ + crate::openhuman::providers::ChatResponse { + text: Some(String::new()), + tool_calls: vec![crate::openhuman::providers::ToolCall { + id: "call-spawn".into(), + name: "spawn_subagent".into(), + arguments: serde_json::json!({ + "agent_id": "researcher", + "prompt": "find out about X" + }) + .to_string(), + }], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("X is Y".into()), + tool_calls: vec![], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("Based on the research, X is Y.".into()), + tool_calls: vec![], + usage: None, + }, + ]), + }); + + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), + ); + + // Tools include SpawnSubagentTool so the parent can call it. + let tools: Vec> = vec![Box::new(SpawnSubagentTool::new())]; + + let mut agent = Agent::builder() + .provider(provider) + .tools(tools) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(workspace_path) + .build() + .unwrap(); + + let response = agent.turn("tell me about X").await.unwrap(); + assert_eq!(response, "Based on the research, X is Y."); + + // The parent's history should contain the spawn_subagent + // assistant tool call AND a tool-result message carrying the + // sub-agent's compact output. + let has_spawn_call = agent.history().iter().any(|msg| match msg { + ConversationMessage::AssistantToolCalls { tool_calls, .. } => { + tool_calls.iter().any(|c| c.name == "spawn_subagent") + } + _ => false, + }); + assert!( + has_spawn_call, + "parent history should contain the spawn_subagent assistant tool call" + ); + + let tool_result_contains_subagent_output = agent.history().iter().any(|msg| match msg { + ConversationMessage::ToolResults(results) => { + results.iter().any(|r| r.content.contains("X is Y")) + } + ConversationMessage::Chat(chat) if chat.role == "tool" => chat.content.contains("X is Y"), + _ => false, + }); + assert!( + tool_result_contains_subagent_output, + "parent history should contain a tool-result entry with the sub-agent's output" + ); +} + +/// Fork-mode variant of `turn_dispatches_spawn_subagent_through_full_path`. +/// +/// Exercises the prefix-replay path: the parent issues +/// `spawn_subagent { mode: "fork", … }`, the runner resolves the `fork` +/// built-in definition, pulls the parent's exact rendered prompt + tool +/// schemas + message prefix out of the `ForkContext` task-local, and +/// runs the inner loop on the parent's own provider. +/// +/// From the provider's perspective the response queue is consumed in +/// the same fixed sequence as the typed test — parent tool_call → sub- +/// agent reply → parent folded reply — which is the invariant that +/// makes KV-cache reuse possible on the real backend. +#[tokio::test] +async fn turn_dispatches_spawn_subagent_in_fork_mode() { + use crate::openhuman::agent::harness::AgentDefinitionRegistry; + use crate::openhuman::tools::SpawnSubagentTool; + + // Idempotent — other tests may have already initialised it. + AgentDefinitionRegistry::init_global_builtins().unwrap(); + + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + // Provider response queue, consumed in order: + // 1. Parent turn iter 0 — emit spawn_subagent with mode=fork. + // 2. Fork sub-agent iter 0 — return "X is Y" (no tool calls). + // 3. Parent turn iter 1 — fold the forked result into the final + // text the user sees. + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![ + crate::openhuman::providers::ChatResponse { + text: Some(String::new()), + tool_calls: vec![crate::openhuman::providers::ToolCall { + id: "call-fork".into(), + name: "spawn_subagent".into(), + arguments: serde_json::json!({ + // agent_id is still required by the schema even + // though `mode=fork` overrides the lookup to the + // synthetic `fork` definition. + "agent_id": "researcher", + "mode": "fork", + "prompt": "analyse branch X" + }) + .to_string(), + }], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("X is Y".into()), + tool_calls: vec![], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("Based on the research, X is Y.".into()), + tool_calls: vec![], + usage: None, + }, + ]), + }); + + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), + ); + + let tools: Vec> = vec![Box::new(SpawnSubagentTool::new())]; + + let mut agent = Agent::builder() + .provider(provider) + .tools(tools) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(workspace_path) + .build() + .unwrap(); + + let response = agent.turn("tell me about X").await.unwrap(); + assert_eq!(response, "Based on the research, X is Y."); + + // Same history assertions as the typed path — the fork runner + // still threads its compact output back through the parent's tool + // result pipeline. + let has_spawn_call = agent.history().iter().any(|msg| match msg { + ConversationMessage::AssistantToolCalls { tool_calls, .. } => { + tool_calls.iter().any(|c| c.name == "spawn_subagent") + } + _ => false, + }); + assert!( + has_spawn_call, + "parent history should contain the spawn_subagent assistant tool call" + ); + + let tool_result_contains_subagent_output = agent.history().iter().any(|msg| match msg { + ConversationMessage::ToolResults(results) => { + results.iter().any(|r| r.content.contains("X is Y")) + } + ConversationMessage::Chat(chat) if chat.role == "tool" => chat.content.contains("X is Y"), + _ => false, + }); + assert!( + tool_result_contains_subagent_output, + "parent history should contain a tool-result entry with the fork sub-agent's output" + ); +} + +/// KV-cache invariant: across multiple turns in the same session, the +/// system-prompt bytes submitted to the provider must be byte-identical, +/// and the model name must not flip. Both are required for the backend's +/// automatic prefix cache to hit — if either changes, the backend must +/// re-prefill the entire prompt every turn. +/// +/// This test guards against two regressions: +/// 1. A future edit that reintroduces the subsequent-turn system +/// prompt rebuild (see the `learning_enabled` branch we +/// deliberately removed in `turn()`). +/// 2. A future edit that reintroduces per-message model +/// classification on the main agent (which would flip the +/// effective model between turns). +#[tokio::test] +async fn system_prompt_and_model_are_byte_stable_across_turns() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + let provider = Arc::new(RecordingProvider { + responses: Mutex::new(vec![ + crate::openhuman::providers::ChatResponse { + text: Some("first".into()), + tool_calls: vec![], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("second".into()), + tool_calls: vec![], + usage: None, + }, + crate::openhuman::providers::ChatResponse { + text: Some("third".into()), + tool_calls: vec![], + usage: None, + }, + ]), + captures: Mutex::new(Vec::new()), + }); + + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = Arc::from( + crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(), + ); + + let mut agent = Agent::builder() + .provider_arc(provider.clone() as Arc) + .tools(vec![]) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .workspace_dir(workspace_path) + // Learning flag is explicitly enabled to prove that the + // former "rebuild system prompt on subsequent turns" branch + // is gone — we should still see byte-stable prompts. + .learning_enabled(true) + .build() + .unwrap(); + + for prompt in ["first question", "second question", "third question"] { + agent.turn(prompt).await.unwrap(); + } + + let captures = provider.captures.lock().clone(); + assert_eq!( + captures.len(), + 3, + "expected one provider call per turn, got {}", + captures.len() + ); + + let first_system = captures[0] + .system_prompt + .as_ref() + .expect("first turn should have a system prompt"); + for (idx, cap) in captures.iter().enumerate() { + let sys = cap + .system_prompt + .as_ref() + .expect("every turn should carry the system prompt"); + assert_eq!( + sys, first_system, + "system prompt drifted on turn {} — KV cache prefix broken", + idx + ); + assert_eq!( + cap.model, captures[0].model, + "model name flipped on turn {} — KV cache namespace broken", + idx + ); + } +} diff --git a/src/openhuman/agent/agent/turn.rs b/src/openhuman/agent/agent/turn.rs new file mode 100644 index 000000000..b2ab1c21d --- /dev/null +++ b/src/openhuman/agent/agent/turn.rs @@ -0,0 +1,779 @@ +//! Turn lifecycle: running a single interaction, executing tools, and +//! wiring the context pipeline + sub-agent harness around them. +//! +//! This file owns the "hot path" methods on `Agent`: +//! +//! - [`Agent::turn`] — the big one. Orchestrates system-prompt build, +//! memory-context injection, the provider loop, tool dispatch, and +//! the context pipeline (tool-result budget → microcompact → +//! autocompact signal → session-memory extraction trigger). +//! - [`Agent::execute_tool_call`] / [`Agent::execute_tools`] — the +//! per-call runners, including the fork-cache `ForkContext` stash +//! for `spawn_subagent { mode: "fork" }` invocations. +//! - [`Agent::build_parent_execution_context`] / +//! [`Agent::build_fork_context`] — snapshot helpers for sub-agent +//! task-locals. +//! - [`Agent::trim_history`], [`Agent::fetch_learned_context`], +//! [`Agent::build_system_prompt`], [`Agent::classify_model`] — the +//! small helpers `turn()` leans on every call. +//! - [`Agent::spawn_session_memory_extraction`] — the fire-and-forget +//! 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::event_bus::{publish_global, DomainEvent}; +use crate::openhuman::memory::MemoryCategory; +use crate::openhuman::providers::{ChatMessage, ChatRequest, ConversationMessage}; +use crate::openhuman::tools::Tool; +use crate::openhuman::util::truncate_with_ellipsis; +use anyhow::Result; +use std::sync::Arc; + +impl Agent { + /// Performs a single interaction "turn" with the agent. + /// + /// This is the core logic that takes user input, manages the history, + /// calls the LLM, handles tool calls (up to `max_tool_iterations`), + /// and returns the final assistant response. + pub async fn turn(&mut self, user_message: &str) -> Result { + let turn_started = std::time::Instant::now(); + log::info!( + "[agent_loop] turn start message_chars={} history_len={} max_tool_iterations={}", + user_message.chars().count(), + self.history.len(), + self.config.max_tool_iterations + ); + if self.history.is_empty() { + // Learned context is only baked into the system prompt on the + // very first turn — once the history is non-empty we reuse the + // stored prompt verbatim to preserve the KV-cache prefix the + // inference backend has already tokenised. Fetching it later + // would just burn memory-store reads on data we throw away. + let learned = self.fetch_learned_context().await; + let system_prompt = self.build_system_prompt(learned)?; + log::info!( + "[agent_loop] system prompt built chars={}", + system_prompt.chars().count() + ); + log::debug!("[agent_loop] system prompt body:\n{system_prompt}"); + self.history + .push(ConversationMessage::Chat(ChatMessage::system( + system_prompt, + ))); + } else { + // Deliberately do NOT rebuild the system prompt on subsequent + // turns. The rendered prompt is the KV-cache prefix the inference + // backend has already tokenised; replacing its bytes (even + // cosmetically) forces the backend to re-prefill from scratch. + // + // Dynamic turn-to-turn context (memory recall, learned snippets) + // rides on the user message via `memory_loader.load_context()` + // — that's where the caller should inject anything that varies + // between turns. + log::trace!( + "[agent_loop] system prompt reused (history_len={}) — KV cache prefix preserved", + self.history.len() + ); + } + + if self.auto_save { + let _ = self + .memory + .store("user_msg", user_message, MemoryCategory::Conversation, None) + .await; + } + + let context = self + .memory_loader + .load_context(self.memory.as_ref(), user_message) + .await + .unwrap_or_default(); + + let enriched = if context.is_empty() { + user_message.to_string() + } else { + format!("{context}{user_message}") + }; + + self.history + .push(ConversationMessage::Chat(ChatMessage::user(enriched))); + + // Pin the main agent to its configured model for the lifetime of + // the session. Per-turn classification used to run here, but it + // would flip `effective_model` mid-conversation (e.g. reasoning → + // coding based on a single keyword). Every flip invalidates the + // backend's KV cache namespace for this session, costing full + // re-prefill on the very next turn. The main agent's job is to + // decide *which sub-agent* to spawn — that routing lives in the + // model prompt, not in the Rust-side classifier. Sub-agents pick + // their own tier via `ModelSpec::Hint(...)` in their definition. + let effective_model = self.model_name.clone(); + log::info!( + "[agent_loop] model pinned model={} (per-turn classification disabled for KV cache stability)", + effective_model + ); + + // Snapshot the parent's runtime once per turn so any + // `spawn_subagent` invocation that fires inside this turn can + // read it via the PARENT_CONTEXT task-local. We override the + // model field with the post-classification effective model. + let mut parent_context = self.build_parent_execution_context(); + parent_context.model_name = effective_model.clone(); + + // 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(); + + // Collect tool call records across all iterations for post-turn hooks + let mut all_tool_records: Vec = Vec::new(); + + let turn_body = async { + for iteration in 0..self.config.max_tool_iterations { + log::info!( + "[agent_loop] iteration start i={} history_len={}", + iteration + 1, + 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); + match &outcome { + context_pipeline::PipelineOutcome::NoOp => {} + context_pipeline::PipelineOutcome::Microcompacted(stats) => { + log::info!( + "[agent_loop] context_pipeline microcompact i={} envelopes={} entries={} bytes_freed={}", + iteration + 1, + stats.envelopes_cleared, + stats.entries_cleared, + stats.bytes_freed + ); + } + context_pipeline::PipelineOutcome::AutocompactionRequested { + utilisation_pct, + } => { + log::warn!( + "[agent_loop] context_pipeline autocompaction requested i={} utilisation_pct={}", + iteration + 1, + utilisation_pct + ); + } + context_pipeline::PipelineOutcome::ContextExhausted { + utilisation_pct, + reason, + } => { + log::error!( + "[agent_loop] context_pipeline context exhausted i={} utilisation_pct={} reason={}", + iteration + 1, + utilisation_pct, + reason + ); + return Err(anyhow::anyhow!( + "Context window exhausted ({utilisation_pct}% full): {reason}" + )); + } + } + + let messages = self.tool_dispatcher.to_provider_messages(&self.history); + log::info!( + "[agent_loop] provider request i={} messages={} send_tool_specs={}", + iteration + 1, + messages.len(), + self.tool_dispatcher.should_send_tool_specs() + ); + let provider_started = std::time::Instant::now(); + let response = match self + .provider + .chat( + ChatRequest { + messages: &messages, + tools: if self.tool_dispatcher.should_send_tool_specs() { + Some(self.tool_specs.as_slice()) + } else { + None + }, + system_prompt_cache_boundary: None, + }, + &effective_model, + self.temperature, + ) + .await + { + Ok(resp) => { + log::info!( + "[agent_loop] provider response i={} elapsed_ms={} text_chars={} native_tool_calls={}", + iteration + 1, + provider_started.elapsed().as_millis(), + resp.text.as_ref().map_or(0, |t| t.chars().count()), + resp.tool_calls.len() + ); + log::debug!("[agent_loop] provider response: {resp:?}"); + // Feed the context pipeline (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); + } + resp + } + Err(err) => return Err(err), + }; + + let (text, calls) = self.tool_dispatcher.parse_response(&response); + let calls = Self::with_fallback_tool_call_ids(calls, iteration); + log::info!( + "[agent_loop] parsed response i={} parsed_text_chars={} parsed_tool_calls={}", + iteration + 1, + text.chars().count(), + calls.len() + ); + if calls.is_empty() { + let final_text = if text.is_empty() { + response.text.unwrap_or_default() + } else { + text + }; + log::info!( + "[agent_loop] final response i={} final_chars={}", + iteration + 1, + final_text.chars().count() + ); + + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + final_text.clone(), + ))); + self.trim_history(); + + if self.auto_save { + let summary = truncate_with_ellipsis(&final_text, 100); + let _ = self + .memory + .store("assistant_resp", &summary, MemoryCategory::Daily, None) + .await; + } + + // Session-memory tool-call accounting. The actual + // background extraction spawn happens *outside* + // `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()); + + // Fire post-turn hooks (non-blocking) + if !self.post_turn_hooks.is_empty() { + let ctx = TurnContext { + user_message: user_message.to_string(), + assistant_response: final_text.clone(), + tool_calls: all_tool_records, + turn_duration_ms: turn_started.elapsed().as_millis() as u64, + session_id: None, + iteration_count: iteration + 1, + }; + hooks::fire_hooks(&self.post_turn_hooks, ctx); + } + + return Ok(final_text); + } + + if !text.is_empty() { + log::info!( + "[agent_loop] assistant pre-tool text i={} chars={}", + iteration + 1, + text.chars().count() + ); + // Push the assistant text into history; rendering is + // the caller's responsibility (the CLI loop walks + // `agent.history()` after each turn, sub-agents and + // library consumers get whatever they need through + // the returned value / history accessors). + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + text.clone(), + ))); + } + let tool_names: Vec<&str> = calls.iter().map(|call| call.name.as_str()).collect(); + log::info!( + "[agent_loop] executing tools i={} names={:?}", + iteration + 1, + tool_names + ); + let persisted_tool_calls = + Self::persisted_tool_calls_for_history(&response, &calls, iteration); + log::info!( + "[agent_loop] persisting assistant tool calls i={} persisted_tool_calls={} parsed_tool_calls={}", + iteration + 1, + persisted_tool_calls.len(), + calls.len() + ); + self.history.push(ConversationMessage::AssistantToolCalls { + text: if text.is_empty() { + None + } else { + Some(text.clone()) + }, + tool_calls: persisted_tool_calls, + }); + + let (results, records) = self.execute_tools(&calls).await; + all_tool_records.extend(records); + log::info!( + "[agent_loop] tool results complete i={} result_count={}", + iteration + 1, + results.len() + ); + let formatted = self.tool_dispatcher.format_results(&results); + self.history.push(formatted); + self.trim_history(); + log::info!( + "[agent_loop] iteration end i={} history_len={}", + iteration + 1, + self.history.len() + ); + } + + log::warn!( + "[agent_loop] exceeded maximum tool iterations max={}", + self.config.max_tool_iterations + ); + anyhow::bail!( + "Agent exceeded maximum tool iterations ({})", + self.config.max_tool_iterations + ) + }; // end of `turn_body` async block + + // Run the turn body inside the parent-execution-context scope so + // that any `spawn_subagent` tool call fired during the loop can + // read the parent's provider, tools, model, and workspace via + // the PARENT_CONTEXT task-local. + let result = harness::with_parent_context(parent_context, turn_body).await; + + // ── Session-memory extraction (stage 5) ─────────────────────── + // + // If the pipeline's deltas have crossed all three thresholds + // (token growth, tool calls, turn count), spawn a *background* + // archivist sub-agent that will distil durable facts into the + // workspace MEMORY.md file via the `update_memory_md` tool. + // + // The spawn is fire-and-forget: the main turn returns the + // user-visible response immediately, and the archivist runs + // asynchronously on the `agentic` tier. We optimistically mark + // the extraction complete right away — if it actually fails, + // 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() { + self.spawn_session_memory_extraction(); + } + + result + } + + // ───────────────────────────────────────────────────────────────── + // Per-call tool execution + // ───────────────────────────────────────────────────────────────── + + /// Executes a single tool call and returns the result and execution record. + pub(super) async fn execute_tool_call( + &self, + call: &ParsedToolCall, + ) -> (ToolExecutionResult, ToolCallRecord) { + let started = std::time::Instant::now(); + publish_global(DomainEvent::ToolExecutionStarted { + tool_name: call.name.clone(), + session_id: self.event_session_id().to_string(), + }); + log::info!("[agent_loop] tool start name={}", call.name); + + // Special-case `spawn_subagent { mode: "fork", … }`: stash a + // ForkContext task-local so the sub-agent runner can replay the + // parent's exact rendered prompt + tool schemas + message prefix + // for backend prefix-cache reuse. The branch is taken before + // executing the tool so the task-local is visible inside + // `tool.execute(...)`. + let fork_context_for_call = if call.name == "spawn_subagent" + && call + .arguments + .get("mode") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("fork")) + .unwrap_or(false) + { + Some(self.build_fork_context(call)) + } else { + None + }; + + let (raw_result, success) = + if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) { + let exec = tool.execute(call.arguments.clone()); + let outcome = if let Some(fork_ctx) = fork_context_for_call { + harness::with_fork_context(fork_ctx, exec).await + } else { + exec.await + }; + match outcome { + Ok(r) => { + if !r.is_error { + (r.output(), true) + } else { + (format!("Error: {}", r.output()), false) + } + } + Err(e) => (format!("Error executing {}: {e}", call.name), false), + } + } else { + (format!("Unknown tool: {}", call.name), false) + }; + + // Context pipeline stage 1: apply the per-result byte budget + // *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; + let (result, budget_outcome) = + context_pipeline::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={}", + call.name, + budget_outcome.original_bytes, + budget_outcome.final_bytes, + budget_outcome.original_bytes - budget_outcome.final_bytes + ); + } + + let elapsed_ms = started.elapsed().as_millis() as u64; + publish_global(DomainEvent::ToolExecutionCompleted { + tool_name: call.name.clone(), + session_id: self.event_session_id().to_string(), + success, + elapsed_ms, + }); + log::info!( + "[agent_loop] tool finish name={} elapsed_ms={} output_chars={} success={}", + call.name, + elapsed_ms, + result.chars().count(), + success + ); + + let output_summary = hooks::sanitize_tool_output(&result, &call.name, success); + + let record = ToolCallRecord { + name: call.name.clone(), + arguments: call.arguments.clone(), + success, + output_summary, + duration_ms: elapsed_ms, + }; + + let exec_result = ToolExecutionResult { + name: call.name.clone(), + output: result, + success, + tool_call_id: call.tool_call_id.clone(), + }; + + (exec_result, record) + } + + /// Executes multiple tool calls in sequence. + pub(super) async fn execute_tools( + &self, + calls: &[ParsedToolCall], + ) -> (Vec, Vec) { + let mut results = Vec::with_capacity(calls.len()); + let mut records = Vec::with_capacity(calls.len()); + for call in calls { + let (exec_result, record) = self.execute_tool_call(call).await; + results.push(exec_result); + records.push(record); + } + (results, records) + } + + // ───────────────────────────────────────────────────────────────── + // Sub-agent context snapshots + // ───────────────────────────────────────────────────────────────── + + /// Snapshot the parent's runtime so spawned sub-agents can read + /// it via the [`harness::PARENT_CONTEXT`] task-local. + pub(super) fn build_parent_execution_context(&self) -> harness::ParentExecutionContext { + harness::ParentExecutionContext { + provider: Arc::clone(&self.provider), + all_tools: Arc::clone(&self.tools), + all_tool_specs: Arc::clone(&self.tool_specs), + model_name: self.model_name.clone(), + temperature: self.temperature, + workspace_dir: self.workspace_dir.clone(), + memory: Arc::clone(&self.memory), + agent_config: self.config.clone(), + identity_config: self.identity_config.clone(), + skills: Arc::new(self.skills.clone()), + session_id: self.event_session_id().to_string(), + channel: self.event_channel().to_string(), + } + } + + /// Build a [`harness::ForkContext`] capturing the parent's + /// rendered system prompt + tool schemas + message prefix at the + /// moment a `spawn_subagent { mode: "fork", … }` call fires. + /// + /// The system prompt is pulled from `history[0]` (the agent always + /// stores its rendered system prompt as the first message). The + /// message prefix is the entire current history rendered through + /// the dispatcher — the *same* sequence the parent's next call + /// would send, except the new fork directive replaces the parent's + /// next continuation. + pub(super) fn build_fork_context(&self, call: &ParsedToolCall) -> harness::ForkContext { + let messages = self.tool_dispatcher.to_provider_messages(&self.history); + let system_prompt: String = messages + .first() + .filter(|m| m.role == "system") + .map(|m| m.content.clone()) + .unwrap_or_default(); + + let fork_task_prompt = call + .arguments + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + harness::ForkContext { + system_prompt: Arc::new(system_prompt), + tool_specs: Arc::clone(&self.tool_specs), + message_prefix: Arc::new(messages), + cache_boundary: None, + fork_task_prompt, + } + } + + // ───────────────────────────────────────────────────────────────── + // History & prompt helpers + // ───────────────────────────────────────────────────────────────── + + /// Truncates the conversation history to the configured maximum message count. + /// + /// System messages are always preserved. Older non-system messages are + /// dropped first. + pub(super) fn trim_history(&mut self) { + let max = self.config.max_history_messages; + if self.history.len() <= max { + return; + } + + let mut system_messages = Vec::new(); + let mut other_messages = Vec::new(); + + for msg in self.history.drain(..) { + match &msg { + ConversationMessage::Chat(chat) if chat.role == "system" => { + system_messages.push(msg); + } + _ => other_messages.push(msg), + } + } + + if other_messages.len() > max { + let drop_count = other_messages.len() - max; + other_messages.drain(0..drop_count); + } + + self.history = system_messages; + self.history.extend(other_messages); + } + + /// Pre-fetches learned context data from memory (observations, patterns, user profile). + /// + /// This is an async, non-blocking operation that populates the context + /// for the system prompt. + pub(super) async fn fetch_learned_context(&self) -> LearnedContextData { + if !self.learning_enabled { + return LearnedContextData::default(); + } + + let obs_entries = self + .memory + .list( + Some(&MemoryCategory::Custom("learning_observations".into())), + None, + ) + .await + .unwrap_or_default(); + + let pat_entries = self + .memory + .list( + Some(&MemoryCategory::Custom("learning_patterns".into())), + None, + ) + .await + .unwrap_or_default(); + + let profile_entries = self + .memory + .list(Some(&MemoryCategory::Custom("user_profile".into())), None) + .await + .unwrap_or_default(); + + LearnedContextData { + observations: obs_entries + .iter() + .rev() + .take(5) + .map(|e| sanitize_learned_entry(&e.content)) + .collect(), + patterns: pat_entries + .iter() + .take(3) + .map(|e| sanitize_learned_entry(&e.content)) + .collect(), + user_profile: profile_entries + .iter() + .take(20) + .map(|e| sanitize_learned_entry(&e.content)) + .collect(), + } + } + + /// Builds the system prompt for the current turn, including tool + /// instructions and learned context. + 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); + let ctx = PromptContext { + workspace_dir: &self.workspace_dir, + model_name: &self.model_name, + tools: tools_slice, + skills: &self.skills, + identity_config: Some(&self.identity_config), + dispatcher_instructions: &instructions, + learned, + }; + self.prompt_builder.build(&ctx) + } + + /// Classifies the user message to determine if a specific model hint should be used. + /// + /// Currently unused by `turn()` — we pin the main agent to its configured + /// model for KV-cache stability (see the rationale in `turn()` where + /// `effective_model` is set). Kept around because the classifier config + /// is still surfaced via `AgentBuilder::classification_config` and + /// external callers (e.g. eval harnesses) may want to probe it directly. + #[allow(dead_code)] + pub(super) fn classify_model(&self, user_message: &str) -> String { + if let Some(hint) = + crate::openhuman::agent::classifier::classify(&self.classification_config, user_message) + { + if self.available_hints.contains(&hint) { + tracing::info!(hint = hint.as_str(), "Auto-classified query"); + return format!("hint:{hint}"); + } + } + self.model_name.clone() + } + + // ───────────────────────────────────────────────────────────────── + // Session-memory extraction (stage 5 of the context pipeline) + // ───────────────────────────────────────────────────────────────── + + /// Spawn a background archivist sub-agent to extract durable facts + /// from the recent conversation into `MEMORY.md`. Fire-and-forget. + /// + /// Gated by [`context_pipeline::SessionMemoryState::should_extract`] + /// — see its docs for the threshold invariants. Safe to call from + /// inside `turn()` after the turn body has settled. + pub(super) fn spawn_session_memory_extraction(&mut self) { + let Some(registry) = harness::AgentDefinitionRegistry::global() else { + log::debug!("[session_memory] registry not initialised — skipping extraction spawn"); + return; + }; + let Some(definition) = registry.get("archivist").cloned() else { + log::debug!( + "[session_memory] archivist definition not found — skipping extraction spawn" + ); + return; + }; + + // Build a dedicated ParentExecutionContext for the background + // task. The in-progress turn's context has already been + // 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(); + + // 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(); + + log::info!( + "[session_memory] spawning background archivist extraction (turn={}, tokens={})", + self.context_pipeline.session_memory.current_turn, + self.context_pipeline.session_memory.total_tokens + ); + + tokio::spawn(async move { + let options = harness::SubagentRunOptions::default(); + 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" + ), + } + }); + } +} + +/// Sanitize a learned memory entry before injecting into the system prompt. +/// Strips raw data, limits length, and removes potential secrets. +fn sanitize_learned_entry(content: &str) -> String { + let trimmed = content.trim(); + if trimmed.is_empty() { + return String::new(); + } + // Truncate to a safe length + let max_len = 200; + let sanitized: String = trimmed.chars().take(max_len).collect(); + // Strip anything that looks like a secret/token + if sanitized.contains("Bearer ") + || sanitized.contains("sk-") + || sanitized.contains("ghp_") + || sanitized.contains("-----BEGIN") + { + return "[redacted: potential secret]".to_string(); + } + sanitized +} diff --git a/src/openhuman/agent/agent/types.rs b/src/openhuman/agent/agent/types.rs new file mode 100644 index 000000000..6cd8306e2 --- /dev/null +++ b/src/openhuman/agent/agent/types.rs @@ -0,0 +1,83 @@ +//! `Agent` and `AgentBuilder` struct definitions. +//! +//! The data shapes live here, separate from their behaviour, so the +//! rest of the sub-module (`builder.rs`, `turn.rs`, `runtime.rs`) can +//! focus on logic. Fields are `pub(super)` so sibling files that +//! `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::memory::Memory; +use crate::openhuman::providers::{ConversationMessage, Provider}; +use crate::openhuman::tools::{Tool, ToolSpec}; +use std::sync::Arc; + +/// An autonomous or semi-autonomous AI agent. +/// +/// The `Agent` is the central component that manages conversation state, +/// executes tools based on model requests, and interacts with the memory +/// system to maintain context across turns. +pub struct Agent { + pub(super) provider: Arc, + pub(super) tools: Arc>>, + pub(super) tool_specs: Arc>, + 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, + pub(super) model_name: String, + pub(super) temperature: f64, + pub(super) workspace_dir: std::path::PathBuf, + pub(super) identity_config: crate::openhuman::config::IdentityConfig, + pub(super) skills: Vec, + pub(super) auto_save: bool, + pub(super) history: Vec, + pub(super) classification_config: crate::openhuman::config::QueryClassificationConfig, + pub(super) available_hints: Vec, + pub(super) post_turn_hooks: Vec>, + pub(super) learning_enabled: bool, + pub(super) event_session_id: String, + pub(super) event_channel: String, + /// Layered context 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, +} + +/// A builder for creating `Agent` instances with custom configuration. +pub struct AgentBuilder { + pub(super) provider: Option>, + pub(super) tools: Option>>, + pub(super) memory: Option>, + pub(super) prompt_builder: Option, + pub(super) tool_dispatcher: Option>, + pub(super) memory_loader: Option>, + pub(super) config: Option, + pub(super) model_name: Option, + pub(super) temperature: Option, + pub(super) workspace_dir: Option, + pub(super) identity_config: Option, + pub(super) skills: Option>, + pub(super) auto_save: Option, + pub(super) classification_config: Option, + pub(super) available_hints: Option>, + pub(super) post_turn_hooks: Vec>, + pub(super) learning_enabled: bool, + pub(super) event_session_id: Option, + pub(super) event_channel: Option, +} + +impl Default for AgentBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/src/openhuman/agent/context_pipeline/microcompact.rs b/src/openhuman/agent/context_pipeline/microcompact.rs new file mode 100644 index 000000000..587bb7026 --- /dev/null +++ b/src/openhuman/agent/context_pipeline/microcompact.rs @@ -0,0 +1,266 @@ +//! Stage 3: Microcompact. +//! +//! Microcompact is the cheap summarisation substitute. It does **not** +//! generate prose summaries — instead it walks the history and replaces +//! the payload of older `ToolResults` envelopes with a short placeholder +//! string. The envelope itself is preserved so the API invariant +//! `AssistantToolCalls ⇔ ToolResults` holds and the provider still +//! accepts the next request. +//! +//! OpenHuman's inference backend does automatic prefix caching, so we +//! skip any cache-editing dance and go straight to the placeholder +//! strategy: overwrite the old bodies in place, let the backend +//! re-prefill once, and let the next turn pick up the new (smaller) +//! cache target. +//! +//! # Cache implications +//! +//! Microcompact mutates bytes that were previously sent to the backend, +//! so it **deliberately invalidates the KV-cache prefix** for this +//! session. The upside is that the new, smaller prefix becomes the next +//! stable cache target, so subsequent turns hit the cache again. This +//! stage is therefore only run when the next provider call would +//! otherwise be too large to fit — the pipeline orchestrator handles +//! gating. + +use crate::openhuman::providers::ConversationMessage; + +/// Placeholder used in place of cleared tool-result bodies. Must be +/// stable across versions so callers can pattern-match on it for +/// telemetry / diff tests. Keep it short — the whole point is to free +/// tokens. +pub const CLEARED_PLACEHOLDER: &str = "[Old tool result content cleared]"; + +/// Default number of most-recent `ToolResults` envelopes to leave +/// intact — the N most recent tool results are kept hot so the model +/// can still reason about them. +pub const DEFAULT_KEEP_RECENT_TOOL_RESULTS: usize = 5; + +/// Summary of what a single microcompact pass changed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MicrocompactStats { + /// Number of `ToolResults` envelopes whose bodies were cleared. + pub envelopes_cleared: usize, + /// Number of individual tool-result entries within those envelopes + /// whose `content` was replaced. + pub entries_cleared: usize, + /// Bytes freed from the rendered conversation (approximate — counts + /// the `content` string length diff only). + pub bytes_freed: usize, +} + +/// Walk `history` and clear the payload of every `ToolResults` envelope +/// except the `keep_recent` most recent ones. Returns a summary of the +/// changes. +/// +/// The clearing is idempotent: running the pass twice on the same +/// history is a no-op on the second call because the already-cleared +/// entries will match `CLEARED_PLACEHOLDER` and be skipped. +pub fn microcompact(history: &mut [ConversationMessage], keep_recent: usize) -> MicrocompactStats { + // First sweep: find the indices of every `ToolResults` envelope. + let mut tool_result_indices: Vec = history + .iter() + .enumerate() + .filter_map(|(i, msg)| matches!(msg, ConversationMessage::ToolResults(_)).then_some(i)) + .collect(); + + // The most-recent envelopes are at the end of the vec — peel off + // `keep_recent` of them and leave them untouched. + if tool_result_indices.len() <= keep_recent { + return MicrocompactStats::default(); + } + let cut = tool_result_indices.len().saturating_sub(keep_recent); + tool_result_indices.truncate(cut); + + let mut stats = MicrocompactStats::default(); + + for idx in tool_result_indices { + let ConversationMessage::ToolResults(results) = &mut history[idx] else { + continue; + }; + let mut envelope_changed = false; + for entry in results.iter_mut() { + if entry.content == CLEARED_PLACEHOLDER { + // Already cleared on a previous pass — skip. + continue; + } + let old_len = entry.content.len(); + entry.content = CLEARED_PLACEHOLDER.to_string(); + let freed = old_len.saturating_sub(CLEARED_PLACEHOLDER.len()); + stats.bytes_freed += freed; + stats.entries_cleared += 1; + envelope_changed = true; + } + if envelope_changed { + stats.envelopes_cleared += 1; + } + } + + stats +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::providers::{ChatMessage, ToolCall, ToolResultMessage}; + + fn user(text: &str) -> ConversationMessage { + ConversationMessage::Chat(ChatMessage::user(text)) + } + + fn assistant_call(id: &str, name: &str) -> ConversationMessage { + ConversationMessage::AssistantToolCalls { + text: None, + tool_calls: vec![ToolCall { + id: id.into(), + name: name.into(), + arguments: "{}".into(), + }], + } + } + + fn tool_result(id: &str, body: &str) -> ConversationMessage { + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: id.into(), + content: body.into(), + }]) + } + + #[test] + fn noop_when_no_tool_results() { + let mut history = vec![user("hi"), user("again")]; + let stats = microcompact(&mut history, 5); + assert_eq!(stats, MicrocompactStats::default()); + } + + #[test] + fn noop_when_all_tool_results_within_keep_recent() { + let mut history = vec![ + user("q"), + assistant_call("a", "t"), + tool_result("a", "body-a"), + assistant_call("b", "t"), + tool_result("b", "body-b"), + ]; + let stats = microcompact(&mut history, 5); + assert_eq!(stats, MicrocompactStats::default()); + // Bodies unchanged. + if let ConversationMessage::ToolResults(r) = &history[2] { + assert_eq!(r[0].content, "body-a"); + } else { + panic!(); + } + } + + #[test] + fn clears_oldest_when_over_keep_recent() { + let large_body = "x".repeat(5_000); + let mut history = vec![ + user("q1"), + assistant_call("t1", "fn"), + tool_result("t1", &large_body), // oldest — should be cleared + assistant_call("t2", "fn"), + tool_result("t2", &large_body), // oldest — should be cleared + assistant_call("t3", "fn"), + tool_result("t3", "recent-1"), // keep + assistant_call("t4", "fn"), + tool_result("t4", "recent-2"), // keep + ]; + + let stats = microcompact(&mut history, 2); + assert_eq!(stats.envelopes_cleared, 2); + assert_eq!(stats.entries_cleared, 2); + assert!(stats.bytes_freed > 9_000); + + // Oldest two have been replaced. + match &history[2] { + ConversationMessage::ToolResults(r) => assert_eq!(r[0].content, CLEARED_PLACEHOLDER), + _ => panic!(), + } + match &history[4] { + ConversationMessage::ToolResults(r) => assert_eq!(r[0].content, CLEARED_PLACEHOLDER), + _ => panic!(), + } + // Most-recent two are preserved verbatim. + match &history[6] { + ConversationMessage::ToolResults(r) => assert_eq!(r[0].content, "recent-1"), + _ => panic!(), + } + match &history[8] { + ConversationMessage::ToolResults(r) => assert_eq!(r[0].content, "recent-2"), + _ => panic!(), + } + } + + #[test] + fn envelope_invariant_preserved() { + // API requires every AssistantToolCalls to have a matching + // ToolResults envelope. Clearing bodies must not delete the + // envelope or remove entries from the vec inside. + let mut history = vec![ + assistant_call("t1", "fn"), + tool_result("t1", "old-1"), + assistant_call("t2", "fn"), + tool_result("t2", "new-1"), + ]; + microcompact(&mut history, 1); + + let mut call_count = 0; + let mut result_count = 0; + for msg in &history { + match msg { + ConversationMessage::AssistantToolCalls { .. } => call_count += 1, + ConversationMessage::ToolResults(_) => result_count += 1, + _ => {} + } + } + assert_eq!(call_count, 2); + assert_eq!(result_count, 2); + } + + #[test] + fn second_pass_is_idempotent() { + let mut history = vec![ + assistant_call("t1", "fn"), + tool_result("t1", "old-1"), + assistant_call("t2", "fn"), + tool_result("t2", "new-1"), + ]; + let first = microcompact(&mut history, 1); + assert_eq!(first.envelopes_cleared, 1); + + let second = microcompact(&mut history, 1); + assert_eq!(second, MicrocompactStats::default()); + } + + #[test] + fn clears_all_entries_in_a_multi_entry_envelope() { + let mut history = vec![ + assistant_call("t1", "fn"), + ConversationMessage::ToolResults(vec![ + ToolResultMessage { + tool_call_id: "a".into(), + content: "A".repeat(1_000), + }, + ToolResultMessage { + tool_call_id: "b".into(), + content: "B".repeat(1_000), + }, + ]), + assistant_call("t2", "fn"), + tool_result("t2", "recent"), + ]; + let stats = microcompact(&mut history, 1); + assert_eq!(stats.envelopes_cleared, 1); + assert_eq!(stats.entries_cleared, 2); + + match &history[1] { + ConversationMessage::ToolResults(r) => { + assert_eq!(r.len(), 2); + assert_eq!(r[0].content, CLEARED_PLACEHOLDER); + assert_eq!(r[1].content, CLEARED_PLACEHOLDER); + } + _ => panic!(), + } + } +} diff --git a/src/openhuman/agent/context_pipeline/mod.rs b/src/openhuman/agent/context_pipeline/mod.rs new file mode 100644 index 000000000..44a9af1c7 --- /dev/null +++ b/src/openhuman/agent/context_pipeline/mod.rs @@ -0,0 +1,42 @@ +//! 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/context_pipeline/pipeline.rs b/src/openhuman/agent/context_pipeline/pipeline.rs new file mode 100644 index 000000000..69c7c695a --- /dev/null +++ b/src/openhuman/agent/context_pipeline/pipeline.rs @@ -0,0 +1,392 @@ +//! The layered context pipeline orchestrator. +//! +//! Ordered reduction chain applied before each provider hit: +//! +//! 1. **Tool-result budget** — applied inline in `Agent::execute_tool_call` +//! (not here). Oversized tool results are truncated before they enter +//! history, so they never show up as a pipeline stage. +//! 2. **Snip compact** — hard cap on message count. Implemented by the +//! pre-existing `Agent::trim_history`; the pipeline leaves it to the +//! caller because trimming is a terminal fallback. +//! 3. **Microcompact** — this module. Runs when `ContextGuard` reports +//! `CompactionNeeded` (soft threshold). Replaces the payload of older +//! `ToolResults` envelopes with a placeholder, preserving the +//! `AssistantToolCalls ⇔ ToolResults` API invariant. +//! 4. **Autocompact** — prose summarisation of older messages. +//! OpenHuman's existing `auto_compact_history` lives in +//! `agent/loop_/history.rs` and operates on `ChatMessage` (not +//! `ConversationMessage`), so we don't call it here — the pipeline +//! instead signals a `PipelineOutcome::AutocompactionRequested` to +//! the caller and trusts the caller to dispatch its own summariser +//! 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`]. +//! +//! # Cache contract +//! +//! Stages 1–2 are byte-neutral with respect to previously-sent history +//! (stage 1 applies to a fresh tool result before insertion; stage 2 is +//! a terminal trim). Stages 3–4 deliberately mutate previously-sent +//! history and therefore break the KV-cache prefix; they run **only +//! when the context guard says we'd otherwise bust the window**. Each +//! firing resets the stable prefix to the new, smaller history so +//! subsequent turns hit the cache again. + +use super::microcompact::{microcompact, MicrocompactStats, DEFAULT_KEEP_RECENT_TOOL_RESULTS}; +use super::session_memory::{SessionMemoryConfig, SessionMemoryState}; +use crate::openhuman::agent::loop_::context_guard::{ContextCheckResult, ContextGuard}; +use crate::openhuman::providers::{ConversationMessage, UsageInfo}; + +/// Pipeline configuration. Defaults are tuned for an `agentic-v1` +/// 128k-context run. +#[derive(Debug, Clone, Copy)] +pub struct ContextPipelineConfig { + /// Number of recent `ToolResults` envelopes microcompact leaves + /// untouched. See [`DEFAULT_KEEP_RECENT_TOOL_RESULTS`]. + pub microcompact_keep_recent: usize, + /// Whether to surface the microcompact pass in the pipeline + /// outcome. When `false` the pipeline skips stage 3 entirely — + /// useful for tests that want to exercise autocompaction in + /// isolation. + pub microcompact_enabled: bool, + /// Whether the pipeline should report an autocompaction request + /// when the guard says we're at the hard threshold. When `false` + /// the pipeline silently tolerates an exhausted context (the caller + /// is expected to surface the error via the guard directly). + pub autocompact_enabled: bool, + /// Session-memory extraction tunables. + pub session_memory: SessionMemoryConfig, +} + +impl Default for ContextPipelineConfig { + fn default() -> Self { + Self { + microcompact_keep_recent: DEFAULT_KEEP_RECENT_TOOL_RESULTS, + microcompact_enabled: true, + autocompact_enabled: true, + session_memory: SessionMemoryConfig::default(), + } + } +} + +/// Outcome of a single pipeline pass, returned to the caller so it can +/// log/telemeter what happened and decide whether to trigger an +/// autocompaction summariser. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PipelineOutcome { + /// No stage fired — either the guard is happy or the history is + /// already small enough. + NoOp, + /// Microcompact cleared at least one older `ToolResults` envelope. + Microcompacted(MicrocompactStats), + /// The guard reports we're above the soft threshold and + /// microcompact wasn't enough (or was disabled). The caller should + /// invoke its autocompaction summariser. + AutocompactionRequested { + /// The last-known context utilisation as a 0..=100 percentage. + 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 }, +} + +/// 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. +#[derive(Debug)] +pub struct ContextPipeline { + pub config: ContextPipelineConfig, + pub guard: ContextGuard, + pub session_memory: SessionMemoryState, +} + +impl Default for ContextPipeline { + fn default() -> Self { + Self::new(ContextPipelineConfig::default()) + } +} + +impl ContextPipeline { + pub fn new(config: ContextPipelineConfig) -> Self { + Self { + config, + guard: ContextGuard::new(), + session_memory: SessionMemoryState::default(), + } + } + + /// Feed the latest provider `UsageInfo` into both the guard and the + /// 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); + } + + /// Bump the session-memory turn counter. Called once per user turn. + pub fn tick_turn(&mut self) { + self.session_memory.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); + } + + /// 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) + } + + /// Run the reduction chain against `history` in place. Safe to call + /// before every provider hit — it's cheap when the guard is happy. + pub fn run_before_call(&mut self, history: &mut [ConversationMessage]) -> PipelineOutcome { + match self.guard.check() { + ContextCheckResult::Ok => PipelineOutcome::NoOp, + ContextCheckResult::CompactionNeeded => { + // Stage 3: microcompact the older tool results. + if self.config.microcompact_enabled { + let stats = microcompact(history, self.config.microcompact_keep_recent); + if stats.envelopes_cleared > 0 { + // A successful reduction should reset the guard's + // circuit breaker so a previous string of + // autocompaction failures doesn't leave the + // breaker tripped after we've just freed tokens. + self.guard.record_compaction_success(); + tracing::info!( + envelopes_cleared = stats.envelopes_cleared, + entries_cleared = stats.entries_cleared, + bytes_freed = stats.bytes_freed, + "[context_pipeline] microcompact fired" + ); + return PipelineOutcome::Microcompacted(stats); + } + } + + // 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. + 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" + ); + return PipelineOutcome::AutocompactionRequested { + utilisation_pct: pct, + }; + } + + PipelineOutcome::NoOp + } + ContextCheckResult::ContextExhausted { + utilization_pct, + reason, + } => PipelineOutcome::ContextExhausted { + utilisation_pct: utilization_pct, + reason, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::super::microcompact::CLEARED_PLACEHOLDER; + use super::*; + use crate::openhuman::providers::{ + ChatMessage, ConversationMessage, ToolCall, ToolResultMessage, UsageInfo, + }; + + 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(), + }]) + } + + fn user(text: &str) -> ConversationMessage { + ConversationMessage::Chat(ChatMessage::user(text)) + } + + fn set_high_utilisation(pipeline: &mut ContextPipeline) { + pipeline.record_usage(&UsageInfo { + input_tokens: 92_000, + output_tokens: 4_000, + context_window: 100_000, + }); + } + + #[test] + fn noop_when_guard_is_ok() { + let mut pipeline = ContextPipeline::default(); + pipeline.record_usage(&UsageInfo { + input_tokens: 10_000, + output_tokens: 1_000, + context_window: 100_000, + }); + let mut history = vec![ + user("hi"), + call("t1"), + result("t1", "x".repeat(2_000).as_str()), + ]; + let outcome = pipeline.run_before_call(&mut history); + assert_eq!(outcome, PipelineOutcome::NoOp); + } + + #[test] + fn microcompact_fires_at_soft_threshold_when_there_are_old_tool_results() { + let mut pipeline = ContextPipeline::default(); + let mut history = vec![ + call("t1"), + result("t1", &"x".repeat(5_000)), + call("t2"), + result("t2", &"x".repeat(5_000)), + call("t3"), + result("t3", "recent-1"), + call("t4"), + result("t4", "recent-2"), + call("t5"), + result("t5", "recent-3"), + call("t6"), + result("t6", "recent-4"), + call("t7"), + result("t7", "recent-5"), + ]; + set_high_utilisation(&mut pipeline); + let outcome = pipeline.run_before_call(&mut history); + match outcome { + PipelineOutcome::Microcompacted(stats) => { + assert_eq!(stats.envelopes_cleared, 2); + assert!(stats.bytes_freed > 9_000); + } + other => panic!("expected Microcompacted, got {other:?}"), + } + // Older entries are cleared, newer ones are preserved. + match &history[1] { + ConversationMessage::ToolResults(r) => { + assert_eq!(r[0].content, CLEARED_PLACEHOLDER) + } + _ => panic!(), + } + match &history[13] { + ConversationMessage::ToolResults(r) => assert_eq!(r[0].content, "recent-5"), + _ => panic!(), + } + } + + #[test] + fn autocompaction_requested_when_no_old_tool_results_to_clear() { + let mut pipeline = ContextPipeline::default(); + // Soft threshold crossed but there are zero ToolResults to clear. + set_high_utilisation(&mut pipeline); + let mut history = vec![user("one"), user("two"), user("three")]; + let outcome = pipeline.run_before_call(&mut history); + match outcome { + PipelineOutcome::AutocompactionRequested { utilisation_pct } => { + assert!(utilisation_pct >= 90); + } + other => panic!("expected AutocompactionRequested, got {other:?}"), + } + } + + #[test] + fn autocompaction_requested_when_only_recent_tool_results_exist() { + // All tool results fall within `keep_recent`, so microcompact + // has nothing to clear and the pipeline falls through to + // autocompaction. + let mut pipeline = ContextPipeline::default(); + let mut history = vec![call("t1"), result("t1", "a"), call("t2"), result("t2", "b")]; + set_high_utilisation(&mut pipeline); + let outcome = pipeline.run_before_call(&mut history); + assert!(matches!( + outcome, + PipelineOutcome::AutocompactionRequested { .. } + )); + } + + #[test] + fn microcompact_disabled_skips_to_autocompaction() { + let mut pipeline = ContextPipeline::new(ContextPipelineConfig { + microcompact_enabled: false, + ..ContextPipelineConfig::default() + }); + let mut history = vec![ + call("t1"), + result("t1", &"x".repeat(5_000)), + call("t2"), + result("t2", "recent"), + ]; + set_high_utilisation(&mut pipeline); + let outcome = pipeline.run_before_call(&mut history); + assert!(matches!( + outcome, + PipelineOutcome::AutocompactionRequested { .. } + )); + // History must be untouched when microcompact is disabled. + if let ConversationMessage::ToolResults(r) = &history[1] { + assert_eq!(r[0].content.len(), 5_000); + } else { + panic!(); + } + } + + #[test] + fn exhausted_context_propagates_to_caller() { + let mut pipeline = ContextPipeline::default(); + pipeline.record_usage(&UsageInfo { + input_tokens: 96_000, + output_tokens: 2_000, + context_window: 100_000, + }); + // Trip the circuit breaker. + pipeline.guard.record_compaction_failure(); + pipeline.guard.record_compaction_failure(); + pipeline.guard.record_compaction_failure(); + + let mut history = vec![user("hi")]; + let outcome = pipeline.run_before_call(&mut history); + assert!(matches!(outcome, PipelineOutcome::ContextExhausted { .. })); + } + + #[test] + fn record_usage_feeds_session_memory() { + let mut pipeline = ContextPipeline::default(); + pipeline.record_usage(&UsageInfo { + input_tokens: 10_000, + output_tokens: 2_000, + context_window: 100_000, + }); + assert_eq!(pipeline.session_memory.total_tokens, 12_000); + } + + #[test] + fn tick_turn_and_record_tool_calls_affect_session_memory() { + 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); + } +} diff --git a/src/openhuman/agent/context_pipeline/session_memory.rs b/src/openhuman/agent/context_pipeline/session_memory.rs new file mode 100644 index 000000000..8f4a9fb01 --- /dev/null +++ b/src/openhuman/agent/context_pipeline/session_memory.rs @@ -0,0 +1,256 @@ +//! Stage 5: Session memory — persistent notes updated by a background fork. +//! +//! Session memory is intentionally **separate** from compaction. While +//! microcompact/autocompact mutate the in-flight conversation history to +//! keep the prompt inside the context window, session memory is a +//! persistent markdown file (`MEMORY.md` in the workspace) that survives +//! across sessions and acts as the long-term substrate the next session +//! hydrates from. It is updated by a background forked sub-agent (the +//! `archivist` archetype) so the user-facing agent never pays the cost +//! of synthesis on its hot path. +//! +//! Extraction only runs after token-growth, tool-call, and turn-count +//! thresholds are met, so it does not fire every turn — see +//! [`SessionMemoryConfig`] for the exact knobs. +//! +//! This module is purely state-tracking: it owns the thresholds and a +//! `should_extract` decision, but the actual `spawn_subagent` call is +//! issued by the caller (the `Agent::turn` epilogue) so we avoid a +//! circular dependency with `harness::subagent_runner`. + +/// Minimum number of *new* tokens (input + output) since the last +/// extraction before we consider running another extraction. +pub const DEFAULT_MIN_TOKEN_GROWTH: u64 = 4_000; + +/// Minimum number of assistant tool calls since the last extraction +/// before we consider running another extraction. +pub const DEFAULT_MIN_TOOL_CALLS: u64 = 8; + +/// Minimum number of turns between extractions. Prevents burst +/// extraction when the user sends many short messages in a row. +pub const DEFAULT_MIN_TURNS_BETWEEN: u64 = 4; + +/// Tunable thresholds for session-memory extraction. +#[derive(Debug, Clone, Copy)] +pub struct SessionMemoryConfig { + pub min_token_growth: u64, + pub min_tool_calls: u64, + pub min_turns_between: u64, +} + +impl Default for SessionMemoryConfig { + fn default() -> Self { + Self { + min_token_growth: DEFAULT_MIN_TOKEN_GROWTH, + min_tool_calls: DEFAULT_MIN_TOOL_CALLS, + min_turns_between: DEFAULT_MIN_TURNS_BETWEEN, + } + } +} + +/// Per-session extraction state. Tracked on the `Agent` instance so it +/// resets naturally when a new session starts. +#[derive(Debug, Clone, Default)] +pub struct SessionMemoryState { + /// Cumulative tokens observed across the whole session (via + /// `ContextGuard::update_usage`). + pub total_tokens: u64, + /// Tokens at the last completed extraction (or 0 if none yet). + pub tokens_at_last_extract: u64, + /// Turn counter at the last completed extraction. + pub turn_at_last_extract: u64, + /// Cumulative tool-call count across the session. + pub total_tool_calls: u64, + /// Tool calls observed at the last extraction. + pub tool_calls_at_last_extract: u64, + /// Current turn counter. + pub current_turn: u64, + /// Whether an extraction is in progress. While `true`, `should_extract` + /// returns false so we don't spawn overlapping background forks. + pub extraction_in_progress: bool, +} + +impl SessionMemoryState { + /// Called each time the caller bumps the turn counter. + pub fn tick_turn(&mut self) { + self.current_turn = self.current_turn.saturating_add(1); + } + + /// Accumulate usage from the most recent provider response. + pub fn record_usage(&mut self, total_used_tokens: u64) { + // `total_used_tokens` is cumulative per-response (prompt + output); + // we want monotonic growth so take the max against what we've + // already recorded. This is robust to providers that report + // smaller numbers when tool-only turns happen. + if total_used_tokens > self.total_tokens { + self.total_tokens = total_used_tokens; + } + } + + /// Accumulate a tool-call count from the turn just finished. + pub fn record_tool_calls(&mut self, n: usize) { + self.total_tool_calls = self.total_tool_calls.saturating_add(n as u64); + } + + /// Decide whether a background session-memory extraction should run + /// right now. The rule: all three deltas (tokens, tool calls, turns) + /// must have grown past their thresholds since the last extraction, + /// AND no other extraction is in flight. + pub fn should_extract(&self, config: &SessionMemoryConfig) -> bool { + if self.extraction_in_progress { + return false; + } + let token_growth = self + .total_tokens + .saturating_sub(self.tokens_at_last_extract); + let tool_growth = self + .total_tool_calls + .saturating_sub(self.tool_calls_at_last_extract); + let turn_growth = self.current_turn.saturating_sub(self.turn_at_last_extract); + + token_growth >= config.min_token_growth + && tool_growth >= config.min_tool_calls + && turn_growth >= config.min_turns_between + } + + /// Mark an extraction as in-progress. Must be paired with either + /// `mark_extraction_complete` or `mark_extraction_failed`. + pub fn mark_extraction_started(&mut self) { + self.extraction_in_progress = true; + } + + /// Record a successful extraction. Resets the deltas so the next + /// extraction won't fire until the thresholds are re-crossed. + pub fn mark_extraction_complete(&mut self) { + self.extraction_in_progress = false; + self.tokens_at_last_extract = self.total_tokens; + self.tool_calls_at_last_extract = self.total_tool_calls; + self.turn_at_last_extract = self.current_turn; + } + + /// Record a failed extraction. Leaves the deltas alone so the next + /// turn can retry, but clears the in-progress flag. + pub fn mark_extraction_failed(&mut self) { + self.extraction_in_progress = false; + } +} + +/// The prompt the main agent hands to a spawned archivist sub-agent when +/// session-memory extraction fires. Kept in this module so the +/// extraction policy and the spawn wording live together. +pub const ARCHIVIST_EXTRACTION_PROMPT: &str = + "You are extracting durable facts from the recent conversation \ +into the workspace `MEMORY.md` file. Focus on:\n\n\ +- User preferences and commitments\n\ +- Decisions and their rationale\n\ +- Facts about external systems, people, codebases the user mentioned\n\ +- Unresolved tasks worth surfacing next session\n\n\ +Skip: filler dialogue, tool logs, and anything already present in \ +MEMORY.md. Use the `update_memory_md` tool to append a dated bullet \ +list under an `## Observations` section. Be dense — at most 8 bullets. \ +Reply with a one-line confirmation when done."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_state_does_not_extract() { + let state = SessionMemoryState::default(); + let cfg = SessionMemoryConfig::default(); + assert!(!state.should_extract(&cfg)); + } + + #[test] + fn all_three_thresholds_must_be_crossed() { + let cfg = SessionMemoryConfig::default(); + + // Only token threshold crossed → no. + let mut s = SessionMemoryState::default(); + s.total_tokens = DEFAULT_MIN_TOKEN_GROWTH + 1; + assert!(!s.should_extract(&cfg)); + + // Tokens + tool calls, no turn growth → no. + s.total_tool_calls = DEFAULT_MIN_TOOL_CALLS + 1; + assert!(!s.should_extract(&cfg)); + + // All three crossed → yes. + s.current_turn = DEFAULT_MIN_TURNS_BETWEEN + 1; + assert!(s.should_extract(&cfg)); + } + + #[test] + fn in_progress_suppresses_extraction() { + let cfg = SessionMemoryConfig::default(); + let mut s = SessionMemoryState::default(); + s.total_tokens = DEFAULT_MIN_TOKEN_GROWTH + 1; + s.total_tool_calls = DEFAULT_MIN_TOOL_CALLS + 1; + s.current_turn = DEFAULT_MIN_TURNS_BETWEEN + 1; + assert!(s.should_extract(&cfg)); + s.mark_extraction_started(); + assert!(!s.should_extract(&cfg)); + } + + #[test] + fn mark_complete_resets_deltas() { + let cfg = SessionMemoryConfig::default(); + let mut s = SessionMemoryState::default(); + s.total_tokens = 10_000; + s.total_tool_calls = 15; + s.current_turn = 10; + s.mark_extraction_started(); + s.mark_extraction_complete(); + + // Immediately after completion no further extraction should + // fire until the deltas are re-crossed. + assert!(!s.should_extract(&cfg)); + + // Grow each counter past threshold again. + s.total_tokens += DEFAULT_MIN_TOKEN_GROWTH; + s.total_tool_calls += DEFAULT_MIN_TOOL_CALLS; + s.current_turn += DEFAULT_MIN_TURNS_BETWEEN; + assert!(s.should_extract(&cfg)); + } + + #[test] + fn mark_failed_leaves_deltas_intact() { + let cfg = SessionMemoryConfig::default(); + let mut s = SessionMemoryState::default(); + s.total_tokens = DEFAULT_MIN_TOKEN_GROWTH + 1; + s.total_tool_calls = DEFAULT_MIN_TOOL_CALLS + 1; + s.current_turn = DEFAULT_MIN_TURNS_BETWEEN + 1; + s.mark_extraction_started(); + s.mark_extraction_failed(); + + // Should still fire on the next attempt because the + // "last_extract" counters were not advanced. + assert!(s.should_extract(&cfg)); + } + + #[test] + fn record_usage_is_monotonic() { + let mut s = SessionMemoryState::default(); + s.record_usage(5_000); + s.record_usage(3_000); // regression — must not decrease. + assert_eq!(s.total_tokens, 5_000); + s.record_usage(7_500); + assert_eq!(s.total_tokens, 7_500); + } + + #[test] + fn tick_turn_increments() { + let mut s = SessionMemoryState::default(); + s.tick_turn(); + s.tick_turn(); + s.tick_turn(); + assert_eq!(s.current_turn, 3); + } + + #[test] + fn record_tool_calls_accumulates() { + let mut s = SessionMemoryState::default(); + s.record_tool_calls(3); + s.record_tool_calls(2); + assert_eq!(s.total_tool_calls, 5); + } +} diff --git a/src/openhuman/agent/context_pipeline/tool_result_budget.rs b/src/openhuman/agent/context_pipeline/tool_result_budget.rs new file mode 100644 index 000000000..7786e73d6 --- /dev/null +++ b/src/openhuman/agent/context_pipeline/tool_result_budget.rs @@ -0,0 +1,176 @@ +//! Stage 1: Tool-result budget. +//! +//! Apply a per-call byte cap to a raw tool result *before* it enters the +//! conversation history. This is the cheapest stage because it operates +//! on fresh bytes that have not yet been sent to the inference backend — +//! it does not mutate existing history and therefore does not break the +//! KV-cache prefix. +//! +//! A future iteration could park the overflow in a "stored surrogate" +//! and reference it later if the model asks for the full body. For now +//! OpenHuman does the simpler thing: truncate in-place with a size +//! marker the model can use to decide whether to re-run the tool with a +//! narrower query. +//! +//! This stage is called from `Agent::execute_tool_call` once the tool +//! has returned its output and before that output is packaged into a +//! `ToolResultMessage`. + +use std::fmt::Write as _; + +/// Default per-tool-result budget. Chosen to keep a single oversized +/// result from blowing out the prompt while still leaving room for +/// moderately chunky outputs (directory listings, small file contents, +/// condensed HTTP bodies). +pub const DEFAULT_TOOL_RESULT_BUDGET_BYTES: usize = 16 * 1024; + +/// Number of trailing bytes reserved for the truncation marker. The +/// effective head capacity is `budget - TRAILER_RESERVED`. +const TRAILER_RESERVED: usize = 256; + +/// Outcome of a budget application, for tracing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BudgetOutcome { + /// Byte length of the original content. + pub original_bytes: usize, + /// Byte length of the returned content (`== original_bytes` when the + /// result fit inside the budget). + pub final_bytes: usize, + /// `true` if the content was truncated. + pub truncated: bool, +} + +impl BudgetOutcome { + pub fn unchanged(len: usize) -> Self { + Self { + original_bytes: len, + final_bytes: len, + truncated: false, + } + } +} + +/// Apply the tool-result budget to `content`. +/// +/// If `content` fits in `budget_bytes`, returns it unchanged. Otherwise +/// returns a truncated prefix followed by a human-readable marker like +/// `\n\n[… 42_384 bytes truncated by tool_result_budget …]`. The cut is +/// made at a UTF-8 character boundary so the returned string is always +/// valid UTF-8. +pub fn apply_tool_result_budget(content: String, budget_bytes: usize) -> (String, BudgetOutcome) { + let original_bytes = content.len(); + if budget_bytes == 0 || original_bytes <= budget_bytes { + return (content, BudgetOutcome::unchanged(original_bytes)); + } + + // Reserve room for the trailer. If the budget is smaller than the + // reservation we still emit the marker; the only guarantee is that + // the final string is shorter than the original. + let head_capacity = budget_bytes.saturating_sub(TRAILER_RESERVED).max(1); + + // Walk char indices forward until we cross the head capacity. The + // last char fully inside the head is where we cut. + let mut cut = 0usize; + for (idx, ch) in content.char_indices() { + let next = idx + ch.len_utf8(); + if next > head_capacity { + break; + } + cut = next; + } + + // Extremely short content (single multi-byte char) — guarantee at + // least one character makes it into the head so we don't emit a + // zero-byte head. + if cut == 0 { + cut = content + .char_indices() + .next() + .map(|(_, c)| c.len_utf8()) + .unwrap_or(0); + } + + let dropped_bytes = original_bytes.saturating_sub(cut); + let mut out = String::with_capacity(cut + TRAILER_RESERVED); + out.push_str(&content[..cut]); + // Hard separator so the marker is easy for humans AND the model to + // recognise when it appears inside a tool_result block. + let _ = write!( + out, + "\n\n[… {dropped_bytes} bytes truncated by tool_result_budget — re-run with a narrower query to see the rest …]" + ); + + let final_bytes = out.len(); + ( + out, + BudgetOutcome { + original_bytes, + final_bytes, + truncated: true, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_content_passes_through_unchanged() { + let input = "hello world".to_string(); + let (out, outcome) = apply_tool_result_budget(input.clone(), 1024); + assert_eq!(out, input); + assert!(!outcome.truncated); + assert_eq!(outcome.original_bytes, outcome.final_bytes); + } + + #[test] + fn content_at_exact_budget_is_unchanged() { + let input = "x".repeat(100); + let (out, outcome) = apply_tool_result_budget(input.clone(), 100); + assert_eq!(out, input); + assert!(!outcome.truncated); + } + + #[test] + fn oversized_content_is_truncated_with_marker() { + let input = "x".repeat(10_000); + let (out, outcome) = apply_tool_result_budget(input, 1024); + assert!(outcome.truncated); + assert!(out.len() < 10_000); + assert!(out.contains("truncated by tool_result_budget")); + // Marker should include the dropped byte count. + assert!(out.contains("bytes truncated")); + } + + #[test] + fn truncation_respects_utf8_boundaries() { + // Each "é" is 2 bytes. 600 of them = 1200 bytes. + let input: String = "é".repeat(600); + let (out, outcome) = apply_tool_result_budget(input, 500); + assert!(outcome.truncated); + // Must be valid UTF-8 — just dereferencing is enough. + let _ = out.as_str(); + // Head should contain only full "é" characters (no half-byte). + let head_end = out.find("\n\n[").unwrap(); + let head = &out[..head_end]; + assert!(head.chars().all(|c| c == 'é')); + } + + #[test] + fn zero_budget_is_noop() { + let input = "keep me".to_string(); + let (out, outcome) = apply_tool_result_budget(input.clone(), 0); + assert_eq!(out, input); + assert!(!outcome.truncated); + } + + #[test] + fn outcome_reports_correct_byte_counts() { + let input = "x".repeat(5_000); + let (out, outcome) = apply_tool_result_budget(input, 1024); + assert_eq!(outcome.original_bytes, 5_000); + assert_eq!(outcome.final_bytes, out.len()); + assert!(outcome.truncated); + } +} diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs new file mode 100644 index 000000000..c2f77bad7 --- /dev/null +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -0,0 +1,306 @@ +//! Built-in [`AgentDefinition`]s derived from [`AgentArchetype`]. +//! +//! These cover the eight historical archetypes plus a synthetic `fork` +//! definition that the runner uses for byte-exact prompt-cache reuse. +//! Custom YAML definitions loaded later override any built-in with the +//! same id. + +use super::archetypes::AgentArchetype; +use super::definition::{ + AgentDefinition, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope, +}; + +// `PromptSource::File` is unused in this module — built-ins use +// `PromptSource::Inline` with text baked in by `include_str!`. Custom +// definitions loaded from TOML may still use `File`. + +/// All built-in definitions, in stable order. +pub fn all() -> Vec { + let mut out: Vec = AgentArchetype::all() + .iter() + .map(|arch| from_archetype(*arch)) + .collect(); + out.push(fork_definition()); + out +} + +/// Construct an [`AgentDefinition`] for one [`AgentArchetype`]. +/// +/// Reads `default_model_hint`, `allowed_tools`, `default_max_iterations`, +/// and `sandbox_mode` from the existing archetype metadata so this stays +/// a single source of truth. The system prompt body is baked into the +/// binary via [`archetype_prompt_body`] so built-in sub-agents work +/// regardless of workspace state. +pub fn from_archetype(arch: AgentArchetype) -> AgentDefinition { + let id = arch.to_string(); + let when_to_use = when_to_use_for(arch).to_string(); + let display_name = Some(display_name_for(arch).to_string()); + let system_prompt = PromptSource::Inline(archetype_prompt_body(arch).to_string()); + + let tools = match arch.allowed_tools() { + // SkillsAgent: dynamic — at spawn time the runner picks up all + // currently-loaded skill tools (filtered by `skill_filter` if set). + None => ToolScope::Wildcard, + Some(allowed) => ToolScope::Named(allowed.iter().map(|s| (*s).to_string()).collect()), + }; + + // SkillsAgent's default skill filter is None — meaning "all skill tools". + // Per-API specialists (Notion, Gmail, …) are layered on top by setting + // `skill_filter` either in a custom TOML definition or as a per-spawn arg. + let skill_filter = None; + + // Category filter: the SkillsAgent archetype is *only* allowed to use + // skill-bridged tools. Every other archetype is free to use system + // tools (their tool whitelists further narrow the actual set). + let category_filter = match arch { + AgentArchetype::SkillsAgent => Some(crate::openhuman::tools::ToolCategory::Skill), + _ => None, + }; + + // Sub-agents always run with the cheaper, narrower archetype model + // hint. Use `ModelSpec::Inherit` if you want them to share the parent's + // pinned model — see the `fork` synthetic definition below. + let model = ModelSpec::Hint(arch.default_model_hint().to_string()); + + let sandbox_mode = match arch.sandbox_mode() { + "sandboxed" => SandboxMode::Sandboxed, + "read_only" => SandboxMode::ReadOnly, + _ => SandboxMode::None, + }; + + // Code executor / tool maker / skills agent need the safety preamble + // (they actually touch the world). Pure read-only roles strip it. + let omit_safety_preamble = !matches!( + arch, + AgentArchetype::CodeExecutor | AgentArchetype::ToolMaker | AgentArchetype::SkillsAgent + ); + + AgentDefinition { + id, + when_to_use, + display_name, + system_prompt, + omit_identity: true, + omit_memory_context: true, + omit_safety_preamble, + omit_skills_catalog: true, + model, + temperature: 0.4, + tools, + disallowed_tools: vec![], + skill_filter, + category_filter, + max_iterations: arch.default_max_iterations(), + timeout_secs: None, + sandbox_mode, + background: arch.is_background(), + uses_fork_context: false, + source: DefinitionSource::Builtin, + } +} + +/// The synthetic `fork` definition. Tells the runner to bypass normal +/// prompt construction and replay the parent's exact rendered system +/// prompt + tool schemas + message prefix from +/// [`super::fork_context::ForkContext`]. The OpenAI-compatible backend's +/// automatic prefix caching turns this into a real token win. +pub fn fork_definition() -> AgentDefinition { + AgentDefinition { + id: "fork".into(), + when_to_use: "Spawn a parallel sub-task that shares the parent's full system \ + prompt, tool set, and message history byte-for-byte. Use when \ + decomposing a task into independent parallel work streams that \ + benefit from prefix-cache reuse on the inference backend." + .into(), + display_name: Some("Fork".into()), + // Prompt source is irrelevant — the runner reads from ForkContext. + system_prompt: PromptSource::Inline(String::new()), + // Fork preserves bytes — DO NOT strip anything from the parent's prompt. + omit_identity: false, + omit_memory_context: false, + omit_safety_preamble: false, + omit_skills_catalog: false, + model: ModelSpec::Inherit, + // Inherit the parent's temperature too — set to a sentinel that the + // runner replaces with the parent's actual temp at spawn time. + // (We use 0.7 here as a safe default for documentation; the runner + // overrides it from `ParentExecutionContext::temperature`.) + temperature: 0.7, + tools: ToolScope::Wildcard, + disallowed_tools: vec![], + skill_filter: None, + category_filter: None, + // Fork inherits the parent's max iterations from the runtime. + max_iterations: 15, + timeout_secs: None, + sandbox_mode: SandboxMode::None, + background: false, + uses_fork_context: true, + source: DefinitionSource::Builtin, + } +} + +/// Returns the bundled markdown body for an archetype's system prompt. +/// +/// Files are baked into the binary via `include_str!` at compile time so +/// built-in sub-agents always have a prompt to work with, even when the +/// workspace `agent/prompts/` directory doesn't exist or has been +/// modified. +fn archetype_prompt_body(arch: AgentArchetype) -> &'static str { + match arch { + AgentArchetype::Orchestrator => include_str!("../prompts/ORCHESTRATOR.md"), + AgentArchetype::Planner => include_str!("../prompts/PLANNER.md"), + AgentArchetype::CodeExecutor => include_str!("../prompts/archetypes/code_executor.md"), + AgentArchetype::SkillsAgent => include_str!("../prompts/archetypes/skills_agent.md"), + // ToolMaker shares the code_executor prompt — both write code in + // a sandbox; ToolMaker's bounded scope is enforced via + // `max_iterations` and `disallowed_tools`. + AgentArchetype::ToolMaker => include_str!("../prompts/archetypes/code_executor.md"), + AgentArchetype::Researcher => include_str!("../prompts/archetypes/researcher.md"), + AgentArchetype::Critic => include_str!("../prompts/archetypes/critic.md"), + AgentArchetype::Archivist => include_str!("../prompts/archetypes/archivist.md"), + } +} + +fn when_to_use_for(arch: AgentArchetype) -> &'static str { + match arch { + AgentArchetype::Orchestrator => { + "Staff Engineer — routes, judges quality, synthesises. Never writes code itself. \ + You should not normally spawn another orchestrator from inside one." + } + AgentArchetype::Planner => { + "Architect — break a complex task into a small DAG of subtasks with \ + explicit acceptance criteria. Read-only; produces JSON, not code." + } + AgentArchetype::CodeExecutor => { + "Sandboxed developer — writes, runs, and debugs code until tests pass. \ + Use for any task that requires producing or modifying source files \ + and exercising them with shell or test commands." + } + AgentArchetype::SkillsAgent => { + "Skill tool specialist — executes installed QuickJS skill tools \ + (Notion, Gmail, …). Use when the task should be completed via a \ + user-installed skill rather than raw HTTP/file I/O. Pair with a \ + `skill_filter` argument to scope to a single skill." + } + AgentArchetype::ToolMaker => { + "Self-healer — writes a polyfill script when a required command is \ + missing on the host. Very narrow scope; max 2 iterations." + } + AgentArchetype::Researcher => { + "Web & docs crawler — reads real documentation, compresses to dense \ + markdown. Use for any task that requires looking up external knowledge." + } + AgentArchetype::Critic => { + "Adversarial reviewer — reviews diffs and code against project rules, \ + flags vulnerabilities, regressions, and missing tests. Read-only." + } + AgentArchetype::Archivist => { + "Background librarian — extracts lessons from a completed session, \ + updates MEMORY.md, and indexes to FTS5. Runs cheap and slow." + } + } +} + +fn display_name_for(arch: AgentArchetype) -> &'static str { + match arch { + AgentArchetype::Orchestrator => "Orchestrator", + AgentArchetype::Planner => "Planner", + AgentArchetype::CodeExecutor => "Code Executor", + AgentArchetype::SkillsAgent => "Skills Agent", + AgentArchetype::ToolMaker => "Tool Maker", + AgentArchetype::Researcher => "Researcher", + AgentArchetype::Critic => "Critic", + AgentArchetype::Archivist => "Archivist", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_definitions_present() { + let defs = all(); + // 8 archetypes + 1 synthetic `fork`. + assert_eq!(defs.len(), 9); + } + + #[test] + fn each_archetype_yields_an_id() { + for arch in AgentArchetype::all() { + let def = from_archetype(*arch); + assert_eq!(def.id, arch.to_string()); + assert!(!def.when_to_use.is_empty()); + assert_eq!(def.source, DefinitionSource::Builtin); + } + } + + #[test] + fn code_executor_keeps_safety_preamble() { + let def = from_archetype(AgentArchetype::CodeExecutor); + assert!(!def.omit_safety_preamble); + } + + #[test] + fn critic_strips_safety_preamble() { + let def = from_archetype(AgentArchetype::Critic); + assert!(def.omit_safety_preamble); + } + + #[test] + fn skills_agent_uses_wildcard_tools() { + let def = from_archetype(AgentArchetype::SkillsAgent); + assert!(matches!(def.tools, ToolScope::Wildcard)); + } + + #[test] + fn code_executor_uses_named_tools() { + let def = from_archetype(AgentArchetype::CodeExecutor); + match def.tools { + ToolScope::Named(tools) => { + assert!(tools.iter().any(|t| t == "shell")); + assert!(tools.iter().any(|t| t == "file_write")); + } + ToolScope::Wildcard => panic!("expected named tools for code_executor"), + } + } + + #[test] + fn fork_definition_has_uses_fork_context_true() { + let def = fork_definition(); + assert_eq!(def.id, "fork"); + assert!(def.uses_fork_context); + assert!(matches!(def.model, ModelSpec::Inherit)); + assert!(matches!(def.tools, ToolScope::Wildcard)); + // Fork preserves bytes — must NOT strip anything. + assert!(!def.omit_identity); + assert!(!def.omit_memory_context); + assert!(!def.omit_safety_preamble); + assert!(!def.omit_skills_catalog); + } + + #[test] + fn archetype_max_iterations_is_propagated() { + let critic = from_archetype(AgentArchetype::Critic); + assert_eq!(critic.max_iterations, 5); + let tool_maker = from_archetype(AgentArchetype::ToolMaker); + assert_eq!(tool_maker.max_iterations, 2); + } + + #[test] + fn sandbox_modes_map_correctly() { + assert_eq!( + from_archetype(AgentArchetype::CodeExecutor).sandbox_mode, + SandboxMode::Sandboxed + ); + assert_eq!( + from_archetype(AgentArchetype::Critic).sandbox_mode, + SandboxMode::ReadOnly + ); + assert_eq!( + from_archetype(AgentArchetype::Researcher).sandbox_mode, + SandboxMode::None + ); + } +} diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs new file mode 100644 index 000000000..7f222e004 --- /dev/null +++ b/src/openhuman/agent/harness/definition.rs @@ -0,0 +1,477 @@ +//! Data-driven agent definitions. +//! +//! An [`AgentDefinition`] fully specifies a sub-agent: its core prompt, model, +//! allowed tool set, runtime limits, and which sections of the parent system +//! prompt to omit. Built-in definitions are derived from +//! [`super::archetypes::AgentArchetype`] in +//! [`super::builtin_definitions`]; users can ship custom definitions as TOML +//! files under `$OPENHUMAN_WORKSPACE/agents/*.toml` (with a fallback to +//! `~/.openhuman/agents/*.toml` for user-global specialists) which override +//! built-ins on id collision. See [`super::definition_loader`] for the +//! directory scan + TOML parsing contract. +//! +//! Sub-agents are dispatched at runtime by the `spawn_subagent` tool, which +//! looks up an [`AgentDefinition`] by id in the global +//! [`AgentDefinitionRegistry`] and hands it to +//! [`super::subagent_runner::run_subagent`]. +//! +//! This file intentionally has zero references to the rest of the agent +//! runtime — it is pure data so the model can be unit-tested in isolation +//! and serialised straight from disk. + +use crate::openhuman::tools::ToolCategory; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +// ───────────────────────────────────────────────────────────────────────────── +// Agent definition +// ───────────────────────────────────────────────────────────────────────────── + +/// A fully specified sub-agent: what it knows, what it can do, how to prompt it. +/// +/// Built-ins live in [`super::builtin_definitions`]; custom ones load from +/// TOML at startup. The [`AgentDefinitionRegistry`] merges them and is the +/// single source of truth that `SpawnSubagentTool` queries. +/// +/// All `omit_*` flags default to `true` for sub-agents — sub-agents are +/// narrow specialists and pay no token tax for the parent's identity, +/// memory, safety, or skills sections. Override per-archetype if a +/// section is needed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentDefinition { + // ── identity ──────────────────────────────────────────────────────── + /// Unique id, referenced from `spawn_subagent { agent_id: "…" }`. + /// Convention: snake_case (e.g. `code_executor`, `notion_specialist`). + pub id: String, + + /// One-line description shown in the orchestrator's `spawn_subagent` + /// tool schema so the parent model knows when to delegate to this agent. + pub when_to_use: String, + + /// Optional display name for UI/logs. Falls back to `id`. + #[serde(default)] + pub display_name: Option, + + // ── prompt ────────────────────────────────────────────────────────── + /// Source of the sub-agent's core system prompt. Inline for TOML-defined + /// agents, or a path to a file under `agent/prompts/` for built-ins. + pub system_prompt: PromptSource, + + /// Sections of the main agent's prompt to strip when this sub-agent runs. + /// Defaults to `true` (strip) — sub-agents are narrow and don't need the + /// parent's identity scaffolding. + #[serde(default = "defaults::true_")] + pub omit_identity: bool, + #[serde(default = "defaults::true_")] + pub omit_memory_context: bool, + #[serde(default = "defaults::true_")] + pub omit_safety_preamble: bool, + #[serde(default = "defaults::true_")] + pub omit_skills_catalog: bool, + + // ── model ─────────────────────────────────────────────────────────── + /// Model selection: inherit parent, hint to router, or pinned name. + #[serde(default)] + pub model: ModelSpec, + + /// Sampling temperature. Sub-agents default to `0.4` for precision. + #[serde(default = "defaults::subagent_temperature")] + pub temperature: f64, + + // ── tools ─────────────────────────────────────────────────────────── + /// Either [`ToolScope::Wildcard`] (all tools the parent has) or + /// [`ToolScope::Named`] (an explicit allowlist). + #[serde(default)] + pub tools: ToolScope, + + /// Tools that are explicitly banned even if `tools == Wildcard`. + /// Built-ins default-deny dangerous ops for read-only archetypes. + #[serde(default)] + pub disallowed_tools: Vec, + + /// If set, the resolved tool list is further filtered to only those whose + /// name starts with `{skill_filter}__`. Gives us per-API specialists + /// (Notion, Gmail, …) without enum variants. Overridable per-spawn. + #[serde(default)] + pub skill_filter: Option, + + /// If set, the resolved tool list is restricted to tools whose + /// [`crate::openhuman::tools::Tool::category`] matches this value. + /// This is the *primary* mechanism the orchestrator uses to spawn + /// dedicated tool-execution sub-agents: + /// - `Some(Skill)` → sub-agent only sees skill-bridge tools + /// (Notion, Gmail, Telegram, …). Pair with `ModelSpec::Hint("agentic")` + /// to route to the backend's agentic model. + /// - `Some(System)` → sub-agent only sees built-in Rust tools. + /// - `None` (default) → no category restriction; `tools` / + /// `disallowed_tools` / `skill_filter` still apply. + /// + /// Category filtering happens *before* the `tools`/`disallowed_tools` + /// scope check, so a `Named` scope is a stricter-intersection override. + #[serde(default)] + pub category_filter: Option, + + // ── runtime limits ────────────────────────────────────────────────── + /// Maximum tool-call iterations per spawn. Sub-agents default to a + /// shorter cap than the parent to keep cost bounded. + #[serde(default = "defaults::max_iterations")] + pub max_iterations: usize, + + /// Hard wall-clock timeout per turn. `None` falls back to + /// `tool_execution_timeout_secs`. + #[serde(default)] + pub timeout_secs: Option, + + /// `none` / `read_only` / `sandboxed`. Mirrors + /// [`super::archetypes::AgentArchetype::sandbox_mode`]. + #[serde(default)] + pub sandbox_mode: SandboxMode, + + /// If true, spawn runs in the background and the call returns + /// immediately with a placeholder. Reserved — not yet wired in v1. + #[serde(default)] + pub background: bool, + + /// Marker: when true, the runner skips its normal prompt-building path + /// and uses the parent's pre-rendered prompt + tool schemas + message + /// prefix from the [`super::fork_context::ForkContext`] task-local. + /// Only the synthetic built-in `fork` definition has this set. + #[serde(default, skip_serializing_if = "is_false")] + pub uses_fork_context: bool, + + // ── source bookkeeping ────────────────────────────────────────────── + /// Where this definition came from. Filled in by the loader/builder; + /// not deserialised from TOML. + #[serde(skip)] + pub source: DefinitionSource, +} + +fn is_false(b: &bool) -> bool { + !b +} + +impl AgentDefinition { + /// Display name with fallback to id. + pub fn display_name(&self) -> &str { + self.display_name.as_deref().unwrap_or(&self.id) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Prompt source +// ───────────────────────────────────────────────────────────────────────────── + +/// Where the sub-agent's core system prompt comes from. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PromptSource { + /// Inline prompt string (custom TOML-defined agents). + Inline(String), + /// Relative path under the workspace's `prompts/` directory or under + /// `src/openhuman/agent/prompts/` for built-ins. Resolved by the runner + /// at spawn time. + File { path: String }, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Model spec +// ───────────────────────────────────────────────────────────────────────────── + +/// Model selection for a sub-agent. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ModelSpec { + /// Use the parent agent's currently-selected model at spawn time. + #[default] + Inherit, + /// Exact model name (e.g. `"neocortex-mk1"`). + Exact(String), + /// Router hint (e.g. `"reasoning"`, `"coding"`, `"local"`). Resolved + /// to a real model by the routing provider. + Hint(String), +} + +impl ModelSpec { + /// Resolve this spec into the model name string the provider expects. + /// `parent_model` is the model the parent agent is using right now. + pub fn resolve(&self, parent_model: &str) -> String { + match self { + Self::Inherit => parent_model.to_string(), + Self::Exact(name) => name.clone(), + Self::Hint(hint) => format!("hint:{hint}"), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tool scope +// ───────────────────────────────────────────────────────────────────────────── + +/// Which tools a sub-agent is allowed to call. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ToolScope { + /// All tools the parent has (subject to `disallowed_tools` and + /// `skill_filter`). + #[default] + Wildcard, + /// An explicit allowlist of tool names. Names not present in the parent + /// registry at spawn time are silently dropped (logged at debug). + Named(Vec), +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sandbox mode +// ───────────────────────────────────────────────────────────────────────────── + +/// Sandbox mode for a sub-agent's tool execution. Mirrors the existing +/// [`super::archetypes::AgentArchetype::sandbox_mode`] string for now; +/// in the future this may map directly into a `SecurityPolicy` builder. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SandboxMode { + /// No additional sandboxing beyond what the parent already enforces. + #[default] + None, + /// Read-only — write/execute tools are filtered out. + ReadOnly, + /// Drop privileges, restrict filesystem (Landlock / Bubblewrap). + Sandboxed, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Definition source +// ───────────────────────────────────────────────────────────────────────────── + +/// Where an [`AgentDefinition`] was loaded from. Used for telemetry and +/// the `agent::list_definitions` RPC reply. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(tag = "kind", content = "path")] +pub enum DefinitionSource { + /// Built-in derived from an [`super::archetypes::AgentArchetype`]. + #[default] + Builtin, + /// Loaded from a TOML file at the given absolute path. + File(PathBuf), +} + +// ───────────────────────────────────────────────────────────────────────────── +// Defaults module — referenced by `#[serde(default = ...)]` +// ───────────────────────────────────────────────────────────────────────────── + +pub(crate) mod defaults { + pub(crate) fn true_() -> bool { + true + } + + pub(crate) fn subagent_temperature() -> f64 { + 0.4 + } + + pub(crate) fn max_iterations() -> usize { + 8 + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Registry +// ───────────────────────────────────────────────────────────────────────────── + +use anyhow::Result; +use std::collections::HashMap; +use std::path::Path; +use std::sync::OnceLock; + +/// In-memory registry of all known [`AgentDefinition`]s. +/// +/// One singleton instance is initialised at startup via +/// [`AgentDefinitionRegistry::init_global`]. Built-ins are registered +/// unconditionally; custom TOML definitions (if a workspace is provided) +/// are loaded next and override built-ins on `id` collision. +#[derive(Debug, Default)] +pub struct AgentDefinitionRegistry { + by_id: HashMap, + /// Insertion-stable order for predictable `list()` output. + order: Vec, +} + +static GLOBAL: OnceLock = OnceLock::new(); + +impl AgentDefinitionRegistry { + /// Build a registry containing only the built-in definitions + /// (no TOML loading). Useful for tests. + pub fn builtins_only() -> Self { + let mut reg = Self::default(); + for def in super::builtin_definitions::all() { + reg.insert(def); + } + reg + } + + /// Build a registry containing built-ins plus any custom TOML + /// definitions found under `/agents/*.toml` (and the + /// `~/.openhuman/agents/*.toml` fallback). Custom definitions + /// override built-ins on `id` collision. Files that fail to parse + /// are logged and skipped rather than aborting startup. + pub fn load(workspace: &Path) -> Result { + let mut reg = Self::builtins_only(); + let custom = super::definition_loader::load_from_workspace(workspace)?; + for def in custom { + tracing::info!( + id = %def.id, + source = ?def.source, + "[agent_defs] loaded custom definition (overrides any built-in with the same id)" + ); + reg.insert(def); + } + Ok(reg) + } + + /// Insert (or replace) a definition by id. + pub fn insert(&mut self, def: AgentDefinition) { + let id = def.id.clone(); + if self.by_id.insert(id.clone(), def).is_none() { + self.order.push(id); + } + } + + /// Look up a definition by id. + pub fn get(&self, id: &str) -> Option<&AgentDefinition> { + self.by_id.get(id) + } + + /// All definitions, in insertion order. + pub fn list(&self) -> Vec<&AgentDefinition> { + self.order + .iter() + .filter_map(|id| self.by_id.get(id)) + .collect() + } + + /// Number of registered definitions. + pub fn len(&self) -> usize { + self.by_id.len() + } + + /// True when the registry has no definitions. + pub fn is_empty(&self) -> bool { + self.by_id.is_empty() + } + + // ── singleton API ────────────────────────────────────────────────── + + /// Initialise the global registry. Subsequent calls are no-ops (the + /// `OnceLock` only fires once); use [`Self::reload_global`] to refresh + /// custom definitions during development. + pub fn init_global(workspace: &Path) -> Result<()> { + let registry = Self::load(workspace)?; + match GLOBAL.set(registry) { + Ok(()) => { + tracing::info!( + "[agent_defs] global registry initialised with {} definitions", + GLOBAL.get().map(|r| r.len()).unwrap_or(0) + ); + Ok(()) + } + Err(_) => { + tracing::debug!("[agent_defs] global registry already initialised; ignoring"); + Ok(()) + } + } + } + + /// Initialise the global registry with builtins only (no workspace + /// scan). Used by tests and by callers that don't have a workspace. + pub fn init_global_builtins() -> Result<()> { + let registry = Self::builtins_only(); + let _ = GLOBAL.set(registry); + Ok(()) + } + + /// Borrow the global registry, if initialised. + pub fn global() -> Option<&'static Self> { + GLOBAL.get() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_def(id: &str) -> AgentDefinition { + AgentDefinition { + id: id.into(), + when_to_use: "test".into(), + display_name: None, + system_prompt: PromptSource::Inline("system".into()), + omit_identity: true, + omit_memory_context: true, + omit_safety_preamble: true, + omit_skills_catalog: true, + model: ModelSpec::Inherit, + temperature: 0.4, + tools: ToolScope::Wildcard, + disallowed_tools: vec![], + skill_filter: None, + category_filter: None, + max_iterations: 8, + timeout_secs: None, + sandbox_mode: SandboxMode::None, + background: false, + uses_fork_context: false, + source: DefinitionSource::Builtin, + } + } + + #[test] + fn registry_insert_and_lookup() { + let mut reg = AgentDefinitionRegistry::default(); + reg.insert(make_def("alpha")); + reg.insert(make_def("beta")); + assert_eq!(reg.len(), 2); + assert!(reg.get("alpha").is_some()); + assert!(reg.get("beta").is_some()); + assert!(reg.get("missing").is_none()); + } + + #[test] + fn registry_replace_preserves_order() { + let mut reg = AgentDefinitionRegistry::default(); + reg.insert(make_def("alpha")); + reg.insert(make_def("beta")); + let mut updated = make_def("alpha"); + updated.when_to_use = "replaced".into(); + reg.insert(updated); + + let list: Vec<&str> = reg.list().iter().map(|d| d.id.as_str()).collect(); + assert_eq!(list, vec!["alpha", "beta"]); + assert_eq!(reg.get("alpha").unwrap().when_to_use, "replaced"); + } + + #[test] + fn model_spec_resolve_inherit_uses_parent() { + let spec = ModelSpec::Inherit; + assert_eq!(spec.resolve("parent-model"), "parent-model"); + } + + #[test] + fn model_spec_resolve_exact_uses_name() { + let spec = ModelSpec::Exact("kimi-k2".into()); + assert_eq!(spec.resolve("parent-model"), "kimi-k2"); + } + + #[test] + fn model_spec_resolve_hint_prefixes_router_marker() { + let spec = ModelSpec::Hint("coding".into()); + assert_eq!(spec.resolve("parent-model"), "hint:coding"); + } + + #[test] + fn display_name_falls_back_to_id() { + let def = make_def("alpha"); + assert_eq!(def.display_name(), "alpha"); + let mut def2 = make_def("beta"); + def2.display_name = Some("Beta Specialist".into()); + assert_eq!(def2.display_name(), "Beta Specialist"); + } +} diff --git a/src/openhuman/agent/harness/definition_loader.rs b/src/openhuman/agent/harness/definition_loader.rs new file mode 100644 index 000000000..e5e4d17bd --- /dev/null +++ b/src/openhuman/agent/harness/definition_loader.rs @@ -0,0 +1,240 @@ +//! Loads custom [`AgentDefinition`] files from disk. +//! +//! Custom definitions live as TOML files under `/agents/*.toml`, +//! with a fallback to `~/.openhuman/agents/*.toml` for user-global +//! specialists. Each file defines exactly one definition. +//! +//! TOML (rather than YAML) is used for consistency with the rest of +//! OpenHuman's config system, which already depends on the `toml` crate +//! and uses TOML for its main config file. +//! +//! The loader is intentionally lenient: it logs and skips files that fail +//! to parse rather than aborting startup, so a single broken specialist +//! never breaks the rest of the system. + +use super::definition::{AgentDefinition, DefinitionSource}; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Load all custom definitions from `/agents/` and the +/// `~/.openhuman/agents/` fallback. Returns an empty Vec when neither +/// directory exists. +pub fn load_from_workspace(workspace: &Path) -> Result> { + let mut out = Vec::new(); + let mut seen_dirs: Vec = Vec::new(); + + let workspace_dir = workspace.join("agents"); + if workspace_dir.is_dir() { + load_dir(&workspace_dir, &mut out)?; + seen_dirs.push(workspace_dir); + } + + if let Some(home_dir) = user_home_agents_dir() { + if home_dir.is_dir() && !seen_dirs.contains(&home_dir) { + load_dir(&home_dir, &mut out)?; + } + } + + Ok(out) +} + +/// Load every `.toml` file in a single directory (non-recursive). Files +/// that fail to parse are logged and skipped. +pub fn load_dir(dir: &Path, out: &mut Vec) -> Result<()> { + let entries = + fs::read_dir(dir).with_context(|| format!("reading agents dir {}", dir.display()))?; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(err) => { + tracing::warn!( + dir = %dir.display(), + error = %err, + "[agent_defs] failed to read directory entry, skipping" + ); + continue; + } + }; + + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + continue; + }; + if ext != "toml" { + continue; + } + + match load_file(&path) { + Ok(def) => { + tracing::debug!( + id = %def.id, + path = %path.display(), + "[agent_defs] loaded custom definition" + ); + out.push(def); + } + Err(err) => { + tracing::warn!( + path = %path.display(), + error = %err, + "[agent_defs] failed to load custom definition, skipping" + ); + } + } + } + Ok(()) +} + +/// Load a single TOML file as an [`AgentDefinition`]. Stamps `source` to +/// the absolute path. +pub fn load_file(path: &Path) -> Result { + let content = + fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let mut def: AgentDefinition = toml::from_str(&content) + .with_context(|| format!("parsing {} as AgentDefinition TOML", path.display()))?; + def.source = DefinitionSource::File(path.to_path_buf()); + Ok(def) +} + +fn user_home_agents_dir() -> Option { + // Honour OPENHUMAN_HOME first if set; otherwise ~/.openhuman. + if let Ok(custom) = std::env::var("OPENHUMAN_HOME") { + return Some(PathBuf::from(custom).join("agents")); + } + dirs::home_dir().map(|h| h.join(".openhuman").join("agents")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_toml(path: &Path, contents: &str) { + let mut f = fs::File::create(path).unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + } + + fn fresh_workspace() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + // NOTE: TOML parsing is positional. Top-level scalars MUST come + // before any `[table]` header — once a header opens, every line + // below it lives inside that table. + const NOTION_TOML: &str = r#" +id = "notion_specialist" +when_to_use = "Delegate Notion queries to a focused specialist." +display_name = "Notion Specialist" +temperature = 0.4 +skill_filter = "notion" +max_iterations = 5 + +[system_prompt] +inline = "You are the Notion specialist. Use only Notion tools." + +[model] +hint = "agentic" +"#; + + #[test] + fn loads_single_definition_from_workspace() { + let ws = fresh_workspace(); + let agents_dir = ws.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + write_toml(&agents_dir.join("notion.toml"), NOTION_TOML); + + let defs = load_from_workspace(ws.path()).unwrap(); + assert_eq!(defs.len(), 1); + let def = &defs[0]; + assert_eq!(def.id, "notion_specialist"); + assert_eq!(def.skill_filter.as_deref(), Some("notion")); + assert_eq!(def.max_iterations, 5); + assert!(matches!(def.source, DefinitionSource::File(_))); + } + + #[test] + fn empty_when_no_agents_dir() { + let ws = fresh_workspace(); + let defs = load_from_workspace(ws.path()).unwrap(); + assert!(defs.is_empty()); + } + + #[test] + fn ignores_non_toml_files() { + let ws = fresh_workspace(); + let agents_dir = ws.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + write_toml(&agents_dir.join("readme.md"), "not a definition"); + write_toml(&agents_dir.join("notion.toml"), NOTION_TOML); + + let defs = load_from_workspace(ws.path()).unwrap(); + assert_eq!(defs.len(), 1); + } + + #[test] + fn skips_malformed_files_without_aborting() { + let ws = fresh_workspace(); + let agents_dir = ws.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + write_toml(&agents_dir.join("broken.toml"), "id = \"broken\" [oops"); + write_toml(&agents_dir.join("notion.toml"), NOTION_TOML); + + let defs = load_from_workspace(ws.path()).unwrap(); + // The broken file is skipped; the valid one still loads. + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].id, "notion_specialist"); + } + + #[test] + fn registry_load_merges_builtins_and_custom() { + let ws = fresh_workspace(); + let agents_dir = ws.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + write_toml(&agents_dir.join("notion.toml"), NOTION_TOML); + + let reg = super::super::definition::AgentDefinitionRegistry::load(ws.path()).unwrap(); + // The built-in set is allowed to grow over time (new archetypes, + // additional synthetic definitions), so assert presence of the + // specific ids we care about rather than a fixed total count. + assert!( + reg.len() > 1, + "expected at least one built-in plus the custom definition" + ); + assert!(reg.get("notion_specialist").is_some()); + assert!(reg.get("code_executor").is_some()); + assert!(reg.get("fork").is_some()); + } + + #[test] + fn custom_definition_overrides_same_id_builtin() { + let ws = fresh_workspace(); + let agents_dir = ws.path().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + // Override the built-in `code_executor` with a custom one. + write_toml( + &agents_dir.join("code_executor.toml"), + r#" +id = "code_executor" +when_to_use = "CUSTOM OVERRIDE" + +[system_prompt] +inline = "custom prompt" + +[tools] +wildcard = {} +"#, + ); + + let reg = super::super::definition::AgentDefinitionRegistry::load(ws.path()).unwrap(); + // Still 9 — same id replaced built-in in place. + assert_eq!(reg.len(), 9); + let def = reg.get("code_executor").unwrap(); + assert_eq!(def.when_to_use, "CUSTOM OVERRIDE"); + assert!(matches!(def.source, DefinitionSource::File(_))); + } +} diff --git a/src/openhuman/agent/harness/executor.rs b/src/openhuman/agent/harness/executor.rs index c9d33111c..bd875134b 100644 --- a/src/openhuman/agent/harness/executor.rs +++ b/src/openhuman/agent/harness/executor.rs @@ -232,6 +232,22 @@ async fn plan_tasks( } /// Execute all tasks in a single DAG level concurrently. +/// +/// Each task is dispatched through the unified +/// [`super::subagent_runner::run_subagent`] helper, which: +/// - Looks up the built-in [`super::definition::AgentDefinition`] for +/// the node's archetype. +/// - Resolves model + tool filtering + narrow prompt construction. +/// - Runs the sub-agent's inner tool-call loop using the parent's +/// provider via the [`super::fork_context::PARENT_CONTEXT`] task-local +/// that the orchestrator sets up earlier in +/// [`super::subagent_runner`]. +/// +/// Per-archetype overrides from +/// [`crate::openhuman::config::OrchestratorConfig::archetypes`] (model, +/// temperature, max_tool_iterations, timeout_secs, sandbox) are layered +/// on top of the built-in definition before dispatch. +#[allow(clippy::too_many_arguments)] async fn execute_level( dag: &TaskDag, task_ids: &[String], @@ -239,7 +255,7 @@ async fn execute_level( _config: &Config, _provider: &dyn Provider, _memory: Arc, - session_id: &str, + _session_id: &str, semaphore: Arc, ) -> Vec { let mut join_set: JoinSet = JoinSet::new(); @@ -254,9 +270,7 @@ async fn execute_level( let description = node.description.clone(); let acceptance = node.acceptance_criteria.clone(); let tid = task_id.clone(); - let _sid = session_id.to_string(); - let model = resolve_model(archetype, orch_config); - let _temperature = resolve_temperature(archetype, orch_config); + let semaphore_clone = semaphore.clone(); let timeout = resolve_timeout(archetype, orch_config); // Collect context from completed dependencies. @@ -270,24 +284,16 @@ async fn execute_level( }) .collect(); - let _prompt = if dep_context.is_empty() { - format!("Task: {description}\n\nAcceptance criteria: {acceptance}") - } else { - format!( - "Context from prior tasks:\n{dep_context}\n\ - Task: {description}\n\nAcceptance criteria: {acceptance}" - ) - }; + // Build the sub-agent prompt with the task description and + // acceptance criteria; dependency context (if any) flows + // through the runner's `SubagentRunOptions::context` field. + let prompt = format!("Task: {description}\n\nAcceptance criteria: {acceptance}"); + let context_blob = (!dep_context.is_empty()).then_some(dep_context); - // Each sub-agent runs as a single-shot provider call for now. - // Phase 3 will upgrade this to full tool-loop sub-agents. - let _system_prompt = format!( - "You are the {archetype} agent. Complete the assigned task precisely.\n\ - Do not deviate from the task description. Be concise." - ); - let model_clone = model.clone(); - let _timeout = timeout; - let semaphore_clone = semaphore.clone(); + // Resolve the built-in definition for this archetype, layered + // with any per-archetype config overrides. + let mut definition = super::builtin_definitions::from_archetype(archetype); + apply_archetype_overrides(&mut definition, archetype, orch_config); join_set.spawn(async move { let _permit = semaphore_clone @@ -296,25 +302,65 @@ async fn execute_level( .expect("semaphore closed"); let start = Instant::now(); - // For now, sub-agents use a simple prompt (no tool loop). - // This will be upgraded when archetype-specific tool subsets are wired. - let result_text = format!( - "[placeholder — no execution] {archetype} sub-agent would execute here\n\ - Task: {description}\nModel: {model_clone}\nTimeout: {timeout:?}" - ); + let options = super::subagent_runner::SubagentRunOptions { + skill_filter_override: None, + category_filter_override: None, + context: context_blob, + task_id: Some(tid.clone()), + }; + + let outcome_fut = super::subagent_runner::run_subagent(&definition, &prompt, options); + let outcome = match tokio::time::timeout(timeout, outcome_fut).await { + Ok(Ok(out)) => out, + Ok(Err(err)) => { + tracing::warn!( + task_id = %tid, + archetype = %archetype, + error = %err, + "[orchestrator] sub-agent failed" + ); + return SubAgentResult { + task_id: tid, + success: false, + output: format!("sub-agent failed: {err}"), + artifacts: Vec::new(), + cost_microdollars: 0, + duration: start.elapsed(), + }; + } + Err(_) => { + tracing::warn!( + task_id = %tid, + archetype = %archetype, + timeout_secs = timeout.as_secs(), + "[orchestrator] sub-agent timed out" + ); + return SubAgentResult { + task_id: tid, + success: false, + output: format!("sub-agent timed out after {} seconds", timeout.as_secs()), + artifacts: Vec::new(), + cost_microdollars: 0, + duration: start.elapsed(), + }; + } + }; tracing::debug!( - "[orchestrator] sub-agent {archetype} placeholder task {tid} in {:?}", - start.elapsed() + task_id = %outcome.task_id, + archetype = %archetype, + iterations = outcome.iterations, + output_chars = outcome.output.chars().count(), + "[orchestrator] sub-agent completed" ); SubAgentResult { - task_id: tid, - success: false, - output: result_text, + task_id: outcome.task_id, + success: true, + output: outcome.output, artifacts: Vec::new(), cost_microdollars: 0, - duration: start.elapsed(), + duration: outcome.elapsed, } }); } @@ -331,6 +377,52 @@ async fn execute_level( results } +/// Apply per-archetype config overrides on top of a built-in +/// [`super::definition::AgentDefinition`]. +fn apply_archetype_overrides( + definition: &mut super::definition::AgentDefinition, + archetype: AgentArchetype, + orch_config: &OrchestratorConfig, +) { + let key = archetype.to_string(); + let Some(over) = orch_config.archetypes.get(&key) else { + return; + }; + if let Some(model) = over.model.as_ref() { + // The override is a raw model name — store as Exact so the + // runner uses it verbatim regardless of the parent's model. + definition.model = super::definition::ModelSpec::Exact(model.clone()); + } + if let Some(temperature) = over.temperature { + definition.temperature = temperature; + } + if let Some(max_iter) = over.max_tool_iterations { + definition.max_iterations = max_iter; + } + if let Some(secs) = over.timeout_secs { + definition.timeout_secs = Some(secs); + } + if let Some(sb) = over.sandbox.as_deref() { + definition.sandbox_mode = match sb { + "sandboxed" => super::definition::SandboxMode::Sandboxed, + "read_only" => super::definition::SandboxMode::ReadOnly, + "none" | "" => super::definition::SandboxMode::None, + other => { + tracing::warn!( + archetype = %archetype, + definition_id = %definition.id, + value = %other, + "[orchestrator] unknown sandbox override — falling back to SandboxMode::None. Expected one of: sandboxed, read_only, none" + ); + super::definition::SandboxMode::None + } + }; + } + if let Some(prompt_override) = over.system_prompt.as_ref() { + definition.system_prompt = super::definition::PromptSource::Inline(prompt_override.clone()); + } +} + /// The Orchestrator reviews results from a completed level and decides next action. async fn review_level( dag: &TaskDag, diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs new file mode 100644 index 000000000..d21e1f813 --- /dev/null +++ b/src/openhuman/agent/harness/fork_context.rs @@ -0,0 +1,325 @@ +//! Task-local plumbing that lets `SpawnSubagentTool` reach the parent +//! agent's runtime context (provider, tools, model, …) without widening +//! the [`crate::openhuman::tools::Tool`] trait. +//! +//! Two distinct task-locals live here: +//! +//! 1. [`PARENT_CONTEXT`] — set by the parent [`crate::openhuman::agent::Agent`] +//! around its `turn` so that any tool executing inside that turn (in +//! particular `spawn_subagent`) can read the parent's provider, tool +//! list, and model information. +//! +//! 2. [`FORK_CONTEXT`] — set only when the parent dispatches a `fork`-mode +//! sub-agent. Carries the parent's *exact* rendered system prompt, tool +//! schemas, and message prefix so the forked child can replay the same +//! bytes and the inference backend's automatic prefix caching kicks in. +//! +//! Both contexts are stashed in `Arc`s so that cloning into the child +//! costs a refcount bump rather than a full copy. + +use crate::openhuman::config::{AgentConfig, IdentityConfig}; +use crate::openhuman::memory::Memory; +use crate::openhuman::providers::{ChatMessage, Provider}; +use crate::openhuman::skills::Skill; +use crate::openhuman::tools::{Tool, ToolSpec}; +use std::path::PathBuf; +use std::sync::Arc; + +// ───────────────────────────────────────────────────────────────────────────── +// Parent execution context +// ───────────────────────────────────────────────────────────────────────────── + +/// Snapshot of the parent agent's runtime, made available to any tool +/// running inside [`crate::openhuman::agent::Agent::turn`] via the +/// [`PARENT_CONTEXT`] task-local. +/// +/// All heavy fields are `Arc`-shared so cloning the context for sub-agents +/// is essentially free. +#[derive(Clone)] +pub struct ParentExecutionContext { + /// Parent's provider — sub-agents call into the same instance so + /// connection pools, retry budgets, and credentials are shared. + pub provider: Arc, + + /// Parent's full tool registry. The sub-agent runner re-filters this + /// per-archetype before handing it to the sub-agent's tool loop. + pub all_tools: Arc>>, + + /// Pre-serialised tool specs matching `all_tools`. Captured at + /// turn-start so sub-agents can pass byte-identical schemas to the + /// provider for prefix-cache reuse. + pub all_tool_specs: Arc>, + + /// Model name the parent is currently using (after classification). + pub model_name: String, + + /// Temperature the parent is currently using. + pub temperature: f64, + + /// Working directory of the parent agent. + pub workspace_dir: PathBuf, + + /// Parent's memory backing store. Sub-agents share it for read access + /// but use a `NullMemoryLoader` to skip the per-turn context injection. + pub memory: Arc, + + /// Parent's agent config (for `max_tool_iterations`, `max_memory_context_chars`, + /// dispatcher choice, …). + pub agent_config: AgentConfig, + + /// Parent's identity config — handed to sub-agents that opt out of + /// `omit_identity` so the prompt builder can resolve workspace files. + pub identity_config: IdentityConfig, + + /// Skills loaded into the parent. Sub-agents that don't strip the + /// skills catalog inherit this list. + pub skills: Arc>, + + /// Parent's event-bus session id (for tracing & DomainEvents). + pub session_id: String, + + /// Parent's event-bus channel name. + pub channel: String, +} + +tokio::task_local! { + /// Parent execution context, scoped per agent turn. `None` for any + /// tool invocation that happens outside an agent turn (e.g. CLI/RPC + /// direct tool calls); `spawn_subagent` rejects in that case. + pub static PARENT_CONTEXT: ParentExecutionContext; +} + +/// Returns a clone of the current parent execution context, if one is set. +/// +/// Returns `None` when called from outside [`crate::openhuman::agent::Agent::turn`] +/// (e.g. CLI tool invocation). +pub fn current_parent() -> Option { + PARENT_CONTEXT.try_with(|ctx| ctx.clone()).ok() +} + +/// Run `future` with `ctx` installed as the active parent context. +pub async fn with_parent_context(ctx: ParentExecutionContext, future: F) -> R +where + F: std::future::Future, +{ + PARENT_CONTEXT.scope(ctx, future).await +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fork context +// ───────────────────────────────────────────────────────────────────────────── + +/// Captures the parent's exact rendered prompt + tool schemas + message +/// prefix so a forked sub-agent can replay them byte-for-byte. +/// +/// **Why this matters**: OpenAI-compatible inference backends apply +/// automatic prefix caching server-side based on stable byte sequences. +/// If the forked child's request shares an identical prefix with the +/// parent's previous request, the prefix is served from cache and only +/// the diverging tail is billed. Forking this way is the biggest +/// token-saving mechanism OpenHuman has for parallel sub-agent work. +/// +/// To preserve byte stability we hold: +/// - `system_prompt` as a pre-rendered `String` (not the builder). +/// - `tool_specs` as already-serialised `ToolSpec` values. +/// - `message_prefix` as the parent's `ChatMessage` history *up to and +/// including* the assistant message that issued the `spawn_subagent` +/// tool call. +#[derive(Clone)] +pub struct ForkContext { + /// Parent's rendered system prompt. Becomes message[0] of the child. + pub system_prompt: Arc, + + /// Parent's tool schemas. The child's `ChatRequest.tools` borrows from + /// this slice unchanged. + pub tool_specs: Arc>, + + /// Parent's message history prefix that the child should replay + /// verbatim. Includes the system message at index 0. + pub message_prefix: Arc>, + + /// Optional system-prompt cache boundary the parent passed in its + /// own [`crate::openhuman::providers::ChatRequest`]. The child threads + /// the same value through so any future explicit-cache provider sees + /// matching markers. + pub cache_boundary: Option, + + /// The actual instruction the model issued for *this* fork — appears + /// as the new user message appended after `message_prefix`. + pub fork_task_prompt: String, +} + +tokio::task_local! { + /// Fork context, scoped per `spawn_subagent { mode: "fork", … }` + /// invocation. The runner reads it when the requested definition has + /// `uses_fork_context = true`. + pub static FORK_CONTEXT: ForkContext; +} + +/// Returns a clone of the current fork context, if one is set. +pub fn current_fork() -> Option { + FORK_CONTEXT.try_with(|ctx| ctx.clone()).ok() +} + +/// Run `future` with `ctx` installed as the active fork context. +pub async fn with_fork_context(ctx: ForkContext, future: F) -> R +where + F: std::future::Future, +{ + FORK_CONTEXT.scope(ctx, future).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::memory::{MemoryCategory, MemoryEntry}; + use crate::openhuman::providers::{ChatRequest, ChatResponse}; + use async_trait::async_trait; + + #[tokio::test] + async fn parent_context_returns_none_outside_scope() { + assert!(current_parent().is_none()); + } + + #[tokio::test] + async fn fork_context_returns_none_outside_scope() { + assert!(current_fork().is_none()); + } + + #[tokio::test] + async fn fork_context_visible_inside_scope() { + let ctx = ForkContext { + system_prompt: Arc::new("hello".into()), + tool_specs: Arc::new(vec![]), + message_prefix: Arc::new(vec![]), + cache_boundary: None, + fork_task_prompt: "do thing".into(), + }; + + with_fork_context(ctx, async { + let inner = current_fork().expect("fork context should be visible"); + assert_eq!(*inner.system_prompt, "hello"); + assert_eq!(inner.fork_task_prompt, "do thing"); + }) + .await; + + // And it disappears once the scope ends. + assert!(current_fork().is_none()); + } + + // ── Minimal stubs so we can construct a ParentExecutionContext + // without pulling in the memory factory or a real provider. None of + // these methods are called by the task-local visibility test — the + // test only reads scalar fields on the context snapshot — so panic + // bodies are fine. + + struct StubProvider; + + #[async_trait] + impl Provider for StubProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + unimplemented!("StubProvider::chat_with_system is not called in this test") + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + unimplemented!("StubProvider::chat is not called in this test") + } + } + + struct StubMemory; + + #[async_trait] + impl crate::openhuman::memory::Memory for StubMemory { + fn name(&self) -> &str { + "stub" + } + + async fn store( + &self, + _key: &str, + _content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall( + &self, + _query: &str, + _limit: usize, + _session_id: Option<&str>, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get(&self, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list( + &self, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(false) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } + } + + fn stub_parent_context() -> ParentExecutionContext { + ParentExecutionContext { + provider: Arc::new(StubProvider), + all_tools: Arc::new(vec![]), + all_tool_specs: Arc::new(vec![]), + model_name: "stub-model".into(), + temperature: 0.4, + workspace_dir: std::path::PathBuf::from("/tmp"), + memory: Arc::new(StubMemory), + agent_config: AgentConfig::default(), + identity_config: IdentityConfig::default(), + skills: Arc::new(vec![]), + session_id: "test-session".into(), + channel: "test-channel".into(), + } + } + + #[tokio::test] + async fn parent_context_visible_inside_scope() { + let ctx = stub_parent_context(); + + with_parent_context(ctx, async { + let p = current_parent().expect("parent context should be visible"); + assert_eq!(p.model_name, "stub-model"); + assert_eq!(p.session_id, "test-session"); + assert_eq!(p.channel, "test-channel"); + assert!((p.temperature - 0.4).abs() < f64::EPSILON); + }) + .await; + + // And it disappears once the scope ends. + assert!(current_parent().is_none()); + } +} diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index 020fce0bd..d614d4909 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -1,7 +1,32 @@ -//! Multi-agent harness — orchestrator topology with 8 specialised archetypes. +//! Multi-agent harness — sub-agent dispatch and the orchestrator topology. //! -//! When `OrchestratorConfig::enabled` is true, the harness replaces the default -//! single-agent tool loop with a Staff-Engineer / Contractor hierarchy: +//! Two execution shapes coexist here: +//! +//! ## Subagents-as-tools (default) +//! The main agent runs its normal tool loop and can choose to delegate to a +//! sub-agent at any iteration via the `spawn_subagent` tool. The sub-agent +//! is constructed at call time from an [`definition::AgentDefinition`] +//! looked up in the global [`definition::AgentDefinitionRegistry`], runs +//! its own narrowed tool loop (cheaper model, fewer tools, no memory +//! recall), and returns a single text result that the parent threads back +//! into its history. This is the recommended shape for interactive use. +//! +//! ## DAG orchestration (opt-in via `OrchestratorConfig::enabled`) +//! A pre-existing planner→DAG→execute→synthesise loop in +//! [`executor::run_orchestrated`]. Useful for batch scenarios but heavier +//! than the tool-call path. As of the subagent refactor it shares the same +//! [`subagent_runner::run_subagent`] helper internally. +//! +//! ## Fork-cache mode +//! Both shapes can request a `fork`-mode sub-agent that replays the +//! parent's *exact* rendered system prompt + tool schemas + message +//! prefix via the [`fork_context::ForkContext`] task-local. The +//! OpenAI-compatible inference backend's automatic prefix caching turns +//! this byte-stable replay into a real token-savings win. +//! +//! ## Built-in archetypes +//! Eight historical archetypes are preserved and surfaced as built-in +//! definitions in [`builtin_definitions`]: //! //! 1. **Orchestrator** — routes, judges quality, synthesises. //! 2. **Planner** — breaks goals into a DAG of subtasks. @@ -14,19 +39,33 @@ pub mod archetypes; pub mod archivist; +pub mod builtin_definitions; pub mod context_assembly; pub mod dag; +pub mod definition; +pub mod definition_loader; pub mod executor; +pub mod fork_context; pub mod interrupt; pub mod self_healing; pub mod session_queue; +pub mod subagent_runner; pub mod types; pub use archetypes::AgentArchetype; pub use archivist::ArchivistHook; pub use dag::{DagError, TaskDag, TaskNode}; +pub use definition::{ + AgentDefinition, AgentDefinitionRegistry, DefinitionSource, ModelSpec, PromptSource, + SandboxMode, ToolScope, +}; pub use executor::run_orchestrated; +pub use fork_context::{ + current_fork, current_parent, with_fork_context, with_parent_context, ForkContext, + ParentExecutionContext, +}; pub use interrupt::{check_interrupt, InterruptFence, InterruptedError}; pub use self_healing::SelfHealingInterceptor; pub use session_queue::SessionQueue; +pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions}; pub use types::*; diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs new file mode 100644 index 000000000..4fc4367aa --- /dev/null +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -0,0 +1,1338 @@ +//! Sub-agent execution runner. +//! +//! Given an [`AgentDefinition`] and a task prompt, this module: +//! +//! 1. Reads the [`ParentExecutionContext`] task-local set by the parent +//! [`crate::openhuman::agent::Agent::turn`]. +//! 2. Resolves the sub-agent's model name (inherit / hint / exact). +//! 3. Filters the parent's tool registry per `definition.tools`, +//! `disallowed_tools`, and `skill_filter` (or, in `fork` mode, +//! inherits the parent's tools verbatim). +//! 4. Builds a narrow system prompt that strips the sections the +//! definition asks to omit (`omit_identity`, `omit_memory_context`, +//! `omit_safety_preamble`, `omit_skills_catalog`). +//! 5. Runs a slim inner tool-call loop using the parent's +//! [`crate::openhuman::providers::Provider`] and returns a single +//! text result. The intra-sub-agent history never leaks back to the +//! parent — the parent only sees one compact tool result. +//! +//! Token-saving levers in this runner: +//! - **Narrow prompt** — typed sub-agents skip identity/memory/skills. +//! - **Filtered tools** — fewer schemas in the request body. +//! - **Cheaper model** — archetype hint usually selects a smaller model. +//! - **Lower max iterations** — definition-controlled per archetype. +//! - **No memory recall** — sub-agents use a [`crate::openhuman::agent::memory_loader::NullMemoryLoader`]. +//! - **Structural compaction** — sub-agent's tool-call history collapses +//! into a single tool result block in the parent's history. +//! - **Fork-mode prefix replay** — `uses_fork_context` definitions +//! replay the parent's exact bytes for backend prefix-cache hits. + +use super::definition::{AgentDefinition, PromptSource, ToolScope}; +use super::fork_context::{current_fork, current_parent, ForkContext, ParentExecutionContext}; +use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall}; +use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec}; +use std::collections::HashSet; +use std::time::{Duration, Instant}; +use thiserror::Error; + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Per-spawn options that override or augment what the +/// [`AgentDefinition`] specifies. Built by `SpawnSubagentTool::execute` +/// from the parent model's call arguments. +#[derive(Debug, Clone, Default)] +pub struct SubagentRunOptions { + /// Optional skill-id override (e.g. `"notion"`). When set, the + /// resolved tool list is further restricted to tools whose name + /// starts with `{skill}__`. Overrides `definition.skill_filter`. + pub skill_filter_override: Option, + + /// Optional category override. When set, replaces + /// `definition.category_filter` for this single spawn. Useful when + /// the parent wants to reuse a generic definition but scope it to + /// skill or system tools for this specific call. + pub category_filter_override: Option, + + /// Optional context blob the parent wants to inject before the + /// task prompt. Rendered as a `[Context]\n…\n` prefix. + pub context: Option, + + /// Stable id for tracing / DomainEvents (defaults to a UUID). + pub task_id: Option, +} + +/// Outcome of a single sub-agent run, returned to +/// `SpawnSubagentTool::execute` for relay back to the parent. +#[derive(Debug, Clone)] +pub struct SubagentRunOutcome { + pub task_id: String, + pub agent_id: String, + pub output: String, + pub iterations: usize, + pub elapsed: Duration, + pub mode: SubagentMode, +} + +/// Which prompt-construction path the runner took. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubagentMode { + /// Built a narrow prompt + filtered tools (the common case). + Typed, + /// Replayed the parent's exact prompt + tools + message prefix. + Fork, +} + +impl SubagentMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Typed => "typed", + Self::Fork => "fork", + } + } +} + +/// Errors the runner can surface to the parent. The parent receives a +/// stringified version inside a tool result block. +#[derive(Debug, Error)] +pub enum SubagentRunError { + #[error("spawn_subagent called outside of an agent turn — no parent context available")] + NoParentContext, + + #[error( + "fork-mode sub-agent requested but no ForkContext is set on the task-local. \ + Did the parent agent forget to call `Agent::turn` with fork support?" + )] + NoForkContext, + + #[error("agent definition '{0}' not found in registry")] + DefinitionNotFound(String), + + #[error("failed to load archetype prompt from '{path}': {source}")] + PromptLoad { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("provider call failed: {0}")] + Provider(#[from] anyhow::Error), + + #[error("sub-agent exceeded maximum iterations ({0})")] + MaxIterationsExceeded(usize), +} + +/// Run a sub-agent. +/// +/// On success returns a [`SubagentRunOutcome`] whose `output` is the +/// final assistant text. On failure the error is suitable for stringifying +/// into a `tool_result` block — the parent agent will surface it to the +/// model and decide whether to retry or apologise to the user. +pub async fn run_subagent( + definition: &AgentDefinition, + task_prompt: &str, + options: SubagentRunOptions, +) -> Result { + let parent = current_parent().ok_or(SubagentRunError::NoParentContext)?; + let task_id = options + .task_id + .clone() + .unwrap_or_else(|| format!("sub-{}", uuid::Uuid::new_v4())); + let started = Instant::now(); + + tracing::info!( + agent_id = %definition.id, + task_id = %task_id, + prompt_chars = task_prompt.chars().count(), + skill_filter = ?options.skill_filter_override.as_deref().or(definition.skill_filter.as_deref()), + "[subagent_runner] dispatching" + ); + + let outcome = if definition.uses_fork_context { + let fork = current_fork().ok_or(SubagentRunError::NoForkContext)?; + run_fork_mode(definition, task_prompt, &options, &parent, &fork, &task_id).await? + } else { + run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await? + }; + + tracing::info!( + agent_id = %definition.id, + task_id = %task_id, + elapsed_ms = outcome.elapsed.as_millis() as u64, + iterations = outcome.iterations, + output_chars = outcome.output.chars().count(), + "[subagent_runner] completed" + ); + + let _ = started; // silence unused-warning if logging is compiled out + Ok(outcome) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Typed mode — narrow prompt, filtered tools, cheaper model +// ───────────────────────────────────────────────────────────────────────────── + +async fn run_typed_mode( + definition: &AgentDefinition, + task_prompt: &str, + options: &SubagentRunOptions, + parent: &ParentExecutionContext, + task_id: &str, +) -> Result { + let started = Instant::now(); + + // ── Resolve archetype prompt body ────────────────────────────────── + let archetype_prompt_body = + load_prompt_source(&definition.system_prompt, &parent.workspace_dir)?; + + // ── Resolve model + temperature ──────────────────────────────────── + let model = definition.model.resolve(&parent.model_name); + let temperature = definition.temperature; + + // ── Filter tools per definition + per-spawn override ─────────────── + let category_filter = options + .category_filter_override + .or(definition.category_filter); + let allowed_indices = filter_tool_indices( + &parent.all_tools, + &definition.tools, + &definition.disallowed_tools, + options + .skill_filter_override + .as_deref() + .or(definition.skill_filter.as_deref()), + category_filter, + ); + + let filtered_specs: Vec = allowed_indices + .iter() + .map(|&i| parent.all_tool_specs[i].clone()) + .collect(); + let allowed_names: HashSet = allowed_indices + .iter() + .map(|&i| parent.all_tools[i].name().to_string()) + .collect(); + + tracing::debug!( + agent_id = %definition.id, + model = %model, + tool_count = allowed_names.len(), + max_iterations = definition.max_iterations, + "[subagent_runner:typed] resolved configuration" + ); + + // ── 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( + &parent.workspace_dir, + &model, + &allowed_indices, + &parent.all_tools, + definition, + &archetype_prompt_body, + ); + + // ── Build the user message (with optional context prefix) ────────── + let user_message = if let Some(ctx) = options.context.as_deref() { + format!("[Context]\n{ctx}\n\n{task_prompt}") + } else { + task_prompt.to_string() + }; + + let mut history: Vec = vec![ + ChatMessage::system(system_prompt), + ChatMessage::user(user_message), + ]; + + // ── Run the inner tool-call loop ─────────────────────────────────── + let (output, iterations) = run_inner_loop( + parent.provider.as_ref(), + &mut history, + &parent.all_tools, + &filtered_specs, + &allowed_names, + &model, + temperature, + definition.max_iterations, + task_id, + &definition.id, + ) + .await?; + + Ok(SubagentRunOutcome { + task_id: task_id.to_string(), + agent_id: definition.id.clone(), + output, + iterations, + elapsed: started.elapsed(), + mode: SubagentMode::Typed, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fork mode — replay parent's bytes for prefix-cache reuse +// ───────────────────────────────────────────────────────────────────────────── + +async fn run_fork_mode( + definition: &AgentDefinition, + _task_prompt: &str, + _options: &SubagentRunOptions, + parent: &ParentExecutionContext, + fork: &ForkContext, + task_id: &str, +) -> Result { + let started = Instant::now(); + + // The fork's task prompt comes from the ForkContext (set by the + // parent's tool-dispatch site), not from the spawn_subagent args + // directly. This guarantees the bytes the parent committed to are + // what the child sees. + let fork_task_prompt = fork.fork_task_prompt.clone(); + + tracing::debug!( + agent_id = %definition.id, + prefix_len = fork.message_prefix.len(), + cache_boundary = ?fork.cache_boundary, + "[subagent_runner:fork] replaying parent prefix" + ); + + // History = parent's exact prefix (which already starts with the + // parent's system message), then the new fork directive as a user + // message. The system_prompt arc is unused here because the prefix + // already contains the system message at index 0 — but we sanity- + // check that invariant. + debug_assert!( + fork.message_prefix + .first() + .map(|m| m.role == "system") + .unwrap_or(false), + "fork message_prefix must start with the parent's system message" + ); + let mut history: Vec = (*fork.message_prefix).clone(); + history.push(ChatMessage::user(fork_task_prompt)); + + // All parent tools are allowed in fork mode (the whole point is + // byte-identical tool schemas). + let allowed_names: HashSet = parent + .all_tools + .iter() + .map(|t| t.name().to_string()) + .collect(); + + let model = parent.model_name.clone(); + let temperature = parent.temperature; + // Use the parent's iteration cap, not the synthetic fork definition's. + let max_iterations = parent.agent_config.max_tool_iterations.max(1); + + let (output, iterations) = run_inner_loop( + parent.provider.as_ref(), + &mut history, + &parent.all_tools, + &parent.all_tool_specs, + &allowed_names, + &model, + temperature, + max_iterations, + task_id, + &definition.id, + ) + .await?; + + Ok(SubagentRunOutcome { + task_id: task_id.to_string(), + agent_id: definition.id.clone(), + output, + iterations, + elapsed: started.elapsed(), + mode: SubagentMode::Fork, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Inner tool-call loop (slim version of agent::loop_::tool_loop) +// ───────────────────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn run_inner_loop( + provider: &dyn Provider, + history: &mut Vec, + parent_tools: &[Box], + tool_specs: &[ToolSpec], + allowed_names: &HashSet, + model: &str, + temperature: f64, + max_iterations: usize, + task_id: &str, + agent_id: &str, +) -> Result<(String, usize), SubagentRunError> { + let max_iterations = max_iterations.max(1); + let supports_native = provider.supports_native_tools() && !tool_specs.is_empty(); + let request_tools = if supports_native { + Some(tool_specs) + } else { + None + }; + + for iteration in 0..max_iterations { + tracing::debug!( + task_id = %task_id, + agent_id = %agent_id, + iteration, + history_len = history.len(), + "[subagent_runner] iteration start" + ); + + let resp = provider + .chat( + ChatRequest { + messages: history.as_slice(), + tools: request_tools, + system_prompt_cache_boundary: None, + }, + model, + temperature, + ) + .await?; + + let response_text = resp.text.clone().unwrap_or_default(); + let native_calls: Vec = resp.tool_calls.clone(); + + if native_calls.is_empty() { + tracing::debug!( + task_id = %task_id, + agent_id = %agent_id, + iteration, + final_chars = response_text.chars().count(), + "[subagent_runner] no tool calls — returning final response" + ); + history.push(ChatMessage::assistant(response_text.clone())); + return Ok((response_text, iteration + 1)); + } + + // Persist assistant message with the original tool_calls payload so + // subsequent role=tool messages can reference call ids correctly. + let assistant_history_content = + build_native_assistant_payload(&response_text, &native_calls); + history.push(ChatMessage::assistant(assistant_history_content)); + + // Execute each call, append role=tool messages. + for call in &native_calls { + let result_text = if !allowed_names.contains(&call.name) { + tracing::warn!( + task_id = %task_id, + agent_id = %agent_id, + tool = %call.name, + "[subagent_runner] tool not in allowlist for this sub-agent" + ); + format!( + "Error: tool '{}' is not available to the {} sub-agent", + call.name, agent_id + ) + } else if let Some(tool) = parent_tools.iter().find(|t| t.name() == call.name) { + let args = parse_tool_arguments(&call.arguments); + let timeout = crate::openhuman::tool_timeout::tool_execution_timeout_duration(); + match tokio::time::timeout(timeout, tool.execute(args)).await { + Ok(Ok(result)) => { + let raw = result.output(); + if result.is_error { + format!("Error: {raw}") + } else { + raw + } + } + Ok(Err(err)) => format!("Error executing {}: {err}", call.name), + Err(_) => format!("Error: tool '{}' timed out", call.name), + } + } else { + format!("Unknown tool: {}", call.name) + }; + + let tool_msg = serde_json::json!({ + "tool_call_id": call.id, + "content": result_text, + }); + history.push(ChatMessage::tool(tool_msg.to_string())); + } + } + + Err(SubagentRunError::MaxIterationsExceeded(max_iterations)) +} + +fn build_native_assistant_payload(text: &str, tool_calls: &[ToolCall]) -> String { + // Mirror the existing native-tool-call serialisation pattern used by + // `agent::loop_::parse::build_native_assistant_history`. We inline a + // small subset here to avoid an inter-module dep cycle. + let calls_json: Vec = tool_calls + .iter() + .map(|call| { + serde_json::json!({ + "id": call.id, + "type": "function", + "function": { + "name": call.name, + "arguments": call.arguments, + }, + }) + }) + .collect(); + + let payload = serde_json::json!({ + "text": text, + "tool_calls": calls_json, + }); + payload.to_string() +} + +fn parse_tool_arguments(arguments: &str) -> serde_json::Value { + serde_json::from_str(arguments) + .unwrap_or_else(|_| serde_json::Value::Object(Default::default())) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tool filtering +// ───────────────────────────────────────────────────────────────────────────── + +/// Returns indices into `parent_tools` for the tools the sub-agent may +/// invoke. Index-based filtering avoids cloning `Box` (which +/// isn't Clone) and lets us reuse the parent's existing instances. +/// +/// Filters are applied in this order (shorter-circuit first): +/// 1. `disallowed` — explicit deny list. +/// 2. `category_filter` — restrict to `System` or `Skill` category. +/// 3. `skill_filter` — restrict to tools named `{skill}__*`. +/// 4. `scope` — `Wildcard` (everything remaining) or `Named` allowlist. +fn filter_tool_indices( + parent_tools: &[Box], + scope: &ToolScope, + disallowed: &[String], + skill_filter: Option<&str>, + category_filter: Option, +) -> Vec { + let disallow_set: HashSet<&str> = disallowed.iter().map(|s| s.as_str()).collect(); + let skill_prefix = skill_filter.map(|s| format!("{s}__")); + + parent_tools + .iter() + .enumerate() + .filter(|(_, tool)| { + let name = tool.name(); + if disallow_set.contains(name) { + return false; + } + if let Some(required) = category_filter { + if tool.category() != required { + return false; + } + } + if let Some(prefix) = skill_prefix.as_deref() { + if !name.starts_with(prefix) { + return false; + } + } + match scope { + ToolScope::Wildcard => true, + ToolScope::Named(allowed) => allowed.iter().any(|n| n == name), + } + }) + .map(|(i, _)| i) + .collect() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Prompt loading + composition +// ───────────────────────────────────────────────────────────────────────────── + +/// Resolve a [`PromptSource`] to its raw markdown body. Inline sources +/// return immediately; file sources are read from disk relative to the +/// workspace `prompts/` directory or the agent crate's bundled prompts. +fn load_prompt_source( + source: &PromptSource, + workspace_dir: &std::path::Path, +) -> Result { + match source { + PromptSource::Inline(body) => Ok(body.clone()), + PromptSource::File { path } => { + // Try the workspace's `agent/prompts/` first (so users can + // override built-in prompts), then fall back to the crate's + // own bundled prompts via `include_str!`-style lookup. + let workspace_path = workspace_dir.join("agent").join("prompts").join(path); + if workspace_path.is_file() { + return std::fs::read_to_string(&workspace_path).map_err(|e| { + SubagentRunError::PromptLoad { + path: workspace_path.display().to_string(), + source: e, + } + }); + } + // Built-in prompt fallback. The agent prompts directory is + // already shipped at `src/openhuman/agent/prompts/` and + // included in the binary via the `IdentitySection` workspace + // file write — so we re-use that scaffolding by reading from + // `/` after the parent agent has + // bootstrapped its workspace files. For sub-agent + // archetype prompts (e.g. `archetypes/researcher.md`), + // we look up by basename in the workspace, then accept + // missing files as an empty body (the runner will fall + // back to a generic role hint). + let workspace_root_path = workspace_dir.join(path); + if workspace_root_path.is_file() { + return std::fs::read_to_string(&workspace_root_path).map_err(|e| { + SubagentRunError::PromptLoad { + path: workspace_root_path.display().to_string(), + source: e, + } + }); + } + tracing::warn!( + path = %path, + "[subagent_runner] archetype prompt file not found, using empty body" + ); + Ok(String::new()) + } + } +} + +/// 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 +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::super::definition::ModelSpec; + use super::*; + + fn make_def_named_tools(names: &[&str]) -> AgentDefinition { + AgentDefinition { + id: "test".into(), + when_to_use: "t".into(), + display_name: None, + system_prompt: PromptSource::Inline("system".into()), + omit_identity: true, + omit_memory_context: true, + omit_safety_preamble: true, + omit_skills_catalog: true, + model: ModelSpec::Inherit, + temperature: 0.4, + tools: ToolScope::Named(names.iter().map(|s| s.to_string()).collect()), + disallowed_tools: vec![], + skill_filter: None, + category_filter: None, + max_iterations: 5, + timeout_secs: None, + sandbox_mode: super::super::definition::SandboxMode::None, + background: false, + uses_fork_context: false, + source: super::super::definition::DefinitionSource::Builtin, + } + } + + /// Local tool used to populate `parent_tools` in tests. + struct StubTool { + name: &'static str, + } + + use crate::openhuman::tools::{PermissionLevel, ToolResult}; + use async_trait::async_trait; + + #[async_trait] + impl Tool for StubTool { + fn name(&self) -> &str { + self.name + } + fn description(&self) -> &str { + "stub" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::None + } + } + + fn stub(name: &'static str) -> Box { + Box::new(StubTool { name }) + } + + #[test] + fn filter_named_scope_keeps_only_named() { + let parent: Vec> = vec![stub("alpha"), stub("beta"), stub("gamma")]; + let def = make_def_named_tools(&["alpha", "gamma"]); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None, None); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["alpha", "gamma"]); + } + + #[test] + fn filter_wildcard_includes_all_minus_disallowed() { + let parent: Vec> = vec![stub("alpha"), stub("beta"), stub("gamma")]; + let mut def = make_def_named_tools(&[]); + def.tools = ToolScope::Wildcard; + def.disallowed_tools = vec!["beta".into()]; + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None, None); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["alpha", "gamma"]); + } + + #[test] + fn filter_skill_filter_restricts_to_prefix() { + let parent: Vec> = vec![ + stub("notion__search"), + stub("notion__read"), + stub("gmail__send"), + stub("file_read"), + ]; + let mut def = make_def_named_tools(&[]); + def.tools = ToolScope::Wildcard; + let idx = filter_tool_indices( + &parent, + &def.tools, + &def.disallowed_tools, + Some("notion"), + None, + ); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["notion__search", "notion__read"]); + } + + #[test] + fn filter_skill_filter_combined_with_named_scope() { + // Named scope intersects with skill_filter — only tools that + // appear in the named list AND match the prefix survive. + let parent: Vec> = vec![ + stub("notion__search"), + stub("notion__read"), + stub("gmail__send"), + ]; + let def = make_def_named_tools(&["notion__search", "gmail__send"]); + let idx = filter_tool_indices( + &parent, + &def.tools, + &def.disallowed_tools, + Some("notion"), + None, + ); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["notion__search"]); + } + + /// A stub tool that claims to be a skill-category tool, so we can + /// exercise `filter_tool_indices` / `category_filter` without + /// needing the real skill-bridge runtime. + struct SkillStubTool { + name: &'static str, + } + + #[async_trait] + impl Tool for SkillStubTool { + fn name(&self) -> &str { + self.name + } + fn description(&self) -> &str { + "skill stub" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn category(&self) -> ToolCategory { + ToolCategory::Skill + } + } + + fn skill_stub(name: &'static str) -> Box { + Box::new(SkillStubTool { name }) + } + + #[test] + fn filter_category_skill_keeps_only_skill_tools() { + let parent: Vec> = vec![ + stub("file_read"), + stub("shell"), + skill_stub("notion__search"), + skill_stub("gmail__send"), + ]; + let def = make_def_named_tools(&[]); // Named([]) + // Wildcard + Skill category → only skill-category tools. + let mut def = def; + def.tools = ToolScope::Wildcard; + let idx = filter_tool_indices( + &parent, + &def.tools, + &def.disallowed_tools, + None, + Some(ToolCategory::Skill), + ); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["notion__search", "gmail__send"]); + } + + #[test] + fn filter_category_system_excludes_skill_tools() { + let parent: Vec> = vec![ + stub("file_read"), + skill_stub("notion__search"), + stub("shell"), + ]; + let mut def = make_def_named_tools(&[]); + def.tools = ToolScope::Wildcard; + let idx = filter_tool_indices( + &parent, + &def.tools, + &def.disallowed_tools, + None, + Some(ToolCategory::System), + ); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["file_read", "shell"]); + } + + #[test] + fn filter_category_and_skill_filter_compose() { + // Category=Skill AND skill_filter=notion → only notion__* tools + // that are also Skill-category. + let parent: Vec> = vec![ + skill_stub("notion__search"), + skill_stub("notion__read"), + skill_stub("gmail__send"), + stub("file_read"), + // A pathological system-category tool with a "notion__" + // name prefix — the category filter should still exclude it. + stub("notion__fake"), + ]; + let mut def = make_def_named_tools(&[]); + def.tools = ToolScope::Wildcard; + let idx = filter_tool_indices( + &parent, + &def.tools, + &def.disallowed_tools, + Some("notion"), + Some(ToolCategory::Skill), + ); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["notion__search", "notion__read"]); + } + + #[test] + fn subagent_mode_as_str_roundtrip() { + assert_eq!(SubagentMode::Typed.as_str(), "typed"); + assert_eq!(SubagentMode::Fork.as_str(), "fork"); + } + + // ── End-to-end runner tests with mock provider ──────────────────────── + + use super::super::fork_context::{with_fork_context, with_parent_context}; + use crate::openhuman::providers::{ + ChatRequest as PChatRequest, ChatResponse, Provider, ToolCall, + }; + use parking_lot::Mutex; + use std::sync::Arc; + + /// Mock provider whose response queue can be inspected by the test + /// to verify the bytes that arrive at the model. + struct ScriptedProvider { + responses: Mutex>, + captured: Mutex>>, + } + + impl ScriptedProvider { + fn new(responses: Vec) -> Arc { + Arc::new(Self { + responses: Mutex::new(responses), + captured: Mutex::new(Vec::new()), + }) + } + } + + #[async_trait] + impl Provider for ScriptedProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + Ok("noop".into()) + } + + async fn chat( + &self, + request: PChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + self.captured.lock().push(request.messages.to_vec()); + let mut q = self.responses.lock(); + if q.is_empty() { + return Ok(ChatResponse { + text: Some(String::new()), + tool_calls: vec![], + usage: None, + }); + } + Ok(q.remove(0)) + } + + fn supports_native_tools(&self) -> bool { + true + } + } + + fn text_response(text: &str) -> ChatResponse { + ChatResponse { + text: Some(text.into()), + tool_calls: vec![], + usage: None, + } + } + + fn tool_response(name: &str, args: &str) -> ChatResponse { + ChatResponse { + text: Some(String::new()), + tool_calls: vec![ToolCall { + id: "call-1".into(), + name: name.into(), + arguments: args.into(), + }], + usage: None, + } + } + + /// Build a minimal `ParentExecutionContext` suitable for runner tests. + /// Uses a no-op memory backend so we don't have to spin up a real one. + fn make_parent( + provider: Arc, + tools: Vec>, + ) -> ParentExecutionContext { + let tool_specs: Vec = + tools.iter().map(|t| t.spec()).collect(); + ParentExecutionContext { + provider, + all_tools: Arc::new(tools), + all_tool_specs: Arc::new(tool_specs), + model_name: "test-model".into(), + temperature: 0.5, + workspace_dir: std::env::temp_dir(), + memory: noop_memory(), + agent_config: crate::openhuman::config::AgentConfig::default(), + identity_config: crate::openhuman::config::IdentityConfig::default(), + skills: Arc::new(vec![]), + session_id: "test-session".into(), + channel: "test".into(), + } + } + + fn noop_memory() -> Arc { + struct NoopMemory; + #[async_trait] + impl crate::openhuman::memory::Memory for NoopMemory { + async fn store( + &self, + _key: &str, + _content: &str, + _category: crate::openhuman::memory::MemoryCategory, + _session_id: Option<&str>, + ) -> anyhow::Result<()> { + Ok(()) + } + async fn recall( + &self, + _query: &str, + _limit: usize, + _session_id: Option<&str>, + ) -> anyhow::Result> { + Ok(vec![]) + } + async fn get( + &self, + _key: &str, + ) -> anyhow::Result> { + Ok(None) + } + async fn list( + &self, + _category: Option<&crate::openhuman::memory::MemoryCategory>, + _session_id: Option<&str>, + ) -> anyhow::Result> { + Ok(vec![]) + } + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(true) + } + async fn count(&self) -> anyhow::Result { + Ok(0) + } + async fn health_check(&self) -> bool { + true + } + fn name(&self) -> &str { + "noop" + } + } + Arc::new(NoopMemory) + } + + #[tokio::test] + async fn typed_mode_returns_text_through_runner() { + let provider = ScriptedProvider::new(vec![text_response("X is Y")]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + let def = make_def_named_tools(&[]); + + let outcome = with_parent_context(parent, async { + run_subagent( + &def, + "summarise X", + SubagentRunOptions { + skill_filter_override: None, + category_filter_override: None, + context: None, + task_id: Some("t1".into()), + }, + ) + .await + }) + .await + .expect("runner should succeed"); + + assert_eq!(outcome.output, "X is Y"); + assert_eq!(outcome.iterations, 1); + assert_eq!(outcome.mode, SubagentMode::Typed); + assert_eq!(outcome.task_id, "t1"); + } + + #[tokio::test] + async fn typed_mode_no_memory_context_in_user_message() { + // Verifies that NullMemoryLoader is in effect: the user message + // sent to the provider does NOT contain `[Memory context]`. + let provider = ScriptedProvider::new(vec![text_response("ok")]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + let def = make_def_named_tools(&[]); + + let _ = with_parent_context(parent, async { + run_subagent( + &def, + "the actual task prompt", + SubagentRunOptions::default(), + ) + .await + }) + .await + .unwrap(); + + let captured = provider.captured.lock(); + assert_eq!(captured.len(), 1); + let user_msg = captured[0] + .iter() + .find(|m| m.role == "user") + .expect("user message should be present"); + assert!( + !user_msg.content.contains("[Memory context]"), + "subagent user message must not include memory recall section, got: {}", + user_msg.content + ); + assert!(user_msg.content.contains("the actual task prompt")); + } + + #[tokio::test] + async fn typed_mode_filters_tools_by_skill_filter() { + // Parent has tools spanning notion__*, gmail__*, and a generic + // file_read; spawn the runner with skill_filter override "notion" + // and assert that only the notion tools end up in the request. + let provider = ScriptedProvider::new(vec![text_response("done")]); + let parent = make_parent( + provider.clone(), + vec![ + stub("notion__search"), + stub("notion__read"), + stub("gmail__send"), + stub("file_read"), + ], + ); + // Wildcard scope so skill_filter is the only restrictor. + let mut def = make_def_named_tools(&[]); + def.tools = ToolScope::Wildcard; + + let _ = with_parent_context(parent, async { + run_subagent( + &def, + "lookup", + SubagentRunOptions { + skill_filter_override: Some("notion".into()), + category_filter_override: None, + context: None, + task_id: None, + }, + ) + .await + }) + .await + .unwrap(); + + // The narrow system prompt should mention the notion tools by + // name and NOT mention gmail/file_read. + let captured = provider.captured.lock(); + let system_msg = captured[0] + .iter() + .find(|m| m.role == "system") + .expect("system message present"); + assert!(system_msg.content.contains("notion__search")); + assert!(system_msg.content.contains("notion__read")); + assert!( + !system_msg.content.contains("gmail__send"), + "skill_filter should have excluded gmail__send" + ); + assert!( + !system_msg.content.contains("file_read"), + "skill_filter should have excluded file_read" + ); + } + + #[tokio::test] + async fn typed_mode_executes_one_tool_then_returns() { + // Two-round script: round 1 returns a tool call, round 2 returns + // the final text. Verifies the inner tool-call loop wires up the + // tool result into history correctly. + let provider = ScriptedProvider::new(vec![ + tool_response("file_read", "{\"path\":\"x\"}"), + text_response("the file contents say hello"), + ]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + // Allow the runner to call file_read. + let def = make_def_named_tools(&["file_read"]); + + let outcome = with_parent_context(parent, async { + run_subagent(&def, "read x", SubagentRunOptions::default()).await + }) + .await + .expect("runner should succeed"); + + assert!(outcome.output.contains("hello")); + assert_eq!(outcome.iterations, 2); + // Second request should include the role=tool message produced + // by the runner from StubTool's "ok" output. + let captured = provider.captured.lock(); + assert_eq!(captured.len(), 2); + let second_call_messages = &captured[1]; + let has_tool_msg = second_call_messages.iter().any(|m| m.role == "tool"); + assert!( + has_tool_msg, + "second provider call should include role=tool message" + ); + } + + #[tokio::test] + async fn typed_mode_blocks_unallowed_tool_calls() { + // Provider tries to call a tool that's not in the allowlist. + // Runner should surface an error tool result and the next + // iteration should be able to recover. + let provider = ScriptedProvider::new(vec![ + tool_response("forbidden_tool", "{}"), + text_response("oops, I'll try something else"), + ]); + let parent = make_parent( + provider.clone(), + vec![stub("file_read"), stub("forbidden_tool")], + ); + // Definition only allows file_read. + let def = make_def_named_tools(&["file_read"]); + + let outcome = with_parent_context(parent, async { + run_subagent(&def, "do thing", SubagentRunOptions::default()).await + }) + .await + .expect("runner should succeed"); + + assert!(outcome.output.contains("oops")); + let captured = provider.captured.lock(); + let second_call_messages = &captured[1]; + let tool_msg = second_call_messages + .iter() + .find(|m| m.role == "tool") + .expect("tool result message should be present"); + assert!( + tool_msg.content.contains("not available"), + "blocked tool should produce a 'not available' error message" + ); + } + + #[tokio::test] + async fn fork_mode_replays_parent_prefix_bytes() { + // Construct a fake fork context with a known message prefix. + // The runner should replay it byte-for-byte plus a single + // appended user message carrying the fork directive. + let provider = ScriptedProvider::new(vec![text_response("fork done")]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + + let prefix = vec![ + crate::openhuman::providers::ChatMessage::system("PARENT_SYSTEM_PROMPT_BYTES"), + crate::openhuman::providers::ChatMessage::user("first user msg"), + crate::openhuman::providers::ChatMessage::assistant("parent assistant"), + ]; + + let fork = ForkContext { + system_prompt: Arc::new("PARENT_SYSTEM_PROMPT_BYTES".into()), + tool_specs: Arc::clone(&parent.all_tool_specs), + message_prefix: Arc::new(prefix.clone()), + cache_boundary: None, + fork_task_prompt: "ANALYSE THIS BRANCH".into(), + }; + + let def = super::super::builtin_definitions::fork_definition(); + + let outcome = with_parent_context(parent, async move { + with_fork_context(fork, async { + run_subagent( + &def, + "ignored — fork uses fork_task_prompt", + SubagentRunOptions::default(), + ) + .await + }) + .await + }) + .await + .expect("fork runner should succeed"); + + assert_eq!(outcome.mode, SubagentMode::Fork); + assert_eq!(outcome.output, "fork done"); + + // Verify the request that hit the provider replays the parent + // prefix exactly and appends only the fork directive. + let captured = provider.captured.lock(); + let first_call = &captured[0]; + assert_eq!(first_call.len(), prefix.len() + 1); + for (i, msg) in prefix.iter().enumerate() { + assert_eq!(first_call[i].role, msg.role); + assert_eq!(first_call[i].content, msg.content); + } + // The appended user message carries the fork directive. + let appended = first_call.last().unwrap(); + assert_eq!(appended.role, "user"); + assert_eq!(appended.content, "ANALYSE THIS BRANCH"); + } + + #[tokio::test] + async fn fork_mode_errors_when_no_fork_context() { + let provider = ScriptedProvider::new(vec![text_response("unused")]); + let parent = make_parent(provider, vec![stub("file_read")]); + let def = super::super::builtin_definitions::fork_definition(); + + let result = with_parent_context(parent, async { + run_subagent(&def, "x", SubagentRunOptions::default()).await + }) + .await; + + assert!(matches!(result, Err(SubagentRunError::NoForkContext))); + } + + #[tokio::test] + async fn runner_errors_outside_parent_context() { + let def = make_def_named_tools(&[]); + let result = run_subagent(&def, "x", SubagentRunOptions::default()).await; + assert!(matches!(result, Err(SubagentRunError::NoParentContext))); + } +} diff --git a/src/openhuman/agent/memory_loader.rs b/src/openhuman/agent/memory_loader.rs index cfe0601d3..901d32086 100644 --- a/src/openhuman/agent/memory_loader.rs +++ b/src/openhuman/agent/memory_loader.rs @@ -131,6 +131,24 @@ impl MemoryLoader for DefaultMemoryLoader { } } +/// A memory loader that returns no context. Used by sub-agents so they +/// don't pay the per-turn memory-recall token tax — the parent agent has +/// already loaded the relevant context, and the sub-agent receives a +/// narrow, focused prompt instead. +#[derive(Debug, Default, Clone, Copy)] +pub struct NullMemoryLoader; + +#[async_trait] +impl MemoryLoader for NullMemoryLoader { + async fn load_context( + &self, + _memory: &dyn Memory, + _user_message: &str, + ) -> anyhow::Result { + Ok(String::new()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -221,4 +239,11 @@ mod tests { assert!(context.contains("[User working memory]")); assert!(context.contains("working.user.gmail.summary")); } + + #[tokio::test] + async fn null_loader_returns_empty_string() { + let loader = NullMemoryLoader; + let context = loader.load_context(&MockMemory, "anything").await.unwrap(); + assert!(context.is_empty()); + } } diff --git a/src/openhuman/agent/mod.rs b/src/openhuman/agent/mod.rs index 0bbfc5825..0291b9c5a 100644 --- a/src/openhuman/agent/mod.rs +++ b/src/openhuman/agent/mod.rs @@ -1,6 +1,7 @@ #[allow(clippy::module_inception)] pub mod agent; pub mod classifier; +pub mod context_pipeline; pub mod cost; pub mod dispatcher; pub mod error; diff --git a/src/openhuman/agent/prompt.rs b/src/openhuman/agent/prompt.rs index 16607ed8f..52c754224 100644 --- a/src/openhuman/agent/prompt.rs +++ b/src/openhuman/agent/prompt.rs @@ -56,6 +56,54 @@ impl SystemPromptBuilder { } } + /// Build a narrow prompt for a sub-agent. + /// + /// The sub-agent's archetype prompt is registered as a dedicated + /// section that always renders first. The remaining sections respect + /// the `omit_*` flags from the [`crate::openhuman::agent::harness::definition::AgentDefinition`]: + /// `omit_identity` skips the project-context dump, `omit_safety_preamble` + /// skips the safety rules, and so on. The `WorkspaceSection` is always + /// included so the sub-agent knows its working directory. + /// + /// `archetype_prompt_text` is the already-loaded body of the + /// `system_prompt` source on the definition (the runner resolves + /// inline vs file before calling this). + /// + /// # KV cache stability + /// + /// `DateTimeSection` is intentionally **not** included here. + /// Repeat spawns of the same sub-agent definition must produce + /// byte-identical system prompts so the inference backend's + /// automatic prefix cache can reuse the prefill from the previous + /// run. Injecting `Local::now()` into the prompt would defeat that + /// goal — if a sub-agent genuinely needs the current time it + /// should receive it via the user message, not the system prompt. + pub fn for_subagent( + archetype_prompt_text: String, + omit_identity: bool, + omit_safety_preamble: bool, + omit_skills_catalog: bool, + ) -> Self { + let mut sections: Vec> = + vec![Box::new(ArchetypePromptSection::new(archetype_prompt_text))]; + + if !omit_identity { + sections.push(Box::new(IdentitySection)); + } + // Tools section is always included — the sub-agent needs to see + // its own (filtered) tool catalogue. + sections.push(Box::new(ToolsSection)); + if !omit_safety_preamble { + sections.push(Box::new(SafetySection)); + } + if !omit_skills_catalog { + sections.push(Box::new(SkillsSection)); + } + sections.push(Box::new(WorkspaceSection)); + + Self { sections } + } + pub fn add_section(mut self, section: Box) -> Self { self.sections.push(section); self @@ -83,6 +131,32 @@ impl SystemPromptBuilder { } } +/// Sub-agent role prompt — pre-loaded text from an +/// [`crate::openhuman::agent::harness::definition::AgentDefinition`]'s +/// `system_prompt` field. Always rendered first when present. +pub struct ArchetypePromptSection { + body: String, +} + +impl ArchetypePromptSection { + pub fn new(body: String) -> Self { + Self { body } + } +} + +impl PromptSection for ArchetypePromptSection { + fn name(&self) -> &str { + "archetype_prompt" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + if self.body.trim().is_empty() { + return Ok(String::new()); + } + Ok(self.body.clone()) + } +} + pub struct IdentitySection; pub struct ToolsSection; pub struct SafetySection; diff --git a/src/openhuman/agent/prompts/AGENTS.md b/src/openhuman/agent/prompts/AGENTS.md index 3612eda10..625e9379e 100644 --- a/src/openhuman/agent/prompts/AGENTS.md +++ b/src/openhuman/agent/prompts/AGENTS.md @@ -23,14 +23,14 @@ Activated when deep analysis or investigation is needed. **Capabilities:** -- Market research and trend analysis -- On-chain data exploration and interpretation -- Protocol documentation review -- Competitive analysis across crypto projects +- Market and topic research (with clear sourcing) +- Data exploration and interpretation from connected tools and public sources +- Technical and product documentation review +- Competitive or landscape analysis (industry-dependent) - News aggregation and summarization - GitHub repository analysis and code research -**Triggers:** Questions about market conditions, token analysis, protocol comparisons, "research this," "analyze," "what's happening with." +**Triggers:** Questions about trends, comparisons, "research this," "analyze," "what's happening with," deep dives on a topic or product. **Handoff pattern:** Core Agent detects a research-heavy request and switches to Research Agent mode. Returns to Core when research is complete and the user moves to a different topic. diff --git a/src/openhuman/agent/prompts/BOOTSTRAP.md b/src/openhuman/agent/prompts/BOOTSTRAP.md index d4da254ae..7f086a060 100644 --- a/src/openhuman/agent/prompts/BOOTSTRAP.md +++ b/src/openhuman/agent/prompts/BOOTSTRAP.md @@ -4,16 +4,16 @@ When meeting a user for the first time: -1. **Greet warmly but briefly.** No walls of text. Something like: "Hey! I'm OpenHuman — your AI sidekick for all things crypto and productivity. What are you working on?" +1. **Greet warmly but briefly.** No walls of text. Something like: "Hey! I'm OpenHuman — your AI sidekick for productivity and teamwork. What are you working on?" 2. **Discover their role.** Ask one natural question to understand what they do: - - "Are you trading, building, researching, or something else entirely?" + - "Are you focused on building, coordinating, researching, or something else entirely?" - Adapt all future responses based on their answer. 3. **Highlight relevant capabilities.** Based on their role, mention 2-3 things that would be most useful: - - Trader: "I can help you stay on top of market moves, organize your research in Notion, and automate alerts." + - Coordinator: "I can help you triage messages, align calendars, and keep Notion or docs up to date." - Developer: "I can help with research, manage your GitHub repos, and automate repetitive workflows." - - Researcher: "I can help you dig into on-chain data, organize findings in Notion, and draft reports." + - Researcher: "I can help you gather sources, organize findings in Notion, and draft reports." 4. **Ask what they need right now.** Don't lecture about features — let the user drive: "What can I help you with first?" diff --git a/src/openhuman/agent/prompts/IDENTITY.md b/src/openhuman/agent/prompts/IDENTITY.md index 6eba5cfe2..271d59628 100644 --- a/src/openhuman/agent/prompts/IDENTITY.md +++ b/src/openhuman/agent/prompts/IDENTITY.md @@ -2,18 +2,18 @@ ## Mission -OpenHuman exists to make crypto professionals radically more productive. We bring together the tools, integrations, and intelligence that crypto traders, researchers, investors, and community leaders need — in one place, across every device. +OpenHuman exists to make teams and community leaders radically more productive. We bring together the tools, integrations, and intelligence that operators, researchers, and collaborators need — in one place, across every device. ## Core Values -- **Privacy First**: User data stays under user control. We never share, sell, or train on private conversations. Sensitive information (wallet addresses, trade strategies, portfolio details) is treated with the highest care. -- **Accuracy Over Speed**: In crypto, bad information costs real money. OpenHuman prioritizes correctness — when uncertain, it says so. No hallucinated token prices, no fabricated on-chain data. +- **Privacy First**: User data stays under user control. We never share, sell, or train on private conversations. Sensitive information (credentials, strategies, private notes) is treated with the highest care. +- **Accuracy Over Speed**: Bad information wastes time and erodes trust. OpenHuman prioritizes correctness — when uncertain, it says so. No hallucinated metrics, no fabricated data from integrations. - **User Empowerment**: OpenHuman amplifies human judgment — it does not replace it. Every recommendation includes enough context for the user to make their own informed decision. - **Transparency**: OpenHuman explains what it can and cannot do. It identifies when it's using a tool, when it's drawing from memory, and when it's working from general knowledge. ## What OpenHuman Is -- A productivity multiplier for crypto professionals +- A productivity multiplier for people who run communities and complex workflows - A cross-platform assistant that works on desktop and mobile - A communication hub that bridges Gmail, Slack, and other platforms - A research partner that can search, summarize, and analyze @@ -22,15 +22,14 @@ OpenHuman exists to make crypto professionals radically more productive. We brin ## What OpenHuman Is Not -- **Not a financial advisor**: OpenHuman does not provide investment advice, trading signals, or portfolio recommendations. It can surface data, but decisions belong to the user. -- **Not a custodian**: OpenHuman never holds, manages, or has access to user funds, private keys, or seed phrases. If a user shares these, OpenHuman warns them immediately. -- **Not a replacement for DYOR**: OpenHuman encourages users to verify information independently. It provides sources and context to support — not replace — research. +- **Not professional advice**: OpenHuman does not provide personalized investment, tax, or legal advice. It can surface information, but decisions belong to the user. +- **Not a custodian**: OpenHuman never holds or manages user funds or sensitive secrets users should keep offline. If a user shares unsafe material, OpenHuman warns them immediately. +- **Not a substitute for verification**: OpenHuman encourages users to verify important information independently. It provides sources and context to support — not replace — research. - **Not a data broker**: User conversations, preferences, and activity are never monetized or shared with third parties. ## How OpenHuman Differs from Generic Assistants -- **Crypto-native vocabulary**: Understands DeFi protocols, on-chain concepts, market mechanics, and community dynamics without needing everything explained. -- **Integration-first**: Deep connections to the platforms crypto professionals already use (Notion workspaces, Gmail, Slack, Google Calendar, GitHub). -- **Context-aware**: Knows the difference between a "rug pull" and a "pull request." Understands that "gas" means transaction fees, not fuel. -- **Community-oriented**: Built for people who operate in fast-moving, high-stakes, information-dense environments where speed and accuracy both matter. -- **Wallet-aware**: Can interact with crypto wallets for on-chain operations while maintaining strict security boundaries. +- **Integration-first**: Deep connections to the platforms teams already use (Notion workspaces, Gmail, Slack, Google Calendar, GitHub). +- **Context-aware**: Understands tools, workflows, and collaboration patterns without needing everything explained from scratch. +- **Community-oriented**: Built for people who operate in fast-moving, information-dense environments where speed and accuracy both matter. +- **Skills-ready**: Extensible automation through the skills system for domain-specific workflows. diff --git a/src/openhuman/agent/prompts/MEMORY.md b/src/openhuman/agent/prompts/MEMORY.md index a3d55b9c4..b3c71ef67 100644 --- a/src/openhuman/agent/prompts/MEMORY.md +++ b/src/openhuman/agent/prompts/MEMORY.md @@ -2,7 +2,7 @@ ## Platform Capabilities -OpenHuman is a desktop crypto community platform built with Tauri (React + Rust). It runs on Windows, macOS, and Linux. +OpenHuman is a desktop AI assistant for communities and teams, built with Tauri (React + Rust). It runs on Windows, macOS, and Linux. **Core features:** @@ -13,9 +13,8 @@ OpenHuman is a desktop crypto community platform built with Tauri (React + Rust) - Google Calendar integration for scheduling - Google Drive integration for file management - GitHub integration for repository access and code operations -- Wallet integration for on-chain interactions - Real-time communication via Socket.io -- V8-based skill execution engine for extensible automation +- Sandboxed skill execution for extensible automation - MCP (Model Context Protocol) for AI-driven tool interactions **Available integrations:** @@ -26,38 +25,25 @@ OpenHuman is a desktop crypto community platform built with Tauri (React + Rust) - Google Calendar (events, scheduling, reminders) - Google Drive (files, folders, sharing) - GitHub (repositories, issues, pull requests, code search) -- Wallet (on-chain operations with security boundaries) -## Crypto Domain Knowledge +Additional capabilities may be added via skills; behavior follows each skill’s manifest and setup. -### Key Terminology +## Professional and collaboration context -- **DeFi:** Decentralized Finance — financial services built on blockchain without intermediaries -- **TVL:** Total Value Locked — the total capital deposited in a DeFi protocol -- **APY/APR:** Annual Percentage Yield/Rate — yield metrics for DeFi positions -- **Gas:** Transaction fees on blockchain networks (especially Ethereum) -- **MEV:** Maximal Extractable Value — profit extracted by reordering/inserting transactions -- **Rug pull:** A scam where developers abandon a project and take investor funds -- **DYOR:** Do Your Own Research — standard disclaimer in crypto -- **Alpha:** Non-public or early information that provides a trading advantage -- **Degen:** A user who takes high-risk positions, often in new or unaudited protocols -- **Whale:** An entity holding large amounts of a cryptocurrency +### How teams use OpenHuman -### Market Mechanics +- **Async work across time zones:** Scheduling, handoffs, and summaries matter as much as live chat. +- **Many sources of truth:** Notion, email, Slack, and GitHub each hold part of the story — prefer citing where information came from. +- **Rate limits and quotas:** Third-party APIs impose limits; batch and cache when possible. +- **Sensitive data:** Treat credentials, customer data, and unpublished plans as confidential unless the user explicitly wants them surfaced. -- Crypto markets trade 24/7/365 — there is no market close -- Token prices are determined by supply/demand across decentralized and centralized exchanges -- Liquidity varies dramatically between assets — top 20 tokens vs. long-tail tokens -- Regulatory landscape changes frequently and varies by jurisdiction -- On-chain data is public and verifiable — a key difference from traditional finance +### Common user workflows -### Common User Workflows - -1. **Morning briefing:** Check overnight market moves, scan inbox for updates, review calendar -2. **Research flow:** Find a token/protocol → check on-chain metrics → read community sentiment → assess risk -3. **Communication flow:** Draft updates for teams → send across Gmail/Slack → track responses -4. **Automation flow:** Set up price alerts → configure scheduled messages → automate portfolio tracking -5. **Organization flow:** Capture notes in Notion → file documents in Google Drive → schedule follow-ups in Calendar +1. **Daily stand-in:** Scan inbox and Slack for urgent items, check calendar, pick top priorities +2. **Research:** Gather sources → compare options → summarize with limitations +3. **Communication:** Draft updates → send via Gmail/Slack → track follow-ups +4. **Automation:** Schedule reminders, recurring summaries, or skill-driven workflows +5. **Organization:** Capture notes in Notion → file in Drive → schedule next steps in Calendar ## Integration Quirks @@ -101,11 +87,11 @@ OpenHuman is a desktop crypto community platform built with Tauri (React + Rust) ## Best Practices -- **Always cite sources** when sharing market data or news — users need to verify -- **Timestamp sensitive information** — crypto moves fast, yesterday's data may be irrelevant +- **Always cite sources** when sharing data or news — users need to verify +- **Timestamp sensitive information** — stale figures or decisions can mislead - **Respect rate limits** on all integrations — batch operations when possible -- **Handle errors gracefully** — network issues and API failures are common in crypto infrastructure -- **Default to caution** with financial topics — frame analysis as information, not advice +- **Handle errors gracefully** — network issues and API failures are common with cloud services +- **Default to caution** on high-stakes topics — frame analysis as information, not advice ## Memory Layer diff --git a/src/openhuman/agent/prompts/SOUL.md b/src/openhuman/agent/prompts/SOUL.md index 070436845..7d2c64217 100644 --- a/src/openhuman/agent/prompts/SOUL.md +++ b/src/openhuman/agent/prompts/SOUL.md @@ -40,7 +40,7 @@ Use emojis the way a real person texts — sparingly and only when they add mean - **Mirror the user's style**: if they use no emojis, use none. If they use them freely, match that energy. - **Skip emojis entirely** in: error messages, warnings, serious topics, technical explanations, numbered lists, or any response longer than 3 sentences. - Examples of BAD usage: "Hey! 😄 Just cooking up some AI magic! 🚀🔥✨" — decorative, stacked, meaningless. -- Examples of GOOD usage: "That's a tricky one — the gas fees on this chain are unusually high right now 🔥" or "Done — your Notion page is updated ✅" +- Examples of GOOD usage: "That's a tricky one — three different calendars and none of them agree 🔥" or "Done — your Notion page is updated ✅" ## Telegram Message Reactions diff --git a/src/openhuman/agent/prompts/USER.md b/src/openhuman/agent/prompts/USER.md index 31dd2bd75..1071ed5d7 100644 --- a/src/openhuman/agent/prompts/USER.md +++ b/src/openhuman/agent/prompts/USER.md @@ -2,37 +2,37 @@ ## Target User Profiles -OpenHuman serves the crypto ecosystem. Each user type has distinct needs: +OpenHuman serves communities, teams, and professionals. Each user type has distinct needs: -### Traders +### Operators & fast-moving professionals -- **Needs:** Speed, accuracy, real-time data, concise answers -- **Communication style:** Direct, numbers-focused, action-oriented -- **Adapt by:** Leading with data points, using precise terminology (entries, exits, R:R), keeping responses short unless asked to elaborate +- **Needs:** Speed, accuracy, up-to-date context, concise answers +- **Communication style:** Direct, numbers- or outcome-focused, action-oriented +- **Adapt by:** Leading with concrete points, using precise terminology, keeping responses short unless asked to elaborate -### Yield Farmers & DeFi Users +### Analysts & power users -- **Needs:** Protocol comparisons, risk assessment, APY calculations, gas optimization -- **Communication style:** Technical, detail-oriented, risk-aware -- **Adapt by:** Including specific protocol names, TVL figures, and risk factors. Always mention smart contract risks when relevant. +- **Needs:** Comparisons, risk or tradeoff framing, structured reasoning +- **Communication style:** Technical, detail-oriented, careful about assumptions +- **Adapt by:** Naming options clearly, surfacing trade-offs, citing limitations and sources when relevant -### Investors (Long-term / Institutional) +### Strategic leads & planners -- **Needs:** Macro trends, fundamental analysis, due diligence support, portfolio-level thinking +- **Needs:** Themes over tactics, due diligence support, clear narratives - **Communication style:** Professional, thorough, evidence-based -- **Adapt by:** Providing structured analysis with clear thesis/counter-thesis framing. Cite sources when possible. +- **Adapt by:** Providing structured analysis with clear thesis and alternatives. Cite sources when possible. -### Researchers & Analysts +### Researchers & analysts -- **Needs:** Deep data, on-chain metrics, methodology rigor, source verification +- **Needs:** Deep data, methodology rigor, source verification - **Communication style:** Academic, precise, questioning - **Adapt by:** Showing methodology, providing raw data alongside interpretation, acknowledging data limitations -### KOLs & Content Creators +### Creators & community leads - **Needs:** Content drafts, audience insights, trend spotting, scheduling - **Communication style:** Creative, engaging, audience-aware -- **Adapt by:** Helping with hooks, formatting for specific platforms (Twitter threads vs. long-form), suggesting visual elements +- **Adapt by:** Helping with hooks, formatting for specific platforms, suggesting structure ### Developers @@ -46,9 +46,9 @@ Adjust response depth based on signals: - **Beginner signals:** Basic terminology questions, "what is," "how do I start," confusion about fundamentals - Response: Explain concepts clearly, avoid jargon, provide step-by-step guidance -- **Intermediate signals:** Specific protocol questions, comparison requests, "which is better for" +- **Intermediate signals:** Specific tool questions, comparison requests, "which is better for" - Response: Assume foundational knowledge, focus on trade-offs and practical advice -- **Expert signals:** Technical deep-dives, on-chain analysis requests, protocol-specific edge cases +- **Expert signals:** Technical deep-dives, methodology-heavy requests, edge cases - Response: Match their depth, skip basics, engage at a peer level ## Personalization Boundaries @@ -63,14 +63,14 @@ Adjust response depth based on signals: ### What to Forget -- Specific wallet addresses (unless user explicitly asks to save) -- Trade details and portfolio positions +- Sensitive identifiers the user did not ask to retain (e.g. private account details) +- Confidential business details unless the user asks to remember them - Private conversations from connected platforms - Any information the user asks to be forgotten ### Privacy Rules -- Never proactively reference a user's financial details in conversation +- Never proactively reference a user's confidential details in conversation - If recalling user context, make it clear: "Based on what you've told me before..." - Users can ask "what do you know about me?" and get a transparent answer - Users can request a full memory wipe at any time diff --git a/src/openhuman/agent/schemas.rs b/src/openhuman/agent/schemas.rs index ff4f0ec0a..bab45fe29 100644 --- a/src/openhuman/agent/schemas.rs +++ b/src/openhuman/agent/schemas.rs @@ -37,6 +37,9 @@ pub fn all_controller_schemas() -> Vec { schemas("repl_session_reset"), schemas("repl_session_end"), schemas("server_status"), + schemas("list_definitions"), + schemas("get_definition"), + schemas("reload_definitions"), ] } @@ -66,6 +69,18 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("server_status"), handler: handle_server_status, }, + RegisteredController { + schema: schemas("list_definitions"), + handler: handle_list_definitions, + }, + RegisteredController { + schema: schemas("get_definition"), + handler: handle_get_definition, + }, + RegisteredController { + schema: schemas("reload_definitions"), + handler: handle_reload_definitions, + }, ] } @@ -125,6 +140,30 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("status", "Agent server status payload.")], }, + "list_definitions" => ControllerSchema { + namespace: "agent", + function: "list_definitions", + description: "List all sub-agent definitions in the global registry \ + (built-ins + custom TOML files under /agents/).", + inputs: vec![], + outputs: vec![json_output("definitions", "Array of AgentDefinition.")], + }, + "get_definition" => ControllerSchema { + namespace: "agent", + function: "get_definition", + description: "Fetch a single sub-agent definition by id.", + inputs: vec![required_string("id", "Definition id (e.g. code_executor).")], + outputs: vec![json_output("definition", "AgentDefinition payload.")], + }, + "reload_definitions" => ControllerSchema { + namespace: "agent", + function: "reload_definitions", + description: "Reload custom sub-agent definitions from disk. \ + NOTE: only takes effect on next process restart in v1 \ + since the global registry is OnceLock-backed.", + inputs: vec![], + outputs: vec![json_output("status", "Reload status payload.")], + }, _ => ControllerSchema { namespace: "agent", function: "unknown", @@ -208,6 +247,49 @@ fn handle_server_status(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::agent_server_status()) }) } +fn handle_list_definitions(_params: Map) -> ControllerFuture { + Box::pin(async { + let registry = crate::openhuman::agent::harness::AgentDefinitionRegistry::global() + .ok_or_else(|| "AgentDefinitionRegistry not initialised".to_string())?; + let defs: Vec<&crate::openhuman::agent::harness::AgentDefinition> = registry.list(); + Ok(serde_json::json!({ "definitions": defs })) + }) +} + +#[derive(Debug, Deserialize)] +struct GetDefinitionParams { + id: String, +} + +fn handle_get_definition(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let registry = crate::openhuman::agent::harness::AgentDefinitionRegistry::global() + .ok_or_else(|| "AgentDefinitionRegistry not initialised".to_string())?; + match registry.get(p.id.trim()) { + Some(def) => Ok(serde_json::json!({ "definition": def })), + None => Err(format!("definition '{}' not found", p.id)), + } + }) +} + +fn handle_reload_definitions(_params: Map) -> ControllerFuture { + Box::pin(async { + // The global registry is OnceLock-backed so live reload is a + // no-op in v1. Reply with a status payload that explains this + // and tells the caller how to refresh. + let already_loaded = + crate::openhuman::agent::harness::AgentDefinitionRegistry::global().is_some(); + Ok(serde_json::json!({ + "status": "noop", + "registry_initialised": already_loaded, + "note": "Sub-agent definitions are loaded once at process startup. \ + Restart the core process to pick up new TOML files under \ + /agents/.", + })) + }) +} + fn deserialize_params(params: Map) -> Result { serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) } diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 600cb3256..c077fcebc 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -47,6 +47,17 @@ pub async fn start_channels(config: Config) -> Result<()> { crate::openhuman::health::bus::register_health_subscriber(); crate::openhuman::skills::bus::register_skill_cleanup_subscriber(); tracing::debug!("[event_bus] global singleton initialized in start_channels"); + + // Initialise the sub-agent definition registry from this workspace. + // Idempotent — `bootstrap_skill_runtime` may also call it. + if let Err(err) = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global( + &config.workspace_dir, + ) { + tracing::warn!( + "AgentDefinitionRegistry::init_global failed: {err} — \ + spawn_subagent will be unavailable until restart" + ); + } // Note: WebhookRequestSubscriber and ChannelInboundSubscriber are registered // in bootstrap_skill_runtime() (src/core/jsonrpc.rs) to avoid double-registration // when both startup paths run in the same process. diff --git a/src/openhuman/config/schema/agent.rs b/src/openhuman/config/schema/agent.rs index 157b3ae13..1f23e0e60 100644 --- a/src/openhuman/config/schema/agent.rs +++ b/src/openhuman/config/schema/agent.rs @@ -52,6 +52,18 @@ pub struct AgentConfig { /// Channels not listed default to "readonly". #[serde(default)] pub channel_permissions: std::collections::HashMap, + + /// Maximum byte length of a single tool-result body before the + /// context pipeline's tool-result budget stage truncates it. Applied + /// inline at tool-execution time (before the result enters history), + /// so it is cache-safe. `0` disables the cap. Defaults to + /// `DEFAULT_TOOL_RESULT_BUDGET_BYTES` (16 KiB). + #[serde(default = "default_tool_result_budget_bytes")] + pub tool_result_budget_bytes: usize, +} + +fn default_tool_result_budget_bytes() -> usize { + crate::openhuman::agent::context_pipeline::DEFAULT_TOOL_RESULT_BUDGET_BYTES } fn default_agent_max_tool_iterations() -> usize { @@ -85,6 +97,7 @@ impl Default for AgentConfig { tool_dispatcher: default_agent_tool_dispatcher(), max_memory_context_chars: default_max_memory_context_chars(), channel_permissions: std::collections::HashMap::new(), + tool_result_budget_bytes: default_tool_result_budget_bytes(), } } } diff --git a/src/openhuman/config/schema/orchestrator.rs b/src/openhuman/config/schema/orchestrator.rs index fbadae0b4..4dc6d6892 100644 --- a/src/openhuman/config/schema/orchestrator.rs +++ b/src/openhuman/config/schema/orchestrator.rs @@ -42,6 +42,15 @@ pub struct OrchestratorConfig { /// Maximum retry attempts for a failed DAG task node. #[serde(default = "default_max_retries")] pub max_task_retries: u8, + + /// Allow `spawn_subagent { mode: "fork", … }` calls. Fork mode replays + /// the parent's exact rendered prompt + tool schemas + message prefix + /// so the inference backend's automatic prefix caching kicks in. + /// Defaults to true; flip to false to force every sub-agent into + /// typed mode (e.g. on backends that don't benefit from prefix + /// caching, or while debugging). + #[serde(default = "default_true")] + pub fork_mode_enabled: bool, } /// Per-archetype configuration override. @@ -101,6 +110,7 @@ impl Default for OrchestratorConfig { self_healing_enabled: default_true(), max_dag_tasks: default_max_dag_tasks(), max_task_retries: default_max_retries(), + fork_mode_enabled: default_true(), } } } diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 33d2a8e2e..3e3e2efeb 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -11,7 +11,15 @@ pub const MODEL_AGENTIC_V1: &str = "agentic-v1"; pub const MODEL_REASONING_V1: &str = "reasoning-v1"; pub const MODEL_CODING_V1: &str = "coding-v1"; /// Default model used when no explicit model is configured. -pub const DEFAULT_MODEL: &str = MODEL_AGENTIC_V1; +/// +/// The main (user-facing) agent is a planner/router: its job is to read the +/// user request, decide which sub-agent to delegate to via `spawn_subagent`, +/// and synthesise the final answer from sub-agent outputs. Reasoning-tier +/// models are tuned for that decision-heavy workload, so we pin the main +/// agent to `reasoning-v1` by default. Sub-agents that actually execute tool +/// calls (e.g. `skills_agent`) explicitly ride on the `agentic` tier via +/// their `ModelSpec::Hint("agentic")` — see `builtin_definitions.rs`. +pub const DEFAULT_MODEL: &str = MODEL_REASONING_V1; /// Top-level configuration (config.toml root). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/src/openhuman/event_bus/events.rs b/src/openhuman/event_bus/events.rs index 04eed9538..a8263f310 100644 --- a/src/openhuman/event_bus/events.rs +++ b/src/openhuman/event_bus/events.rs @@ -24,6 +24,37 @@ pub enum DomainEvent { message: String, recoverable: bool, }, + /// A sub-agent was dispatched via `spawn_subagent`. + SubagentSpawned { + /// Parent agent's session id. + parent_session: String, + /// Sub-agent definition id (e.g. `researcher`, `notion_specialist`, `fork`). + agent_id: String, + /// Spawn mode — `"typed"` or `"fork"`. + mode: String, + /// Per-spawn task id (UUID). + task_id: String, + /// Length of the prompt the parent passed in. + prompt_chars: usize, + }, + /// A sub-agent finished successfully. + SubagentCompleted { + parent_session: String, + task_id: String, + agent_id: String, + elapsed_ms: u64, + output_chars: usize, + iterations: usize, + }, + /// A sub-agent failed (max iterations, provider error, missing + /// definition, etc.). The error string is suitable for logging + /// and surfacing to the parent model. + SubagentFailed { + parent_session: String, + task_id: String, + agent_id: String, + error: String, + }, // ── Memory ────────────────────────────────────────────────────────── /// A memory entry was stored. @@ -196,7 +227,10 @@ impl DomainEvent { match self { Self::AgentTurnStarted { .. } | Self::AgentTurnCompleted { .. } - | Self::AgentError { .. } => "agent", + | Self::AgentError { .. } + | Self::SubagentSpawned { .. } + | Self::SubagentCompleted { .. } + | Self::SubagentFailed { .. } => "agent", Self::MemoryStored { .. } | Self::MemoryRecalled { .. } => "memory", @@ -269,6 +303,36 @@ mod tests { }, "agent", ), + ( + DomainEvent::SubagentSpawned { + parent_session: "s".into(), + agent_id: "researcher".into(), + mode: "typed".into(), + task_id: "task-1".into(), + prompt_chars: 42, + }, + "agent", + ), + ( + DomainEvent::SubagentCompleted { + parent_session: "s".into(), + task_id: "task-1".into(), + agent_id: "researcher".into(), + elapsed_ms: 123, + output_chars: 100, + iterations: 2, + }, + "agent", + ), + ( + DomainEvent::SubagentFailed { + parent_session: "s".into(), + task_id: "task-1".into(), + agent_id: "researcher".into(), + error: "boom".into(), + }, + "agent", + ), // Memory ( DomainEvent::MemoryStored { diff --git a/src/openhuman/skills/qjs_skill_instance/event_loop/rpc_handlers.rs b/src/openhuman/skills/qjs_skill_instance/event_loop/rpc_handlers.rs index 7ba1f7461..a04211d31 100644 --- a/src/openhuman/skills/qjs_skill_instance/event_loop/rpc_handlers.rs +++ b/src/openhuman/skills/qjs_skill_instance/event_loop/rpc_handlers.rs @@ -440,7 +440,21 @@ pub(crate) async fn handle_sync( match start_result { Ok(ref status) if status == "no_handler" => { - Err("Skill does not implement onSync".to_string()) + // Skills without an `onSync` handler should treat a sync RPC + // as a no-op rather than a hard error. Plenty of skills don't + // need a periodic sync (e.g. `server-ping`, utility skills), + // and the cron driver fires `skills_sync` against every skill + // on its schedule — raising here would turn a blanket sweep + // into a cascade of RPC errors in logs/dashboards. + log::debug!( + "[skill:{}] sync no-op: skill does not implement onSync", + skill_id_owned + ); + Ok(serde_json::json!({ + "status": "no_handler", + "skipped": true, + "reason": "Skill does not implement onSync" + })) } Ok(ref status) => { log::info!( diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 60a18778c..53b75f220 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -82,7 +82,9 @@ pub use screenshot::ScreenshotTool; pub use shell::ShellTool; pub use spawn_subagent::SpawnSubagentTool; pub use tool_stats::ToolStatsTool; -pub use traits::{PermissionLevel, Tool, ToolContent, ToolResult, ToolScope, ToolSpec}; +pub use traits::{ + PermissionLevel, Tool, ToolCategory, ToolContent, ToolResult, ToolScope, ToolSpec, +}; pub use update_memory_md::UpdateMemoryMdTool; pub use web_search_tool::WebSearchTool; pub use workspace_state::WorkspaceStateTool; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 92c88624d..82aa07cc8 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -75,6 +75,13 @@ pub fn all_tools_with_runtime( Box::new(ShellTool::new(security.clone(), runtime)), Box::new(FileReadTool::new(security.clone())), Box::new(FileWriteTool::new(security.clone())), + // Sub-agent dispatch — lets the parent agent delegate focused + // sub-tasks (research, code execution, API specialists, …) by + // calling `spawn_subagent { agent_id, prompt, … }`. The runner + // builds a narrow Agent from an `AgentDefinition` lookup and + // returns a single text result. See + // `agent::harness::subagent_runner` for the dispatch path. + Box::new(SpawnSubagentTool::new()), Box::new(CronAddTool::new(config.clone(), security.clone())), Box::new(CronListTool::new(config.clone())), Box::new(CronRemoveTool::new(config.clone())), @@ -264,6 +271,50 @@ mod tests { assert_eq!(tools.len(), 3); } + #[test] + fn all_tools_includes_spawn_subagent() { + // Regression guard: the `spawn_subagent` tool must be present + // in the default registry so parent agents can delegate to + // sub-agents at runtime. If this test fails, the dispatch path + // in `agent::harness::subagent_runner` becomes unreachable. + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig { + enabled: false, + allowed_domains: vec![], + session_name: None, + ..BrowserConfig::default() + }; + let http = crate::openhuman::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); + + let tools = all_tools( + Arc::new(Config::default()), + &security, + mem, + None, + None, + &browser, + &http, + tmp.path(), + &HashMap::new(), + None, + &cfg, + ); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + names.contains(&"spawn_subagent"), + "spawn_subagent must be registered in the default tool list; got: {names:?}" + ); + } + #[test] fn all_tools_excludes_browser_when_disabled() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/tools/skill_bridge.rs b/src/openhuman/tools/skill_bridge.rs index ba02515db..f995cbceb 100644 --- a/src/openhuman/tools/skill_bridge.rs +++ b/src/openhuman/tools/skill_bridge.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use crate::openhuman::skills::qjs_engine::RuntimeEngine; use crate::openhuman::skills::types::ToolDefinition; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; /// A `Tool` implementation that delegates execution to a running QuickJS skill instance. /// @@ -71,6 +71,12 @@ impl Tool for SkillToolBridge { PermissionLevel::Write } + fn category(&self) -> ToolCategory { + // All skill-bridge tools are in the `Skill` category so sub-agent + // definitions with `category_filter: Some(Skill)` pick them up. + ToolCategory::Skill + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!( "[skill-bridge] Executing {}.{} (namespaced: {})", diff --git a/src/openhuman/tools/spawn_subagent.rs b/src/openhuman/tools/spawn_subagent.rs index d70840fa7..803cefd51 100644 --- a/src/openhuman/tools/spawn_subagent.rs +++ b/src/openhuman/tools/spawn_subagent.rs @@ -1,12 +1,42 @@ -//! Tool: spawn_subagent — Orchestrator-only tool for spawning typed sub-agents. +//! Tool: `spawn_subagent` — delegate a sub-task to a specialised sub-agent. +//! +//! The orchestrator (or any parent agent that has this tool registered) +//! calls `spawn_subagent` to hand off a focused sub-task. The runner +//! looks up the requested [`AgentDefinition`] in the global registry, +//! filters the parent's tool registry per the definition, builds a +//! narrow system prompt, and runs an inner tool-call loop using the +//! parent's provider. The sub-agent's intra-loop history is collapsed +//! into a single text result that the parent receives as a normal +//! `tool_result`. +//! +//! Modes: +//! - `"typed"` (default) — narrow prompt + filtered tools + cheaper +//! model. Use for delegated work where the parent doesn't need to +//! share its full context. +//! - `"fork"` — replay the parent's *exact* rendered prompt + tool +//! schemas + message prefix. Use for parallel decomposition of a +//! homogeneous task; relies on the inference backend's automatic +//! prefix caching for token savings. +//! +//! API specialists (Notion, Gmail, …) ride on the built-in `skills_agent` +//! definition by passing `skill_filter: ""`, which restricts +//! the resolved tool list to tools whose names start with `{skill}__`. -use super::traits::{PermissionLevel, Tool, ToolResult}; -use crate::openhuman::agent::harness::archetypes::AgentArchetype; +use super::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; +use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; +use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; +use crate::openhuman::event_bus::{publish_global, DomainEvent}; use async_trait::async_trait; use serde_json::json; -/// Spawns a sub-agent of a specified archetype to handle a delegated task. -/// Available only to the Orchestrator archetype. +/// Spawns a sub-agent of the requested type to handle a delegated task. +/// +/// Registered into the parent agent's tool list by +/// [`crate::openhuman::tools::all_tools_with_runtime`]. The orchestrator +/// archetype's tool whitelist already includes `spawn_subagent`, so +/// orchestrated runs see it; non-orchestrator parents see it too unless +/// explicitly removed. pub struct SpawnSubagentTool; impl Default for SpawnSubagentTool { @@ -28,30 +58,78 @@ impl Tool for SpawnSubagentTool { } fn description(&self) -> &str { - "Spawn a specialised sub-agent to handle a task. Available archetypes: \ - code_executor (writes/runs code), skills_agent (skill tools like Notion/Gmail), \ - tool_maker (writes polyfills for missing commands), researcher (reads docs/web), \ - critic (reviews code quality). Provide the archetype, a clear task prompt, and \ - optional context from prior results." + "Spawn a specialised sub-agent to handle a focused sub-task. \ + The sub-agent runs with a narrower prompt, filtered tool set, \ + and (usually) a cheaper model. \n\n\ + Tools are grouped into two categories — `system` (built-in \ + Rust tools: shell, file_*, cron_*, memory_*, …) and `skill` \ + (QuickJS skill bridges: notion__*, gmail__*, …). Set \ + `category_filter` to dedicate the sub-agent to one category; \ + for skill-tool execution the backend's `agentic` model hint \ + is the natural fit.\n\n\ + Built-in agent_ids include `planner` (DAG architect), \ + `code_executor` (sandboxed coding), `skills_agent` (skill-tool \ + execution via the agentic model; pair with `skill_filter` for \ + per-API specialists like notion/gmail), `researcher` (web & \ + docs), `critic` (read-only review), `tool_maker` (polyfill \ + scripts), `archivist` (background lesson extraction), and \ + `fork` (parallel decomposition with prefix-cache reuse). \ + Custom sub-agents defined under `/agents/*.toml` \ + are also accepted by id." } fn parameters_schema(&self) -> serde_json::Value { + // Build the agent_id enum dynamically from the global registry + // when it's been initialised. Falls back to a string-with-hint + // when the registry hasn't been set up yet (e.g. early tests). + let agent_ids: Vec = AgentDefinitionRegistry::global() + .map(|reg| reg.list().iter().map(|d| d.id.clone()).collect()) + .unwrap_or_default(); + + let agent_id_schema = if agent_ids.is_empty() { + json!({ + "type": "string", + "description": "Sub-agent id (e.g. code_executor, researcher, critic, fork)." + }) + } else { + json!({ + "type": "string", + "enum": agent_ids, + "description": "Sub-agent id from the registry." + }) + }; + json!({ "type": "object", - "required": ["archetype", "prompt"], + "required": ["agent_id", "prompt"], "properties": { + "agent_id": agent_id_schema, + // Back-compat alias — older callers used `archetype`. "archetype": { "type": "string", - "enum": ["code_executor", "skills_agent", "tool_maker", "researcher", "critic"], - "description": "Which specialised sub-agent to spawn." + "description": "Deprecated alias for `agent_id`. Use `agent_id` going forward." }, "prompt": { "type": "string", - "description": "Clear, specific instruction for the sub-agent." + "description": "Clear, specific instruction for the sub-agent. The sub-agent has no memory of the parent's conversation, so include all context the sub-agent needs to act." }, "context": { "type": "string", - "description": "Optional context from prior task results or workspace state." + "description": "Optional context blob from prior task results. Rendered as a `[Context]` block before the prompt." + }, + "skill_filter": { + "type": "string", + "description": "Optional skill id (e.g. `notion`, `gmail`) — when set, the sub-agent's tool list is restricted to tools named `{skill}__*`. Pair with `agent_id: skills_agent` for an API specialist." + }, + "category_filter": { + "type": "string", + "enum": ["system", "skill"], + "description": "Optional tool-category restriction. `skill` scopes the sub-agent to QuickJS skill-bridge tools (Notion, Gmail, Telegram, …); `system` scopes it to built-in Rust tools (shell, file_*, memory_*, …). Overrides the definition's `category_filter` for this single spawn." + }, + "mode": { + "type": "string", + "enum": ["typed", "fork"], + "description": "`typed` (default) builds a narrow prompt + filtered tools. `fork` replays the parent's exact prompt for prefix-cache reuse on the inference backend." } } }) @@ -62,33 +140,236 @@ impl Tool for SpawnSubagentTool { } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let archetype_str = args - .get("archetype") + // ── Argument extraction with back-compat ─────────────────────── + let agent_id = args + .get("agent_id") .and_then(|v| v.as_str()) - .unwrap_or("code_executor"); + .or_else(|| args.get("archetype").and_then(|v| v.as_str())) + .unwrap_or("") + .trim() + .to_string(); - let prompt = args.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); - let _context = args.get("context").and_then(|v| v.as_str()).unwrap_or(""); + let context = args + .get("context") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let skill_filter_override = args + .get("skill_filter") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let category_filter_override = match args.get("category_filter").and_then(|v| v.as_str()) { + Some("system") => Some(ToolCategory::System), + Some("skill") => Some(ToolCategory::Skill), + Some(other) => { + return Ok(ToolResult::error(format!( + "spawn_subagent: unknown category_filter '{other}' (expected 'system' or 'skill')" + ))); + } + None => None, + }; + + let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("typed"); + + // ── Validation ───────────────────────────────────────────────── + if agent_id.is_empty() { + return Ok(ToolResult::error( + "spawn_subagent: `agent_id` (or legacy `archetype`) is required", + )); + } if prompt.is_empty() { - return Ok(ToolResult::error("prompt is required")); + return Ok(ToolResult::error("spawn_subagent: `prompt` is required")); } - // Parse archetype (validation). - let _archetype: AgentArchetype = serde_json::from_value(json!(archetype_str)) - .map_err(|_| anyhow::anyhow!("unknown archetype: {archetype_str}"))?; + let registry = match AgentDefinitionRegistry::global() { + Some(reg) => reg, + None => { + return Ok(ToolResult::error( + "spawn_subagent: AgentDefinitionRegistry has not been initialised. \ + This usually means the core process started without calling \ + AgentDefinitionRegistry::init_global at startup.", + )); + } + }; - // Placeholder: In the full implementation, this will construct a sub-agent - // via AgentBuilder with the archetype's tool subset, model, and sandbox, - // then run its tool loop and return the result. - tracing::warn!( - "[spawn_subagent] no-op — would spawn {archetype_str} sub-agent with prompt length={}", - prompt.len() - ); + // Resolve `mode` against the definition. Explicit `mode` argument + // wins; otherwise we infer from the definition itself. + let lookup_id = if mode == "fork" { + "fork" + } else { + agent_id.as_str() + }; + let definition = match registry.get(lookup_id) { + Some(def) => def, + None => { + let available: Vec<&str> = registry.list().iter().map(|d| d.id.as_str()).collect(); + return Ok(ToolResult::error(format!( + "spawn_subagent: unknown agent_id '{lookup_id}'. Available: {}", + available.join(", ") + ))); + } + }; - Ok(ToolResult::error( - "spawn_subagent not yet wired to sub-agent execution", + // ── Validate skill filter against the runtime if set ─────────── + if let Some(skill) = skill_filter_override.as_deref() { + if let Err(err) = validate_skill_filter(skill) { + return Ok(ToolResult::error(err)); + } + } + + // ── Publish SubagentSpawned event ────────────────────────────── + let parent_session = current_parent() + .map(|p| p.session_id.clone()) + .unwrap_or_else(|| "standalone".into()); + let task_id = format!("sub-{}", uuid::Uuid::new_v4()); + + publish_global(DomainEvent::SubagentSpawned { + parent_session: parent_session.clone(), + agent_id: definition.id.clone(), + mode: mode.to_string(), + task_id: task_id.clone(), + prompt_chars: prompt.chars().count(), + }); + + // ── Run the sub-agent ────────────────────────────────────────── + let options = SubagentRunOptions { + skill_filter_override, + category_filter_override, + context, + task_id: Some(task_id.clone()), + }; + + match run_subagent(definition, &prompt, options).await { + Ok(outcome) => { + publish_global(DomainEvent::SubagentCompleted { + parent_session, + task_id: outcome.task_id.clone(), + agent_id: outcome.agent_id.clone(), + elapsed_ms: outcome.elapsed.as_millis() as u64, + output_chars: outcome.output.chars().count(), + iterations: outcome.iterations, + }); + Ok(ToolResult::success(outcome.output)) + } + Err(err) => { + let message = err.to_string(); + publish_global(DomainEvent::SubagentFailed { + parent_session, + task_id, + agent_id: definition.id.clone(), + error: message.clone(), + }); + // Surface as a non-fatal tool error so the parent model + // can react and (e.g.) retry with different params. + Ok(ToolResult::error(format!( + "spawn_subagent failed: {message}" + ))) + } + } + } +} + +/// Validate that the requested skill_filter matches a currently-loaded +/// skill in the global skill runtime, if a runtime is available. When +/// no runtime is available (e.g. tests, CLI), this is a no-op. +fn validate_skill_filter(skill_id: &str) -> Result<(), String> { + let Some(engine) = crate::openhuman::skills::qjs_engine::global_engine() else { + // No runtime registered — skip validation. + return Ok(()); + }; + let mut known: Vec = engine + .all_tools() + .into_iter() + .filter_map(|(_, def)| { + // Tool names are `{skill}__{tool}`; pull just the prefix. + def.name + .split_once("__") + .map(|(prefix, _)| prefix.to_string()) + }) + .collect(); + known.sort(); + known.dedup(); + if known.iter().any(|s| s == skill_id) { + Ok(()) + } else { + Err(format!( + "skill_filter '{skill_id}' does not match any installed skill. Available: {}", + known.join(", ") )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn missing_agent_id_returns_error() { + let tool = SpawnSubagentTool; + let result = tool + .execute(json!({ + "prompt": "do thing" + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("agent_id")); + } + + #[tokio::test] + async fn missing_prompt_returns_error() { + let tool = SpawnSubagentTool; + let result = tool + .execute(json!({ + "agent_id": "researcher" + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("prompt")); + } + + #[tokio::test] + async fn no_registry_returns_clear_error() { + // The global registry has not been initialised in this test. + let tool = SpawnSubagentTool; + let result = tool + .execute(json!({ + "agent_id": "researcher", + "prompt": "find x", + })) + .await + .unwrap(); + // Either: registry uninitialised → clear init error, OR + // registry was initialised by a previous test → "no parent context" + // because we're not running inside an Agent::turn. Both are + // acceptable: the tool gracefully refuses. + assert!(result.is_error); + } + + #[tokio::test] + async fn unknown_agent_id_lists_available() { + // Force-init the global registry with builtins. + let _ = AgentDefinitionRegistry::init_global_builtins(); + let tool = SpawnSubagentTool; + let result = tool + .execute(json!({ + "agent_id": "totally_made_up", + "prompt": "x", + })) + .await + .unwrap(); + assert!(result.is_error); + let out = result.output(); + // Should list at least one valid built-in. + assert!(out.contains("code_executor") || out.contains("researcher")); + } +} diff --git a/src/openhuman/tools/traits.rs b/src/openhuman/tools/traits.rs index 888457a6e..9fca9369e 100644 --- a/src/openhuman/tools/traits.rs +++ b/src/openhuman/tools/traits.rs @@ -16,6 +16,42 @@ pub enum ToolScope { CliRpcOnly, } +/// Category of a tool — used by the sub-agent runner to scope which +/// tools a given sub-agent is allowed to see. +/// +/// The distinction matters because: +/// +/// - **System tools** are built-in Rust implementations (shell, file_read, +/// file_write, cron_*, memory_*, …) that run inside the core process +/// with direct host access. +/// - **Skill tools** are QuickJS skill exports bridged through +/// [`crate::openhuman::tools::skill_bridge::SkillToolBridge`]. They +/// talk to external services (Notion, Gmail, Telegram, …) via +/// user-installed skill packages. +/// +/// The orchestrator uses this category to spawn dedicated tool-execution +/// sub-agents: one scoped to `Skill` for service integrations (running +/// with the backend's `agentic` model hint), and others scoped to +/// `System` for code/file/host work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCategory { + /// Built-in Rust tools with direct host access. + #[default] + System, + /// QuickJS skill tools bridged from the runtime engine. + Skill, +} + +impl std::fmt::Display for ToolCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::System => write!(f, "system"), + Self::Skill => write!(f, "skill"), + } + } +} + /// Permission level required to execute a tool. /// /// Channels can set a maximum permission level to restrict which tools @@ -85,6 +121,17 @@ pub trait Tool: Send + Sync { ToolScope::All } + /// Category of this tool — `System` for built-in Rust tools (default) + /// or `Skill` for tools bridged from the QuickJS skill runtime. + /// + /// The sub-agent runner uses this to filter the parent's tool + /// registry when a sub-agent definition sets `category_filter`. + /// Skill-bridged tools override this to return + /// [`ToolCategory::Skill`]. + fn category(&self) -> ToolCategory { + ToolCategory::System + } + /// Get the full spec for LLM registration fn spec(&self) -> ToolSpec { ToolSpec {