From c2f2d8497c322c1d74ad35a6ebccf4eba817e4f9 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Fri, 8 May 2026 01:00:10 +0530 Subject: [PATCH] feat(agent): enforce subagent role contract with concise delegated outputs (#1336) --- docs/agent-subagent-tool-flow.md | 1 + .../agent/harness/subagent_runner/ops.rs | 42 +++++++++++++++++ .../harness/subagent_runner/ops_tests.rs | 47 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/docs/agent-subagent-tool-flow.md b/docs/agent-subagent-tool-flow.md index 51392de48..602d86832 100644 --- a/docs/agent-subagent-tool-flow.md +++ b/docs/agent-subagent-tool-flow.md @@ -65,6 +65,7 @@ There are two related but distinct execution tiers: 2. `run_subagent` This is an isolated delegated run. It does not become a nested full `Agent` session. It runs a smaller inner loop and returns a single compact text result to the parent as a normal tool result. + Every typed subagent prompt now also appends a shared "Sub-agent Role Contract" suffix that explicitly states sub-agent role expectations and requires concise, synthesis-ready outputs. That distinction matters when debugging. A subagent is not a second copy of the full session runtime. diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs index bc7499d00..a7d6a522a 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops.rs @@ -33,6 +33,46 @@ use crate::openhuman::memory::conversations::ConversationMessage; use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall}; use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec}; +/// Prompt suffix injected into every typed sub-agent run. +/// +/// Purpose: +/// - make the child explicitly aware it is acting as a sub-agent +/// - keep delegated outputs concise so parent-context growth stays bounded +/// - discourage verbose restatement of the delegated task/context +const SUBAGENT_ROLE_CONTRACT_SUFFIX: &str = "## Sub-agent Role Contract\n\n\ +You are a sub-agent working for a parent OpenHuman agent, not a direct end-user assistant.\n\ +- Stay tightly scoped to the delegated task.\n\ +- Keep tool arguments and follow-up prompts compact, include only required fields/context.\n\ +- Keep your final response concise and synthesis-ready for the parent, prefer short bullets or short paragraphs.\n\ +- Do not restate the full task/context unless strictly required for correctness.\n"; + +fn append_subagent_role_contract(base_prompt: String, agent_id: &str) -> String { + if base_prompt.contains(SUBAGENT_ROLE_CONTRACT_SUFFIX.trim()) { + tracing::debug!( + agent_id = %agent_id, + base_chars = base_prompt.chars().count(), + "[subagent_runner] sub-agent role contract already present in system prompt" + ); + return base_prompt; + } + + let mut prompt = base_prompt; + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + prompt.push('\n'); + prompt.push_str(SUBAGENT_ROLE_CONTRACT_SUFFIX); + + tracing::debug!( + agent_id = %agent_id, + suffix_chars = SUBAGENT_ROLE_CONTRACT_SUFFIX.chars().count(), + final_chars = prompt.chars().count(), + "[subagent_runner] appended sub-agent role contract to system prompt" + ); + + prompt +} + /// Lazy resolver that lets `integrations_agent` recover when the model /// calls a Composio action slug that exists in the bound toolkit's full /// catalogue but was filtered out of the up-front fuzzy top-K. On a @@ -675,6 +715,8 @@ async fn run_typed_mode( } }; + let system_prompt = append_subagent_role_contract(system_prompt, &definition.id); + // ── Build the user message (with optional context prefix) ────────── // Merge explicit orchestrator context with the parent's auto-loaded // memory context, but only when the definition opts into memory diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 87abd6cab..c05d3e650 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -115,6 +115,21 @@ fn subagent_mode_as_str_roundtrip() { assert_eq!(SubagentMode::Typed.as_str(), "typed"); } +#[test] +fn append_subagent_role_contract_adds_role_and_brevity_rules() { + let rendered = append_subagent_role_contract("base prompt".to_string(), "researcher"); + assert!(rendered.contains("## Sub-agent Role Contract")); + assert!(rendered.contains("You are a sub-agent working for a parent OpenHuman agent")); + assert!(rendered.contains("Keep your final response concise and synthesis-ready")); +} + +#[test] +fn append_subagent_role_contract_is_idempotent() { + let once = append_subagent_role_contract("base prompt".to_string(), "researcher"); + let twice = append_subagent_role_contract(once.clone(), "researcher"); + assert_eq!(once, twice, "contract suffix should only appear once"); +} + // ── End-to-end runner tests with mock provider ──────────────────────── use crate::openhuman::agent::harness::fork_context::with_parent_context; @@ -317,6 +332,38 @@ async fn typed_mode_injects_current_date_and_time_into_user_message() { ); } +#[tokio::test] +async fn typed_mode_system_prompt_includes_subagent_role_contract() { + let provider = ScriptedProvider::new(vec![text_response("ok")]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + let def = make_def_named_tools(&[]); + + let _ = with_parent_context(parent, async { + run_subagent( + &def, + "the actual task prompt", + SubagentRunOptions::default(), + ) + .await + }) + .await + .unwrap(); + + let captured = provider.captured.lock(); + let system_msg = captured[0] + .messages + .iter() + .find(|m| m.role == "system") + .expect("system message should be present"); + assert!(system_msg.content.contains("## Sub-agent Role Contract")); + assert!(system_msg + .content + .contains("You are a sub-agent working for a parent OpenHuman agent")); + assert!(system_msg + .content + .contains("Keep your final response concise and synthesis-ready")); +} + #[tokio::test] async fn typed_mode_returns_text_through_runner() { let provider = ScriptedProvider::new(vec![text_response("X is Y")]);