feat(agent): enforce subagent role contract with concise delegated outputs (#1336)

This commit is contained in:
YellowSnnowmann
2026-05-07 12:30:10 -07:00
committed by GitHub
parent 34188130a0
commit c2f2d8497c
3 changed files with 90 additions and 0 deletions
+1
View File
@@ -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.
@@ -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
@@ -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")]);