feat(agent-registry): add configurable agent registry (#2993)

This commit is contained in:
Steven Enamakel
2026-05-29 16:58:03 -07:00
committed by GitHub
parent 123e8b0353
commit 86b7b6f30f
109 changed files with 1556 additions and 68 deletions
+4
View File
@@ -124,6 +124,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::webview_apis::all_webview_apis_registered_controllers());
// Agent definition and prompt inspection
controllers.extend(crate::openhuman::agent::all_agent_registered_controllers());
// User-facing agent registry: defaults, enablement, custom agents, tool policy.
controllers
.extend(crate::openhuman::agent_registry::all_agent_registry_registered_controllers());
// Local procedural operating experience for agent self-learning
controllers
.extend(crate::openhuman::agent_experience::all_agent_experience_registered_controllers());
@@ -298,6 +301,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::mcp_registry::all_mcp_registry_controller_schemas());
schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas());
schemas.extend(crate::openhuman::agent::all_agent_controller_schemas());
schemas.extend(crate::openhuman::agent_registry::all_agent_registry_controller_schemas());
schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas());
schemas.extend(crate::openhuman::health::all_health_controller_schemas());
schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas());
+1 -1
View File
@@ -28,7 +28,7 @@ In-process pub/sub plus typed request/response. Owns the global `EventBus` singl
## Called by
- ~33 sites across the workspace. Hot consumers:
- `src/openhuman/agent/bus.rs`, `agent/triage/{events,evaluator,escalation}.rs`, `agent/tools/{dispatch,spawn_subagent}.rs` — agent + sub-agent events.
- `src/openhuman/agent/bus.rs`, `agent/triage/{events,evaluator,escalation}.rs`, `agent_registry/tools/{dispatch,spawn_subagent}.rs` — agent + sub-agent events.
- `src/openhuman/memory/conversations/bus.rs` — conversation persistence subscriber.
- `src/openhuman/channels/bus.rs``ChannelInboundSubscriber`.
- `src/openhuman/cron/{bus,scheduler}.rs``CronDeliverySubscriber` + `CronJobTriggered` emission.
+2 -2
View File
@@ -13,7 +13,7 @@ Multi-agent orchestration domain. Owns the LLM tool-calling loop, sub-agent disp
- `pub trait ToolDispatcher` / `pub struct ParsedToolCall` / `pub struct ToolExecutionResult``dispatcher.rs:14-50` — pluggable tool-call format (XML / JSON / P-Format).
- `pub mod triage` (`run_triage`, `apply_decision`, `TriggerEnvelope`, `TriageDecision`, `TriageAction`) — `triage/mod.rs:34-45` — classify external triggers, escalate to sub-agents.
- `pub mod prompts::SystemPromptBuilder``prompts/` — system-prompt section composer.
- `pub mod agents``agents/mod.rs` — built-in archetypes (orchestrator, planner, researcher, code_executor, summarizer, archivist, trigger_triage, trigger_reactor, etc.).
- Built-in archetypes live in `src/openhuman/agent_registry/agents/`; this module stays focused on harness/runtime behavior.
- RPC `agent.chat`, `agent.chat_simple`, `agent.server_status`, `agent.list_definitions`, `agent.get_definition`, `agent.reload_definitions`, `agent.triage_evaluate``schemas.rs:17-158`.
## Calls into
@@ -34,7 +34,7 @@ Multi-agent orchestration domain. Owns the LLM tool-calling loop, sub-agent disp
- `src/openhuman/composio/bus.rs` — Composio trigger envelopes go through `agent::triage`.
- `src/openhuman/notifications/rpc.rs` — surfaces agent runs to the UI.
- `src/openhuman/learning/{reflection,tool_tracker,user_profile}.rs` — read transcripts + tool outcomes.
- `src/openhuman/agent/tools/{dispatch,spawn_subagent}.rs``spawn_subagent` tool delegates here.
- `src/openhuman/agent_registry/tools/{dispatch,spawn_subagent}.rs``spawn_subagent` tool delegates here.
- `src/core/all.rs` — controller registry wires `all_agent_registered_controllers`.
## Tests
@@ -1,7 +1,7 @@
//! Built-in [`AgentDefinition`]s.
//!
//! The authoritative list of built-in agents lives in
//! [`crate::openhuman::agent::agents`] — each agent is a subfolder
//! [`crate::openhuman::agent_registry::agents`] — each agent is a subfolder
//! containing `agent.toml` + `prompt.md`. This module is a thin
//! wrapper that loads that set.
//!
@@ -17,8 +17,8 @@ use super::definition::DefinitionSource;
/// Panics if the baked-in built-in TOML fails to parse. `include_str!`
/// guarantees at compile time that each file exists, but the actual
/// TOML parse happens at runtime; the unit tests in
/// [`crate::openhuman::agent::agents`] verify in CI that every entry in
/// [`crate::openhuman::agent::agents::BUILTINS`] still parses cleanly.
/// [`crate::openhuman::agent_registry::agents`] verify in CI that every entry in
/// [`crate::openhuman::agent_registry::agents::BUILTINS`] still parses cleanly.
///
/// In `#[cfg(test)]` builds the list additionally contains
/// [`test_inherit_echo_def`] — a sub-agent with `ModelSpec::Inherit`
@@ -29,7 +29,7 @@ use super::definition::DefinitionSource;
/// test's `MockProvider`). It is never compiled into release builds.
pub fn all() -> Vec<AgentDefinition> {
#[allow(unused_mut)]
let mut defs = crate::openhuman::agent::agents::load_builtins()
let mut defs = crate::openhuman::agent_registry::agents::load_builtins()
.expect("built-in agent TOML must always parse (see agents/*/agent.toml)");
#[cfg(test)]
{
@@ -123,7 +123,7 @@ mod tests {
// +2 for the cfg(test) inherit-based test defs appended by all().
assert_eq!(
defs.len(),
crate::openhuman::agent::agents::BUILTINS.len() + 2
crate::openhuman::agent_registry::agents::BUILTINS.len() + 2
);
}
+14 -12
View File
@@ -3,7 +3,7 @@
//! An [`AgentDefinition`] fully specifies a sub-agent: its core prompt, model,
//! allowed tool set, runtime limits, and which sections of the parent system
//! prompt to omit. Built-in definitions live in
//! [`crate::openhuman::agent::agents`] — one subfolder per agent, each
//! [`crate::openhuman::agent_registry::agents`] — one subfolder per agent, each
//! holding an `agent.toml` (metadata) and `prompt.md` (system prompt). A
//! thin wrapper in [`super::builtin_definitions`] loads them and appends
//! the synthetic `fork` definition. Users can ship custom definitions as
@@ -167,8 +167,8 @@ pub struct AgentDefinition {
/// so that reading a TOML makes the distinction obvious: `tools` is
/// "what I execute directly", `subagents` is "what I can delegate to".
///
/// [`ArchetypeDelegationTool`]: crate::openhuman::agent::tools::ArchetypeDelegationTool
/// [`SkillDelegationTool`]: crate::openhuman::agent::tools::SkillDelegationTool
/// [`ArchetypeDelegationTool`]: crate::openhuman::agent_registry::tools::ArchetypeDelegationTool
/// [`SkillDelegationTool`]: crate::openhuman::agent_registry::tools::SkillDelegationTool
#[serde(default)]
pub subagents: Vec<SubagentEntry>,
@@ -481,7 +481,7 @@ pub enum SandboxMode {
#[serde(tag = "kind", content = "path")]
pub enum DefinitionSource {
/// Built-in definition shipped as part of the binary (loaded from
/// [`crate::openhuman::agent::agents`]).
/// [`crate::openhuman::agent_registry::agents`]).
#[default]
Builtin,
/// Loaded from a TOML file at the given absolute path.
@@ -572,15 +572,17 @@ impl AgentDefinitionRegistry {
// merged in — a workspace TOML can legally replace a built-in
// (same id) and is held to the same spawn-hierarchy contract
// as the bundled set. See
// [`super::super::agents::loader::validate_tier_hierarchy`].
// [`crate::openhuman::agent_registry::agents::loader::validate_tier_hierarchy`].
let snapshot: Vec<AgentDefinition> = reg.list().into_iter().cloned().collect();
super::super::agents::validate_tier_hierarchy(&snapshot).map_err(|e| {
anyhow::anyhow!(
"agent registry rejected after merging workspace overrides from {}: {}",
workspace.display(),
e
)
})?;
crate::openhuman::agent_registry::agents::validate_tier_hierarchy(&snapshot).map_err(
|e| {
anyhow::anyhow!(
"agent registry rejected after merging workspace overrides from {}: {}",
workspace.display(),
e
)
},
)?;
Ok(reg)
}
@@ -95,7 +95,7 @@ pub fn load_dir(dir: &Path, out: &mut Vec<AgentDefinition>) -> Result<()> {
///
/// Rejects definitions that omit (or leave blank) their `system_prompt`
/// — built-in agents are loaded separately and have their prompts
/// injected by [`crate::openhuman::agent::agents::load_builtins`], so a
/// injected by [`crate::openhuman::agent_registry::agents::load_builtins`], so a
/// file-loaded definition that arrives with the
/// [`defaults::empty_inline_prompt`] placeholder is always a caller
/// mistake. Custom definitions must set either
@@ -1548,8 +1548,8 @@ impl Tool for TestDelegationTool {
#[tokio::test]
async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() {
use crate::openhuman::agent::agents::orchestrator::prompt as orch_prompt;
use crate::openhuman::agent::prompts::types::ConnectedIntegration;
use crate::openhuman::agent_registry::agents::orchestrator::prompt as orch_prompt;
use crate::openhuman::composio::ComposioActionTool;
use crate::openhuman::config::TEST_ENV_LOCK;
+2 -3
View File
@@ -9,8 +9,8 @@
//! - **[`harness::session::Agent`]**: The primary entry point for running a
//! conversation. It manages the loop of sending prompts to a provider and
//! executing the resulting tool calls.
//! - **[`agents`]**: Definitions for built-in specialized agents (Orchestrator,
//! Code Executor, Researcher, etc.).
//! - **[`crate::openhuman::agent_registry::agents`]**: Definitions for built-in
//! specialized agents (Orchestrator, Code Executor, Researcher, etc.).
//! - **[`triage`]**: A high-performance pipeline for classifying and responding
//! to external triggers (webhooks, cron jobs) using small local models.
//! - **[`dispatcher`]**: Pluggable strategies for how tool calls are formatted
@@ -18,7 +18,6 @@
//! - **[`harness::subagent_runner`]**: Logic for spawning "sub-agents" from
//! within a parent agent's tool loop, enabling hierarchical delegation.
pub mod agents;
pub mod bus;
pub mod cost;
pub mod debug;
-13
View File
@@ -1,21 +1,12 @@
mod archetype_delegation;
mod ask_clarification;
mod delegate;
mod delegate_to_personality;
mod dispatch;
mod plan_exit;
pub mod remember_preference;
mod run_skill;
pub mod save_preference;
mod skill_delegation;
mod spawn_parallel_agents;
mod spawn_subagent;
pub mod spawn_worker_thread;
mod todo;
pub(crate) use dispatch::dispatch_subagent;
pub use archetype_delegation::ArchetypeDelegationTool;
pub use ask_clarification::AskClarificationTool;
pub use delegate::DelegateTool;
pub use delegate_to_personality::DelegateToPersonalityTool;
@@ -23,8 +14,4 @@ pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER};
pub use remember_preference::RememberPreferenceTool;
pub use run_skill::{RunSkillTool, RUN_SKILL_TOOL_NAME};
pub use save_preference::SavePreferenceTool;
pub use skill_delegation::SkillDelegationTool;
pub use spawn_parallel_agents::SpawnParallelAgentsTool;
pub use spawn_subagent::SpawnSubagentTool;
pub use spawn_worker_thread::SpawnWorkerThreadTool;
pub use todo::TodoTool;
+1 -1
View File
@@ -3,7 +3,7 @@
//! local model is likely to produce.
//!
//! The contract is described in
//! `src/openhuman/agent/agents/trigger_triage/prompt.md` — the triage
//! `src/openhuman/agent_registry/agents/trigger_triage/prompt.md` — the triage
//! agent must end its reply with a JSON object of the form:
//!
//! ```json
@@ -1,7 +1,7 @@
use super::*;
use crate::openhuman::agent::agents::BUILTINS;
use crate::openhuman::agent::bus::{mock_agent_run_turn, AgentTurnResponse};
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
use crate::openhuman::agent_registry::agents::BUILTINS;
use crate::openhuman::inference::provider::Provider;
use async_trait::async_trait;
use serde_json::json;
@@ -26,11 +26,11 @@
//!
//! The synthetic `fork` definition is *not* listed here — it's a
//! byte-stable replay of the parent and has no standalone prompt. It is
//! added by [`super::harness::builtin_definitions::all`] on top of the
//! added by [`crate::openhuman::agent::harness::builtin_definitions::all`] on top of the
//! loader output.
//!
//! Workspace-level overrides (`$OPENHUMAN_WORKSPACE/agents/*.toml`) are
//! handled separately by [`super::harness::definition_loader`] and merged
//! handled separately by [`crate::openhuman::agent::harness::definition_loader`] and merged
//! into the global registry, where they replace built-ins on `id`
//! collision.
+87
View File
@@ -0,0 +1,87 @@
//! Default registry entries derived from shipped harness definitions.
use serde_json::Value;
use crate::openhuman::agent::harness::definition::{
AgentDefinition, ModelSpec, SubagentEntry, ToolScope,
};
use super::types::{AgentRegistryEntry, AgentRegistrySource};
pub fn default_agents() -> Vec<AgentRegistryEntry> {
crate::openhuman::agent_registry::agents::load_builtins()
.map(|defs| {
defs.into_iter()
.map(default_entry_from_definition)
.collect()
})
.unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"[agent_registry] failed to load default agent definitions"
);
Vec::new()
})
}
fn default_entry_from_definition(def: AgentDefinition) -> AgentRegistryEntry {
AgentRegistryEntry {
id: def.id.clone(),
name: def.display_name().to_string(),
description: def.when_to_use,
source: AgentRegistrySource::Default,
enabled: true,
model: model_to_registry_value(&def.model),
system_prompt: None,
tool_allowlist: tools_to_allowlist(&def.tools, &def.extra_tools),
tool_denylist: def.disallowed_tools,
subagents: def
.subagents
.into_iter()
.filter_map(|entry| match entry {
SubagentEntry::AgentId(id) => Some(id),
SubagentEntry::Skills(_) => None,
})
.collect(),
tags: vec![def.agent_tier.as_str().to_string()],
metadata: Value::Null,
}
}
fn model_to_registry_value(model: &ModelSpec) -> Option<String> {
match model {
ModelSpec::Inherit => Some("inherit".to_string()),
ModelSpec::Exact(value) => Some(value.clone()),
ModelSpec::Hint(value) => Some(format!("hint:{value}")),
}
}
fn tools_to_allowlist(scope: &ToolScope, extra_tools: &[String]) -> Vec<String> {
let mut tools = match scope {
ToolScope::Wildcard => vec!["*".to_string()],
ToolScope::Named(names) => names.clone(),
};
for tool in extra_tools {
if !tools.contains(tool) {
tools.push(tool.clone());
}
}
tools
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_agents_include_core_personas() {
let agents = default_agents();
let ids: Vec<&str> = agents.iter().map(|agent| agent.id.as_str()).collect();
assert!(ids.contains(&"orchestrator"));
assert!(ids.contains(&"researcher"));
assert!(ids.contains(&"code_executor"));
assert!(agents
.iter()
.all(|agent| agent.source == AgentRegistrySource::Default));
}
}
+25
View File
@@ -0,0 +1,25 @@
//! User-facing agent registry.
//!
//! This high-level domain owns the product registry of agents: shipped
//! defaults, user-authored custom agents, enable/disable state, and tool
//! visibility policy. The lower-level `openhuman::agent` module remains the
//! execution harness and prompt/runtime implementation.
pub mod agents;
mod defaults;
mod ops;
mod rpc;
mod schemas;
pub mod tools;
pub mod types;
pub use defaults::default_agents;
pub use ops::{
get_agent, list_agents, merge_entries, remove_agent, set_agent_enabled, update_agent,
upsert_custom_agent,
};
pub use schemas::{
all_controller_schemas as all_agent_registry_controller_schemas,
all_registered_controllers as all_agent_registry_registered_controllers,
};
pub use types::{AgentRegistryConfig, AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource};
+267
View File
@@ -0,0 +1,267 @@
//! Config-backed operations for the user-facing agent registry.
use std::collections::HashMap;
use crate::openhuman::config::rpc as config_rpc;
use super::defaults::default_agents;
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource};
const ORCHESTRATOR_AGENT_ID: &str = "orchestrator";
pub async fn list_agents(include_disabled: bool) -> Result<Vec<AgentRegistryEntry>, String> {
let config = config_rpc::load_config_with_timeout().await?;
Ok(merge_entries(
&config.agent_registry.entries,
include_disabled,
))
}
pub async fn get_agent(id: &str) -> Result<Option<AgentRegistryEntry>, String> {
let id = id.trim();
Ok(list_agents(true)
.await?
.into_iter()
.find(|agent| agent.id == id))
}
pub async fn upsert_custom_agent(
mut entry: AgentRegistryEntry,
) -> Result<AgentRegistryEntry, String> {
entry.source = AgentRegistrySource::Custom;
entry.validate()?;
if default_agents().iter().any(|agent| agent.id == entry.id) {
return Err(format!(
"agent '{}' is a default agent; use update_agent to override it",
entry.id
));
}
let mut config = config_rpc::load_config_with_timeout().await?;
match config
.agent_registry
.entries
.iter_mut()
.find(|agent| agent.id == entry.id)
{
Some(existing) => *existing = entry.clone(),
None => config.agent_registry.entries.push(entry.clone()),
}
config
.save()
.await
.map_err(|e| format!("failed to save config: {e:#}"))?;
Ok(entry)
}
pub async fn update_agent(
id: &str,
patch: AgentRegistryPatch,
) -> Result<AgentRegistryEntry, String> {
let id = id.trim();
if id.is_empty() {
return Err("id is required".to_string());
}
let defaults = default_agents();
let mut config = config_rpc::load_config_with_timeout().await?;
let entry = match config
.agent_registry
.entries
.iter_mut()
.find(|agent| agent.id == id)
{
Some(existing) => existing,
None => {
let base = defaults
.iter()
.find(|agent| agent.id == id)
.cloned()
.ok_or_else(|| format!("agent '{id}' not found"))?;
config.agent_registry.entries.push(base);
config
.agent_registry
.entries
.last_mut()
.expect("pushed entry")
}
};
apply_patch(entry, patch);
entry.validate()?;
ensure_orchestrator_enabled(entry)?;
let updated = entry.clone();
config
.save()
.await
.map_err(|e| format!("failed to save config: {e:#}"))?;
Ok(updated)
}
pub async fn set_agent_enabled(id: &str, enabled: bool) -> Result<AgentRegistryEntry, String> {
update_agent(
id,
AgentRegistryPatch {
enabled: Some(enabled),
..AgentRegistryPatch::default()
},
)
.await
}
pub async fn remove_agent(id: &str) -> Result<bool, String> {
let id = id.trim();
if id.is_empty() {
return Err("id is required".to_string());
}
let mut config = config_rpc::load_config_with_timeout().await?;
let before = config.agent_registry.entries.len();
config.agent_registry.entries.retain(|agent| agent.id != id);
let removed = config.agent_registry.entries.len() < before;
if removed {
config
.save()
.await
.map_err(|e| format!("failed to save config: {e:#}"))?;
}
Ok(removed)
}
pub fn merge_entries(
configured: &[AgentRegistryEntry],
include_disabled: bool,
) -> Vec<AgentRegistryEntry> {
let mut default_order = Vec::new();
let mut merged: HashMap<String, AgentRegistryEntry> = HashMap::new();
for agent in default_agents() {
default_order.push(agent.id.clone());
merged.insert(agent.id.clone(), agent);
}
let mut custom_order = Vec::new();
for entry in configured {
if matches!(entry.source, AgentRegistrySource::Custom) && !merged.contains_key(&entry.id) {
custom_order.push(entry.id.clone());
}
merged.insert(entry.id.clone(), entry.clone());
}
let mut result = Vec::new();
for id in default_order.into_iter().chain(custom_order) {
if let Some(agent) = merged.remove(&id) {
if include_disabled || agent.enabled {
result.push(agent);
}
}
}
result
}
fn apply_patch(entry: &mut AgentRegistryEntry, patch: AgentRegistryPatch) {
if let Some(name) = patch.name {
entry.name = name;
}
if let Some(description) = patch.description {
entry.description = description;
}
if let Some(enabled) = patch.enabled {
entry.enabled = enabled;
}
if let Some(model) = patch.model {
entry.model = Some(model);
}
if let Some(system_prompt) = patch.system_prompt {
entry.system_prompt = Some(system_prompt);
}
if let Some(tool_allowlist) = patch.tool_allowlist {
entry.tool_allowlist = tool_allowlist;
}
if let Some(tool_denylist) = patch.tool_denylist {
entry.tool_denylist = tool_denylist;
}
if let Some(subagents) = patch.subagents {
entry.subagents = subagents;
}
if let Some(tags) = patch.tags {
entry.tags = tags;
}
if let Some(metadata) = patch.metadata {
entry.metadata = metadata;
}
}
fn ensure_orchestrator_enabled(entry: &AgentRegistryEntry) -> Result<(), String> {
if entry.id == ORCHESTRATOR_AGENT_ID && !entry.enabled {
return Err("orchestrator agent cannot be disabled".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
fn custom_agent(id: &str, enabled: bool) -> AgentRegistryEntry {
AgentRegistryEntry {
id: id.to_string(),
name: "Custom".to_string(),
description: "Handles custom work.".to_string(),
source: AgentRegistrySource::Custom,
enabled,
model: Some("reasoning-v1".to_string()),
system_prompt: Some("Do custom work.".to_string()),
tool_allowlist: vec!["memory.search".to_string()],
tool_denylist: Vec::new(),
subagents: Vec::new(),
tags: vec!["custom".to_string()],
metadata: Value::Null,
}
}
#[test]
fn merge_entries_applies_default_overrides_and_filters_disabled() {
let configured = vec![AgentRegistryEntry {
id: "researcher".to_string(),
name: "Researcher".to_string(),
description: "Disabled for this workspace.".to_string(),
source: AgentRegistrySource::Default,
enabled: false,
model: None,
system_prompt: None,
tool_allowlist: vec!["*".to_string()],
tool_denylist: Vec::new(),
subagents: Vec::new(),
tags: Vec::new(),
metadata: Value::Null,
}];
let visible = merge_entries(&configured, false);
assert!(!visible.iter().any(|agent| agent.id == "researcher"));
let all = merge_entries(&configured, true);
let researcher = all.iter().find(|agent| agent.id == "researcher").unwrap();
assert!(!researcher.enabled);
}
#[test]
fn merge_entries_appends_custom_agents() {
let configured = vec![custom_agent("finance_analyst", true)];
let merged = merge_entries(&configured, true);
assert!(merged.iter().any(|agent| agent.id == "orchestrator"));
assert_eq!(merged.last().unwrap().id, "finance_analyst");
}
#[test]
fn ensure_orchestrator_enabled_rejects_disabled_orchestrator() {
let mut entry = custom_agent("orchestrator", false);
entry.source = AgentRegistrySource::Default;
assert_eq!(
ensure_orchestrator_enabled(&entry).unwrap_err(),
"orchestrator agent cannot be disabled"
);
}
}
+172
View File
@@ -0,0 +1,172 @@
//! RPC payloads and handlers for the agent registry domain.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::rpc::RpcOutcome;
use super::ops;
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource};
#[derive(Debug, Deserialize)]
pub struct ListRequest {
#[serde(default)]
pub include_disabled: bool,
}
#[derive(Debug, Serialize)]
pub struct ListResponse {
pub agents: Vec<AgentRegistryEntry>,
}
pub async fn list_rpc(req: ListRequest) -> Result<RpcOutcome<ListResponse>, String> {
Ok(RpcOutcome::new(
ListResponse {
agents: ops::list_agents(req.include_disabled).await?,
},
vec![],
))
}
#[derive(Debug, Deserialize)]
pub struct GetRequest {
pub id: String,
}
#[derive(Debug, Serialize)]
pub struct GetResponse {
pub agent: Option<AgentRegistryEntry>,
}
pub async fn get_rpc(req: GetRequest) -> Result<RpcOutcome<GetResponse>, String> {
Ok(RpcOutcome::new(
GetResponse {
agent: ops::get_agent(&req.id).await?,
},
vec![],
))
}
#[derive(Debug, Deserialize)]
pub struct CreateCustomRequest {
pub id: String,
pub name: String,
pub description: String,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub tool_allowlist: Vec<String>,
#[serde(default)]
pub tool_denylist: Vec<String>,
#[serde(default)]
pub subagents: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Option<Value>,
}
impl CreateCustomRequest {
fn into_entry(self) -> AgentRegistryEntry {
AgentRegistryEntry {
id: self.id,
name: self.name,
description: self.description,
source: AgentRegistrySource::Custom,
enabled: self.enabled.unwrap_or(true),
model: self.model,
system_prompt: self.system_prompt,
tool_allowlist: self.tool_allowlist,
tool_denylist: self.tool_denylist,
subagents: self.subagents,
tags: self.tags,
metadata: self.metadata.unwrap_or(Value::Null),
}
}
}
pub async fn create_custom_rpc(
req: CreateCustomRequest,
) -> Result<RpcOutcome<AgentResponse>, String> {
Ok(RpcOutcome::new(
AgentResponse {
agent: ops::upsert_custom_agent(req.into_entry()).await?,
},
vec![],
))
}
#[derive(Debug, Deserialize)]
pub struct UpsertCustomRequest {
pub agent: AgentRegistryEntry,
}
#[derive(Debug, Serialize)]
pub struct AgentResponse {
pub agent: AgentRegistryEntry,
}
pub async fn upsert_custom_rpc(
req: UpsertCustomRequest,
) -> Result<RpcOutcome<AgentResponse>, String> {
Ok(RpcOutcome::new(
AgentResponse {
agent: ops::upsert_custom_agent(req.agent).await?,
},
vec![],
))
}
#[derive(Debug, Deserialize)]
pub struct UpdateRequest {
pub id: String,
#[serde(flatten)]
pub patch: AgentRegistryPatch,
}
pub async fn update_rpc(req: UpdateRequest) -> Result<RpcOutcome<AgentResponse>, String> {
Ok(RpcOutcome::new(
AgentResponse {
agent: ops::update_agent(&req.id, req.patch).await?,
},
vec![],
))
}
#[derive(Debug, Deserialize)]
pub struct SetEnabledRequest {
pub id: String,
pub enabled: bool,
}
pub async fn set_enabled_rpc(req: SetEnabledRequest) -> Result<RpcOutcome<AgentResponse>, String> {
Ok(RpcOutcome::new(
AgentResponse {
agent: ops::set_agent_enabled(&req.id, req.enabled).await?,
},
vec![],
))
}
#[derive(Debug, Deserialize)]
pub struct RemoveRequest {
pub id: String,
}
#[derive(Debug, Serialize)]
pub struct RemoveResponse {
pub removed: bool,
}
pub async fn remove_rpc(req: RemoveRequest) -> Result<RpcOutcome<RemoveResponse>, String> {
Ok(RpcOutcome::new(
RemoveResponse {
removed: ops::remove_agent(&req.id).await?,
},
vec![],
))
}
+300
View File
@@ -0,0 +1,300 @@
//! Controller-registry schemas for `openhuman.agent_registry_*`.
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
use super::rpc;
const NAMESPACE: &str = "agent_registry";
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("list"),
schemas("get"),
schemas("create_custom"),
schemas("upsert_custom"),
schemas("update"),
schemas("set_enabled"),
schemas("remove"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("list"),
handler: handle_list,
},
RegisteredController {
schema: schemas("get"),
handler: handle_get,
},
RegisteredController {
schema: schemas("create_custom"),
handler: handle_create_custom,
},
RegisteredController {
schema: schemas("upsert_custom"),
handler: handle_upsert_custom,
},
RegisteredController {
schema: schemas("update"),
handler: handle_update,
},
RegisteredController {
schema: schemas("set_enabled"),
handler: handle_set_enabled,
},
RegisteredController {
schema: schemas("remove"),
handler: handle_remove,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"list" => ControllerSchema {
namespace: NAMESPACE,
function: "list",
description: "List default and custom agents available in the high-level registry.",
inputs: vec![FieldSchema {
name: "include_disabled",
ty: TypeSchema::Bool,
comment: "When true, include disabled agents in the response.",
required: false,
}],
outputs: vec![FieldSchema {
name: "agents",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("AgentRegistryEntry"))),
comment: "Registry entries in default-first order.",
required: true,
}],
},
"get" => ControllerSchema {
namespace: NAMESPACE,
function: "get",
description: "Get one agent registry entry by id.",
inputs: vec![required_string("id", "Agent id.")],
outputs: vec![FieldSchema {
name: "agent",
ty: TypeSchema::Option(Box::new(TypeSchema::Ref("AgentRegistryEntry"))),
comment: "Agent registry entry if found.",
required: false,
}],
},
"upsert_custom" => ControllerSchema {
namespace: NAMESPACE,
function: "upsert_custom",
description: "Create or replace a custom user-authored agent with its tool policy.",
inputs: vec![FieldSchema {
name: "agent",
ty: TypeSchema::Ref("AgentRegistryEntry"),
comment: "Custom agent entry. Source is forced to custom.",
required: true,
}],
outputs: vec![agent_output()],
},
"create_custom" => ControllerSchema {
namespace: NAMESPACE,
function: "create_custom",
description: "Create or replace a custom user-authored agent from flat RPC params.",
inputs: vec![
required_string("id", "Custom agent id."),
required_string("name", "Display name."),
required_string("description", "When this agent should be used."),
optional_bool("enabled", "Enable or disable this agent. Defaults to true."),
optional_string("model", "Model id or route hint."),
optional_string("system_prompt", "Custom instructions."),
optional_string_array("tool_allowlist", "Allowed tool names; '*' means all."),
optional_string_array("tool_denylist", "Denied tool names."),
optional_string_array("subagents", "Delegated agent ids."),
optional_string_array("tags", "UI grouping/search tags."),
FieldSchema {
name: "metadata",
ty: TypeSchema::Json,
comment: "Free-form metadata.",
required: false,
},
],
outputs: vec![agent_output()],
},
"update" => ControllerSchema {
namespace: NAMESPACE,
function: "update",
description: "Patch either a default-agent override or a custom agent.",
inputs: vec![
required_string("id", "Agent id."),
optional_string("name", "New display name."),
optional_string("description", "New description."),
optional_bool("enabled", "Enable or disable this agent."),
optional_string("model", "Model id or route hint."),
optional_string("system_prompt", "Custom instructions."),
optional_string_array("tool_allowlist", "Allowed tool names; '*' means all."),
optional_string_array("tool_denylist", "Denied tool names."),
optional_string_array("subagents", "Delegated agent ids."),
optional_string_array("tags", "UI grouping/search tags."),
FieldSchema {
name: "metadata",
ty: TypeSchema::Json,
comment: "Free-form metadata.",
required: false,
},
],
outputs: vec![agent_output()],
},
"set_enabled" => ControllerSchema {
namespace: NAMESPACE,
function: "set_enabled",
description: "Enable or disable a default or custom agent.",
inputs: vec![
required_string("id", "Agent id."),
FieldSchema {
name: "enabled",
ty: TypeSchema::Bool,
comment: "Desired enabled state.",
required: true,
},
],
outputs: vec![agent_output()],
},
"remove" => ControllerSchema {
namespace: NAMESPACE,
function: "remove",
description: "Remove a custom agent or reset a default-agent override.",
inputs: vec![required_string("id", "Agent id.")],
outputs: vec![FieldSchema {
name: "removed",
ty: TypeSchema::Bool,
comment: "True when a configured entry was removed.",
required: true,
}],
},
other => panic!("unknown agent_registry schema function: {other}"),
}
}
fn handle_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::ListRequest>(Value::Object(params))?;
to_json(rpc::list_rpc(req).await?)
})
}
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::GetRequest>(Value::Object(params))?;
to_json(rpc::get_rpc(req).await?)
})
}
fn handle_create_custom(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::CreateCustomRequest>(Value::Object(params))?;
to_json(rpc::create_custom_rpc(req).await?)
})
}
fn handle_upsert_custom(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::UpsertCustomRequest>(Value::Object(params))?;
to_json(rpc::upsert_custom_rpc(req).await?)
})
}
fn handle_update(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::UpdateRequest>(Value::Object(params))?;
to_json(rpc::update_rpc(req).await?)
})
}
fn handle_set_enabled(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::SetEnabledRequest>(Value::Object(params))?;
to_json(rpc::set_enabled_rpc(req).await?)
})
}
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::RemoveRequest>(Value::Object(params))?;
to_json(rpc::remove_rpc(req).await?)
})
}
fn required_string(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::String,
comment,
required: true,
}
}
fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment,
required: false,
}
}
fn optional_bool(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment,
required: false,
}
}
fn optional_string_array(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment,
required: false,
}
}
fn agent_output() -> FieldSchema {
FieldSchema {
name: "agent",
ty: TypeSchema::Ref("AgentRegistryEntry"),
comment: "Updated agent registry entry.",
required: true,
}
}
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_controller_schemas_and_registered_controllers_stay_in_sync() {
let schemas = all_controller_schemas();
let controllers = all_registered_controllers();
assert_eq!(schemas.len(), controllers.len());
assert!(schemas.iter().all(|schema| schema.namespace == NAMESPACE));
}
#[test]
#[should_panic(expected = "unknown agent_registry schema function")]
fn schemas_panics_on_unknown_function() {
schemas("missing");
}
}
+20
View File
@@ -0,0 +1,20 @@
#[path = "tools/archetype_delegation.rs"]
mod archetype_delegation;
#[path = "tools/dispatch.rs"]
mod dispatch;
#[path = "tools/skill_delegation.rs"]
mod skill_delegation;
#[path = "tools/spawn_parallel_agents.rs"]
mod spawn_parallel_agents;
#[path = "tools/spawn_subagent.rs"]
mod spawn_subagent;
#[path = "tools/spawn_worker_thread.rs"]
pub mod spawn_worker_thread;
pub(crate) use dispatch::dispatch_subagent;
pub use archetype_delegation::ArchetypeDelegationTool;
pub use skill_delegation::{SkillDelegationTool, INTEGRATIONS_DELEGATE_TOOL_NAME};
pub use spawn_parallel_agents::SpawnParallelAgentsTool;
pub use spawn_subagent::SpawnSubagentTool;
pub use spawn_worker_thread::SpawnWorkerThreadTool;
@@ -125,7 +125,7 @@ mod tests {
use super::*;
use crate::openhuman::tools::traits::Tool;
use super::super::AskClarificationTool;
use crate::openhuman::agent::tools::AskClarificationTool;
#[test]
fn ask_clarification_tool_re_exported() {
+106
View File
@@ -0,0 +1,106 @@
//! Public data model for the user-facing agent registry.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AgentRegistrySource {
Default,
Custom,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AgentRegistryEntry {
/// Stable id used by routing and UI selection.
pub id: String,
/// User-facing name.
pub name: String,
/// Short explanation of when this agent should be used.
pub description: String,
/// Default agents ship with OpenHuman; custom agents are user-authored.
pub source: AgentRegistrySource,
/// Whether this agent is available for selection/delegation.
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional model or route hint for this agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Optional custom instructions. Default agents usually keep this empty
/// because their runtime prompts are generated by the harness.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// Tool names this agent may use. `"*"` means all currently visible tools.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_allowlist: Vec<String>,
/// Tool names that must be hidden even if they match the allowlist.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_denylist: Vec<String>,
/// Optional delegated agent ids this agent may spawn.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subagents: Vec<String>,
/// Search/filter tags for UI grouping.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Free-form UI/runtime metadata. Keep execution-critical fields typed.
#[serde(default, skip_serializing_if = "Value::is_null")]
pub metadata: Value,
}
impl AgentRegistryEntry {
pub fn validate(&self) -> Result<(), String> {
if self.id.trim().is_empty() {
return Err("id is required".to_string());
}
if self
.id
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
{
return Err("id may only contain ASCII letters, numbers, '_' and '-'".to_string());
}
if self.name.trim().is_empty() {
return Err("name is required".to_string());
}
if self.description.trim().is_empty() {
return Err("description is required".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AgentRegistryConfig {
/// User-authored agents plus persisted overrides for shipped default agents.
#[serde(default)]
pub entries: Vec<AgentRegistryEntry>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AgentRegistryPatch {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub tool_allowlist: Option<Vec<String>>,
#[serde(default)]
pub tool_denylist: Option<Vec<String>>,
#[serde(default)]
pub subagents: Option<Vec<String>>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub metadata: Option<Value>,
}
+6
View File
@@ -229,6 +229,11 @@ pub struct Config {
#[serde(default)]
pub memory_sources: Vec<crate::openhuman::memory_sources::types::MemorySourceEntry>,
/// User-facing agent registry — shipped default agents plus user-authored
/// custom agents and persisted enable/disable/tool-policy overrides.
#[serde(default)]
pub agent_registry: crate::openhuman::agent_registry::types::AgentRegistryConfig,
#[serde(default)]
pub computer_control: ComputerControlConfig,
@@ -667,6 +672,7 @@ impl Default for Config {
proxy: ProxyConfig::default(),
cost: CostConfig::default(),
memory_sources: Vec::new(),
agent_registry: crate::openhuman::agent_registry::types::AgentRegistryConfig::default(),
computer_control: ComputerControlConfig::default(),
agents: HashMap::new(),
local_ai: LocalAiConfig::default(),

Some files were not shown because too many files have changed in this diff Show More