mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): gate subagent delegation by allowlist (#3426)
This commit is contained in:
@@ -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,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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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![]),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user