feat(agent): gate subagent delegation by allowlist (#3426)

This commit is contained in:
Steven Enamakel
2026-06-06 11:42:24 -04:00
committed by GitHub
parent 08cbd9813d
commit 5c6dbf3649
39 changed files with 564 additions and 59 deletions
+7 -3
View File
@@ -17,6 +17,10 @@ const log = debug('agentRegistryApi');
export type AgentRegistrySource = 'default' | 'custom';
export interface AgentSubagentPolicy {
allowlist?: string[];
}
/** Mirror of the Rust `AgentRegistryEntry` (snake_case on the wire). */
export interface AgentRegistryEntry {
id: string;
@@ -28,7 +32,7 @@ export interface AgentRegistryEntry {
system_prompt?: string | null;
tool_allowlist?: string[];
tool_denylist?: string[];
subagents?: string[];
subagents?: AgentSubagentPolicy;
tags?: string[];
metadata?: unknown;
}
@@ -43,7 +47,7 @@ export interface CreateCustomAgentInput {
system_prompt?: string | null;
tool_allowlist?: string[];
tool_denylist?: string[];
subagents?: string[];
subagents?: AgentSubagentPolicy;
tags?: string[];
}
@@ -62,7 +66,7 @@ export interface UpdateAgentInput {
system_prompt?: string | null;
tool_allowlist?: string[];
tool_denylist?: string[];
subagents?: string[];
subagents?: AgentSubagentPolicy;
tags?: string[];
}
@@ -3,7 +3,7 @@
*
* The default chat agent after onboarding is the **orchestrator**
* (`src/openhuman/channels/providers/web.rs::pick_target_agent_id`).
* Its `subagents = [...]` list synthesises one delegated archetype tool
* Its `[subagents] allowlist = [...]` section synthesises one delegated archetype tool
* per archetype at build time (see
* `src/openhuman/tools/orchestrator_tools.rs`). When the LLM calls
* `research` (or any other delegated archetype tool), the tool dispatches
@@ -170,7 +170,7 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'check my email');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Q3 Budget Review/i)).toBeVisible();
const log = await requests();
@@ -210,7 +210,7 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'list my GitHub repos');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/openhuman/i)).toBeVisible();
const log = await requests();
@@ -244,7 +244,7 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'check my email inbox please');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/unable to fetch your emails/i)).toBeVisible();
});
@@ -286,7 +286,7 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'create a linear issue titled Fix authentication timeout');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/I have created the Linear issue/i)).toBeVisible();
});
});
@@ -154,7 +154,7 @@ test.describe('Harness - Search tool-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'what did we discuss about project Atlas');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Based on my memory search/i)).toBeVisible();
const log = await requests();
@@ -219,7 +219,7 @@ test.describe('Harness - Search tool-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'read the README');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/OpenHuman is an AI assistant/i)).toBeVisible();
const log = await requests();
@@ -33,12 +33,56 @@ pub fn all() -> Vec<AgentDefinition> {
.expect("built-in agent TOML must always parse (see agents/*/agent.toml)");
#[cfg(test)]
{
defs.push(test_main_def());
defs.push(test_inherit_echo_def());
defs.push(test_inherit_parallel_worker_def());
}
defs
}
/// Test-only parent used by `AgentBuilder`'s default `agent_definition_name = "main"`.
///
/// Production builds do not ship a `main` agent definition. In tests, the
/// default builder path drives inherit-based fake subagents through the real
/// delegation tools, so the parent must explicitly allow those test children.
#[cfg(test)]
pub(crate) fn test_main_def() -> AgentDefinition {
use super::definition::{
AgentTier, ModelSpec, PromptSource, SandboxMode, SubagentEntry, ToolScope,
};
AgentDefinition {
id: "main".into(),
when_to_use: "test-only default parent agent".into(),
display_name: None,
system_prompt: PromptSource::Inline("You are the test parent agent.".into()),
omit_identity: true,
omit_memory_context: true,
omit_safety_preamble: true,
omit_skills_catalog: true,
omit_profile: true,
omit_memory_md: true,
model: ModelSpec::Inherit,
temperature: 0.0,
tools: ToolScope::Wildcard,
disallowed_tools: vec![],
skill_filter: None,
extra_tools: vec![],
max_iterations: 8,
iteration_policy: Default::default(),
max_result_chars: None,
timeout_secs: None,
sandbox_mode: SandboxMode::None,
background: false,
subagents: vec![
SubagentEntry::AgentId("__test_inherit_echo".into()),
SubagentEntry::AgentId("__test_inherit_parallel_worker".into()),
],
delegate_name: None,
agent_tier: AgentTier::Chat,
source: DefinitionSource::Builtin,
}
}
/// Test-only sub-agent: `ModelSpec::Inherit`, wildcard tools, minimal
/// prompt. Inherit means the runner uses `parent.provider` verbatim,
/// so a test's scripted `MockProvider` reaches the sub-agent loop —
@@ -122,13 +166,32 @@ mod tests {
#[test]
fn all_definitions_present() {
let defs = all();
// +2 for the cfg(test) inherit-based test defs appended by all().
// +3 for the cfg(test) default parent and inherit-based test defs appended by all().
assert_eq!(
defs.len(),
crate::openhuman::agent_registry::agents::BUILTINS.len() + 2
crate::openhuman::agent_registry::agents::BUILTINS.len() + 3
);
}
#[test]
fn test_main_allows_test_inherit_workers() {
use super::super::definition::SubagentEntry;
let def = all()
.into_iter()
.find(|d| d.id == "main")
.expect("test-only main agent must be registered in test builds");
let allowed = def
.subagents
.iter()
.filter_map(|entry| match entry {
SubagentEntry::AgentId(id) => Some(id.as_str()),
SubagentEntry::Skills(_) => None,
})
.collect::<std::collections::HashSet<_>>();
assert!(allowed.contains("__test_inherit_echo"));
assert!(allowed.contains("__test_inherit_parallel_worker"));
}
#[test]
fn test_inherit_echo_is_present_and_inherits() {
use super::super::definition::ModelSpec;
+22 -3
View File
@@ -22,7 +22,7 @@
//! and serialised straight from disk.
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use std::path::PathBuf;
/// Iteration-cap policy for a sub-agent.
@@ -199,7 +199,7 @@ pub struct AgentDefinition {
///
/// [`ArchetypeDelegationTool`]: crate::openhuman::agent_orchestration::tools::ArchetypeDelegationTool
/// [`SkillDelegationTool`]: crate::openhuman::agent_orchestration::tools::SkillDelegationTool
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_subagent_entries")]
pub subagents: Vec<SubagentEntry>,
/// Optional override for the tool name this agent is exposed as when
@@ -305,7 +305,8 @@ impl AgentTier {
/// # TOML shapes
///
/// ```toml
/// subagents = [
/// [subagents]
/// allowlist = [
/// "researcher", # AgentId("researcher")
/// "code_executor", # AgentId("code_executor")
/// { skills = "*" }, # Skills { pattern: "*" }
@@ -335,6 +336,24 @@ pub struct SkillsWildcard {
pub skills: String,
}
fn deserialize_subagent_entries<'de, D>(deserializer: D) -> Result<Vec<SubagentEntry>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Section { allowlist: Vec<SubagentEntry> },
LegacyList(Vec<SubagentEntry>),
}
match Option::<Wire>::deserialize(deserializer)? {
Some(Wire::Section { allowlist }) => Ok(allowlist),
Some(Wire::LegacyList(entries)) => Ok(entries),
None => Ok(Vec::new()),
}
}
impl SkillsWildcard {
/// True when this wildcard should expand to every connected toolkit.
pub fn matches_all(&self) -> bool {
@@ -129,6 +129,35 @@ named = ["query_memory"]
);
}
#[test]
fn subagents_section_parses_allowlist_entries() {
let toml_src = r#"
id = "orchestrator"
when_to_use = "Routes work to the right specialist"
temperature = 0.4
max_iterations = 15
[subagents]
allowlist = [
"researcher",
"code_executor",
{ skills = "*" },
]
[tools]
named = ["query_memory"]
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert_eq!(
def.subagents,
vec![
SubagentEntry::AgentId("researcher".into()),
SubagentEntry::AgentId("code_executor".into()),
SubagentEntry::Skills(SkillsWildcard { skills: "*".into() }),
]
);
}
/// `subagents` is optional — omitting it should yield an empty Vec
/// rather than a deserialization error. Most non-delegating agents
/// (archivist, code_executor, etc.) will not list any.
@@ -16,6 +16,7 @@ use crate::openhuman::inference::provider::Provider;
use crate::openhuman::memory::Memory;
use crate::openhuman::tools::{Tool, ToolSpec};
use crate::openhuman::workflows::Workflow;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
@@ -31,6 +32,13 @@ use std::sync::Arc;
/// is essentially free.
#[derive(Clone)]
pub struct ParentExecutionContext {
/// Canonical registry id of the parent agent definition.
pub agent_definition_id: String,
/// Subagent ids this parent is allowed to spawn directly through the
/// generic `spawn_subagent` tool. Empty means no generic subagent spawns.
pub allowed_subagent_ids: HashSet<String>,
/// Parent's provider — sub-agents call into the same instance so
/// connection pools, retry budgets, and credentials are shared.
pub provider: Arc<dyn Provider>,
@@ -656,7 +656,7 @@ impl Agent {
/// What differs per agent id:
/// * `visible_tool_names` is the agent's `ToolScope::Named` list
/// (unioned with the names of synthesised delegation tools when
/// the agent declares `subagents = [...]`). `ToolScope::Wildcard`
/// the agent declares `[subagents] allowlist = [...]`). `ToolScope::Wildcard`
/// yields an empty filter, matching the legacy unfiltered path.
/// * `prompt_builder` uses [`SystemPromptBuilder::for_subagent`]
/// with the agent's inline/file prompt body and `omit_*` flags,
@@ -1307,7 +1307,7 @@ impl Agent {
// whitelist from the target definition (when we have one) or
// fall back to the orchestrator's synthesis path.
//
// For an agent with `subagents = [...]` in its TOML (today:
// For an agent with `[subagents] allowlist = [...]` in its TOML (today:
// orchestrator), `collect_orchestrator_tools` synthesises one
// `ArchetypeDelegationTool` per named sub-agent plus a single
// collapsed `SkillDelegationTool`
@@ -813,7 +813,30 @@ impl Agent {
/// Snapshot the parent's runtime so spawned sub-agents can read
/// it via the [`harness::PARENT_CONTEXT`] task-local.
pub(super) fn build_parent_execution_context(&self) -> harness::ParentExecutionContext {
let allowed_subagent_ids = crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global()
.and_then(|registry| registry.get(&self.agent_definition_id))
.map(|definition| {
definition
.subagents
.iter()
.filter_map(|entry| match entry {
crate::openhuman::agent::harness::definition::SubagentEntry::AgentId(id) => {
Some(id.clone())
}
crate::openhuman::agent::harness::definition::SubagentEntry::Skills(wildcard)
if wildcard.matches_all() =>
{
Some("integrations_agent".to_string())
}
crate::openhuman::agent::harness::definition::SubagentEntry::Skills(_) => None,
})
.collect()
})
.unwrap_or_default();
harness::ParentExecutionContext {
agent_definition_id: self.agent_definition_id.clone(),
allowed_subagent_ids,
provider: Arc::clone(&self.provider),
all_tools: Arc::clone(&self.tools),
all_tool_specs: Arc::clone(&self.tool_specs),
@@ -313,6 +313,10 @@ fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Parent
let tool_specs: Vec<crate::openhuman::tools::ToolSpec> =
tools.iter().map(|t| t.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: ["test".to_string(), "child".to_string(), "inner".to_string()]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
+2
View File
@@ -270,6 +270,8 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<S
// Build the ParentExecutionContext from the Agent's public accessors
// so `run_subagent` can inherit the provider, tools, memory, etc.
let parent_ctx = ParentExecutionContext {
agent_definition_id: "triage".to_string(),
allowed_subagent_ids: [agent_id.to_string()].into_iter().collect(),
provider: agent.provider_arc(),
all_tools: agent.tools_arc(),
all_tool_specs: agent.tool_specs_arc(),
@@ -76,6 +76,8 @@ impl Memory for NoopMemory {
fn parent_context(provider: Arc<dyn Provider>) -> ParentExecutionContext {
ParentExecutionContext {
agent_definition_id: "orchestrator".to_string(),
allowed_subagent_ids: ["researcher".to_string()].into_iter().collect(),
provider,
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
@@ -34,7 +34,25 @@ pub(crate) async fn dispatch_subagent(
}
};
let parent_session = current_parent()
let parent_ctx = current_parent();
if let Some(ctx) = &parent_ctx {
if !ctx.allowed_subagent_ids.contains(&definition.id) {
log::warn!(
"[agent] blocked delegation via {}: parent={} requested={} allowed={:?}",
tool_name,
ctx.agent_definition_id,
definition.id,
ctx.allowed_subagent_ids
);
return Ok(ToolResult::error(format!(
"{tool_name}: agent '{}' is not in parent agent '{}' subagents.allowlist",
definition.id, ctx.agent_definition_id
)));
}
}
let parent_session = parent_ctx
.as_ref()
.map(|p| p.session_id.clone())
.unwrap_or_else(|| "standalone".into());
let task_id = format!("sub-{}", uuid::Uuid::new_v4());
@@ -168,6 +168,19 @@ impl Tool for SpawnAsyncSubagentTool {
}
};
if !parent.allowed_subagent_ids.contains(&definition.id) {
log::warn!(
"[spawn_async_subagent] blocked subagent outside allowlist parent={} requested={} allowed={:?}",
parent.agent_definition_id,
definition.id,
parent.allowed_subagent_ids
);
return Ok(ToolResult::error(format!(
"spawn_async_subagent: agent '{}' is not in parent agent '{}' subagents.allowlist",
definition.id, parent.agent_definition_id
)));
}
if definition.id == "integrations_agent" && toolkit_override.is_none() {
return Ok(ToolResult::error(
"spawn_async_subagent(integrations_agent): the `toolkit` argument is required",
@@ -220,6 +220,32 @@ impl Tool for SpawnParallelAgentsTool {
continue;
};
if !parent.allowed_subagent_ids.contains(&definition.id) {
tracing::warn!(
parent_session = %parent_session,
parent_agent = %parent.agent_definition_id,
task_id = %task_id,
agent_id = %definition.id,
allowed = ?parent.allowed_subagent_ids,
"[spawn_parallel_agents] rejected_task_outside_subagent_allowlist"
);
immediate_results.push(ParallelAgentResult {
task_id,
agent_id: definition.id.clone(),
success: false,
output: None,
error: Some(format!(
"agent '{}' is not in parent agent '{}' subagents.allowlist",
definition.id, parent.agent_definition_id
)),
ownership: task.ownership,
elapsed_ms: 0,
iterations: 0,
stale_parent_reads: Vec::new(),
});
continue;
}
if definition.id == "integrations_agent"
&& task
.toolkit
@@ -185,6 +185,14 @@ fn parent_context_with_provider(
..Default::default()
};
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"researcher".to_string(),
"critic".to_string(),
"integrations_agent".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(Vec::new()),
all_tool_specs: Arc::new(Vec::new()),
@@ -218,6 +218,26 @@ impl Tool for SpawnSubagentTool {
}
};
if let Some(parent_ctx) = current_parent() {
if !parent_ctx.allowed_subagent_ids.contains(&definition.id) {
log::warn!(
"[spawn_subagent] blocked subagent outside parent allowlist parent_agent={} requested_agent={} allowed={:?}",
parent_ctx.agent_definition_id,
definition.id,
parent_ctx.allowed_subagent_ids
);
return Ok(ToolResult::error(format!(
"spawn_subagent: agent '{}' is not in parent agent '{}' subagents.allowlist",
definition.id, parent_ctx.agent_definition_id
)));
}
log::debug!(
"[spawn_subagent] subagent allowlist check passed parent_agent={} requested_agent={}",
parent_ctx.agent_definition_id,
definition.id
);
}
// ── integrations_agent toolkit gate ──────────────────────────────────
// integrations_agent is a platform-parameterised specialist. Every
// spawn MUST name a CONNECTED toolkit so the sub-agent only
@@ -182,6 +182,25 @@ impl Tool for SpawnWorkerThreadTool {
.get(&agent_id)
.ok_or_else(|| anyhow::anyhow!("agent_id '{}' not found", agent_id))?;
if !parent.allowed_subagent_ids.contains(&definition.id) {
tracing::warn!(
parent_agent = %parent.agent_definition_id,
requested_agent = %definition.id,
allowed = ?parent.allowed_subagent_ids,
"[spawn_worker_thread] blocked subagent outside parent allowlist"
);
return Ok(ToolResult::error(format!(
"spawn_worker_thread: agent '{}' is not in parent agent '{}' subagents.allowlist",
definition.id, parent.agent_definition_id
)));
}
tracing::debug!(
parent_agent = %parent.agent_definition_id,
requested_agent = %definition.id,
"[spawn_worker_thread] subagent allowlist check passed"
);
// ── Create Worker Thread ───────────────────────────────────────
// Shared with `spawn_subagent` so both delegation paths persist an
// identical, reopenable sub-thread seeded with the prompt.
@@ -259,12 +278,9 @@ impl Tool for SpawnWorkerThreadTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::harness::definition::{
AgentDefinition, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope,
};
use crate::openhuman::agent::harness::fork_context::with_parent_context;
use crate::openhuman::agent::harness::ParentExecutionContext;
use crate::openhuman::memory_conversations::{ConversationMessage, CreateConversationThread};
use crate::openhuman::memory_conversations::CreateConversationThread;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
@@ -356,6 +372,8 @@ mod tests {
fn test_parent_ctx(workspace_dir: PathBuf) -> ParentExecutionContext {
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: std::collections::HashSet::new(),
session_id: "test".into(),
session_key: "test".into(),
session_parent_prefix: None,
@@ -462,4 +480,29 @@ mod tests {
)
.await;
}
#[tokio::test]
async fn rejects_agent_outside_parent_allowlist() {
let _ = AgentDefinitionRegistry::init_global_builtins();
let temp = TempDir::new().unwrap();
let parent = test_parent_ctx(temp.path().to_path_buf());
with_parent_context(parent, async {
let tool = SpawnWorkerThreadTool::new();
let result = tool
.execute(json!({
"agent_id": "researcher",
"prompt": "do it",
"task_title": "Task"
}))
.await
.unwrap();
assert!(result.is_error);
assert!(result.output().contains(
"spawn_worker_thread: agent 'researcher' is not in parent agent 'orchestrator' subagents.allowlist"
));
})
.await;
}
}
@@ -180,6 +180,10 @@ fn parent_context(
connected_integrations: Vec<ConnectedIntegration>,
) -> ParentExecutionContext {
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: ["researcher".to_string(), "integrations_agent".to_string()]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(Vec::new()),
all_tool_specs: Arc::new(Vec::new()),
@@ -40,11 +40,8 @@ omit_memory_md = false
# function-calling schema, so routing decisions happen at the tool-
# selection layer rather than hidden inside a mega-tool's enum param.
#
# NOTE: `subagents = [...]` MUST appear before any `[table]` section
# header — once a TOML table opens, subsequent top-level keys are
# consumed by that table, and `subagents` would get parsed as
# `tools.subagents` (which fails because `ToolScope` is an enum).
subagents = [
[subagents]
allowlist = [
"researcher",
"planner",
"code_executor",
+10 -9
View File
@@ -6,7 +6,7 @@ use crate::openhuman::agent::harness::definition::{
AgentDefinition, ModelSpec, SubagentEntry, ToolScope,
};
use super::types::{AgentRegistryEntry, AgentRegistrySource};
use super::types::{AgentRegistryEntry, AgentRegistrySource, AgentSubagentPolicy};
pub fn default_agents() -> Vec<AgentRegistryEntry> {
crate::openhuman::agent_registry::agents::load_builtins()
@@ -35,14 +35,15 @@ fn default_entry_from_definition(def: AgentDefinition) -> AgentRegistryEntry {
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(),
subagents: AgentSubagentPolicy::from_allowlist(
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,
}
+3 -2
View File
@@ -242,6 +242,7 @@ mod tests {
use serde_json::Value;
use super::*;
use crate::openhuman::agent_registry::types::AgentSubagentPolicy;
fn custom_agent(id: &str, enabled: bool) -> AgentRegistryEntry {
AgentRegistryEntry {
@@ -254,7 +255,7 @@ mod tests {
system_prompt: Some("Do custom work.".to_string()),
tool_allowlist: vec!["memory.search".to_string()],
tool_denylist: Vec::new(),
subagents: Vec::new(),
subagents: AgentSubagentPolicy::default(),
tags: vec!["custom".to_string()],
metadata: Value::Null,
}
@@ -272,7 +273,7 @@ mod tests {
system_prompt: None,
tool_allowlist: vec!["*".to_string()],
tool_denylist: Vec::new(),
subagents: Vec::new(),
subagents: AgentSubagentPolicy::default(),
tags: Vec::new(),
metadata: Value::Null,
}];
+4 -2
View File
@@ -6,7 +6,9 @@ use serde_json::Value;
use crate::rpc::RpcOutcome;
use super::ops;
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource, AgentToolInfo};
use super::types::{
AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource, AgentSubagentPolicy, AgentToolInfo,
};
#[derive(Debug, Deserialize)]
pub struct ListRequest {
@@ -82,7 +84,7 @@ pub struct CreateCustomRequest {
#[serde(default)]
pub tool_denylist: Vec<String>,
#[serde(default)]
pub subagents: Vec<String>,
pub subagents: AgentSubagentPolicy,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
+24 -2
View File
@@ -129,7 +129,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
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_subagents_policy(
"subagents",
"Subagent delegation policy. Only ids in allowlist may be spawned.",
),
optional_string_array("tags", "UI grouping/search tags."),
FieldSchema {
name: "metadata",
@@ -153,7 +156,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
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_subagents_policy(
"subagents",
"Subagent delegation policy. Only ids in allowlist may be spawned.",
),
optional_string_array("tags", "UI grouping/search tags."),
FieldSchema {
name: "metadata",
@@ -287,6 +293,22 @@ fn optional_string_array(name: &'static str, comment: &'static str) -> FieldSche
}
}
fn optional_subagents_policy(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::Object {
fields: vec![FieldSchema {
name: "allowlist",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Whitelisted subagent ids this agent may call.",
required: false,
}],
})),
comment,
required: false,
}
}
fn agent_output() -> FieldSchema {
FieldSchema {
name: "agent",
+103 -5
View File
@@ -1,7 +1,7 @@
//! Public data model for the user-facing agent registry.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
fn default_true() -> bool {
@@ -15,6 +15,42 @@ pub enum AgentRegistrySource {
Custom,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
pub struct AgentSubagentPolicy {
/// Agent ids this agent may delegate to. Empty means no subagent calls.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowlist: Vec<String>,
}
impl AgentSubagentPolicy {
pub fn from_allowlist(allowlist: Vec<String>) -> Self {
Self { allowlist }
}
pub fn is_empty(&self) -> bool {
self.allowlist.is_empty()
}
}
impl<'de> Deserialize<'de> for AgentSubagentPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Section { allowlist: Vec<String> },
LegacyList(Vec<String>),
}
match Wire::deserialize(deserializer)? {
Wire::Section { allowlist } => Ok(Self { allowlist }),
Wire::LegacyList(allowlist) => Ok(Self { allowlist }),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AgentRegistryEntry {
/// Stable id used by routing and UI selection.
@@ -41,9 +77,9 @@ pub struct AgentRegistryEntry {
/// 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>,
/// Subagent delegation policy. Only ids in `allowlist` may be spawned.
#[serde(default, skip_serializing_if = "AgentSubagentPolicy::is_empty")]
pub subagents: AgentSubagentPolicy,
/// Search/filter tags for UI grouping.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
@@ -70,6 +106,20 @@ impl AgentRegistryEntry {
if self.description.trim().is_empty() {
return Err("description is required".to_string());
}
for child in &self.subagents.allowlist {
if child.trim().is_empty() {
return Err("subagents.allowlist entries must not be blank".to_string());
}
if child
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
{
return Err(
"subagents.allowlist entries may only contain ASCII letters, numbers, '_' and '-'"
.to_string(),
);
}
}
Ok(())
}
}
@@ -108,9 +158,57 @@ pub struct AgentRegistryPatch {
#[serde(default)]
pub tool_denylist: Option<Vec<String>>,
#[serde(default)]
pub subagents: Option<Vec<String>>,
pub subagents: Option<AgentSubagentPolicy>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub metadata: Option<Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn subagent_policy_accepts_legacy_array() {
let entry: AgentRegistryEntry = serde_json::from_value(json!({
"id": "planner",
"name": "Planner",
"description": "Plans work.",
"source": "custom",
"enabled": true,
"subagents": ["researcher", "critic"]
}))
.expect("legacy subagents array should parse");
assert_eq!(
entry.subagents.allowlist,
vec!["researcher".to_string(), "critic".to_string()]
);
}
#[test]
fn subagent_policy_serializes_as_section() {
let entry = AgentRegistryEntry {
id: "planner".to_string(),
name: "Planner".to_string(),
description: "Plans work.".to_string(),
source: AgentRegistrySource::Custom,
enabled: true,
model: None,
system_prompt: None,
tool_allowlist: Vec::new(),
tool_denylist: Vec::new(),
subagents: AgentSubagentPolicy::from_allowlist(vec!["researcher".to_string()]),
tags: Vec::new(),
metadata: Value::Null,
};
let value = serde_json::to_value(entry).expect("serialize entry");
assert_eq!(
value.get("subagents"),
Some(&json!({ "allowlist": ["researcher"] }))
);
}
}
+12 -10
View File
@@ -1134,18 +1134,20 @@ mod tests {
);
// No jobs running. Provider availability differs between local and CI
// harnesses, so a completed ingest may be fully running or degraded
// because semantic recall was skipped. Both are terminal, non-syncing
// states and both preserve the aggregate counters asserted above.
// because semantic recall or wiki structure was skipped. Both are
// terminal, non-syncing states and both preserve the aggregate counters
// asserted above.
match out.status.as_str() {
"running" => assert!(out.reason.is_none()),
"degraded" => assert!(
out.reason
.as_deref()
.unwrap_or_default()
.contains("semantic recall disabled"),
"degraded status should explain semantic recall loss: {:?}",
out.reason
),
"degraded" => {
let reason = out.reason.as_deref().unwrap_or_default();
assert!(
reason.contains("semantic recall disabled")
|| reason.contains("wiki structure incomplete"),
"degraded status should explain recall or structure loss: {:?}",
out.reason
);
}
other => panic!("expected running or degraded after ingest, got {other}"),
}
assert!(!out.is_syncing);
+1 -1
View File
@@ -4,7 +4,7 @@
//! work. Rather than exposing a single generic
//! `spawn_subagent(agent_id, prompt)` mega-tool, we synthesise one named
//! tool per [`SubagentEntry::AgentId`] in the orchestrator's
//! `subagents = [...]` TOML field, so the LLM's function-calling schema
//! `[subagents] allowlist = [...]` TOML section, so the LLM's function-calling schema
//! contains discoverable, well-named tools like `research`, `plan`,
//! `run_code`, etc.
//!
@@ -264,6 +264,14 @@ fn parent_context(workspace: &Path, provider: Arc<ScriptedProvider>) -> ParentEx
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let specs = tools.iter().map(|tool| tool.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"test".to_string(),
"archivist".to_string(),
"summarizer".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
@@ -372,6 +372,14 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
let tools = vec![tool("echo")];
let specs = tools.iter().map(|tool| tool.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"test".to_string(),
"researcher".to_string(),
"code_executor".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
+4
View File
@@ -126,6 +126,10 @@ fn sample_turn() -> TurnContext {
fn stub_parent_context() -> ParentExecutionContext {
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: ["test".to_string(), "researcher".to_string()]
.into_iter()
.collect(),
provider: Arc::new(StubProvider),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
+31
View File
@@ -265,6 +265,14 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"test".to_string(),
"researcher".to_string(),
"code_executor".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
@@ -522,6 +530,29 @@ async fn orchestrator_spawn_subagent_round_trip_streams_child_events_and_returns
let workspace = tempfile::tempdir()?;
let agents_dir = workspace.path().join("agents");
std::fs::create_dir_all(&agents_dir)?;
std::fs::write(
agents_dir.join("coverage_orchestrator.toml"),
r#"
id = "coverage_orchestrator"
display_name = "Coverage Orchestrator"
when_to_use = "Deterministic parent agent used by harness cache tests."
temperature = 0.0
max_iterations = 3
agent_tier = "chat"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = true
omit_skills_catalog = true
omit_profile = true
omit_memory_md = true
[system_prompt]
inline = "Delegate the cache probe and synthesize the result."
[subagents]
allowlist = ["cache_probe_child"]
"#,
)?;
std::fs::write(
agents_dir.join("cache_probe_child.toml"),
r#"
@@ -305,6 +305,14 @@ fn parent(workspace_dir: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExec
let tools: Vec<Box<dyn Tool>> = vec![Box::new(LargePayloadTool)];
let specs = tools.iter().map(|tool| tool.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"test".to_string(),
"researcher".to_string(),
"code_executor".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
@@ -270,6 +270,14 @@ fn parent(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExecutio
let tools = vec![tool("echo"), tool("delegate_nested"), tool("other__skip")];
let specs = tools.iter().map(|tool| tool.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"test".to_string(),
"researcher".to_string(),
"code_executor".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
@@ -889,6 +889,13 @@ async fn subagent_runner_parent_context_filters_tools_caps_output_and_reports_er
];
let all_specs = all_tools.iter().map(|tool| tool.spec()).collect::<Vec<_>>();
let parent = ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"round17_child".to_string(),
"round17_provider_error".to_string(),
]
.into_iter()
.collect(),
provider: provider.clone(),
all_tools: Arc::new(all_tools),
all_tool_specs: Arc::new(all_specs),
+2
View File
@@ -141,6 +141,8 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> {
let _ = openhuman_core::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins();
let parent = openhuman_core::openhuman::agent::harness::ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: ["integrations_agent".to_string()].into_iter().collect(),
provider: provider.clone(),
all_tools: Arc::new(vec![Box::new(MockCalendarTool)]),
all_tool_specs: Arc::new(vec![MockCalendarTool.spec()]),
@@ -337,6 +337,8 @@ async fn drive_subagent() {
});
let parent = ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: ["integrations_agent".to_string()].into_iter().collect(),
provider: provider.clone(),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
+11 -1
View File
@@ -1419,8 +1419,9 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() {
assert_eq!(
updated_custom_agent
.get("subagents")
.and_then(|subagents| subagents.get("allowlist"))
.and_then(Value::as_array)
.and_then(|subagents| subagents.first())
.and_then(|allowlist| allowlist.first())
.and_then(Value::as_str),
Some("researcher")
);
@@ -1478,6 +1479,15 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() {
full_upsert_agent.get("enabled").and_then(Value::as_bool),
Some(false)
);
assert_eq!(
full_upsert_agent
.get("subagents")
.and_then(|subagents| subagents.get("allowlist"))
.and_then(Value::as_array)
.and_then(|allowlist| allowlist.first())
.and_then(Value::as_str),
Some("critic")
);
let visible_after_custom_disable = post_json_rpc(
&rpc_base,
@@ -350,6 +350,14 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
ParentExecutionContext {
agent_definition_id: "orchestrator".into(),
allowed_subagent_ids: [
"test".to_string(),
"tools_agent".to_string(),
"integrations_agent".to_string(),
]
.into_iter()
.collect(),
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),