feat(agent): multi-agent harness with 8 archetypes, DAG planning, and episodic memory (#155)

* refactor(agent): update default model configuration and pricing structure

- Changed the default model name in `AgentBuilder` to use a constant `DEFAULT_MODEL` instead of a hardcoded string.
- Introduced new model constants (`MODEL_AGENTIC_V1`, `MODEL_CODING_V1`, `MODEL_REASONING_V1`) in `types.rs` for better clarity and maintainability.
- Refactored the pricing structure in `identity_cost.rs` to utilize the new model constants, improving consistency across the pricing definitions.

These changes enhance the configurability and readability of the agent's model and pricing settings.

* refactor(models): update default model references and suggestions

- Replaced hardcoded model names with a constant `DEFAULT_MODEL` in multiple files to enhance maintainability.
- Updated model suggestions in the `TauriCommandsPanel` and `Conversations` components to reflect new model names, improving user experience and consistency across the application.

These changes streamline model management and ensure that the application uses the latest model configurations.

* style: fix Prettier formatting for model suggestions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(agent): introduce multi-agent harness with archetypes and task DAG

- Added a new module for the multi-agent harness, defining 8 specialized archetypes (Orchestrator, Planner, CodeExecutor, SkillsAgent, ToolMaker, Researcher, Critic, Archivist) to enhance task management and execution.
- Implemented a Directed Acyclic Graph (DAG) structure for task planning, allowing the Planner archetype to create and manage task dependencies.
- Introduced a session queue to serialize tasks within sessions, preventing race conditions and enabling parallelism across different sessions.
- Updated configuration schema to support orchestrator settings, including per-archetype configurations and maximum concurrent agents.

These changes significantly improve the agent's architecture, enabling more complex task management and execution strategies.

* feat(agent): implement orchestrator executor and interrupt handling

- Introduced a new `executor.rs` module for orchestrated multi-agent execution, enabling a structured run loop that includes planning, executing, reviewing, and synthesizing tasks.
- Added an `interrupt.rs` module to handle graceful interruptions via SIGINT and `/stop` commands, ensuring running sub-agents can be cancelled and memory flushed appropriately.
- Implemented a self-healing interceptor in `self_healing.rs` to automatically create polyfill scripts for missing commands, enhancing the robustness of tool execution.
- Updated the `mod.rs` file to include new modules and functionalities, improving the overall architecture of the agent harness.

These changes significantly enhance the agent's capabilities in managing multi-agent workflows and handling interruptions effectively.

* feat(agent): implement orchestrator executor and interrupt handling

- Introduced a new `executor.rs` module for orchestrated multi-agent execution, enabling a structured run loop that includes planning, executing, reviewing, and synthesizing tasks.
- Added an `interrupt.rs` module to handle graceful interruptions via SIGINT and `/stop` commands, ensuring running sub-agents are cancelled and memory is flushed.
- Implemented a `SelfHealingInterceptor` in `self_healing.rs` to automatically generate polyfill scripts for missing commands, enhancing the agent's resilience.
- Updated the `mod.rs` file to include new modules and functionalities, improving the overall architecture of the agent harness.

These changes significantly enhance the agent's ability to manage complex tasks and respond to interruptions effectively.

* feat(agent): add context assembly module for orchestrator

- Introduced a new `context_assembly.rs` module to handle the assembly of the bootstrap context for the orchestrator, integrating identity files, workspace state, and relevant memory.
- Implemented functions to load archetype prompts and identity contexts, enhancing the orchestrator's ability to generate a comprehensive system prompt.
- Added a `BootstrapContext` struct to encapsulate the assembled context, improving the organization and clarity of context management.
- Updated `mod.rs` to include the new context assembly module, enhancing the overall architecture of the agent harness.

These changes significantly improve the orchestrator's context management capabilities, enabling more effective task execution and user interaction.

* style: apply cargo fmt to multi-agent harness modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve merge conflict in config/mod.rs re-exports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review findings — security, correctness, observability

Inline fixes:
- executor: wire semaphore to enforce max_concurrent_agents cap
- executor: placeholder sub-agents now return success=false
- executor: halt DAG when level has failed tasks after retries
- self_healing: remove overly broad "not found" pattern
- session_queue: fix gc() race with acquire() via Arc::strong_count check
- skills_agent.md: reference injected memory context, not memory_recall tool
- init.rs: run EPISODIC_INIT_SQL during UnifiedMemory::new()
- ask_clarification: make "question" param optional to match execute() default
- insert_sql_record: return success=false for unimplemented stub
- spawn_subagent: return success=false for unimplemented stub
- run_linter: reject absolute paths and ".." in path parameter
- run_tests: catch spawn/timeout errors as ToolResult, fix UTF-8 truncation
- update_memory_md: add symlink escape protection, use async tokio::fs::write

Nitpick fixes:
- archivist: document timestamp offset intent
- dag: add tracing to validate(), hoist id_map out of loop in execution_levels()
- session_queue: add trace logging to acquire/gc
- types: add serde(rename_all) to ReviewDecision, preserve sub-second Duration
- ORCHESTRATOR.md: add escalation rule for Core handoff
- read_diff: add debug logging, simplify base_str with Option::map
- workspace_state: add debug logging at entry and exit
- run_tests: add debug logging for runner selection and exit status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-01 00:00:17 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 00c7b01280
commit ed83cae117
35 changed files with 4095 additions and 12 deletions
+193
View File
@@ -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());
}
}
}
+215
View File
@@ -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<Arc<Mutex<Connection>>>,
/// Whether the archivist is enabled.
enabled: bool,
}
impl ArchivistHook {
/// Create an Archivist hook with a shared SQLite connection.
pub fn new(conn: Arc<Mutex<Connection>>, 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<String> {
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<Mutex<Connection>> {
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();
}
}
@@ -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<dyn Memory>,
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,
}
}
+350
View File
@@ -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<TaskId>,
/// 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<SubAgentResult>,
/// 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<TaskNode>,
}
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<Vec<&TaskId>> {
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)| 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<Vec<&TaskId>> {
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"]);
}
}
+542
View File
@@ -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<dyn Memory>,
_tools: &[Box<dyn Tool>],
session_id: &str,
) -> Result<String> {
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<Vec<String>> = 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<dyn Memory>,
) -> Result<TaskDag> {
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\": \"<user's 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<dyn Memory>,
session_id: &str,
semaphore: Arc<tokio::sync::Semaphore>,
) -> Vec<SubAgentResult> {
let mut join_set: JoinSet<SubAgentResult> = 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<ReviewDecision> {
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<String> = 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<String> {
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<String> {
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"
);
}
}
+139
View File
@@ -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<AtomicBool>,
}
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<AtomicBool>` handle for passing to signal handlers.
pub fn flag_handle(&self) -> Arc<AtomicBool> {
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());
}
}
+32
View File
@@ -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::*;
+260
View File
@@ -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<String, u8>,
}
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<String> {
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<String> {
// 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"));
}
}
@@ -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<HashMap<String, Arc<Semaphore>>>,
}
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);
}
}
+134
View File
@@ -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<ContextChunk>,
/// 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<Artifact>,
/// 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<String>,
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<TaskId>),
/// 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<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_f64(duration.as_secs_f64())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = f64::deserialize(deserializer)?;
Ok(Duration::from_secs_f64(secs))
}
}
+1
View File
@@ -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;
@@ -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.
+40
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+12 -12
View File
@@ -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,
+2
View File
@@ -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,
+119
View File
@@ -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<String, ArchetypeConfig>,
/// 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<String>,
/// System prompt override (inline or path).
#[serde(default)]
pub system_prompt: Option<String>,
/// Temperature override.
#[serde(default)]
pub temperature: Option<f64>,
/// Maximum tool iterations override.
#[serde(default)]
pub max_tool_iterations: Option<usize>,
/// Timeout in seconds for this archetype's sub-agent runs.
#[serde(default)]
pub timeout_secs: Option<u64>,
/// Sandbox mode override: "sandboxed", "read_only", or "none".
#[serde(default)]
pub sandbox: Option<String>,
}
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,
}
}
}
+4
View File
@@ -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(),
}
}
}
+1
View File
@@ -17,4 +17,5 @@ pub use types::{
NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown,
StoredMemoryDocument,
};
pub use unified::fts5;
pub use unified::UnifiedMemory;
+224
View File
@@ -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<i64>,
pub session_id: String,
pub timestamp: f64,
pub role: String,
pub content: String,
pub lesson: Option<String>,
pub tool_calls_json: Option<String>,
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<Mutex<Connection>>, 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<Mutex<Connection>>,
query: &str,
limit: usize,
) -> anyhow::Result<Vec<EpisodicEntry>> {
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::<Result<Vec<_>, _>>()?;
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<Mutex<Connection>>,
session_id: &str,
) -> anyhow::Result<Vec<EpisodicEntry>> {
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::<Result<Vec<_>, _>>()?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_db() -> Arc<Mutex<Connection>> {
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());
}
}
@@ -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,
@@ -16,6 +16,7 @@ pub struct UnifiedMemory {
}
mod documents;
pub mod fts5;
mod graph;
mod helpers;
mod init;
+84
View File
@@ -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<ToolResult> {
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::<Vec<_>>()
.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,
})
}
}
+268
View File
@@ -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<ToolResult> {
// ── 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<SqlitePool>` 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);
}
}
+16
View File
@@ -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;
+116
View File
@@ -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<ToolResult> {
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()),
})
}
}
}
+138
View File
@@ -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<ToolResult> {
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()
))
},
})
}
}
+173
View File
@@ -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<ToolResult> {
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()))
},
})
}
}
+99
View File
@@ -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<ToolResult> {
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()),
})
}
}
+370
View File
@@ -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<ToolResult> {
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<ToolResult> {
// 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<ToolResult> {
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<String> {
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"));
}
}
+149
View File
@@ -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<ToolResult> {
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<String> {
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)
)
}
}