diff --git a/src/openhuman/agent/harness/archetypes.rs b/src/openhuman/agent/harness/archetypes.rs new file mode 100644 index 000000000..88fd93bb0 --- /dev/null +++ b/src/openhuman/agent/harness/archetypes.rs @@ -0,0 +1,193 @@ +//! Agent archetypes — the 8 specialized roles in the multi-agent harness. +//! +//! Each archetype defines a default model tier, tool whitelist, sandbox mode, +//! max iterations, and system prompt path. The Orchestrator uses these to +//! construct ephemeral sub-agents via `AgentBuilder`. + +use serde::{Deserialize, Serialize}; + +/// The 8 specialised agent roles in the multi-agent topology. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentArchetype { + /// Staff Engineer — routes, judges quality, synthesises. Never writes code. + Orchestrator, + /// Architect — breaks complex tasks into a DAG of subtasks with acceptance criteria. + Planner, + /// Sandboxed developer — writes/runs code, fixes bugs until tests pass. + CodeExecutor, + /// Skill tool specialist — executes QuickJS skill tools (Notion, Gmail, …). + SkillsAgent, + /// Self-healer — writes polyfill scripts when a required command is missing. + ToolMaker, + /// Web & doc crawler — reads real documentation, compresses to dense markdown. + Researcher, + /// Adversarial reviewer — reviews diffs against SOUL.md, flags vulnerabilities. + Critic, + /// Background librarian — extracts lessons, updates MEMORY.md, indexes to FTS5. + Archivist, +} + +impl std::fmt::Display for AgentArchetype { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Self::Orchestrator => "orchestrator", + Self::Planner => "planner", + Self::CodeExecutor => "code_executor", + Self::SkillsAgent => "skills_agent", + Self::ToolMaker => "tool_maker", + Self::Researcher => "researcher", + Self::Critic => "critic", + Self::Archivist => "archivist", + }; + write!(f, "{name}") + } +} + +impl AgentArchetype { + /// Model hint passed to `RouterProvider` (prefixed with `"hint:"` at call site). + pub fn default_model_hint(&self) -> &'static str { + match self { + Self::Orchestrator => "reasoning", + Self::Planner => "reasoning", + Self::CodeExecutor => "coding", + Self::SkillsAgent => "agentic", + Self::ToolMaker => "coding", + Self::Researcher => "agentic", + Self::Critic => "reasoning", + // Archivist uses the cheapest available model (local preferred). + Self::Archivist => "local", + } + } + + /// Static tool-name whitelist for this archetype. + /// + /// Returns `None` for `SkillsAgent` because its tools are dynamic + /// (populated from `RuntimeEngine::all_tools()` at construction time). + pub fn allowed_tools(&self) -> Option<&'static [&'static str]> { + match self { + Self::Orchestrator => Some(&[ + "spawn_subagent", + "query_memory", + "read_workspace_state", + "ask_user_clarification", + ]), + Self::Planner => Some(&["query_memory", "read_workspace_state", "file_read"]), + Self::CodeExecutor => Some(&["shell", "file_read", "file_write", "git_operations"]), + Self::SkillsAgent => None, // dynamic — all skill-registered tools + memory_recall + Self::ToolMaker => Some(&["file_write", "shell"]), + Self::Researcher => Some(&["http_request", "web_search", "file_read", "memory_recall"]), + Self::Critic => Some(&["read_diff", "run_linter", "run_tests", "file_read"]), + Self::Archivist => Some(&["update_memory_md", "insert_sql_record", "memory_store"]), + } + } + + /// Maximum tool-call iterations for a single turn. + pub fn default_max_iterations(&self) -> usize { + match self { + Self::Orchestrator => 15, + Self::Planner => 5, + Self::CodeExecutor => 10, + Self::SkillsAgent => 10, + Self::ToolMaker => 2, + Self::Researcher => 8, + Self::Critic => 5, + Self::Archivist => 3, + } + } + + /// Sandbox mode identifier consumed by `SecurityPolicy` construction. + /// + /// * `"sandboxed"` — drop-sudo, restricted filesystem (Landlock / Bubblewrap). + /// * `"read_only"` — only read operations allowed. + /// * `"none"` — application-layer validation only. + pub fn sandbox_mode(&self) -> &'static str { + match self { + Self::CodeExecutor | Self::ToolMaker => "sandboxed", + Self::Critic | Self::Planner => "read_only", + _ => "none", + } + } + + /// Relative path (from `agent/prompts/`) to the archetype's system prompt. + pub fn system_prompt_path(&self) -> &'static str { + match self { + Self::Orchestrator => "ORCHESTRATOR.md", + Self::Planner => "PLANNER.md", + Self::CodeExecutor => "archetypes/code_executor.md", + Self::SkillsAgent => "archetypes/skills_agent.md", + Self::ToolMaker => "archetypes/code_executor.md", // shares with CodeExecutor + Self::Researcher => "archetypes/researcher.md", + Self::Critic => "archetypes/critic.md", + Self::Archivist => "archetypes/archivist.md", + } + } + + /// Whether this archetype runs as a background task (fire-and-forget). + pub fn is_background(&self) -> bool { + matches!(self, Self::Archivist) + } + + /// Whether this archetype should receive learning hooks. + pub fn has_learning_hooks(&self) -> bool { + matches!(self, Self::Orchestrator | Self::Archivist) + } + + /// All archetype variants (useful for iteration / config defaults). + pub fn all() -> &'static [AgentArchetype] { + &[ + Self::Orchestrator, + Self::Planner, + Self::CodeExecutor, + Self::SkillsAgent, + Self::ToolMaker, + Self::Researcher, + Self::Critic, + Self::Archivist, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_archetypes_have_model_hints() { + for arch in AgentArchetype::all() { + let hint = arch.default_model_hint(); + assert!(!hint.is_empty(), "{arch} has empty model hint"); + } + } + + #[test] + fn skills_agent_has_dynamic_tools() { + assert!(AgentArchetype::SkillsAgent.allowed_tools().is_none()); + } + + #[test] + fn code_executor_is_sandboxed() { + assert_eq!(AgentArchetype::CodeExecutor.sandbox_mode(), "sandboxed"); + assert_eq!(AgentArchetype::ToolMaker.sandbox_mode(), "sandboxed"); + } + + #[test] + fn critic_is_read_only() { + assert_eq!(AgentArchetype::Critic.sandbox_mode(), "read_only"); + } + + #[test] + fn orchestrator_never_has_code_tools() { + let tools = AgentArchetype::Orchestrator.allowed_tools().unwrap(); + assert!(!tools.contains(&"shell")); + assert!(!tools.contains(&"file_write")); + } + + #[test] + fn display_roundtrip() { + for arch in AgentArchetype::all() { + let s = arch.to_string(); + assert!(!s.is_empty()); + } + } +} diff --git a/src/openhuman/agent/harness/archivist.rs b/src/openhuman/agent/harness/archivist.rs new file mode 100644 index 000000000..a6eaef2b7 --- /dev/null +++ b/src/openhuman/agent/harness/archivist.rs @@ -0,0 +1,215 @@ +//! Archivist — background PostTurnHook that extracts lessons and indexes +//! episodic records. +//! +//! After each turn, the Archivist: +//! 1. Inserts the turn into the FTS5 episodic table. +//! 2. (Future) Summarises the turn using a cheap local model. +//! 3. (Future) Extracts reusable lessons → appends to MEMORY.md. + +use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext}; +use crate::openhuman::memory::store::fts5::{self, EpisodicEntry}; +use async_trait::async_trait; +use parking_lot::Mutex; +use rusqlite::Connection; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Background Archivist that indexes turns into FTS5 episodic memory. +pub struct ArchivistHook { + /// SQLite connection shared with UnifiedMemory. + conn: Option>>, + /// Whether the archivist is enabled. + enabled: bool, +} + +impl ArchivistHook { + /// Create an Archivist hook with a shared SQLite connection. + pub fn new(conn: Arc>, enabled: bool) -> Self { + Self { + conn: Some(conn), + enabled, + } + } + + /// Create a disabled/no-op Archivist (when FTS5 is not enabled). + pub fn disabled() -> Self { + Self { + conn: None, + enabled: false, + } + } + + fn now_timestamp() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + } +} + +#[async_trait] +impl PostTurnHook for ArchivistHook { + fn name(&self) -> &str { + "archivist" + } + + async fn on_turn_complete(&self, ctx: &TurnContext) -> anyhow::Result<()> { + if !self.enabled { + return Ok(()); + } + + let Some(conn) = &self.conn else { + return Ok(()); + }; + + let session_id = ctx.session_id.as_deref().unwrap_or("unknown"); + let timestamp = Self::now_timestamp(); + + tracing::debug!( + "[archivist] indexing turn: session={session_id}, tools={}, duration={}ms", + ctx.tool_calls.len(), + ctx.turn_duration_ms + ); + + // Index user message. + fts5::episodic_insert( + conn, + &EpisodicEntry { + id: None, + session_id: session_id.to_string(), + timestamp, + role: "user".to_string(), + content: ctx.user_message.clone(), + lesson: None, + tool_calls_json: None, + cost_microdollars: 0, + }, + )?; + + // Index assistant response with tool call summary. + let tool_calls_json = if ctx.tool_calls.is_empty() { + None + } else { + Some(serde_json::to_string(&ctx.tool_calls).unwrap_or_default()) + }; + + // Extract a simple lesson from tool failures (lightweight, no LLM needed). + let lesson = extract_lesson_from_tools(&ctx.tool_calls); + + fts5::episodic_insert( + conn, + &EpisodicEntry { + id: None, + session_id: session_id.to_string(), + // Offset by 1ms so assistant entries sort after user entries within + // the same turn. Relies on turn timestamps having >=1ms resolution. + timestamp: timestamp + 0.001, + role: "assistant".to_string(), + content: ctx.assistant_response.clone(), + lesson, + tool_calls_json, + cost_microdollars: 0, + }, + )?; + + tracing::debug!("[archivist] turn indexed successfully"); + Ok(()) + } +} + +/// Extract simple lessons from tool call outcomes (no LLM needed). +fn extract_lesson_from_tools( + tool_calls: &[crate::openhuman::agent::hooks::ToolCallRecord], +) -> Option { + let failures: Vec<&str> = tool_calls + .iter() + .filter(|tc| !tc.success) + .map(|tc| tc.name.as_str()) + .collect(); + + if failures.is_empty() { + return None; + } + + Some(format!( + "Tools that failed in this turn: {}", + failures.join(", ") + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::agent::hooks::{ToolCallRecord, TurnContext}; + use crate::openhuman::memory::store::fts5; + + fn setup_conn() -> Arc> { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(fts5::EPISODIC_INIT_SQL).unwrap(); + Arc::new(Mutex::new(conn)) + } + + #[tokio::test] + async fn archivist_indexes_turn() { + let conn = setup_conn(); + let hook = ArchivistHook::new(conn.clone(), true); + + let ctx = TurnContext { + user_message: "What is Rust?".into(), + assistant_response: "Rust is a systems programming language.".into(), + tool_calls: vec![], + turn_duration_ms: 500, + session_id: Some("test-session".into()), + iteration_count: 1, + }; + + hook.on_turn_complete(&ctx).await.unwrap(); + + let entries = fts5::episodic_session_entries(&conn, "test-session").unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].role, "user"); + assert_eq!(entries[1].role, "assistant"); + } + + #[tokio::test] + async fn archivist_extracts_failure_lesson() { + let conn = setup_conn(); + let hook = ArchivistHook::new(conn.clone(), true); + + let ctx = TurnContext { + user_message: "Run tests".into(), + assistant_response: "Tests failed.".into(), + tool_calls: vec![ToolCallRecord { + name: "shell".into(), + arguments: serde_json::json!({"command": "cargo test"}), + success: false, + output_summary: "shell: failed (error)".into(), + duration_ms: 3000, + }], + turn_duration_ms: 3500, + session_id: Some("test-session-2".into()), + iteration_count: 2, + }; + + hook.on_turn_complete(&ctx).await.unwrap(); + + let entries = fts5::episodic_session_entries(&conn, "test-session-2").unwrap(); + let assistant_entry = entries.iter().find(|e| e.role == "assistant").unwrap(); + assert!(assistant_entry.lesson.as_ref().unwrap().contains("shell")); + } + + #[tokio::test] + async fn disabled_archivist_is_noop() { + let hook = ArchivistHook::disabled(); + let ctx = TurnContext { + user_message: "test".into(), + assistant_response: "test".into(), + tool_calls: vec![], + turn_duration_ms: 0, + session_id: None, + iteration_count: 0, + }; + // Should not error even without a connection. + hook.on_turn_complete(&ctx).await.unwrap(); + } +} diff --git a/src/openhuman/agent/harness/context_assembly.rs b/src/openhuman/agent/harness/context_assembly.rs new file mode 100644 index 000000000..7465e7d52 --- /dev/null +++ b/src/openhuman/agent/harness/context_assembly.rs @@ -0,0 +1,130 @@ +//! Hook-driven context assembly for the multi-agent harness. +//! +//! Before entering the orchestrator loop, this module assembles the bootstrap +//! context: identity files, workspace state, and relevant memory. + +use crate::openhuman::config::Config; +use crate::openhuman::memory::Memory; +use std::path::Path; +use std::sync::Arc; + +/// Assembled context for the orchestrator's system prompt. +#[derive(Debug, Clone, Default)] +pub struct BootstrapContext { + /// Contents of the archetype-specific system prompt file. + pub archetype_prompt: String, + /// Core identity (from IDENTITY.md / SOUL.md). + pub identity_context: String, + /// Workspace state summary (git status, file tree). + pub workspace_summary: String, + /// Relevant memory context. + pub memory_context: String, +} + +impl BootstrapContext { + /// Render the full system prompt by combining all context sections. + pub fn render(&self) -> String { + let mut parts = Vec::new(); + + if !self.identity_context.is_empty() { + parts.push(format!("## Identity\n{}", self.identity_context)); + } + if !self.archetype_prompt.is_empty() { + parts.push(self.archetype_prompt.clone()); + } + if !self.workspace_summary.is_empty() { + parts.push(format!("## Workspace\n{}", self.workspace_summary)); + } + if !self.memory_context.is_empty() { + parts.push(format!("## Relevant Memory\n{}", self.memory_context)); + } + + parts.join("\n\n---\n\n") + } +} + +/// Load an archetype prompt file from the prompts directory. +pub async fn load_archetype_prompt(prompts_dir: &Path, relative_path: &str) -> String { + let path = prompts_dir.join(relative_path); + match tokio::fs::read_to_string(&path).await { + Ok(content) => { + tracing::debug!( + "[context-assembly] loaded archetype prompt: {}", + path.display() + ); + content + } + Err(e) => { + tracing::warn!( + "[context-assembly] failed to load prompt {}: {e}", + path.display() + ); + String::new() + } + } +} + +/// Load identity context from workspace IDENTITY.md and SOUL.md. +pub async fn load_identity_context(workspace_dir: &Path) -> String { + let mut parts = Vec::new(); + + for filename in &["IDENTITY.md", "SOUL.md"] { + let path = workspace_dir.join(filename); + if let Ok(content) = tokio::fs::read_to_string(&path).await { + parts.push(content); + tracing::debug!( + "[context-assembly] loaded identity file: {}", + path.display() + ); + } + } + + parts.join("\n\n") +} + +/// Build memory context by recalling relevant entries. +pub async fn build_memory_context(memory: &dyn Memory, query: &str, max_chars: usize) -> String { + match memory.recall(query, 5, None).await { + Ok(entries) => { + let mut context = String::new(); + for entry in entries { + let addition = format!("- {}: {}\n", entry.key, entry.content); + if context.len() + addition.len() > max_chars { + break; + } + context.push_str(&addition); + } + context + } + Err(e) => { + tracing::debug!("[context-assembly] memory recall failed: {e}"); + String::new() + } + } +} + +/// Assemble the full bootstrap context for an orchestrator turn. +pub async fn assemble_orchestrator_context( + config: &Config, + memory: Arc, + user_message: &str, +) -> BootstrapContext { + let prompts_dir = config.workspace_dir.join("agent").join("prompts"); + + let archetype_prompt = load_archetype_prompt(&prompts_dir, "ORCHESTRATOR.md").await; + let identity_context = load_identity_context(&config.workspace_dir).await; + + let memory_context = build_memory_context( + memory.as_ref(), + user_message, + config.agent.max_memory_context_chars, + ) + .await; + + BootstrapContext { + archetype_prompt, + identity_context, + workspace_summary: String::new(), // populated by workspace_state tool on demand + memory_context, + } +} diff --git a/src/openhuman/agent/harness/dag.rs b/src/openhuman/agent/harness/dag.rs new file mode 100644 index 000000000..c7665f8b5 --- /dev/null +++ b/src/openhuman/agent/harness/dag.rs @@ -0,0 +1,350 @@ +//! Directed Acyclic Graph (DAG) for task planning. +//! +//! The Planner archetype produces a `TaskDag` that the Orchestrator executes +//! level-by-level. Nodes with satisfied dependencies run concurrently within +//! a level. + +use super::archetypes::AgentArchetype; +use super::types::{SubAgentResult, TaskId, TaskStatus}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// A single task node in the execution DAG. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskNode { + /// Unique identifier within this DAG. + pub id: TaskId, + /// Human-readable description of what this task does. + pub description: String, + /// Which archetype should execute this task. + pub archetype: AgentArchetype, + /// Task IDs that must complete before this node can run. + #[serde(default)] + pub depends_on: Vec, + /// Acceptance criteria — how the Orchestrator judges success. + #[serde(default)] + pub acceptance_criteria: String, + /// Current execution status. + #[serde(default)] + pub status: TaskStatus, + /// Result from the sub-agent, populated after execution. + #[serde(skip)] + pub result: Option, + /// Number of retry attempts made. + #[serde(default)] + pub retry_count: u8, +} + +/// The full task DAG produced by the Planner. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskDag { + /// The user's original goal. + pub root_goal: String, + /// Task nodes in insertion order. + pub nodes: Vec, +} + +impl TaskDag { + /// Create a new DAG with a single direct-execution node (bypass planning overhead). + pub fn single_task(goal: &str, archetype: AgentArchetype, description: &str) -> Self { + Self { + root_goal: goal.to_string(), + nodes: vec![TaskNode { + id: "task-1".to_string(), + description: description.to_string(), + archetype, + depends_on: Vec::new(), + acceptance_criteria: String::new(), + status: TaskStatus::Pending, + result: None, + retry_count: 0, + }], + } + } + + /// Validate the DAG: check for missing dependencies and cycles. + pub fn validate(&self) -> Result<(), DagError> { + let ids: HashSet<&str> = self.nodes.iter().map(|n| n.id.as_str()).collect(); + tracing::trace!("[dag] validating {} node(s): {:?}", ids.len(), ids); + + // Check all dependency references exist. + for node in &self.nodes { + for dep in &node.depends_on { + if !ids.contains(dep.as_str()) { + tracing::debug!("[dag] node '{}' depends on missing node '{}'", node.id, dep); + return Err(DagError::MissingDependency { + node: node.id.clone(), + missing: dep.clone(), + }); + } + } + // Self-dependency check. + if node.depends_on.contains(&node.id) { + tracing::debug!("[dag] node '{}' has self-dependency", node.id); + return Err(DagError::Cycle); + } + } + + // Full cycle detection via Kahn's algorithm. + if self.topological_sort().is_none() { + tracing::debug!("[dag] topological sort detected a cycle"); + return Err(DagError::Cycle); + } + + tracing::trace!("[dag] validation passed"); + Ok(()) + } + + /// Topological sort using Kahn's algorithm. + /// Returns `None` if the graph contains a cycle. + pub fn topological_sort(&self) -> Option> { + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adj: HashMap<&str, Vec<&str>> = HashMap::new(); + + for node in &self.nodes { + in_degree.entry(node.id.as_str()).or_insert(0); + adj.entry(node.id.as_str()).or_default(); + for dep in &node.depends_on { + adj.entry(dep.as_str()).or_default().push(node.id.as_str()); + *in_degree.entry(node.id.as_str()).or_insert(0) += 1; + } + } + + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&id, _)| id) + .collect(); + + let mut sorted = Vec::new(); + while let Some(id) = queue.pop_front() { + sorted.push(id); + if let Some(dependents) = adj.get(id) { + for &dep in dependents { + if let Some(deg) = in_degree.get_mut(dep) { + *deg -= 1; + if *deg == 0 { + queue.push_back(dep); + } + } + } + } + } + + if sorted.len() != self.nodes.len() { + return None; // cycle detected + } + + // Map back to TaskId references. + let id_map: HashMap<&str, &TaskId> = + self.nodes.iter().map(|n| (n.id.as_str(), &n.id)).collect(); + + Some( + sorted + .into_iter() + .filter_map(|s| id_map.get(s).copied()) + .collect(), + ) + } + + /// Return execution levels: groups of task IDs that can run concurrently. + /// Each level contains only tasks whose dependencies are in earlier levels. + pub fn execution_levels(&self) -> Vec> { + let mut remaining: HashMap<&str, HashSet<&str>> = self + .nodes + .iter() + .map(|n| { + let deps: HashSet<&str> = n.depends_on.iter().map(|d| d.as_str()).collect(); + (n.id.as_str(), deps) + }) + .collect(); + + // Build the id_map once outside the loop. + let id_map: HashMap<&str, &TaskId> = + self.nodes.iter().map(|n| (n.id.as_str(), &n.id)).collect(); + + let mut levels = Vec::new(); + let mut completed: HashSet<&str> = HashSet::new(); + + while !remaining.is_empty() { + let ready: Vec<&str> = remaining + .iter() + .filter(|(_, deps)| deps.iter().all(|d| completed.contains(d))) + .map(|(&id, _)| id) + .collect(); + + if ready.is_empty() { + // Remaining nodes have unsatisfied deps (should be caught by validate). + break; + } + + let level: Vec<&TaskId> = ready + .iter() + .filter_map(|&id| id_map.get(id).copied()) + .collect(); + + for &id in &ready { + remaining.remove(id); + completed.insert(id); + } + + levels.push(level); + } + + levels + } + + /// Find a node by ID. + pub fn node(&self, id: &str) -> Option<&TaskNode> { + self.nodes.iter().find(|n| n.id == id) + } + + /// Find a mutable node by ID. + pub fn node_mut(&mut self, id: &str) -> Option<&mut TaskNode> { + self.nodes.iter_mut().find(|n| n.id == id) + } + + /// Number of nodes. + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Whether the DAG is empty. + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Whether all nodes are completed or cancelled. + pub fn is_finished(&self) -> bool { + self.nodes + .iter() + .all(|n| matches!(n.status, TaskStatus::Completed | TaskStatus::Cancelled)) + } + + /// Collect all completed results. + pub fn completed_results(&self) -> Vec<&SubAgentResult> { + self.nodes + .iter() + .filter_map(|n| n.result.as_ref()) + .collect() + } +} + +/// Errors during DAG validation. +#[derive(Debug, thiserror::Error)] +pub enum DagError { + #[error("cycle detected in task DAG")] + Cycle, + #[error("node {node} depends on missing node {missing}")] + MissingDependency { node: String, missing: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_node(id: &str, deps: &[&str]) -> TaskNode { + TaskNode { + id: id.to_string(), + description: format!("Task {id}"), + archetype: AgentArchetype::CodeExecutor, + depends_on: deps.iter().map(|s| s.to_string()).collect(), + acceptance_criteria: String::new(), + status: TaskStatus::Pending, + result: None, + retry_count: 0, + } + } + + #[test] + fn single_task_bypasses_dag() { + let dag = TaskDag::single_task("do thing", AgentArchetype::Researcher, "research it"); + assert_eq!(dag.len(), 1); + assert!(dag.validate().is_ok()); + let levels = dag.execution_levels(); + assert_eq!(levels.len(), 1); + assert_eq!(levels[0].len(), 1); + } + + #[test] + fn linear_chain() { + let dag = TaskDag { + root_goal: "build feature".into(), + nodes: vec![ + make_node("a", &[]), + make_node("b", &["a"]), + make_node("c", &["b"]), + ], + }; + assert!(dag.validate().is_ok()); + let levels = dag.execution_levels(); + assert_eq!(levels.len(), 3); + } + + #[test] + fn parallel_then_join() { + let dag = TaskDag { + root_goal: "parallel work".into(), + nodes: vec![ + make_node("a", &[]), + make_node("b", &[]), + make_node("c", &["a", "b"]), + ], + }; + assert!(dag.validate().is_ok()); + let levels = dag.execution_levels(); + assert_eq!(levels.len(), 2); + assert_eq!(levels[0].len(), 2); // a and b in parallel + assert_eq!(levels[1].len(), 1); // c after both + } + + #[test] + fn cycle_detection() { + let dag = TaskDag { + root_goal: "cycle".into(), + nodes: vec![make_node("a", &["b"]), make_node("b", &["a"])], + }; + assert!(matches!(dag.validate(), Err(DagError::Cycle))); + } + + #[test] + fn missing_dependency() { + let dag = TaskDag { + root_goal: "missing".into(), + nodes: vec![make_node("a", &["nonexistent"])], + }; + assert!(matches!( + dag.validate(), + Err(DagError::MissingDependency { .. }) + )); + } + + #[test] + fn self_dependency() { + let dag = TaskDag { + root_goal: "self".into(), + nodes: vec![make_node("a", &["a"])], + }; + assert!(dag.validate().is_err()); + } + + #[test] + fn topological_sort_order() { + let dag = TaskDag { + root_goal: "order".into(), + nodes: vec![ + make_node("c", &["a", "b"]), + make_node("a", &[]), + make_node("b", &["a"]), + ], + }; + let sorted = dag.topological_sort().unwrap(); + let pos: HashMap<&str, usize> = sorted + .iter() + .enumerate() + .map(|(i, id)| (id.as_str(), i)) + .collect(); + assert!(pos["a"] < pos["b"]); + assert!(pos["b"] < pos["c"]); + } +} diff --git a/src/openhuman/agent/harness/executor.rs b/src/openhuman/agent/harness/executor.rs new file mode 100644 index 000000000..b7f69cae1 --- /dev/null +++ b/src/openhuman/agent/harness/executor.rs @@ -0,0 +1,542 @@ +//! Orchestrator executor — the multi-agent run loop. +//! +//! When `orchestrator.enabled == true`, this replaces the default single-agent +//! tool loop with: +//! +//! 1. **Plan** — Spawn a Planner sub-agent to produce a `TaskDag`. +//! 2. **Execute** — Run DAG levels concurrently via `tokio::JoinSet`. +//! 3. **Review** — Orchestrator reviews each level's results. +//! 4. **Synthesise** — Final Orchestrator call to merge all results. + +use super::archetypes::AgentArchetype; +use super::dag::TaskDag; +use super::types::{ReviewDecision, SubAgentResult, TaskStatus}; +use crate::openhuman::config::{Config, OrchestratorConfig}; +use crate::openhuman::memory::Memory; +use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider}; +use crate::openhuman::tools::Tool; +use anyhow::{Context, Result}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::task::JoinSet; + +/// Top-level entry point for orchestrated multi-agent execution. +/// +/// Called from `Agent::turn()` when `config.orchestrator.enabled == true`. +pub async fn run_orchestrated( + user_message: &str, + config: &Config, + provider: &dyn Provider, + memory: Arc, + _tools: &[Box], + session_id: &str, +) -> Result { + let orch_config = &config.orchestrator; + tracing::info!( + "[orchestrator] starting orchestrated run for session={session_id}, max_dag_tasks={}", + orch_config.max_dag_tasks + ); + + // ── 1. PLAN ────────────────────────────────────────────────────────────── + let dag = plan_tasks(user_message, config, provider, memory.clone()).await?; + tracing::debug!( + "[orchestrator] planner produced {} task(s) for goal: {}", + dag.len(), + dag.root_goal + ); + + if dag.is_empty() { + tracing::warn!( + "[orchestrator] planner returned empty DAG, falling back to direct response" + ); + return direct_response(user_message, provider, config).await; + } + + // ── 2. EXECUTE ─────────────────────────────────────────────────────────── + let semaphore = Arc::new(tokio::sync::Semaphore::new( + orch_config.max_concurrent_agents, + )); + let mut dag = dag; + let levels = dag.execution_levels(); + let level_ids: Vec> = levels + .into_iter() + .map(|lvl| lvl.into_iter().cloned().collect()) + .collect(); + + for (level_idx, task_ids) in level_ids.iter().enumerate() { + tracing::info!( + "[orchestrator] executing level {}/{} with {} task(s)", + level_idx + 1, + level_ids.len(), + task_ids.len() + ); + + let results = execute_level( + &dag, + task_ids, + orch_config, + config, + provider, + memory.clone(), + session_id, + semaphore.clone(), + ) + .await; + + // Apply results to DAG nodes. + for result in &results { + if let Some(node) = dag.node_mut(&result.task_id) { + node.status = if result.success { + TaskStatus::Completed + } else { + TaskStatus::Failed + }; + node.result = Some(result.clone()); + } + } + + // ── 3. REVIEW ──────────────────────────────────────────────────────── + let decision = review_level(&dag, task_ids, provider, config).await?; + match decision { + ReviewDecision::Continue => { + tracing::debug!( + "[orchestrator] level {} approved, continuing", + level_idx + 1 + ); + } + ReviewDecision::Retry(retry_ids) => { + tracing::info!( + "[orchestrator] retrying {} task(s) from level {}", + retry_ids.len(), + level_idx + 1 + ); + let retry_results = execute_level( + &dag, + &retry_ids, + orch_config, + config, + provider, + memory.clone(), + session_id, + semaphore.clone(), + ) + .await; + for result in retry_results { + if let Some(node) = dag.node_mut(&result.task_id) { + node.retry_count += 1; + node.status = if result.success { + TaskStatus::Completed + } else { + TaskStatus::Failed + }; + node.result = Some(result); + } + } + } + ReviewDecision::Abort(reason) => { + tracing::warn!("[orchestrator] aborting DAG: {reason}"); + return Ok(format!( + "I had to stop the multi-step plan: {reason}\n\nHere's what was completed:\n{}", + summarise_completed(&dag) + )); + } + } + + // After retries, check if any task in this level still failed. + let still_failed: Vec<&str> = task_ids + .iter() + .filter(|id| { + dag.node(id) + .is_some_and(|n| matches!(n.status, TaskStatus::Failed)) + }) + .map(|s| s.as_str()) + .collect(); + if !still_failed.is_empty() { + tracing::warn!( + "[orchestrator] level {} has {} task(s) still failed after retries, halting", + level_idx + 1, + still_failed.len() + ); + return Ok(format!( + "Plan halted: {} task(s) failed in level {}.\n\nCompleted so far:\n{}", + still_failed.len(), + level_idx + 1, + summarise_completed(&dag) + )); + } + } + + // ── 4. SYNTHESISE ──────────────────────────────────────────────────────── + synthesise_response(&dag, user_message, provider, config).await +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +/// Ask the Planner archetype to break the user goal into a TaskDag. +async fn plan_tasks( + user_message: &str, + config: &Config, + provider: &dyn Provider, + _memory: Arc, +) -> Result { + let model = resolve_model(AgentArchetype::Planner, &config.orchestrator); + let temperature = resolve_temperature(AgentArchetype::Planner, &config.orchestrator); + + let system_prompt = format!( + "You are the Planner agent. Break the user's goal into a task DAG.\n\ + Return ONLY valid JSON matching this schema:\n\ + ```json\n\ + {{\n\ + \"root_goal\": \"\",\n\ + \"nodes\": [\n\ + {{\n\ + \"id\": \"task-1\",\n\ + \"description\": \"what to do\",\n\ + \"archetype\": \"code_executor|skills_agent|researcher|critic|tool_maker\",\n\ + \"depends_on\": [],\n\ + \"acceptance_criteria\": \"how to verify success\"\n\ + }}\n\ + ]\n\ + }}\n\ + ```\n\ + Available archetypes: code_executor, skills_agent, tool_maker, researcher, critic.\n\ + Max {max} tasks. Use depends_on for ordering. Minimise task count.\n\ + If the goal is simple (single step), return exactly 1 node.", + max = config.orchestrator.max_dag_tasks + ); + + let messages = vec![ + ChatMessage::system(&system_prompt), + ChatMessage::user(user_message), + ]; + + let request = ChatRequest { + messages: &messages, + tools: None, + system_prompt_cache_boundary: None, + }; + + let response = provider.chat(request, &model, temperature).await?; + let text = response.text.as_deref().unwrap_or("").trim(); + + // Extract JSON from potential markdown code fences. + let json_str = extract_json_block(text); + + let dag: TaskDag = + serde_json::from_str(json_str).context("failed to parse Planner DAG JSON")?; + + dag.validate() + .map_err(|e| anyhow::anyhow!("invalid DAG: {e}"))?; + + Ok(dag) +} + +/// Execute all tasks in a single DAG level concurrently. +async fn execute_level( + dag: &TaskDag, + task_ids: &[String], + orch_config: &OrchestratorConfig, + _config: &Config, + _provider: &dyn Provider, + _memory: Arc, + session_id: &str, + semaphore: Arc, +) -> Vec { + let mut join_set: JoinSet = JoinSet::new(); + + for task_id in task_ids { + let Some(node) = dag.node(task_id) else { + tracing::warn!("[orchestrator] task {task_id} not found in DAG, skipping"); + continue; + }; + + let archetype = node.archetype; + 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 timeout = resolve_timeout(archetype, orch_config); + + // Collect context from completed dependencies. + let dep_context: String = node + .depends_on + .iter() + .filter_map(|dep_id| { + dag.node(dep_id) + .and_then(|n| n.result.as_ref()) + .map(|r| format!("## Result from {dep_id}\n{}\n", r.output)) + }) + .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}" + ) + }; + + // 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(); + + join_set.spawn(async move { + let _permit = semaphore_clone + .acquire_owned() + .await + .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:?}" + ); + + tracing::debug!( + "[orchestrator] sub-agent {archetype} placeholder task {tid} in {:?}", + start.elapsed() + ); + + SubAgentResult { + task_id: tid, + success: false, + output: result_text, + artifacts: Vec::new(), + cost_microdollars: 0, + duration: start.elapsed(), + } + }); + } + + let mut results = Vec::new(); + while let Some(res) = join_set.join_next().await { + match res { + Ok(result) => results.push(result), + Err(e) => { + tracing::error!("[orchestrator] sub-agent task panicked: {e}"); + } + } + } + results +} + +/// The Orchestrator reviews results from a completed level and decides next action. +async fn review_level( + dag: &TaskDag, + task_ids: &[String], + _provider: &dyn Provider, + config: &Config, +) -> Result { + let failed: Vec<&str> = task_ids + .iter() + .filter(|id| { + dag.node(id) + .is_some_and(|n| matches!(n.status, TaskStatus::Failed)) + }) + .map(|s| s.as_str()) + .collect(); + + if failed.is_empty() { + return Ok(ReviewDecision::Continue); + } + + let retriable: Vec = failed + .iter() + .filter(|&&id| { + dag.node(id) + .is_some_and(|n| n.retry_count < config.orchestrator.max_task_retries) + }) + .map(|s| s.to_string()) + .collect(); + + if retriable.is_empty() { + return Ok(ReviewDecision::Abort(format!( + "{} task(s) failed with no retries left", + failed.len() + ))); + } + + Ok(ReviewDecision::Retry(retriable)) +} + +/// Final Orchestrator call to synthesise all results into a user response. +async fn synthesise_response( + dag: &TaskDag, + user_message: &str, + provider: &dyn Provider, + config: &Config, +) -> Result { + let model = resolve_model(AgentArchetype::Orchestrator, &config.orchestrator); + let temperature = resolve_temperature(AgentArchetype::Orchestrator, &config.orchestrator); + + let results_summary = summarise_completed(dag); + + let system_prompt = "You are the Orchestrator. Synthesise the sub-agent results into a \ + coherent, helpful response for the user. Be concise and direct."; + + let user_msg = format!( + "Original request: {user_message}\n\n\ + Sub-agent results:\n{results_summary}\n\n\ + Provide the final response." + ); + + let messages = vec![ + ChatMessage::system(system_prompt), + ChatMessage::user(&user_msg), + ]; + + let request = ChatRequest { + messages: &messages, + tools: None, + system_prompt_cache_boundary: None, + }; + + let response = provider.chat(request, &model, temperature).await?; + Ok(response.text.unwrap_or_default()) +} + +/// Fallback: direct single-shot response when DAG is empty. +async fn direct_response( + user_message: &str, + provider: &dyn Provider, + config: &Config, +) -> Result { + let model = config + .default_model + .as_deref() + .unwrap_or(crate::openhuman::config::DEFAULT_MODEL); + + let response = provider + .simple_chat(user_message, model, config.default_temperature) + .await?; + Ok(response) +} + +/// Summarise all completed task results for the Orchestrator's synthesis step. +fn summarise_completed(dag: &TaskDag) -> String { + dag.nodes + .iter() + .filter(|n| n.result.is_some()) + .map(|n| { + let result = n.result.as_ref().unwrap(); + let status = if result.success { "OK" } else { "FAILED" }; + format!( + "### {} [{}] ({})\n{}\n", + n.id, status, n.archetype, result.output + ) + }) + .collect() +} + +/// Extract JSON from a string that may be wrapped in markdown code fences. +fn extract_json_block(text: &str) -> &str { + // Try ```json ... ``` first. + if let Some(start) = text.find("```json") { + let content_start = start + 7; + if let Some(end) = text[content_start..].find("```") { + return text[content_start..content_start + end].trim(); + } + } + // Try ``` ... ```. + if let Some(start) = text.find("```") { + let content_start = start + 3; + // Skip to next line if the fence has a language tag. + let actual_start = text[content_start..] + .find('\n') + .map(|i| content_start + i + 1) + .unwrap_or(content_start); + if let Some(end) = text[actual_start..].find("```") { + return text[actual_start..actual_start + end].trim(); + } + } + // Assume raw JSON. + text.trim() +} + +/// Resolve the model name for an archetype, respecting config overrides. +fn resolve_model(archetype: AgentArchetype, config: &OrchestratorConfig) -> String { + let key = archetype.to_string(); + if let Some(ac) = config.archetypes.get(&key) { + if let Some(ref model) = ac.model { + return model.clone(); + } + } + format!("hint:{}", archetype.default_model_hint()) +} + +/// Resolve temperature for an archetype. +fn resolve_temperature(archetype: AgentArchetype, config: &OrchestratorConfig) -> f64 { + config + .archetypes + .get(&archetype.to_string()) + .and_then(|ac| ac.temperature) + .unwrap_or(0.4) // sub-agents default to lower temperature for precision +} + +/// Resolve timeout for an archetype. +fn resolve_timeout(archetype: AgentArchetype, config: &OrchestratorConfig) -> Duration { + let secs = config + .archetypes + .get(&archetype.to_string()) + .and_then(|ac| ac.timeout_secs) + .unwrap_or(120); + Duration::from_secs(secs) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::ArchetypeConfig; + + #[test] + fn extract_json_from_fenced_block() { + let input = "Here's the plan:\n```json\n{\"root_goal\": \"test\"}\n```\nDone."; + assert_eq!(extract_json_block(input), "{\"root_goal\": \"test\"}"); + } + + #[test] + fn extract_json_raw() { + let input = "{\"root_goal\": \"test\"}"; + assert_eq!(extract_json_block(input), "{\"root_goal\": \"test\"}"); + } + + #[test] + fn resolve_model_with_override() { + let mut config = OrchestratorConfig::default(); + config.archetypes.insert( + "code_executor".into(), + ArchetypeConfig { + model: Some("custom-model".into()), + ..Default::default() + }, + ); + assert_eq!( + resolve_model(AgentArchetype::CodeExecutor, &config), + "custom-model" + ); + } + + #[test] + fn resolve_model_default_hint() { + let config = OrchestratorConfig::default(); + assert_eq!( + resolve_model(AgentArchetype::CodeExecutor, &config), + "hint:coding" + ); + assert_eq!( + resolve_model(AgentArchetype::Orchestrator, &config), + "hint:reasoning" + ); + } +} diff --git a/src/openhuman/agent/harness/interrupt.rs b/src/openhuman/agent/harness/interrupt.rs new file mode 100644 index 000000000..62823623d --- /dev/null +++ b/src/openhuman/agent/harness/interrupt.rs @@ -0,0 +1,139 @@ +//! Graceful interrupt fence — handles SIGINT / Ctrl+C and `/stop` commands. +//! +//! The interrupt fence is checked at key points in the orchestrator loop: +//! - Before each DAG level execution +//! - Before each tool execution in the tool loop +//! - Inside sub-agent spawn points +//! +//! On interrupt, running sub-agents are cancelled, memory is flushed, +//! and the Archivist fires with partial context. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +/// Thread-safe interrupt flag that can be checked throughout the agent harness. +#[derive(Clone)] +pub struct InterruptFence { + flag: Arc, +} + +impl InterruptFence { + /// Create a new interrupt fence (not triggered). + pub fn new() -> Self { + Self { + flag: Arc::new(AtomicBool::new(false)), + } + } + + /// Check whether an interrupt has been requested. + pub fn is_interrupted(&self) -> bool { + self.flag.load(Ordering::Relaxed) + } + + /// Trigger the interrupt (called from signal handler or `/stop` command). + pub fn trigger(&self) { + self.flag.store(true, Ordering::Relaxed); + tracing::info!("[interrupt] interrupt fence triggered"); + } + + /// Reset the fence (e.g. at the start of a new session). + pub fn reset(&self) { + self.flag.store(false, Ordering::Relaxed); + } + + /// Get a raw `Arc` handle for passing to signal handlers. + pub fn flag_handle(&self) -> Arc { + self.flag.clone() + } + + /// Install a `tokio::signal::ctrl_c()` handler that triggers this fence. + /// + /// This spawns a background task that waits for Ctrl+C and sets the flag. + /// The task runs until the process exits. + pub fn install_signal_handler(&self) { + let flag = self.flag.clone(); + tokio::spawn(async move { + loop { + match tokio::signal::ctrl_c().await { + Ok(()) => { + if flag.load(Ordering::Relaxed) { + // Second Ctrl+C — hard exit. + tracing::warn!("[interrupt] second Ctrl+C received, forcing exit"); + std::process::exit(130); + } + flag.store(true, Ordering::Relaxed); + tracing::info!( + "[interrupt] Ctrl+C received — gracefully stopping. Press again to force exit." + ); + } + Err(e) => { + tracing::error!("[interrupt] failed to listen for Ctrl+C: {e}"); + break; + } + } + } + }); + } +} + +impl Default for InterruptFence { + fn default() -> Self { + Self::new() + } +} + +/// Error returned when an operation is cancelled due to an interrupt. +#[derive(Debug, thiserror::Error)] +#[error("operation interrupted by user")] +pub struct InterruptedError; + +/// Helper: check the fence and return `Err(InterruptedError)` if triggered. +pub fn check_interrupt(fence: &InterruptFence) -> Result<(), InterruptedError> { + if fence.is_interrupted() { + Err(InterruptedError) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fence_starts_clear() { + let fence = InterruptFence::new(); + assert!(!fence.is_interrupted()); + } + + #[test] + fn trigger_sets_flag() { + let fence = InterruptFence::new(); + fence.trigger(); + assert!(fence.is_interrupted()); + } + + #[test] + fn reset_clears_flag() { + let fence = InterruptFence::new(); + fence.trigger(); + fence.reset(); + assert!(!fence.is_interrupted()); + } + + #[test] + fn check_interrupt_returns_err_when_triggered() { + let fence = InterruptFence::new(); + assert!(check_interrupt(&fence).is_ok()); + fence.trigger(); + assert!(check_interrupt(&fence).is_err()); + } + + #[test] + fn clone_shares_flag() { + let fence = InterruptFence::new(); + let clone = fence.clone(); + fence.trigger(); + assert!(clone.is_interrupted()); + } +} diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs new file mode 100644 index 000000000..020fce0bd --- /dev/null +++ b/src/openhuman/agent/harness/mod.rs @@ -0,0 +1,32 @@ +//! Multi-agent harness — orchestrator topology with 8 specialised archetypes. +//! +//! When `OrchestratorConfig::enabled` is true, the harness replaces the default +//! single-agent tool loop with a Staff-Engineer / Contractor hierarchy: +//! +//! 1. **Orchestrator** — routes, judges quality, synthesises. +//! 2. **Planner** — breaks goals into a DAG of subtasks. +//! 3. **Code Executor** — writes & runs code in a sandbox. +//! 4. **Skills Agent** — executes QuickJS skill tools. +//! 5. **Tool-Maker** — self-heals missing commands with polyfill scripts. +//! 6. **Researcher** — reads real documentation, compresses to markdown. +//! 7. **Critic** — adversarial QA review. +//! 8. **Archivist** — background post-session knowledge extraction. + +pub mod archetypes; +pub mod archivist; +pub mod context_assembly; +pub mod dag; +pub mod executor; +pub mod interrupt; +pub mod self_healing; +pub mod session_queue; +pub mod types; + +pub use archetypes::AgentArchetype; +pub use archivist::ArchivistHook; +pub use dag::{DagError, TaskDag, TaskNode}; +pub use executor::run_orchestrated; +pub use interrupt::{check_interrupt, InterruptFence, InterruptedError}; +pub use self_healing::SelfHealingInterceptor; +pub use session_queue::SessionQueue; +pub use types::*; diff --git a/src/openhuman/agent/harness/self_healing.rs b/src/openhuman/agent/harness/self_healing.rs new file mode 100644 index 000000000..0fe48544b --- /dev/null +++ b/src/openhuman/agent/harness/self_healing.rs @@ -0,0 +1,260 @@ +//! Self-healing interceptor — auto-polyfill when commands are missing. +//! +//! When the Code Executor's shell tool returns "command not found" or similar, +//! the interceptor spawns a ToolMaker sub-agent to write a polyfill script, +//! then retries the original command. + +use crate::openhuman::tools::ToolResult; +use std::path::{Path, PathBuf}; + +/// Maximum number of self-heal attempts per unique command. +const MAX_HEAL_ATTEMPTS: u8 = 2; + +/// Patterns in tool error output that indicate a missing command/binary. +const MISSING_CMD_PATTERNS: &[&str] = &[ + "command not found", + ": not found", + "not installed", + "No such file or directory", + "not recognized as an internal or external command", + "is not recognized", + "unable to find", +]; + +/// Interceptor that detects missing-command errors and spawns ToolMaker agents. +pub struct SelfHealingInterceptor { + /// Directory where polyfill scripts are written. + polyfill_dir: PathBuf, + /// Whether self-healing is enabled. + enabled: bool, + /// Track heal attempts per command to enforce MAX_HEAL_ATTEMPTS. + attempts: std::collections::HashMap, +} + +impl SelfHealingInterceptor { + pub fn new(workspace_dir: &Path, enabled: bool) -> Self { + let polyfill_dir = workspace_dir.join("polyfills"); + Self { + polyfill_dir, + enabled, + attempts: std::collections::HashMap::new(), + } + } + + /// Check if a tool result indicates a missing command that can be self-healed. + /// + /// Returns `Some(command_name)` if the error matches a known missing-command pattern + /// and we haven't exceeded the retry limit. + pub fn detect_missing_command(&mut self, result: &ToolResult) -> Option { + if !self.enabled || result.success { + return None; + } + + let error_text = result.error.as_deref().unwrap_or("").to_lowercase(); + let output_text = result.output.to_lowercase(); + let combined = format!("{error_text} {output_text}"); + + // Check if the error matches any missing-command pattern. + let is_missing = MISSING_CMD_PATTERNS + .iter() + .any(|pattern| combined.contains(&pattern.to_lowercase())); + + if !is_missing { + return None; + } + + // Try to extract the command name from the error. + let cmd = extract_command_name(&combined)?; + + // Check retry limit. + let count = self.attempts.entry(cmd.clone()).or_insert(0); + if *count >= MAX_HEAL_ATTEMPTS { + tracing::debug!( + "[self-healing] max attempts ({MAX_HEAL_ATTEMPTS}) reached for command: {cmd}" + ); + return None; + } + *count += 1; + + tracing::info!( + "[self-healing] detected missing command: {cmd} (attempt {}/{})", + *count, + MAX_HEAL_ATTEMPTS + ); + + Some(cmd) + } + + /// Build the prompt for the ToolMaker sub-agent. + pub fn tool_maker_prompt(&self, missing_command: &str, original_context: &str) -> String { + format!( + "The command `{missing_command}` is not available in this environment.\n\ + \n\ + Write a polyfill script that accomplishes the equivalent functionality.\n\ + Save it to: {polyfill_dir}/{missing_command}\n\ + Make it executable with `chmod +x`.\n\ + \n\ + Original context:\n{original_context}\n\ + \n\ + Requirements:\n\ + - Use only standard tools likely available (bash, python3, awk, sed, curl).\n\ + - The script should accept the same arguments as the original command.\n\ + - Keep it minimal — just enough to accomplish the immediate task.\n\ + - Do NOT install packages or use sudo.", + polyfill_dir = self.polyfill_dir.display() + ) + } + + /// Get the polyfill directory path. + pub fn polyfill_dir(&self) -> &Path { + &self.polyfill_dir + } + + /// Ensure the polyfill directory exists. + pub async fn ensure_polyfill_dir(&self) -> anyhow::Result<()> { + if !self.polyfill_dir.exists() { + tokio::fs::create_dir_all(&self.polyfill_dir).await?; + tracing::debug!( + "[self-healing] created polyfill directory: {}", + self.polyfill_dir.display() + ); + } + Ok(()) + } + + /// Reset attempt counters (e.g. between sessions). + pub fn reset(&mut self) { + self.attempts.clear(); + } +} + +/// Try to extract a command name from an error message. +/// +/// Handles patterns like: +/// - "bash: foo: command not found" +/// - "sh: 1: foo: not found" +/// - "'foo' is not recognized" +fn extract_command_name(error: &str) -> Option { + // Pattern: "bash: CMD: command not found" + if let Some(idx) = error.find(": command not found") { + let before = &error[..idx]; + if let Some(colon_idx) = before.rfind(": ") { + let cmd = before[colon_idx + 2..].trim(); + if !cmd.is_empty() && cmd.len() < 64 { + return Some(cmd.to_string()); + } + } + // Try without preceding colon. + let cmd = before.trim(); + if let Some(last_word) = cmd.split_whitespace().last() { + if last_word.len() < 64 { + return Some(last_word.to_string()); + } + } + } + + // Pattern: "sh: N: CMD: not found" + if error.contains(": not found") { + let parts: Vec<&str> = error.split(':').collect(); + if parts.len() >= 3 { + let candidate = parts[parts.len() - 2].trim(); + if !candidate.is_empty() + && candidate.len() < 64 + && !candidate.chars().all(|c| c.is_ascii_digit()) + { + return Some(candidate.to_string()); + } + } + } + + // Pattern: "'CMD' is not recognized" + if error.contains("is not recognized") { + let stripped = error.replace('\'', "").replace('"', ""); + if let Some(cmd) = stripped.split_whitespace().next() { + if cmd.len() < 64 { + return Some(cmd.to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_error_result(error: &str) -> ToolResult { + ToolResult { + success: false, + output: String::new(), + error: Some(error.to_string()), + } + } + + #[test] + fn detects_bash_command_not_found() { + let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); + let result = make_error_result("bash: jq: command not found"); + let cmd = interceptor.detect_missing_command(&result); + assert_eq!(cmd, Some("jq".to_string())); + } + + #[test] + fn detects_sh_not_found() { + let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); + let result = make_error_result("sh: 1: nmap: not found"); + let cmd = interceptor.detect_missing_command(&result); + assert_eq!(cmd, Some("nmap".to_string())); + } + + #[test] + fn respects_max_attempts() { + let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); + let result = make_error_result("bash: jq: command not found"); + + // First two attempts should succeed. + assert!(interceptor.detect_missing_command(&result).is_some()); + assert!(interceptor.detect_missing_command(&result).is_some()); + // Third should be None (max attempts reached). + assert!(interceptor.detect_missing_command(&result).is_none()); + } + + #[test] + fn ignores_successful_results() { + let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); + let result = ToolResult { + success: true, + output: "command not found".into(), // misleading output + error: None, + }; + assert!(interceptor.detect_missing_command(&result).is_none()); + } + + #[test] + fn disabled_returns_none() { + let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), false); + let result = make_error_result("bash: jq: command not found"); + assert!(interceptor.detect_missing_command(&result).is_none()); + } + + #[test] + fn reset_clears_attempts() { + let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); + let result = make_error_result("bash: jq: command not found"); + interceptor.detect_missing_command(&result); + interceptor.detect_missing_command(&result); + interceptor.reset(); + // After reset, should detect again. + assert!(interceptor.detect_missing_command(&result).is_some()); + } + + #[test] + fn tool_maker_prompt_includes_command() { + let interceptor = SelfHealingInterceptor::new(Path::new("/workspace"), true); + let prompt = interceptor.tool_maker_prompt("jq", "parse json output"); + assert!(prompt.contains("jq")); + assert!(prompt.contains("/workspace/polyfills/jq")); + assert!(prompt.contains("parse json output")); + } +} diff --git a/src/openhuman/agent/harness/session_queue.rs b/src/openhuman/agent/harness/session_queue.rs new file mode 100644 index 000000000..8a9be7b65 --- /dev/null +++ b/src/openhuman/agent/harness/session_queue.rs @@ -0,0 +1,158 @@ +//! Per-session serialised lane queue. +//! +//! All incoming tasks are serialised per-session to prevent race conditions when +//! writing to files, memory, or other shared resources. Cross-session requests +//! run concurrently. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; + +/// A queue that serialises work within a session while allowing parallelism +/// across sessions. +/// +/// Each session ID maps to a `Semaphore(1)`. Acquiring the permit blocks +/// subsequent requests for the *same* session until the permit is released. +pub struct SessionQueue { + lanes: Mutex>>, +} + +impl SessionQueue { + pub fn new() -> Self { + Self { + lanes: Mutex::new(HashMap::new()), + } + } + + /// Acquire the lane for `session_id`. + /// + /// Returns an `OwnedSemaphorePermit` that the caller must hold for the + /// duration of the request. Subsequent requests on the same session will + /// block until this permit is dropped. + pub async fn acquire(&self, session_id: &str) -> OwnedSemaphorePermit { + let sem = { + let mut map = self.lanes.lock().await; + let is_new = !map.contains_key(session_id); + let sem = map + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(Semaphore::new(1))) + .clone(); + if is_new { + tracing::trace!("[session-queue] created lane for session={session_id}"); + } + tracing::trace!( + "[session-queue] acquiring lane session={session_id}, permits={}", + sem.available_permits() + ); + sem + }; + let permit = sem.acquire_owned().await.expect("session semaphore closed"); + tracing::trace!("[session-queue] acquired lane for session={session_id}"); + permit + } + + /// Remove stale session lanes that have no waiters. + /// Call periodically or after sessions end to prevent unbounded growth. + pub async fn gc(&self) { + let mut map = self.lanes.lock().await; + let before = map.len(); + map.retain(|id, sem| { + let keep = sem.available_permits() < 1 || Arc::strong_count(sem) > 1; + if !keep { + tracing::trace!("[session-queue] pruning idle lane session={id}"); + } + keep + }); + let removed = before - map.len(); + if removed > 0 { + tracing::debug!( + "[session-queue] gc removed {removed} idle lane(s), {} remaining", + map.len() + ); + } + } + + /// Number of tracked session lanes (for diagnostics). + pub async fn lane_count(&self) -> usize { + self.lanes.lock().await.len() + } +} + +impl Default for SessionQueue { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::time::{sleep, Duration}; + + #[tokio::test] + async fn serialises_within_same_session() { + let queue = Arc::new(SessionQueue::new()); + let counter = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for _ in 0..5 { + let q = queue.clone(); + let c = counter.clone(); + handles.push(tokio::spawn(async move { + let _permit = q.acquire("session-1").await; + // If serialised, at most 1 task holds the permit at a time. + let prev = c.fetch_add(1, Ordering::SeqCst); + // While we hold the permit, sleep briefly. + sleep(Duration::from_millis(10)).await; + let current = c.load(Ordering::SeqCst); + // Nobody else should have incremented while we held the permit. + assert_eq!(current, prev + 1); + })); + } + + for h in handles { + h.await.unwrap(); + } + assert_eq!(counter.load(Ordering::SeqCst), 5); + } + + #[tokio::test] + async fn parallel_across_sessions() { + let queue = Arc::new(SessionQueue::new()); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for i in 0..4 { + let q = queue.clone(); + let a = active.clone(); + let m = max_active.clone(); + let session = format!("session-{i}"); + handles.push(tokio::spawn(async move { + let _permit = q.acquire(&session).await; + let current = a.fetch_add(1, Ordering::SeqCst) + 1; + m.fetch_max(current, Ordering::SeqCst); + sleep(Duration::from_millis(50)).await; + a.fetch_sub(1, Ordering::SeqCst); + })); + } + + for h in handles { + h.await.unwrap(); + } + // Multiple sessions should have run concurrently. + assert!(max_active.load(Ordering::SeqCst) > 1); + } + + #[tokio::test] + async fn gc_removes_idle_lanes() { + let queue = SessionQueue::new(); + { + let _permit = queue.acquire("temp-session").await; + } + // Permit dropped, lane is idle. + queue.gc().await; + assert_eq!(queue.lane_count().await, 0); + } +} diff --git a/src/openhuman/agent/harness/types.rs b/src/openhuman/agent/harness/types.rs new file mode 100644 index 000000000..a4369cb3f --- /dev/null +++ b/src/openhuman/agent/harness/types.rs @@ -0,0 +1,134 @@ +//! Shared types for the multi-agent harness: requests, results, task status. + +use super::archetypes::AgentArchetype; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Opaque identifier for a task node inside a `TaskDag`. +pub type TaskId = String; + +/// Current execution status of a DAG task node. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +impl Default for TaskStatus { + fn default() -> Self { + Self::Pending + } +} + +/// Request sent from the orchestrator to spawn a sub-agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubAgentRequest { + /// Task node this request fulfils. + pub task_id: TaskId, + /// Which archetype to instantiate. + pub archetype: AgentArchetype, + /// The specific instruction for this sub-agent. + pub prompt: String, + /// Optional context chunks injected before the prompt. + #[serde(default)] + pub context: Vec, + /// Parent session id (for cost aggregation and memory scoping). + pub parent_session_id: String, + /// Maximum wall-clock time for this sub-agent. + #[serde( + default = "default_subagent_timeout", + with = "humantime_serde", + skip_serializing_if = "is_default_timeout" + )] + pub timeout: Duration, +} + +fn default_subagent_timeout() -> Duration { + Duration::from_secs(120) +} + +fn is_default_timeout(d: &Duration) -> bool { + *d == default_subagent_timeout() +} + +/// A labelled piece of context forwarded to a sub-agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextChunk { + pub label: String, + pub content: String, +} + +/// Result returned by a completed (or failed) sub-agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubAgentResult { + pub task_id: TaskId, + pub success: bool, + pub output: String, + /// File paths or diffs produced by this agent. + #[serde(default)] + pub artifacts: Vec, + /// Cost in micro-dollars (1 USD = 1_000_000). + #[serde(default)] + pub cost_microdollars: u64, + /// Wall-clock duration of the sub-agent run. + #[serde(default, with = "humantime_serde")] + pub duration: Duration, +} + +/// An artifact produced by a sub-agent (file written, diff, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Artifact { + pub kind: ArtifactKind, + pub path: Option, + pub content: String, +} + +/// Classification of artifacts produced by sub-agents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + FileWritten, + Diff, + TestResult, + LintResult, + Summary, + Other, +} + +/// Decision the orchestrator makes after reviewing a completed DAG level. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewDecision { + /// Continue to the next DAG level. + Continue, + /// Retry specific failed tasks (up to limit). + Retry(Vec), + /// Abort the entire DAG with a reason. + Abort(String), +} + +// Provide a minimal humantime_serde so we can skip adding the crate as a dep. +// If humantime_serde is already available, swap these out. +mod humantime_serde { + use serde::{self, Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_f64(duration.as_secs_f64()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let secs = f64::deserialize(deserializer)?; + Ok(Duration::from_secs_f64(secs)) + } +} diff --git a/src/openhuman/agent/mod.rs b/src/openhuman/agent/mod.rs index 14e962d31..2ca3b4cbe 100644 --- a/src/openhuman/agent/mod.rs +++ b/src/openhuman/agent/mod.rs @@ -5,6 +5,7 @@ pub mod cost; pub mod dispatcher; pub mod error; pub mod events; +pub mod harness; pub mod hooks; pub mod host_runtime; pub mod identity; diff --git a/src/openhuman/agent/prompts/ORCHESTRATOR.md b/src/openhuman/agent/prompts/ORCHESTRATOR.md new file mode 100644 index 000000000..51d981089 --- /dev/null +++ b/src/openhuman/agent/prompts/ORCHESTRATOR.md @@ -0,0 +1,31 @@ +# Orchestrator — Staff Engineer + +You are the **Orchestrator**, the senior agent in a multi-agent system. Your role is strategic: you plan, delegate, review, and synthesise. You **never** write code, execute shell commands, or directly modify files. + +## Core Responsibilities + +1. **Understand the user's intent** — Parse the request, identify ambiguity, ask clarifying questions when needed. +2. **Plan the approach** — Decide which specialised sub-agents to spawn and in what order. +3. **Delegate precisely** — Give each sub-agent a clear, specific task with acceptance criteria. +4. **Review results** — Judge the quality of sub-agent output. Retry or adjust if needed. +5. **Synthesise the response** — Merge all sub-agent results into a coherent, helpful answer. + +## Available Sub-Agents + +| Archetype | When to Use | +|-----------|-------------| +| **Planner** | Complex tasks that need a multi-step plan before execution. | +| **Code Executor** | Writing, modifying, or running code. Runs sandboxed. | +| **Skills Agent** | Interacting with connected services (Notion, Gmail, etc.) via skill tools. | +| **Tool-Maker** | When a sub-agent reports a missing command — writes polyfill scripts. | +| **Researcher** | Finding information in docs, web, or files. Compresses to dense markdown. | +| **Critic** | Reviewing code changes for quality, security, and adherence to standards. | + +## Rules + +- **Never spawn yourself** — You cannot delegate to another Orchestrator. +- **Minimise sub-agents** — Use the fewest agents necessary. Simple questions don't need a DAG. +- **Context is expensive** — Pass only relevant context to sub-agents, not everything. +- **Fail gracefully** — If a sub-agent fails after retries, explain what happened clearly. +- **Stay concise** — Your final response should be direct and actionable. +- **Escalate when appropriate** — If orchestration is the wrong mode or a specialist cannot make progress, hand control back to OpenHuman Core with a concise explanation and let Core handle general interactions. diff --git a/src/openhuman/agent/prompts/PLANNER.md b/src/openhuman/agent/prompts/PLANNER.md new file mode 100644 index 000000000..229e4d5c7 --- /dev/null +++ b/src/openhuman/agent/prompts/PLANNER.md @@ -0,0 +1,40 @@ +# Planner — Task Architect + +You are the **Planner** agent. Your job is to decompose a complex user goal into a **directed acyclic graph (DAG)** of discrete tasks. + +## Output Format + +Return **only** valid JSON matching this schema: + +```json +{ + "root_goal": "the user's original goal", + "nodes": [ + { + "id": "task-1", + "description": "Clear, actionable instruction for the sub-agent", + "archetype": "code_executor", + "depends_on": [], + "acceptance_criteria": "How to verify this task is done correctly" + } + ] +} +``` + +## Available Archetypes + +- `code_executor` — Writes and runs code. Use for implementation tasks. +- `skills_agent` — Executes skill tools (Notion, Gmail, etc.). Use for service interactions. +- `tool_maker` — Writes polyfill scripts. Rarely needed in planning. +- `researcher` — Reads docs, web searches. Use for information gathering. +- `critic` — Reviews code quality and security. Use after code changes. + +## Rules + +1. **Minimise tasks** — Use the fewest nodes needed. Don't over-decompose. +2. **Dependencies matter** — Use `depends_on` to express ordering. Independent tasks run in parallel. +3. **Be specific** — Each description should be a complete instruction, not a vague goal. +4. **Include acceptance criteria** — How will we know the task succeeded? +5. **Simple goals = single node** — If the goal is straightforward, return exactly 1 node. +6. **No cycles** — The graph must be a DAG (directed acyclic graph). +7. **Max 8 nodes** — Keep plans manageable. Split larger projects into multiple plans. diff --git a/src/openhuman/agent/prompts/archetypes/archivist.md b/src/openhuman/agent/prompts/archetypes/archivist.md new file mode 100644 index 000000000..4275736aa --- /dev/null +++ b/src/openhuman/agent/prompts/archetypes/archivist.md @@ -0,0 +1,15 @@ +# Archivist — Knowledge Librarian + +You are the **Archivist** agent. You run in the background after sessions to preserve knowledge. + +## Responsibilities +1. **Index turns** — Record each turn in the episodic memory (FTS5) for future recall. +2. **Extract lessons** — Identify reusable patterns, mistakes to avoid, and user preferences. +3. **Update MEMORY.md** — Append significant learnings to the workspace knowledge base. + +## Rules +- **Be concise** — Lessons should be one or two sentences. Dense, not verbose. +- **Be selective** — Not every turn has a lesson. Only persist genuinely useful observations. +- **Never log secrets** — Redact API keys, tokens, passwords, and PII. +- **Use categories** — Label lessons by type: `pattern`, `mistake`, `preference`, `fact`. +- **Deduplicate** — Check existing MEMORY.md before adding duplicates. diff --git a/src/openhuman/agent/prompts/archetypes/code_executor.md b/src/openhuman/agent/prompts/archetypes/code_executor.md new file mode 100644 index 000000000..6490107ac --- /dev/null +++ b/src/openhuman/agent/prompts/archetypes/code_executor.md @@ -0,0 +1,16 @@ +# Code Executor — Sandboxed Developer + +You are the **Code Executor** agent. You write, run, and debug code in a sandboxed environment. + +## Capabilities +- Read and write files +- Execute shell commands +- Run tests and interpret results +- Git operations (commit, diff, status) + +## Rules +- **Fix your own bugs** — If code fails, read the error, diagnose, and fix it. Don't give up after one attempt. +- **Run tests** — After writing code, run relevant tests to verify correctness. +- **Stay in scope** — Only do what was asked. Don't refactor unrelated code. +- **Be safe** — Never run destructive commands (rm -rf, drop tables, etc.) without explicit instruction. +- **Report clearly** — State what you did, what worked, and what didn't. diff --git a/src/openhuman/agent/prompts/archetypes/critic.md b/src/openhuman/agent/prompts/archetypes/critic.md new file mode 100644 index 000000000..45851f743 --- /dev/null +++ b/src/openhuman/agent/prompts/archetypes/critic.md @@ -0,0 +1,22 @@ +# Critic — Adversarial QA Reviewer + +You are the **Critic** agent. Your job is to find problems before they reach production. + +## Capabilities +- Read git diffs to review changes +- Run linters (clippy, eslint) and interpret findings +- Run test suites and verify correctness +- Read project files for context + +## Review Checklist +1. **Security** — SQL injection, XSS, command injection, hardcoded secrets, OWASP top 10. +2. **Correctness** — Edge cases, off-by-one errors, null/None handling, race conditions. +3. **Style** — Naming conventions, code organization, consistency with existing patterns. +4. **Tests** — Are new paths covered? Do existing tests still pass? +5. **SOUL.md compliance** — Does the code align with the project's core principles? + +## Rules +- **Be specific** — "Line 42: SQL string interpolation is injectable" not "code might have security issues". +- **Prioritise** — Flag critical issues first (security > correctness > style). +- **Be constructive** — Suggest fixes, not just complaints. +- **Read-only** — You review but never modify code. Report findings to the Orchestrator. diff --git a/src/openhuman/agent/prompts/archetypes/researcher.md b/src/openhuman/agent/prompts/archetypes/researcher.md new file mode 100644 index 000000000..984b1f1b8 --- /dev/null +++ b/src/openhuman/agent/prompts/archetypes/researcher.md @@ -0,0 +1,16 @@ +# Researcher — Documentation & Web Crawler + +You are the **Researcher** agent. You find accurate, up-to-date information. + +## Capabilities +- Web search for current information +- HTTP requests to fetch documentation +- Read local files for project context +- Memory recall for prior research + +## Rules +- **Read real docs** — Don't guess API signatures or library usage. Look it up. +- **No hallucination** — If you can't find the answer, say so. Never fabricate URLs or APIs. +- **Compress output** — Distill long documents into dense, factual markdown summaries. +- **Cite sources** — Include URLs or file paths for information you reference. +- **Stay focused** — Answer the specific question asked, not everything tangentially related. diff --git a/src/openhuman/agent/prompts/archetypes/skills_agent.md b/src/openhuman/agent/prompts/archetypes/skills_agent.md new file mode 100644 index 000000000..8c039d1f3 --- /dev/null +++ b/src/openhuman/agent/prompts/archetypes/skills_agent.md @@ -0,0 +1,20 @@ +# Skills Agent — Service Integration Specialist + +You are the **Skills Agent**. You interact with connected services through skill tools. + +## Tool Naming Convention +Tools follow the pattern: `{skill_id}__{tool_name}` +Examples: `notion__create_page`, `gmail__send_email`, `notion__query_database` + +## Capabilities +- Execute any registered skill tool +- Use injected memory context about previous interactions +- Handle rate limits with appropriate delays +- Recover from transient failures with retries + +## Rules +- **Respect rate limits** — Notion: max 3 requests/second. Gmail: respect quota limits. +- **Handle errors gracefully** — OAuth token expiry, API errors, rate limits — retry or report clearly. +- **Use memory context** — Consult the injected memory context (provided in your system prompt) for details about the user's integrations and preferences. +- **Be precise** — Skill tools expect specific parameter formats. Validate before calling. +- **Report results** — State what action was taken and the outcome. diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 9fd741ac9..9eef601eb 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -13,18 +13,18 @@ pub use ops::*; pub use schema::{ apply_runtime_proxy_to_builder, build_runtime_proxy_client, build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config, - AgentConfig, AuditConfig, AutocompleteConfig, AutonomyConfig, BrowserComputerUseConfig, - BrowserConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig, - CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EmbeddingRouteConfig, - HardwareConfig, HardwareTransport, HeartbeatConfig, HttpRequestConfig, IMessageConfig, - IdentityConfig, LarkConfig, LearningConfig, LocalAiConfig, MatrixConfig, MemoryConfig, - ModelRouteConfig, MultimodalConfig, ObservabilityConfig, PeripheralBoardConfig, - PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig, ReflectionSource, - ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, - SchedulerConfig, ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, - StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, - WebSearchConfig, WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1, - MODEL_REASONING_V1, + AgentConfig, ArchetypeConfig, AuditConfig, AutocompleteConfig, AutonomyConfig, + BrowserComputerUseConfig, BrowserConfig, ChannelsConfig, ClassificationRule, ComposioConfig, + Config, CostConfig, CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, + EmbeddingRouteConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HttpRequestConfig, + IMessageConfig, IdentityConfig, LarkConfig, LearningConfig, LocalAiConfig, MatrixConfig, + MemoryConfig, ModelRouteConfig, MultimodalConfig, ObservabilityConfig, OrchestratorConfig, + PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig, + ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, + SandboxConfig, SchedulerConfig, ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, + SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, + TelegramConfig, WebSearchConfig, WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, + MODEL_CODING_V1, MODEL_REASONING_V1, }; pub use schemas::{ all_controller_schemas as all_config_controller_schemas, diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 67ca31304..6519ffdec 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -15,6 +15,7 @@ mod learning; mod load; mod local_ai; mod observability; +mod orchestrator; mod proxy; mod routes; mod runtime; @@ -39,6 +40,7 @@ pub use identity_cost::{ pub use learning::{LearningConfig, ReflectionSource}; pub use local_ai::LocalAiConfig; pub use observability::ObservabilityConfig; +pub use orchestrator::{ArchetypeConfig, OrchestratorConfig}; pub use proxy::{ apply_runtime_proxy_to_builder, build_runtime_proxy_client, build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config, diff --git a/src/openhuman/config/schema/orchestrator.rs b/src/openhuman/config/schema/orchestrator.rs new file mode 100644 index 000000000..03b9a2d7a --- /dev/null +++ b/src/openhuman/config/schema/orchestrator.rs @@ -0,0 +1,119 @@ +//! Orchestrator / multi-agent harness configuration. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Configuration for the multi-agent orchestrator harness. +/// +/// When `enabled` is false (default), the system behaves as a single-agent loop +/// using the existing `Agent` + tool-call path. Backward compatible. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct OrchestratorConfig { + /// Enable multi-agent orchestrator mode. + #[serde(default)] + pub enabled: bool, + + /// Per-archetype configuration overrides. + /// Keys are archetype names (e.g. "code_executor", "researcher"). + #[serde(default)] + pub archetypes: HashMap, + + /// Maximum concurrent sub-agents across all sessions. + #[serde(default = "default_max_concurrent_agents")] + pub max_concurrent_agents: usize, + + /// Enable the Archivist background daemon (post-session nudge loop). + #[serde(default = "default_true")] + pub archivist_enabled: bool, + + /// Enable FTS5 episodic recall tables in SQLite memory. + #[serde(default = "default_true")] + pub fts5_enabled: bool, + + /// Enable self-healing (ToolMaker auto-polyfill on "command not found"). + #[serde(default = "default_true")] + pub self_healing_enabled: bool, + + /// Maximum number of task nodes in a single DAG plan. + #[serde(default = "default_max_dag_tasks")] + pub max_dag_tasks: usize, + + /// Maximum retry attempts for a failed DAG task node. + #[serde(default = "default_max_retries")] + pub max_task_retries: u8, +} + +/// Per-archetype configuration override. +/// +/// Any field left `None` uses the archetype's built-in default. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ArchetypeConfig { + /// Model name or hint override (e.g. "coding-v1", "local:phi3"). + #[serde(default)] + pub model: Option, + + /// System prompt override (inline or path). + #[serde(default)] + pub system_prompt: Option, + + /// Temperature override. + #[serde(default)] + pub temperature: Option, + + /// Maximum tool iterations override. + #[serde(default)] + pub max_tool_iterations: Option, + + /// Timeout in seconds for this archetype's sub-agent runs. + #[serde(default)] + pub timeout_secs: Option, + + /// Sandbox mode override: "sandboxed", "read_only", or "none". + #[serde(default)] + pub sandbox: Option, +} + +fn default_max_concurrent_agents() -> usize { + 4 +} + +fn default_true() -> bool { + true +} + +fn default_max_dag_tasks() -> usize { + 8 +} + +fn default_max_retries() -> u8 { + 2 +} + +impl Default for OrchestratorConfig { + fn default() -> Self { + Self { + enabled: false, + archetypes: HashMap::new(), + max_concurrent_agents: default_max_concurrent_agents(), + archivist_enabled: default_true(), + fts5_enabled: default_true(), + self_healing_enabled: default_true(), + max_dag_tasks: default_max_dag_tasks(), + max_task_retries: default_max_retries(), + } + } +} + +impl Default for ArchetypeConfig { + fn default() -> Self { + Self { + model: None, + system_prompt: None, + temperature: None, + max_tool_iterations: None, + timeout_secs: None, + sandbox: None, + } + } +} diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index f6e6d27ae..5384f1d15 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -114,6 +114,9 @@ pub struct Config { #[serde(default)] pub learning: LearningConfig, + + #[serde(default)] + pub orchestrator: OrchestratorConfig, } impl Default for Config { @@ -159,6 +162,7 @@ impl Default for Config { local_ai: LocalAiConfig::default(), query_classification: QueryClassificationConfig::default(), learning: LearningConfig::default(), + orchestrator: OrchestratorConfig::default(), } } } diff --git a/src/openhuman/memory/store/mod.rs b/src/openhuman/memory/store/mod.rs index 2ef23e868..abfee35bd 100644 --- a/src/openhuman/memory/store/mod.rs +++ b/src/openhuman/memory/store/mod.rs @@ -17,4 +17,5 @@ pub use types::{ NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, StoredMemoryDocument, }; +pub use unified::fts5; pub use unified::UnifiedMemory; diff --git a/src/openhuman/memory/store/unified/fts5.rs b/src/openhuman/memory/store/unified/fts5.rs new file mode 100644 index 000000000..5319da8cc --- /dev/null +++ b/src/openhuman/memory/store/unified/fts5.rs @@ -0,0 +1,224 @@ +//! FTS5 episodic memory — full-text search over past sessions. +//! +//! Adds an FTS5 virtual table backed by an `episodic_log` table for storing +//! turn-level records with optional extracted lessons. The Archivist uses +//! this for post-session knowledge extraction and the `search_memory` tool +//! uses it for episodic recall. + +use parking_lot::Mutex; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// A single episodic record (one turn or event). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EpisodicEntry { + pub id: Option, + pub session_id: String, + pub timestamp: f64, + pub role: String, + pub content: String, + pub lesson: Option, + pub tool_calls_json: Option, + pub cost_microdollars: u64, +} + +/// SQL to create the episodic tables. Called during `UnifiedMemory` init. +pub const EPISODIC_INIT_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS episodic_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + timestamp REAL NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + lesson TEXT, + tool_calls_json TEXT, + cost_microdollars INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_episodic_session + ON episodic_log(session_id, timestamp); + +CREATE VIRTUAL TABLE IF NOT EXISTS episodic_fts USING fts5( + session_id, + role, + content, + lesson, + content=episodic_log, + content_rowid=id, + tokenize='porter unicode61' +); + +-- Triggers to keep FTS5 in sync with the backing table. +CREATE TRIGGER IF NOT EXISTS episodic_ai AFTER INSERT ON episodic_log BEGIN + INSERT INTO episodic_fts(rowid, session_id, role, content, lesson) + VALUES (new.id, new.session_id, new.role, new.content, new.lesson); +END; + +CREATE TRIGGER IF NOT EXISTS episodic_ad AFTER DELETE ON episodic_log BEGIN + INSERT INTO episodic_fts(episodic_fts, rowid, session_id, role, content, lesson) + VALUES ('delete', old.id, old.session_id, old.role, old.content, old.lesson); +END; + +CREATE TRIGGER IF NOT EXISTS episodic_au AFTER UPDATE ON episodic_log BEGIN + INSERT INTO episodic_fts(episodic_fts, rowid, session_id, role, content, lesson) + VALUES ('delete', old.id, old.session_id, old.role, old.content, old.lesson); + INSERT INTO episodic_fts(rowid, session_id, role, content, lesson) + VALUES (new.id, new.session_id, new.role, new.content, new.lesson); +END; +"#; + +/// Insert an episodic entry. +pub fn episodic_insert(conn: &Arc>, entry: &EpisodicEntry) -> anyhow::Result<()> { + let conn = conn.lock(); + conn.execute( + "INSERT INTO episodic_log (session_id, timestamp, role, content, lesson, tool_calls_json, cost_microdollars) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + entry.session_id, + entry.timestamp, + entry.role, + entry.content, + entry.lesson, + entry.tool_calls_json, + entry.cost_microdollars as i64, + ], + )?; + tracing::debug!( + "[fts5] inserted episodic entry: session={}, role={}", + entry.session_id, + entry.role + ); + Ok(()) +} + +/// Full-text search over episodic entries. +pub fn episodic_search( + conn: &Arc>, + query: &str, + limit: usize, +) -> anyhow::Result> { + let conn = conn.lock(); + let mut stmt = conn.prepare( + "SELECT el.id, el.session_id, el.timestamp, el.role, el.content, el.lesson, + el.tool_calls_json, el.cost_microdollars + FROM episodic_fts AS ef + JOIN episodic_log AS el ON ef.rowid = el.id + WHERE episodic_fts MATCH ?1 + ORDER BY rank + LIMIT ?2", + )?; + + let rows = stmt + .query_map(rusqlite::params![query, limit as i64], |row| { + Ok(EpisodicEntry { + id: row.get(0)?, + session_id: row.get(1)?, + timestamp: row.get(2)?, + role: row.get(3)?, + content: row.get(4)?, + lesson: row.get(5)?, + tool_calls_json: row.get(6)?, + cost_microdollars: row.get::<_, i64>(7)? as u64, + }) + })? + .collect::, _>>()?; + + tracing::debug!("[fts5] search '{}' returned {} results", query, rows.len()); + Ok(rows) +} + +/// Get all entries for a session (for post-session summary). +pub fn episodic_session_entries( + conn: &Arc>, + session_id: &str, +) -> anyhow::Result> { + let conn = conn.lock(); + let mut stmt = conn.prepare( + "SELECT id, session_id, timestamp, role, content, lesson, tool_calls_json, cost_microdollars + FROM episodic_log + WHERE session_id = ?1 + ORDER BY timestamp ASC", + )?; + + let rows = stmt + .query_map(rusqlite::params![session_id], |row| { + Ok(EpisodicEntry { + id: row.get(0)?, + session_id: row.get(1)?, + timestamp: row.get(2)?, + role: row.get(3)?, + content: row.get(4)?, + lesson: row.get(5)?, + tool_calls_json: row.get(6)?, + cost_microdollars: row.get::<_, i64>(7)? as u64, + }) + })? + .collect::, _>>()?; + + Ok(rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_db() -> Arc> { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(EPISODIC_INIT_SQL).unwrap(); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn insert_and_search() { + let conn = setup_db(); + let entry = EpisodicEntry { + id: None, + session_id: "s1".into(), + timestamp: 1000.0, + role: "user".into(), + content: "How do I deploy to production?".into(), + lesson: Some("User frequently asks about deployment".into()), + tool_calls_json: None, + cost_microdollars: 100, + }; + episodic_insert(&conn, &entry).unwrap(); + + let results = episodic_search(&conn, "deploy production", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].session_id, "s1"); + assert!(results[0].content.contains("deploy")); + } + + #[test] + fn session_entries() { + let conn = setup_db(); + for i in 0..3 { + episodic_insert( + &conn, + &EpisodicEntry { + id: None, + session_id: "s2".into(), + timestamp: 1000.0 + i as f64, + role: if i % 2 == 0 { "user" } else { "assistant" }.into(), + content: format!("Turn {i} content"), + lesson: None, + tool_calls_json: None, + cost_microdollars: 0, + }, + ) + .unwrap(); + } + + let entries = episodic_session_entries(&conn, "s2").unwrap(); + assert_eq!(entries.len(), 3); + assert!(entries[0].timestamp < entries[2].timestamp); + } + + #[test] + fn empty_search_returns_empty() { + let conn = setup_db(); + let results = episodic_search(&conn, "nonexistent query", 10).unwrap(); + assert!(results.is_empty()); + } +} diff --git a/src/openhuman/memory/store/unified/init.rs b/src/openhuman/memory/store/unified/init.rs index 3921d3431..b2a4dc401 100644 --- a/src/openhuman/memory/store/unified/init.rs +++ b/src/openhuman/memory/store/unified/init.rs @@ -104,6 +104,11 @@ impl UnifiedMemory { CREATE INDEX IF NOT EXISTS idx_vector_chunks_ns_doc ON vector_chunks(namespace, document_id);", )?; + // Create FTS5 episodic tables (episodic_log, episodic_fts, and their + // triggers) so the Archivist can call episodic_insert immediately after + // the store is initialised. + conn.execute_batch(super::fts5::EPISODIC_INIT_SQL)?; + Ok(Self { workspace_dir: workspace_dir.to_path_buf(), db_path, diff --git a/src/openhuman/memory/store/unified/mod.rs b/src/openhuman/memory/store/unified/mod.rs index 1d3e39c50..3bafac7ba 100644 --- a/src/openhuman/memory/store/unified/mod.rs +++ b/src/openhuman/memory/store/unified/mod.rs @@ -16,6 +16,7 @@ pub struct UnifiedMemory { } mod documents; +pub mod fts5; mod graph; mod helpers; mod init; diff --git a/src/openhuman/tools/ask_clarification.rs b/src/openhuman/tools/ask_clarification.rs new file mode 100644 index 000000000..fb811edcb --- /dev/null +++ b/src/openhuman/tools/ask_clarification.rs @@ -0,0 +1,84 @@ +//! Tool: ask_user_clarification — pause execution and ask the user a question. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Pauses the current execution to ask the user for clarification. +/// +/// In the orchestrator flow, this surfaces the question to the user via the +/// event channel and waits for a response before continuing. +pub struct AskClarificationTool; + +impl AskClarificationTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for AskClarificationTool { + fn name(&self) -> &str { + "ask_user_clarification" + } + + fn description(&self) -> &str { + "Ask the user a clarifying question when the task is ambiguous or requires \ + a decision. The question will be shown to the user and their response returned. \ + Use sparingly — only when the answer cannot be inferred from context." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The clarifying question to ask the user. \ + If omitted, a generic clarification prompt is used." + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of choices to present to the user." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::None + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let question = args + .get("question") + .and_then(|v| v.as_str()) + .unwrap_or("Could you clarify?"); + + let options = args.get("options").and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }); + + let mut output = format!("[CLARIFICATION NEEDED]\n{question}"); + if let Some(opts) = options { + output.push_str(&format!("\n\nOptions: {opts}")); + } + + // In a full implementation, this would: + // 1. Emit an event to the frontend/CLI. + // 2. Block on a response channel. + // 3. Return the user's answer. + // For now, return the question as output so the orchestrator can surface it. + tracing::info!("[ask_clarification] question: {question}"); + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} diff --git a/src/openhuman/tools/insert_sql_record.rs b/src/openhuman/tools/insert_sql_record.rs new file mode 100644 index 000000000..da631bfa1 --- /dev/null +++ b/src/openhuman/tools/insert_sql_record.rs @@ -0,0 +1,268 @@ +//! Tool: insert_sql_record — insert an episodic record into the FTS5 memory database. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Valid values for the `role` parameter. +const VALID_ROLES: &[&str] = &["user", "assistant", "tool"]; + +/// Inserts an episodic memory record into the FTS5 episodic-memory SQLite table. +/// +/// # Current status +/// The FTS5 schema and connection pool will be wired in Phase 5 of the harness +/// implementation. This stub validates parameters, emits structured trace logs, +/// and returns a success result so calling agents can proceed without blocking. +pub struct InsertSqlRecordTool; + +impl InsertSqlRecordTool { + pub fn new() -> Self { + Self + } +} + +impl Default for InsertSqlRecordTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for InsertSqlRecordTool { + fn name(&self) -> &str { + "insert_sql_record" + } + + fn description(&self) -> &str { + "Insert an episodic memory record into the FTS5 memory database. \ + Records are tagged with a session ID, role (user/assistant/tool), \ + content, and an optional extracted lesson. The database enables \ + full-text search over conversation history for future retrieval." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["session_id", "role", "content"], + "properties": { + "session_id": { + "type": "string", + "description": "Unique identifier for the conversation session." + }, + "role": { + "type": "string", + "enum": ["user", "assistant", "tool"], + "description": "Who produced this record." + }, + "content": { + "type": "string", + "description": "The text content of the message or tool output." + }, + "lesson": { + "type": "string", + "description": "Optional distilled lesson extracted from this exchange." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + // ── Parameter extraction ──────────────────────────────────────────── + let session_id = args + .get("session_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter 'session_id'"))?; + + let role = args + .get("role") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter 'role'"))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter 'content'"))?; + + let lesson = args.get("lesson").and_then(|v| v.as_str()); + + // ── Validation ────────────────────────────────────────────────────── + if !VALID_ROLES.contains(&role) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Invalid role '{role}'. Must be one of: user, assistant, tool." + )), + }); + } + + if session_id.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("'session_id' must not be empty.".into()), + }); + } + + if content.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("'content' must not be empty.".into()), + }); + } + + // ── Structured trace log ──────────────────────────────────────────── + tracing::info!( + session_id = session_id, + role = role, + content_len = content.len(), + has_lesson = lesson.is_some(), + "[insert_sql_record] episodic record queued for FTS5 insert" + ); + + if let Some(lesson_text) = lesson { + tracing::debug!( + session_id = session_id, + "[insert_sql_record] lesson: {lesson_text}" + ); + } + + // ── Placeholder result (FTS5 wire-up deferred to Phase 5) ─────────── + // TODO(phase-5): obtain `Arc` from app state, run: + // sqlx::query!( + // "INSERT INTO episodic_memory(session_id, role, content, lesson, ts) + // VALUES (?, ?, ?, ?, unixepoch())", + // session_id, role, content, lesson + // ).execute(&*pool).await?; + let summary = format!( + "Record staged: session={session_id} role={role} content_len={} lesson={}", + content.len(), + lesson.map_or("none".to_string(), |l| format!("{} chars", l.len())), + ); + + Ok(ToolResult { + success: false, + output: summary, + error: Some("episodic memory write not yet wired (FTS5/SQLite insert pending)".into()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool() -> InsertSqlRecordTool { + InsertSqlRecordTool::new() + } + + #[tokio::test] + async fn inserts_minimal_record() { + let result = tool() + .execute(json!({ + "session_id": "sess-001", + "role": "user", + "content": "Hello, world!" + })) + .await + .unwrap(); + // The tool is a stub: success is false until FTS5 write is wired. + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("not yet wired")); + assert!(result.output.contains("sess-001")); + assert!(result.output.contains("user")); + } + + #[tokio::test] + async fn inserts_with_lesson() { + let result = tool() + .execute(json!({ + "session_id": "sess-002", + "role": "assistant", + "content": "Use cargo fmt before committing.", + "lesson": "Always format Rust code before review." + })) + .await + .unwrap(); + // The tool is a stub: success is false until FTS5 write is wired. + assert!(!result.success); + assert!(result.output.contains("lesson=")); + } + + #[tokio::test] + async fn rejects_invalid_role() { + let result = tool() + .execute(json!({ + "session_id": "sess-003", + "role": "system", + "content": "Invalid role test." + })) + .await + .unwrap(); + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Invalid role")); + } + + #[tokio::test] + async fn rejects_empty_session_id() { + let result = tool() + .execute(json!({ + "session_id": " ", + "role": "user", + "content": "Some content." + })) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("session_id")); + } + + #[tokio::test] + async fn rejects_empty_content() { + let result = tool() + .execute(json!({ + "session_id": "sess-004", + "role": "tool", + "content": "" + })) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("content")); + } + + #[tokio::test] + async fn missing_required_param_returns_error() { + let result = tool() + .execute(json!({ "session_id": "s", "role": "user" })) + .await; + assert!(result.is_err(), "should return Err for missing 'content'"); + } + + #[test] + fn schema_has_required_fields() { + let schema = tool().parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("session_id"))); + assert!(required.contains(&json!("role"))); + assert!(required.contains(&json!("content"))); + } + + #[test] + fn permission_is_write() { + assert_eq!(tool().permission_level(), PermissionLevel::Write); + } +} diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 2268cf80c..44cf12d03 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -1,3 +1,4 @@ +pub mod ask_clarification; pub mod browser; pub mod browser_open; pub mod composio; @@ -17,6 +18,7 @@ pub mod hardware_memory_read; pub mod http_request; pub mod image_info; pub mod image_output; +pub mod insert_sql_record; pub mod local_cli; pub mod memory_forget; pub mod memory_recall; @@ -24,15 +26,22 @@ pub mod memory_store; pub mod ops; pub mod proxy_config; pub mod pushover; +pub mod read_diff; +pub mod run_linter; +pub mod run_tests; pub mod schedule; pub mod schema; mod schemas; pub mod screenshot; pub mod shell; +pub mod spawn_subagent; pub mod tool_stats; pub mod traits; +pub mod update_memory_md; pub mod web_search_tool; +pub mod workspace_state; +pub use ask_clarification::AskClarificationTool; pub use browser::{BrowserTool, ComputerUseConfig}; pub use browser_open::BrowserOpenTool; pub use composio::ComposioTool; @@ -51,12 +60,16 @@ pub use hardware_memory_map::HardwareMemoryMapTool; pub use hardware_memory_read::HardwareMemoryReadTool; pub use http_request::HttpRequestTool; pub use image_info::ImageInfoTool; +pub use insert_sql_record::InsertSqlRecordTool; pub use memory_forget::MemoryForgetTool; pub use memory_recall::MemoryRecallTool; pub use memory_store::MemoryStoreTool; pub use ops::*; pub use proxy_config::ProxyConfigTool; pub use pushover::PushoverTool; +pub use read_diff::ReadDiffTool; +pub use run_linter::RunLinterTool; +pub use run_tests::RunTestsTool; pub use schedule::ScheduleTool; #[allow(unused_imports)] pub use schema::{CleaningStrategy, SchemaCleanr}; @@ -66,8 +79,11 @@ pub use schemas::{ }; pub use screenshot::ScreenshotTool; pub use shell::ShellTool; +pub use spawn_subagent::SpawnSubagentTool; pub use tool_stats::ToolStatsTool; pub use traits::{PermissionLevel, Tool}; #[allow(unused_imports)] pub use traits::{ToolResult, ToolSpec}; +pub use update_memory_md::UpdateMemoryMdTool; pub use web_search_tool::WebSearchTool; +pub use workspace_state::WorkspaceStateTool; diff --git a/src/openhuman/tools/read_diff.rs b/src/openhuman/tools/read_diff.rs new file mode 100644 index 000000000..2dcb86b97 --- /dev/null +++ b/src/openhuman/tools/read_diff.rs @@ -0,0 +1,116 @@ +//! Tool: read_diff — structured git diff output for the Critic archetype. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Returns `git diff` output in a structured format. +pub struct ReadDiffTool { + workspace_dir: PathBuf, +} + +impl ReadDiffTool { + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } +} + +#[async_trait] +impl Tool for ReadDiffTool { + fn name(&self) -> &str { + "read_diff" + } + + fn description(&self) -> &str { + "Get the git diff of current changes. Can diff staged, unstaged, or against a \ + specific base branch/commit. Returns file paths and hunks." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "base": { + "type": "string", + "description": "Base ref to diff against (e.g. 'main', 'HEAD~3'). Default: unstaged changes." + }, + "staged": { + "type": "boolean", + "description": "Show staged changes only (--cached). Default: false." + }, + "path_filter": { + "type": "string", + "description": "Limit diff to a specific path or glob." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let base = args.get("base").and_then(|v| v.as_str()); + let staged = args + .get("staged") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let path_filter = args.get("path_filter").and_then(|v| v.as_str()); + + let mut git_args = vec!["diff", "--stat", "-p"]; + + if staged { + git_args.push("--cached"); + } + + let base_str = base.map(|b| b.to_string()); + if let Some(ref bs) = base_str { + git_args.push(bs); + } + + if let Some(pf) = path_filter { + git_args.push("--"); + git_args.push(pf); + } + + tracing::debug!( + workspace = %self.workspace_dir.display(), + ?git_args, + "[read_diff] running git diff" + ); + + let output = tokio::process::Command::new("git") + .args(&git_args) + .current_dir(&self.workspace_dir) + .output() + .await?; + + if output.status.success() { + let diff = String::from_utf8_lossy(&output.stdout); + tracing::debug!("[read_diff] success, diff length={}", diff.len()); + if diff.trim().is_empty() { + Ok(ToolResult { + success: true, + output: "No changes found.".into(), + error: None, + }) + } else { + Ok(ToolResult { + success: true, + output: diff.to_string(), + error: None, + }) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::debug!("[read_diff] failed: {stderr}"); + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(stderr.to_string()), + }) + } + } +} diff --git a/src/openhuman/tools/run_linter.rs b/src/openhuman/tools/run_linter.rs new file mode 100644 index 000000000..45ac762b7 --- /dev/null +++ b/src/openhuman/tools/run_linter.rs @@ -0,0 +1,138 @@ +//! Tool: run_linter — run linting tools for the Critic archetype. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Runs linters (cargo clippy, eslint) and returns structured findings. +pub struct RunLinterTool { + workspace_dir: PathBuf, +} + +impl RunLinterTool { + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } +} + +#[async_trait] +impl Tool for RunLinterTool { + fn name(&self) -> &str { + "run_linter" + } + + fn description(&self) -> &str { + "Run linting tools on the codebase. Supports 'clippy' for Rust and 'eslint' for \ + TypeScript/JavaScript. Returns warnings and errors." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "linter": { + "type": "string", + "enum": ["clippy", "eslint", "auto"], + "description": "Which linter to run. 'auto' detects from project files.", + "default": "auto" + }, + "path": { + "type": "string", + "description": "Limit linting to a specific path." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let linter = args + .get("linter") + .and_then(|v| v.as_str()) + .unwrap_or("auto"); + + let linter = if linter == "auto" { + if self.workspace_dir.join("Cargo.toml").exists() { + "clippy" + } else if self.workspace_dir.join("package.json").exists() { + "eslint" + } else { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Could not detect project type for linting.".into()), + }); + } + } else { + linter + }; + + let output = match linter { + "clippy" => { + tokio::process::Command::new("cargo") + .args([ + "clippy", + "--message-format=short", + "--", + "-W", + "clippy::all", + ]) + .current_dir(&self.workspace_dir) + .output() + .await? + } + "eslint" => { + let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + if path.starts_with('/') || path.contains("..") { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "path must be a relative path within the workspace \ + (no absolute paths or '..')" + .into(), + ), + }); + } + tokio::process::Command::new("npx") + .args(["eslint", "--format", "compact", path]) + .current_dir(&self.workspace_dir) + .output() + .await? + } + other => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Unknown linter: {other}")), + }); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let combined = if stdout.is_empty() { + stderr.to_string() + } else { + format!("{stdout}\n{stderr}") + }; + + Ok(ToolResult { + success: output.status.success(), + output: combined, + error: if output.status.success() { + None + } else { + Some(format!( + "Linter exited with code {:?}", + output.status.code() + )) + }, + }) + } +} diff --git a/src/openhuman/tools/run_tests.rs b/src/openhuman/tools/run_tests.rs new file mode 100644 index 000000000..0ebd729e4 --- /dev/null +++ b/src/openhuman/tools/run_tests.rs @@ -0,0 +1,173 @@ +//! Tool: run_tests — run test suites for the Critic archetype. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Runs test suites (cargo test, vitest) and returns pass/fail with output. +pub struct RunTestsTool { + workspace_dir: PathBuf, +} + +impl RunTestsTool { + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } +} + +#[async_trait] +impl Tool for RunTestsTool { + fn name(&self) -> &str { + "run_tests" + } + + fn description(&self) -> &str { + "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for \ + TypeScript/JavaScript. Returns pass/fail results with output." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "runner": { + "type": "string", + "enum": ["cargo_test", "vitest", "auto"], + "description": "Which test runner to use. 'auto' detects from project files.", + "default": "auto" + }, + "filter": { + "type": "string", + "description": "Filter to run specific tests (e.g. test name or module)." + }, + "timeout_secs": { + "type": "integer", + "description": "Timeout in seconds (default: 120).", + "default": 120 + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let runner = args + .get("runner") + .and_then(|v| v.as_str()) + .unwrap_or("auto"); + let filter = args.get("filter").and_then(|v| v.as_str()); + let timeout_secs = args + .get("timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(120); + + let runner = if runner == "auto" { + if self.workspace_dir.join("Cargo.toml").exists() { + "cargo_test" + } else if self.workspace_dir.join("package.json").exists() { + "vitest" + } else { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Could not detect project type for testing.".into()), + }); + } + } else { + runner + }; + + let mut cmd = match runner { + "cargo_test" => { + let mut c = tokio::process::Command::new("cargo"); + c.arg("test"); + if let Some(f) = filter { + c.arg(f); + } + c + } + "vitest" => { + let mut c = tokio::process::Command::new("npx"); + c.args(["vitest", "run"]); + if let Some(f) = filter { + c.arg(f); + } + c + } + other => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Unknown test runner: {other}")), + }); + } + }; + + tracing::debug!("[run_tests] runner={runner}, filter={filter:?}, timeout={timeout_secs}s"); + + cmd.current_dir(&self.workspace_dir); + cmd.kill_on_drop(true); + + let output = + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), cmd.output()) + .await + { + Ok(Ok(output)) => output, + Ok(Err(e)) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to spawn test runner: {e}")), + }); + } + Err(_) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("test execution timed out after {timeout_secs}s")), + }); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}\n{stderr}"); + + // Truncate on a safe UTF-8 char boundary. + let truncated = if combined.len() > 8000 { + let safe_end = combined + .char_indices() + .take_while(|(i, _)| *i <= 8000) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + format!( + "{}...\n[truncated, {} total chars]", + &combined[..safe_end], + combined.len() + ) + } else { + combined + }; + + tracing::debug!( + "[run_tests] exit_code={:?}, output_len={}", + output.status.code(), + truncated.len() + ); + + Ok(ToolResult { + success: output.status.success(), + output: truncated, + error: if output.status.success() { + None + } else { + Some(format!("Tests exited with code {:?}", output.status.code())) + }, + }) + } +} diff --git a/src/openhuman/tools/spawn_subagent.rs b/src/openhuman/tools/spawn_subagent.rs new file mode 100644 index 000000000..e30c5c77c --- /dev/null +++ b/src/openhuman/tools/spawn_subagent.rs @@ -0,0 +1,99 @@ +//! Tool: spawn_subagent — Orchestrator-only tool for spawning typed sub-agents. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use crate::openhuman::agent::harness::archetypes::AgentArchetype; +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. +pub struct SpawnSubagentTool; + +impl SpawnSubagentTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for SpawnSubagentTool { + fn name(&self) -> &str { + "spawn_subagent" + } + + 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." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["archetype", "prompt"], + "properties": { + "archetype": { + "type": "string", + "enum": ["code_executor", "skills_agent", "tool_maker", "researcher", "critic"], + "description": "Which specialised sub-agent to spawn." + }, + "prompt": { + "type": "string", + "description": "Clear, specific instruction for the sub-agent." + }, + "context": { + "type": "string", + "description": "Optional context from prior task results or workspace state." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let archetype_str = args + .get("archetype") + .and_then(|v| v.as_str()) + .unwrap_or("code_executor"); + + let prompt = args.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); + + let context = args.get("context").and_then(|v| v.as_str()).unwrap_or(""); + + if prompt.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("prompt is required".into()), + }); + } + + // Parse archetype (validation). + let _archetype: AgentArchetype = serde_json::from_value(json!(archetype_str)) + .map_err(|_| anyhow::anyhow!("unknown archetype: {archetype_str}"))?; + + // 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() + ); + + Ok(ToolResult { + success: false, + output: format!( + "[Sub-agent {archetype_str}] Task received. Prompt: {prompt}\n\ + Context length: {} chars\n\ + (Full sub-agent execution will be wired in next phase)", + context.len() + ), + error: Some("spawn_subagent not yet wired to sub-agent execution".into()), + }) + } +} diff --git a/src/openhuman/tools/update_memory_md.rs b/src/openhuman/tools/update_memory_md.rs new file mode 100644 index 000000000..1586d0c5a --- /dev/null +++ b/src/openhuman/tools/update_memory_md.rs @@ -0,0 +1,370 @@ +//! Tool: update_memory_md — append or update sections in MEMORY.md or SKILL.md. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Allowed workspace markdown files this tool may modify. +const ALLOWED_FILES: &[&str] = &["MEMORY.md", "SKILL.md"]; + +/// Appends or replaces a named section in MEMORY.md or SKILL.md. +/// +/// Supports two actions: +/// - `append`: adds `content` to the end of the file. +/// - `replace_section`: locates the first `## {section_title}` heading and +/// replaces the body (lines until the next `##` heading or EOF) with `content`. +pub struct UpdateMemoryMdTool { + workspace_dir: PathBuf, +} + +impl UpdateMemoryMdTool { + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } +} + +#[async_trait] +impl Tool for UpdateMemoryMdTool { + fn name(&self) -> &str { + "update_memory_md" + } + + fn description(&self) -> &str { + "Append or update sections in MEMORY.md or SKILL.md workspace files. \ + Use 'append' to add new notes at the end, or 'replace_section' to \ + overwrite the body under a named '## Section' heading." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["file", "action", "content"], + "properties": { + "file": { + "type": "string", + "enum": ["MEMORY.md", "SKILL.md"], + "description": "Which workspace markdown file to modify." + }, + "action": { + "type": "string", + "enum": ["append", "replace_section"], + "description": "'append' adds content at the end; \ + 'replace_section' replaces the body of the named section." + }, + "section_title": { + "type": "string", + "description": "Required for 'replace_section': the heading text (without '## ')." + }, + "content": { + "type": "string", + "description": "The markdown text to write." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let file = args + .get("file") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'file' parameter"))?; + + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; + + // Guard: only allow MEMORY.md and SKILL.md. + if !ALLOWED_FILES.contains(&file) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "File '{file}' is not allowed. Permitted files: MEMORY.md, SKILL.md" + )), + }); + } + + let target_path = self.workspace_dir.join(file); + + // Prevent symlink-based workspace escape. + let workspace_canon = self + .workspace_dir + .canonicalize() + .map_err(|e| anyhow::anyhow!("Failed to canonicalize workspace: {e}"))?; + // Check parent dir exists and canonicalize to detect symlinks. + let parent = target_path.parent().unwrap_or(&self.workspace_dir); + let parent_canon = parent + .canonicalize() + .unwrap_or_else(|_| parent.to_path_buf()); + if !parent_canon.starts_with(&workspace_canon) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("File path '{file}' resolves outside workspace")), + }); + } + + tracing::debug!("[update_memory_md] action={action} file={file} path={target_path:?}"); + + match action { + "append" => self.do_append(&target_path, file, content).await, + "replace_section" => { + let section_title = args + .get("section_title") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow::anyhow!("'section_title' is required for 'replace_section' action") + })?; + self.do_replace_section(&target_path, file, section_title, content) + .await + } + other => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Unknown action '{other}'. Use 'append' or 'replace_section'." + )), + }), + } + } +} + +impl UpdateMemoryMdTool { + /// Append `content` to the end of `path`, creating the file if it does not exist. + async fn do_append( + &self, + path: &std::path::Path, + file: &str, + content: &str, + ) -> anyhow::Result { + // Read existing content (empty string if file not found). + let existing = read_or_empty(path).await?; + + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + let new_content = format!("{existing}{separator}{content}\n"); + + tokio::fs::write(path, &new_content) + .await + .map_err(|e| anyhow::anyhow!("Failed to write {file}: {e}"))?; + + let bytes = new_content.len(); + tracing::info!( + "[update_memory_md] appended {} bytes to {file}", + content.len() + ); + + Ok(ToolResult { + success: true, + output: format!( + "Appended {} bytes to {file} ({bytes} bytes total).", + content.len() + ), + error: None, + }) + } + + /// Replace the body of the section headed `## {section_title}` in `path`. + /// + /// If the section is not found it is appended as a new section at the end. + async fn do_replace_section( + &self, + path: &std::path::Path, + file: &str, + section_title: &str, + content: &str, + ) -> anyhow::Result { + let existing = read_or_empty(path).await?; + let heading = format!("## {section_title}"); + + let lines: Vec<&str> = existing.lines().collect(); + let section_start = lines.iter().position(|l| l.trim() == heading.as_str()); + + let new_file_content = if let Some(start_idx) = section_start { + // Find where the next ## heading begins (or end of file). + let body_start = start_idx + 1; + let next_heading = lines[body_start..] + .iter() + .position(|l| l.starts_with("## ")) + .map(|rel| body_start + rel); + + let before: String = lines[..=start_idx].join("\n"); + let after: String = match next_heading { + Some(end_idx) => { + let tail = lines[end_idx..].join("\n"); + format!("\n{tail}") + } + None => String::new(), + }; + + // Ensure content is separated from the heading by a blank line. + let body = if content.trim().is_empty() { + String::new() + } else { + format!("\n{content}") + }; + + format!("{before}{body}{after}\n") + } else { + // Section not found — append it. + tracing::debug!( + "[update_memory_md] section '{section_title}' not found in {file}, appending" + ); + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + format!("{existing}{separator}{heading}\n{content}\n") + }; + + std::fs::write(path, &new_file_content) + .map_err(|e| anyhow::anyhow!("Failed to write {file}: {e}"))?; + + tracing::info!( + "[update_memory_md] replaced section '{}' in {file} ({} bytes written)", + section_title, + new_file_content.len() + ); + + Ok(ToolResult { + success: true, + output: format!( + "Section '{}' updated in {file} ({} bytes).", + section_title, + new_file_content.len() + ), + error: None, + }) + } +} + +/// Read file to string, returning an empty string when the file does not exist. +async fn read_or_empty(path: &std::path::Path) -> anyhow::Result { + match tokio::fs::read_to_string(path).await { + Ok(s) => Ok(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(anyhow::anyhow!("Failed to read {}: {e}", path.display())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_tool(dir: &std::path::Path) -> UpdateMemoryMdTool { + UpdateMemoryMdTool::new(dir.to_path_buf()) + } + + #[tokio::test] + async fn append_creates_file_if_missing() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "append", + "content": "first note" + })) + .await + .unwrap(); + assert!(result.success, "{:?}", result.error); + let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); + assert!(text.contains("first note")); + } + + #[tokio::test] + async fn append_adds_to_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "existing\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "MEMORY.md", + "action": "append", + "content": "second note" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("existing")); + assert!(text.contains("second note")); + } + + #[tokio::test] + async fn replace_section_overwrites_body() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "## Lessons\nold body\n## Other\nkept\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "section_title": "Lessons", + "content": "new body" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("new body"), "new body missing: {text}"); + assert!( + !text.contains("old body"), + "old body should be gone: {text}" + ); + assert!(text.contains("## Other"), "other section missing: {text}"); + assert!(text.contains("kept"), "other section body missing: {text}"); + } + + #[tokio::test] + async fn replace_section_appends_when_not_found() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("SKILL.md"); + std::fs::write(&path, "# Header\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "SKILL.md", + "action": "replace_section", + "section_title": "New Section", + "content": "brand new" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("## New Section"), "heading missing: {text}"); + assert!(text.contains("brand new"), "content missing: {text}"); + } + + #[tokio::test] + async fn rejects_disallowed_file() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "../../etc/passwd", + "action": "append", + "content": "evil" + })) + .await + .unwrap(); + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("not allowed")); + } +} diff --git a/src/openhuman/tools/workspace_state.rs b/src/openhuman/tools/workspace_state.rs new file mode 100644 index 000000000..9cc2fd3a9 --- /dev/null +++ b/src/openhuman/tools/workspace_state.rs @@ -0,0 +1,149 @@ +//! Tool: read_workspace_state — read-only workspace overview for Orchestrator/Planner. + +use super::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Returns a summary of the workspace: git status, file tree, recent commits. +pub struct WorkspaceStateTool { + workspace_dir: PathBuf, +} + +impl WorkspaceStateTool { + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } +} + +#[async_trait] +impl Tool for WorkspaceStateTool { + fn name(&self) -> &str { + "read_workspace_state" + } + + fn description(&self) -> &str { + "Get a read-only overview of the workspace: git status (modified/untracked files), \ + recent commits, and top-level directory structure. Useful for understanding the \ + current project state before planning tasks." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "include_tree": { + "type": "boolean", + "description": "Include top-level directory tree (default: true).", + "default": true + }, + "recent_commits": { + "type": "integer", + "description": "Number of recent commits to show (default: 5).", + "default": 5 + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let include_tree = args + .get("include_tree") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let recent_commits = args + .get("recent_commits") + .and_then(|v| v.as_u64()) + .unwrap_or(5) as usize; + + tracing::debug!( + "[workspace_state] dir={}, include_tree={include_tree}, recent_commits={recent_commits}", + self.workspace_dir.display() + ); + + let mut output = String::new(); + let dir = &self.workspace_dir; + + // Git status + output.push_str("## Git Status\n"); + match run_git(dir, &["status", "--porcelain"]).await { + Ok(status) if status.trim().is_empty() => { + output.push_str("Clean working tree.\n"); + } + Ok(status) => { + output.push_str(&status); + } + Err(e) => { + output.push_str(&format!("(not a git repo or error: {e})\n")); + } + } + + // Recent commits + output.push_str(&format!("\n## Recent Commits (last {recent_commits})\n")); + let log_arg = format!("-{recent_commits}"); + match run_git(dir, &["log", &log_arg, "--oneline", "--no-decorate"]).await { + Ok(log) => output.push_str(&log), + Err(e) => output.push_str(&format!("(error: {e})\n")), + } + + // Directory tree (top-level only) + if include_tree { + output.push_str("\n## Directory Tree (top-level)\n"); + match tokio::fs::read_dir(dir).await { + Ok(mut entries) => { + let mut names = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with('.') { + let suffix = if entry + .file_type() + .await + .map(|ft| ft.is_dir()) + .unwrap_or(false) + { + "/" + } else { + "" + }; + names.push(format!("{name}{suffix}")); + } + } + names.sort(); + for name in &names { + output.push_str(&format!(" {name}\n")); + } + } + Err(e) => output.push_str(&format!("(error reading dir: {e})\n")), + } + } + + tracing::debug!("[workspace_state] output length={}", output.len()); + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +async fn run_git(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { + let output = tokio::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .await?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + } +}