From 5c6dbf36491c2c3ae25c4699d3f38e0126513667 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:42:24 -0400 Subject: [PATCH] feat(agent): gate subagent delegation by allowlist (#3426) --- app/src/services/api/agentRegistryApi.ts | 10 +- .../e2e/specs/chat-harness-subagent.spec.ts | 2 +- .../specs/harness-composio-tool-flow.spec.ts | 8 +- .../specs/harness-search-tool-flow.spec.ts | 4 +- .../agent/harness/builtin_definitions.rs | 67 ++++++++++- src/openhuman/agent/harness/definition.rs | 25 +++- .../agent/harness/definition_tests.rs | 29 +++++ src/openhuman/agent/harness/fork_context.rs | 8 ++ .../agent/harness/session/builder.rs | 4 +- src/openhuman/agent/harness/session/turn.rs | 23 ++++ .../harness/subagent_runner/ops_tests.rs | 4 + src/openhuman/agent/triage/escalation.rs | 2 + .../agent_orchestration/ops_tests.rs | 2 + .../agent_orchestration/tools/dispatch.rs | 20 +++- .../tools/spawn_async_subagent.rs | 13 +++ .../tools/spawn_parallel_agents.rs | 26 +++++ .../tools/spawn_parallel_agents_tests.rs | 8 ++ .../tools/spawn_subagent.rs | 20 ++++ .../tools/spawn_worker_thread.rs | 51 ++++++++- .../tools/tools_e2e_tests.rs | 4 + .../agents/orchestrator/agent.toml | 7 +- src/openhuman/agent_registry/defaults.rs | 19 +-- src/openhuman/agent_registry/ops.rs | 5 +- src/openhuman/agent_registry/rpc.rs | 6 +- src/openhuman/agent_registry/schemas.rs | 26 ++++- src/openhuman/agent_registry/types.rs | 108 +++++++++++++++++- src/openhuman/memory_tree/tree/rpc.rs | 22 ++-- src/openhuman/tools/orchestrator_tools.rs | 2 +- ...rchivist_debug_round21_raw_coverage_e2e.rs | 8 ++ ...gent_harness_leftovers_raw_coverage_e2e.rs | 8 ++ tests/agent_harness_public.rs | 4 + tests/agent_harness_raw_coverage_e2e.rs | 31 +++++ tests/agent_large_round25_raw_coverage_e2e.rs | 8 ++ ...agent_prompts_subagent_raw_coverage_e2e.rs | 8 ++ tests/agent_session_turn_raw_coverage_e2e.rs | 7 ++ tests/calendar_grounding_e2e.rs | 2 + ...io_list_tools_stack_overflow_regression.rs | 2 + tests/json_rpc_e2e.rs | 12 +- ...gent_credentials_state_raw_coverage_e2e.rs | 8 ++ 39 files changed, 564 insertions(+), 59 deletions(-) diff --git a/app/src/services/api/agentRegistryApi.ts b/app/src/services/api/agentRegistryApi.ts index 193567bec..aeecb7171 100644 --- a/app/src/services/api/agentRegistryApi.ts +++ b/app/src/services/api/agentRegistryApi.ts @@ -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[]; } diff --git a/app/test/e2e/specs/chat-harness-subagent.spec.ts b/app/test/e2e/specs/chat-harness-subagent.spec.ts index 69938cd4e..ba39ba117 100644 --- a/app/test/e2e/specs/chat-harness-subagent.spec.ts +++ b/app/test/e2e/specs/chat-harness-subagent.spec.ts @@ -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 diff --git a/app/test/playwright/specs/harness-composio-tool-flow.spec.ts b/app/test/playwright/specs/harness-composio-tool-flow.spec.ts index b025a1856..cf95cc635 100644 --- a/app/test/playwright/specs/harness-composio-tool-flow.spec.ts +++ b/app/test/playwright/specs/harness-composio-tool-flow.spec.ts @@ -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(); }); }); diff --git a/app/test/playwright/specs/harness-search-tool-flow.spec.ts b/app/test/playwright/specs/harness-search-tool-flow.spec.ts index 8c519b493..018afe7aa 100644 --- a/app/test/playwright/specs/harness-search-tool-flow.spec.ts +++ b/app/test/playwright/specs/harness-search-tool-flow.spec.ts @@ -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(); diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index b4417dede..5f919288b 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -33,12 +33,56 @@ pub fn all() -> Vec { .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::>(); + 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; diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index 25836ce3c..ee5ea0004 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -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, /// 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, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Section { allowlist: Vec }, + LegacyList(Vec), + } + + match Option::::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 { diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index ee575dba2..9544b2913 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -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. diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index d8380598f..e04a01be2 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -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, + /// Parent's provider — sub-agents call into the same instance so /// connection pools, retry budgets, and credentials are shared. pub provider: Arc, diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index e6b356633..1ca11359f 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -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` diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index e8930b791..5ecc6bd05 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -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), diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 1edbd9093..f34b6d336 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -313,6 +313,10 @@ fn make_parent(provider: Arc, tools: Vec>) -> Parent let tool_specs: Vec = 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), diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index e0d7581c1..c0eaa3fe2 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -270,6 +270,8 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result) -> ParentExecutionContext { ParentExecutionContext { + agent_definition_id: "orchestrator".to_string(), + allowed_subagent_ids: ["researcher".to_string()].into_iter().collect(), provider, all_tools: Arc::new(Vec::>::new()), all_tool_specs: Arc::new(Vec::::new()), diff --git a/src/openhuman/agent_orchestration/tools/dispatch.rs b/src/openhuman/agent_orchestration/tools/dispatch.rs index 5d9b2030e..458194bcb 100644 --- a/src/openhuman/agent_orchestration/tools/dispatch.rs +++ b/src/openhuman/agent_orchestration/tools/dispatch.rs @@ -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()); diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs index a88fedb99..cfffb1dfa 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -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", diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs index 6dfd5cf0d..02529fdf5 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs @@ -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 diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs index b9ad7716a..34b13cc1e 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs @@ -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()), diff --git a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs index db5af9318..d5eca947e 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs @@ -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 diff --git a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs index c4250f278..91b5d9a22 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs @@ -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; + } } diff --git a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs index 0ba30fd69..3261e8b61 100644 --- a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs +++ b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs @@ -180,6 +180,10 @@ fn parent_context( connected_integrations: Vec, ) -> 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()), diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index ce063e732..96246fd54 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -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", diff --git a/src/openhuman/agent_registry/defaults.rs b/src/openhuman/agent_registry/defaults.rs index 455e7e5fb..6fe73b58e 100644 --- a/src/openhuman/agent_registry/defaults.rs +++ b/src/openhuman/agent_registry/defaults.rs @@ -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 { 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, } diff --git a/src/openhuman/agent_registry/ops.rs b/src/openhuman/agent_registry/ops.rs index facf06dde..3b7e7c3c8 100644 --- a/src/openhuman/agent_registry/ops.rs +++ b/src/openhuman/agent_registry/ops.rs @@ -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, }]; diff --git a/src/openhuman/agent_registry/rpc.rs b/src/openhuman/agent_registry/rpc.rs index 11831a8ff..8a079b260 100644 --- a/src/openhuman/agent_registry/rpc.rs +++ b/src/openhuman/agent_registry/rpc.rs @@ -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, #[serde(default)] - pub subagents: Vec, + pub subagents: AgentSubagentPolicy, #[serde(default)] pub tags: Vec, #[serde(default)] diff --git a/src/openhuman/agent_registry/schemas.rs b/src/openhuman/agent_registry/schemas.rs index 5da56c6eb..5fd55b4c3 100644 --- a/src/openhuman/agent_registry/schemas.rs +++ b/src/openhuman/agent_registry/schemas.rs @@ -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", diff --git a/src/openhuman/agent_registry/types.rs b/src/openhuman/agent_registry/types.rs index f1b0e6f6f..7bf18269c 100644 --- a/src/openhuman/agent_registry/types.rs +++ b/src/openhuman/agent_registry/types.rs @@ -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, +} + +impl AgentSubagentPolicy { + pub fn from_allowlist(allowlist: Vec) -> Self { + Self { allowlist } + } + + pub fn is_empty(&self) -> bool { + self.allowlist.is_empty() + } +} + +impl<'de> Deserialize<'de> for AgentSubagentPolicy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Section { allowlist: Vec }, + LegacyList(Vec), + } + + 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, - /// Optional delegated agent ids this agent may spawn. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub subagents: Vec, + /// 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, @@ -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>, #[serde(default)] - pub subagents: Option>, + pub subagents: Option, #[serde(default)] pub tags: Option>, #[serde(default)] pub metadata: Option, } + +#[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"] })) + ); + } +} diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index a40827363..329b24d34 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -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); diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index 08ee0d8d5..999cb321e 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -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. //! diff --git a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs index be0a341de..44455d1e5 100644 --- a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs +++ b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs @@ -264,6 +264,14 @@ fn parent_context(workspace: &Path, provider: Arc) -> ParentEx let tools: Vec> = 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), diff --git a/tests/agent_harness_leftovers_raw_coverage_e2e.rs b/tests/agent_harness_leftovers_raw_coverage_e2e.rs index 712bfa36e..8289ac80a 100644 --- a/tests/agent_harness_leftovers_raw_coverage_e2e.rs +++ b/tests/agent_harness_leftovers_raw_coverage_e2e.rs @@ -372,6 +372,14 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> 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), diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index b7be69067..989ea68cd 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -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![]), diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index 87a7275e7..c45afae93 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -265,6 +265,14 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> Parent let tools: Vec> = 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#" diff --git a/tests/agent_large_round25_raw_coverage_e2e.rs b/tests/agent_large_round25_raw_coverage_e2e.rs index 3055edb7c..b857e82f4 100644 --- a/tests/agent_large_round25_raw_coverage_e2e.rs +++ b/tests/agent_large_round25_raw_coverage_e2e.rs @@ -305,6 +305,14 @@ fn parent(workspace_dir: PathBuf, provider: Arc) -> ParentExec let tools: Vec> = 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), diff --git a/tests/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/agent_prompts_subagent_raw_coverage_e2e.rs index 8f8298d9e..bf558ed2d 100644 --- a/tests/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/agent_prompts_subagent_raw_coverage_e2e.rs @@ -270,6 +270,14 @@ fn parent(workspace: PathBuf, provider: Arc) -> 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), diff --git a/tests/agent_session_turn_raw_coverage_e2e.rs b/tests/agent_session_turn_raw_coverage_e2e.rs index a696cfabf..e2e7e3751 100644 --- a/tests/agent_session_turn_raw_coverage_e2e.rs +++ b/tests/agent_session_turn_raw_coverage_e2e.rs @@ -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::>(); 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), diff --git a/tests/calendar_grounding_e2e.rs b/tests/calendar_grounding_e2e.rs index 5a90c21db..c9f76ce77 100644 --- a/tests/calendar_grounding_e2e.rs +++ b/tests/calendar_grounding_e2e.rs @@ -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()]), diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 38979df87..9fe45079f 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -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![]), diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 3dd11a01f..c47eaf596 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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, diff --git a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs index f4e519273..2a4cacccf 100644 --- a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs +++ b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs @@ -350,6 +350,14 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> Parent let tools: Vec> = 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),