mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): enforce subagent role contract with concise delegated outputs (#1336)
This commit is contained in:
@@ -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")]);
|
||||
|
||||
Reference in New Issue
Block a user