feat(agent): add control specialists and async subagent spawning (#3379)

This commit is contained in:
Steven Enamakel
2026-06-04 13:42:15 -04:00
committed by GitHub
parent e7e26efb3e
commit e075320d83
33 changed files with 1234 additions and 22 deletions
+22 -19
View File
@@ -156,6 +156,27 @@ function chatDoneExtraMetadata(event: ChatDoneEvent): Record<string, unknown> |
return event.citations?.length ? { citations: event.citations } : undefined;
}
export function findPendingDelegationContext(
entries: ToolTimelineEntry[],
round: number
): { sourceToolName?: string; prompt?: string; spawnEntryId?: string } {
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry.status !== 'running' || entry.round !== round) continue;
if (
['spawn_subagent', 'spawn_async_subagent'].includes(entry.name) ||
entry.name.startsWith('delegate_')
) {
return {
sourceToolName: entry.name,
prompt: entry.detail ?? promptFromArgsBuffer(entry.argsBuffer),
spawnEntryId: entry.id,
};
}
}
return {};
}
const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const dispatch = useAppDispatch();
const { refetch: refetchSnapshot } = useRefetchSnapshotOnTurnEnd();
@@ -301,24 +322,6 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
dispatch(setActiveThread(null));
};
const findPendingDelegationContext = (
entries: ToolTimelineEntry[],
round: number
): { sourceToolName?: string; prompt?: string; spawnEntryId?: string } => {
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry.status !== 'running' || entry.round !== round) continue;
if (entry.name === 'spawn_subagent' || entry.name.startsWith('delegate_')) {
return {
sourceToolName: entry.name,
prompt: entry.detail ?? promptFromArgsBuffer(entry.argsBuffer),
spawnEntryId: entry.id,
};
}
}
return {};
};
rtLog('subscribe_chat_events', { socket: socketStatus });
const cleanup = subscribeChatEvents({
onInferenceStart: (event: ChatInferenceStartEvent) => {
@@ -464,7 +467,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
const pendingContext = findPendingDelegationContext(existing, event.round);
// Collapse the parent's `spawn_subagent`/`delegate_*` tool-call row into
// Collapse the parent's `spawn_subagent`/`spawn_async_subagent`/`delegate_*` tool-call row into
// the subagent row so the timeline shows ONE entry per delegation
// instead of "Research" (the tool call) + "Researching" (the child).
// The tool call's prompt is carried onto the subagent as the parent's
@@ -9,7 +9,7 @@ import { store } from '../../store';
import { clearAllChatRuntime } from '../../store/chatRuntimeSlice';
import { setStatusForUser } from '../../store/socketSlice';
import { clearAllThreads, loadThreads, setSelectedThread } from '../../store/threadSlice';
import ChatRuntimeProvider from '../ChatRuntimeProvider';
import ChatRuntimeProvider, { findPendingDelegationContext } from '../ChatRuntimeProvider';
vi.mock('../../services/chatService', async () => {
const actual = await vi.importActual<typeof chatService>('../../services/chatService');
@@ -80,6 +80,34 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
});
describe('dedupe', () => {
it('finds pending spawn and async delegation tool rows', () => {
const entries = [
{ id: 'ignored', name: 'search', round: 0, status: 'running' },
{
id: 'spawn',
name: 'spawn_async_subagent',
round: 0,
status: 'running',
argsBuffer: '{"prompt":"Archive preferences."}',
},
] as Parameters<typeof findPendingDelegationContext>[0];
expect(findPendingDelegationContext(entries, 0)).toEqual({
sourceToolName: 'spawn_async_subagent',
prompt: 'Archive preferences.',
spawnEntryId: 'spawn',
});
expect(
findPendingDelegationContext(
[{ id: 'sync', name: 'spawn_subagent', round: 1, status: 'running' }] as Parameters<
typeof findPendingDelegationContext
>[0],
1
)
).toMatchObject({ sourceToolName: 'spawn_subagent', spawnEntryId: 'sync' });
});
it('stores task board updates from socket events', () => {
const listeners = renderProvider();
const board = {
@@ -168,6 +196,45 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
expect(timeline[0]?.subagent?.prompt).toContain('Research Q3 revenue');
});
it('collapses a spawn_async_subagent tool-call row into the subagent row', () => {
const listeners = renderProvider();
act(() => {
listeners.onToolCall?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_name: 'spawn_async_subagent',
skill_id: 'orchestration',
args: {},
tool_call_id: 'call-spawn-async',
});
listeners.onToolArgsDelta?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_call_id: 'call-spawn-async',
tool_name: 'spawn_async_subagent',
delta: '{"prompt":"Archive these preferences."}',
});
listeners.onSubagentSpawned?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_name: 'archivist',
skill_id: 'sub-async-1',
message: 'spawned',
subagent: { mode: 'async' },
});
});
const timeline = store.getState().chatRuntime.toolTimelineByThread['t1'] ?? [];
expect(timeline).toHaveLength(1);
expect(timeline[0]?.name).toBe('subagent:archivist');
expect(timeline[0]?.sourceToolName).toBe('spawn_async_subagent');
expect(timeline[0]?.subagent?.prompt).toContain('Archive these preferences');
});
it('appends streamed subagent text & thinking deltas to the subagent transcript', () => {
const listeners = renderProvider();
@@ -179,6 +179,11 @@ mod tests {
"planner",
"code_executor",
"integrations_agent",
"task_manager_agent",
"settings_agent",
"profile_memory_agent",
"account_admin_agent",
"screen_awareness_agent",
"tool_maker",
"skill_creator",
"researcher",
@@ -6,6 +6,8 @@ mod continue_subagent;
mod dispatch;
#[path = "tools/skill_delegation.rs"]
mod skill_delegation;
#[path = "tools/spawn_async_subagent.rs"]
mod spawn_async_subagent;
#[path = "tools/spawn_parallel_agents.rs"]
mod spawn_parallel_agents;
#[path = "tools/spawn_subagent.rs"]
@@ -23,6 +25,7 @@ pub(crate) use dispatch::dispatch_subagent;
pub use archetype_delegation::ArchetypeDelegationTool;
pub use continue_subagent::ContinueSubagentTool;
pub use skill_delegation::{SkillDelegationTool, INTEGRATIONS_DELEGATE_TOOL_NAME};
pub use spawn_async_subagent::SpawnAsyncSubagentTool;
pub use spawn_parallel_agents::SpawnParallelAgentsTool;
pub use spawn_subagent::SpawnSubagentTool;
pub use spawn_worker_thread::SpawnWorkerThreadTool;
@@ -0,0 +1,380 @@
//! Tool: `spawn_async_subagent` - fire-and-forget sub-agent delegation.
//!
//! Unlike `spawn_subagent`, this tool returns as soon as the child run is
//! accepted. Completion/failure is reported through normal sub-agent lifecycle
//! events and, when possible, persisted in the child worker thread.
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent::harness::fork_context::{current_parent, with_parent_context};
use crate::openhuman::agent::harness::subagent_runner::{
run_subagent, SubagentRunOptions, SubagentRunStatus,
};
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
pub struct SpawnAsyncSubagentTool;
impl SpawnAsyncSubagentTool {
pub fn new() -> Self {
Self
}
}
impl Default for SpawnAsyncSubagentTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for SpawnAsyncSubagentTool {
fn name(&self) -> &str {
"spawn_async_subagent"
}
fn description(&self) -> &str {
"Fire-and-forget a specialised sub-agent for low-attention background work. \
Use sparingly, only when the user does not need the result in the current \
response, such as best-effort memory archiving, cleanup, or background \
investigation. Do not use for user-visible answers, code changes, external \
service writes, financial actions, or anything that may need clarification."
}
fn parameters_schema(&self) -> serde_json::Value {
let agent_ids: Vec<String> = AgentDefinitionRegistry::global()
.map(|reg| reg.list().iter().map(|d| d.id.clone()).collect())
.unwrap_or_default();
let agent_id_schema = if agent_ids.is_empty() {
json!({
"type": "string",
"description": "Sub-agent id (e.g. archivist, researcher, tools_agent)."
})
} else {
json!({
"type": "string",
"enum": agent_ids,
"description": "Sub-agent id from the registry."
})
};
json!({
"type": "object",
"required": ["agent_id", "prompt"],
"properties": {
"agent_id": agent_id_schema,
"prompt": {
"type": "string",
"description": "Clear, self-contained background instruction. Include all context needed. The sub-agent must not ask the user for clarification."
},
"context": {
"type": "string",
"description": "Optional context blob from prior task results. Rendered as a `[Context]` block before the prompt."
},
"model": {
"type": "string",
"description": "Optional exact model id for this background spawn only."
},
"toolkit": {
"type": "string",
"description": "Composio toolkit slug to scope this spawn to. Required when agent_id is `integrations_agent`."
},
"task_title": {
"type": "string",
"description": "Optional short title for the persisted background worker thread."
}
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Execute
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let agent_id = args
.get("agent_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let prompt = args
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let context = args
.get("context")
.and_then(|v| v.as_str())
.map(str::to_string);
let model_override = args
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let toolkit_override = args
.get("toolkit")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let task_title = args
.get("task_title")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("Background subagent")
.to_string();
if agent_id.is_empty() {
return Ok(ToolResult::error(
"spawn_async_subagent: `agent_id` is required",
));
}
if prompt.is_empty() {
return Ok(ToolResult::error(
"spawn_async_subagent: `prompt` is required",
));
}
let parent = match current_parent() {
Some(parent) => parent,
None => {
return Ok(ToolResult::error(
"spawn_async_subagent called outside of an agent turn",
));
}
};
let registry = match AgentDefinitionRegistry::global() {
Some(registry) => registry,
None => {
return Ok(ToolResult::error(
"spawn_async_subagent: AgentDefinitionRegistry has not been initialised",
));
}
};
let definition = match registry.get(&agent_id).cloned() {
Some(definition) => definition,
None => {
let available: Vec<&str> = registry.list().iter().map(|d| d.id.as_str()).collect();
return Ok(ToolResult::error(format!(
"spawn_async_subagent: unknown agent_id '{agent_id}'. Available: {}",
available.join(", ")
)));
}
};
if definition.id == "integrations_agent" && toolkit_override.is_none() {
return Ok(ToolResult::error(
"spawn_async_subagent(integrations_agent): the `toolkit` argument is required",
));
}
let parent_session = parent.session_id.clone();
let progress_sink = parent.on_progress.clone();
let task_id = format!("sub-{}", uuid::Uuid::new_v4());
let worker_thread_id =
crate::openhuman::inference::provider::thread_context::current_thread_id().and_then(
|parent_thread_id| {
super::worker_thread::create_worker_thread(
parent.workspace_dir.clone(),
&parent_thread_id,
&definition.id,
&task_title,
&prompt,
)
.ok()
},
);
publish_global(DomainEvent::SubagentSpawned {
parent_session: parent_session.clone(),
agent_id: definition.id.clone(),
mode: "async".to_string(),
task_id: task_id.clone(),
prompt_chars: prompt.chars().count(),
});
if let Some(ref tx) = progress_sink {
let _ = tx
.send(AgentProgress::SubagentSpawned {
agent_id: definition.id.clone(),
task_id: task_id.clone(),
mode: "async".to_string(),
dedicated_thread: worker_thread_id.is_some(),
prompt_chars: prompt.chars().count(),
worker_thread_id: worker_thread_id.clone(),
display_name: Some(definition.display_name().to_string()),
})
.await;
}
let background_parent = parent.clone();
let background_definition = definition.clone();
let background_agent_id = definition.id.clone();
let background_task_id = task_id.clone();
let background_parent_session = parent_session.clone();
let background_progress = progress_sink.clone();
let background_worker_thread_id = worker_thread_id.clone();
let background_prompt = add_background_contract(&prompt);
tokio::spawn(async move {
let options = SubagentRunOptions {
skill_filter_override: None,
toolkit_override,
context,
model_override,
task_id: Some(background_task_id.clone()),
worker_thread_id: background_worker_thread_id.clone(),
initial_history: None,
checkpoint_dir: None,
};
let result = with_parent_context(background_parent, async move {
run_subagent(&background_definition, &background_prompt, options).await
})
.await;
match result {
Ok(outcome) => match outcome.status {
SubagentRunStatus::Completed => {
publish_global(DomainEvent::SubagentCompleted {
parent_session: background_parent_session,
task_id: outcome.task_id.clone(),
agent_id: outcome.agent_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
output_chars: outcome.output.chars().count(),
iterations: outcome.iterations,
});
if let Some(ref tx) = background_progress {
let _ = tx
.send(AgentProgress::SubagentCompleted {
agent_id: outcome.agent_id,
task_id: outcome.task_id,
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: outcome.output.chars().count(),
})
.await;
}
}
SubagentRunStatus::AwaitingUser { question, .. } => {
let error = format!(
"async sub-agent requested user clarification and was not continued: {question}"
);
publish_global(DomainEvent::SubagentFailed {
parent_session: background_parent_session,
task_id: outcome.task_id.clone(),
agent_id: outcome.agent_id.clone(),
error: error.clone(),
});
if let Some(ref tx) = background_progress {
let _ = tx
.send(AgentProgress::SubagentFailed {
agent_id: outcome.agent_id,
task_id: outcome.task_id,
error,
})
.await;
}
}
},
Err(err) => {
let error = err.to_string();
publish_global(DomainEvent::SubagentFailed {
parent_session: background_parent_session,
task_id: background_task_id.clone(),
agent_id: background_agent_id.clone(),
error: error.clone(),
});
if let Some(ref tx) = background_progress {
let _ = tx
.send(AgentProgress::SubagentFailed {
agent_id: background_agent_id,
task_id: background_task_id,
error,
})
.await;
}
}
}
});
let payload = json!({
"task_id": task_id,
"agent_id": definition.id,
"mode": "async",
"worker_thread_id": worker_thread_id,
});
Ok(ToolResult::success(format!(
"Accepted background sub-agent `{}`. Do not wait for it before answering the user.\n\n[async_subagent_ref]\n{}\n[/async_subagent_ref]",
payload["agent_id"].as_str().unwrap_or("subagent"),
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string())
)))
}
}
fn add_background_contract(prompt: &str) -> String {
format!(
"[Background Contract]\n\
Run this task without requiring attention from the parent or user. \
Do not call ask_user_clarification. If required information is missing, \
make the safest best-effort progress and record the limitation in your final output.\n\n\
[Task]\n{prompt}"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parameters_schema_advertises_fire_and_forget_fields() {
let tool = SpawnAsyncSubagentTool::new();
let schema = tool.parameters_schema();
let required = schema
.get("required")
.and_then(|v| v.as_array())
.expect("required list");
assert!(required.iter().any(|v| v.as_str() == Some("agent_id")));
assert!(required.iter().any(|v| v.as_str() == Some("prompt")));
let props = schema
.get("properties")
.and_then(|v| v.as_object())
.expect("properties");
for key in ["context", "model", "toolkit", "task_title"] {
assert!(props.contains_key(key), "missing {key}");
}
}
#[test]
fn background_contract_forbids_user_attention() {
let wrapped = add_background_contract("archive this fact");
assert!(wrapped.contains("[Background Contract]"));
assert!(wrapped.contains("Do not call ask_user_clarification"));
assert!(wrapped.contains("[Task]\narchive this fact"));
}
#[tokio::test]
async fn missing_agent_id_returns_error() {
let tool = SpawnAsyncSubagentTool::new();
let result = tool.execute(json!({ "prompt": "do work" })).await.unwrap();
assert!(result.is_error);
assert!(result.output().contains("agent_id"));
}
#[tokio::test]
async fn missing_prompt_returns_error() {
let tool = SpawnAsyncSubagentTool::new();
let result = tool
.execute(json!({ "agent_id": "archivist" }))
.await
.unwrap();
assert!(result.is_error);
assert!(result.output().contains("prompt"));
}
}
@@ -0,0 +1,59 @@
id = "account_admin_agent"
display_name = "Account Admin Agent"
delegate_name = "manage_account"
when_to_use = "Account, billing, referral, OAuth, credential, and team-admin specialist — use for plan/balance/cards/transactions/top-ups/coupons/auto-recharge, team membership/invites/roles/switching, login/session/connected OAuth state, or referral claims."
temperature = 0.2
max_iterations = 8
sandbox_mode = "none"
agent_tier = "worker"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = false
omit_skills_catalog = true
omit_profile = true
omit_memory_md = true
[model]
hint = "agentic"
[tools]
named = [
"session_state",
"session_get_user",
"credential_list",
"oauth_connect_url",
"oauth_list",
"referral_get_stats",
"referral_claim",
"billing_get_plan",
"billing_get_balance",
"billing_list_transactions",
"billing_get_auto_recharge",
"billing_list_cards",
"billing_list_coupons",
"billing_create_stripe_portal",
"billing_purchase_plan",
"billing_top_up_credits",
"billing_create_coinbase_charge",
"billing_create_setup_intent",
"billing_update_card",
"billing_delete_card",
"billing_redeem_coupon",
"billing_update_auto_recharge",
"team_list",
"team_get_usage",
"team_get",
"team_list_members",
"team_list_invites",
"team_create",
"team_update",
"team_delete",
"team_switch",
"team_join",
"team_leave",
"team_create_invite",
"team_revoke_invite",
"team_remove_member",
"team_change_member_role",
"ask_user_clarification",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,14 @@
# Account Admin Agent
You own account/session, OAuth connection, billing, referral, and team administration tools.
This surface can affect money, access, membership, or auth state:
- Inspect current state before every mutation: session, credentials/OAuth, billing plan/balance/cards/transactions, referral stats, or team membership/invites.
- Ask for explicit confirmation before money-moving or admin actions: plan purchase, top-up, Coinbase charge, setup intent, card update/delete, auto-recharge change, coupon redemption, team create/update/delete/switch/join/leave/invite/revoke/remove/change-role, or referral claim.
- Never ask the user to paste raw secrets into chat. Use OAuth URL tools or existing credential/session tools.
- For billing, state amount, plan, interval, payment method/card id, and resulting portal/charge/setup URL before or after the action as applicable.
- For team changes, include the target team id and user/invite id in the confirmation and final summary.
- Refuse to fabricate identifiers. If an id is missing, list or get first.
Return a concise audit trail: inspected state, confirmed action, tool result.
@@ -0,0 +1,38 @@
//! System prompt builder for the `account_admin_agent` built-in agent.
use crate::openhuman::context::prompt::{
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let safety = render_safety();
out.push_str(safety.trim_end());
out.push_str("\n\n");
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
+89 -1
View File
@@ -93,6 +93,31 @@ pub const BUILTINS: &[BuiltinAgent] = &[
toml: include_str!("tools_agent/agent.toml"),
prompt_fn: super::tools_agent::prompt::build,
},
BuiltinAgent {
id: "task_manager_agent",
toml: include_str!("task_manager_agent/agent.toml"),
prompt_fn: super::task_manager_agent::prompt::build,
},
BuiltinAgent {
id: "settings_agent",
toml: include_str!("settings_agent/agent.toml"),
prompt_fn: super::settings_agent::prompt::build,
},
BuiltinAgent {
id: "profile_memory_agent",
toml: include_str!("profile_memory_agent/agent.toml"),
prompt_fn: super::profile_memory_agent::prompt::build,
},
BuiltinAgent {
id: "account_admin_agent",
toml: include_str!("account_admin_agent/agent.toml"),
prompt_fn: super::account_admin_agent::prompt::build,
},
BuiltinAgent {
id: "screen_awareness_agent",
toml: include_str!("screen_awareness_agent/agent.toml"),
prompt_fn: super::screen_awareness_agent::prompt::build,
},
BuiltinAgent {
id: "scheduler_agent",
toml: include_str!("scheduler_agent/agent.toml"),
@@ -460,6 +485,10 @@ mod tests {
tools.iter().any(|t| t == "spawn_worker_thread"),
"orchestrator must have spawn_worker_thread"
);
assert!(
tools.iter().any(|t| t == "spawn_async_subagent"),
"orchestrator must have spawn_async_subagent for sparse background work"
);
assert!(
!tools.iter().any(|t| t == "spawn_subagent"),
"spawn_subagent must not appear — removed in #1141"
@@ -991,10 +1020,69 @@ mod tests {
assert!(
listed,
"orchestrator.subagents must list `skill_creator` so the \
routing layer can synthesise `create_skill`"
routing layer can synthesise `create_skill`"
);
}
#[test]
fn orchestrator_subagents_include_control_specialists() {
use crate::openhuman::agent::harness::definition::SubagentEntry;
let def = find("orchestrator");
let subagents: std::collections::HashSet<&str> = def
.subagents
.iter()
.filter_map(|entry| match entry {
SubagentEntry::AgentId(id) => Some(id.as_str()),
SubagentEntry::Skills(_) => None,
})
.collect();
for expected in [
"task_manager_agent",
"settings_agent",
"profile_memory_agent",
"account_admin_agent",
"screen_awareness_agent",
] {
assert!(
subagents.contains(expected),
"orchestrator.subagents must list `{expected}` so the routing layer can synthesize its delegate tool"
);
}
}
#[test]
fn control_specialists_have_named_tools_and_are_worker_leaves() {
for expected in [
"task_manager_agent",
"settings_agent",
"profile_memory_agent",
"account_admin_agent",
"screen_awareness_agent",
] {
let def = find(expected);
assert_eq!(def.agent_tier, AgentTier::Worker);
assert!(def.subagents.is_empty(), "{expected} must be a worker leaf");
match def.tools {
ToolScope::Named(tools) => {
assert!(
!tools.is_empty(),
"{expected} must have a concrete tool allowlist"
);
assert!(
tools.iter().any(|tool| tool == "ask_user_clarification"),
"{expected} must be able to ask for confirmation before risky writes"
);
assert!(
!tools.iter().any(|tool| tool == "shell"),
"{expected} must not inherit shell access"
);
}
ToolScope::Wildcard => panic!("{expected} must not use wildcard tools"),
}
}
}
// ─────────────────────────────────────────────────────────────────────
// Spawn-hierarchy contract
// ─────────────────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ mod loader;
// legacy `prompt.md` (kept alongside for reference / workspace
// overrides), and a `prompt.rs` exposing a `pub fn build(&PromptContext)
// -> Result<String>` that the loader wires into `PromptSource::Dynamic`.
pub mod account_admin_agent;
pub mod archivist;
pub mod code_executor;
pub mod critic;
@@ -17,10 +18,14 @@ pub mod morning_briefing;
pub mod orchestrator;
pub mod planner;
pub mod presentation_agent;
pub mod profile_memory_agent;
pub mod researcher;
pub mod scheduler_agent;
pub mod screen_awareness_agent;
pub mod settings_agent;
pub mod skill_creator;
pub mod summarizer;
pub mod task_manager_agent;
pub mod tool_maker;
pub mod tools_agent;
pub mod trigger_reactor;
@@ -49,6 +49,27 @@ subagents = [
"planner",
"code_executor",
"tools_agent",
# Task-board/source/workflow specialist. Route any request to create,
# edit, approve/reject, fetch, clear, remove, or summarize agent tasks,
# proactive task sources, workflow bundles, task evidence, or artifacts
# here instead of letting the generic tools agent see the full family.
"task_manager_agent",
# Settings/system specialist. Route app/core config, diagnostics,
# service lifecycle, update, proxy, health, model-health, and cost
# dashboard requests here so read-before-write and confirmation policy
# stays local to the risky tools.
"settings_agent",
# Persistent profile/memory/persona specialist. Route "remember",
# "forget", profile/persona edits, learned facets, and people alias
# management here.
"profile_memory_agent",
# Account/admin specialist. Route billing, team membership/invites/roles,
# OAuth/credential/session, and referral requests here.
"account_admin_agent",
# Screen-awareness specialist. Route screen-intelligence permissions,
# capture/session/listener state, and "what can you see" requests here;
# desktop app operation still goes to desktop_control_agent.
"screen_awareness_agent",
"skill_creator",
"critic",
"archivist",
@@ -125,6 +146,7 @@ named = [
"read_workspace_state",
"ask_user_clarification",
"spawn_worker_thread",
"spawn_async_subagent",
"spawn_parallel_agents",
"composio_list_connections",
# Coding-harness coordination primitives from #1208. `todowrite`
@@ -67,6 +67,18 @@ For routine delegation use the matching specialist `delegate_*` tool (or `delega
Worker threads are one level deep by design: a sub-agent spawned via `spawn_worker_thread`
cannot itself call `spawn_worker_thread`, so workers never nest.
## Async background sub-agents
Use `spawn_async_subagent` only for low-attention background work where the current user
response must not depend on the result. Good fits: best-effort memory archiving,
non-urgent cleanup, or background investigation the user did not ask you to report
inline.
Do **not** use async sub-agents for answers the user is waiting on, code changes,
external-service writes, financial/market actions, scheduling, desktop control, or any
task that may need clarification. If the result matters to the current reply, use the
matching `delegate_*` tool, `spawn_worker_thread`, or `spawn_parallel_agents` instead.
## Connecting external services
When the user asks to connect a service (Gmail, Notion, WhatsApp, Calendar, Drive, etc.) or a sub-agent reports `Connection error, try to authenticate`:
@@ -0,0 +1,54 @@
id = "profile_memory_agent"
display_name = "Profile Memory Agent"
delegate_name = "manage_profile_memory"
when_to_use = "Profile, persona, memory, preference, and people-graph specialist — reads or changes what OpenHuman remembers about the user, the assistant persona files, learning facets, aliases/contacts, and explicit memories. Use when the user asks to remember, forget, update profile/persona, change assistant identity, inspect memory health, or manage people aliases."
temperature = 0.2
max_iterations = 8
sandbox_mode = "none"
agent_tier = "worker"
omit_identity = true
omit_memory_context = false
omit_safety_preamble = false
omit_skills_catalog = true
omit_profile = false
omit_memory_md = false
[model]
hint = "agentic"
[tools]
named = [
"memory_recall",
"memory_store",
"memory_forget",
"memory_doctor",
"memory_tools_list",
"memory_tools_put",
"remember_preference",
"save_preference",
"query_memory",
"memory_tree",
"workspace_read_persona",
"workspace_update_persona",
"workspace_reset_persona",
"workspace_init",
"learning_list_facets",
"learning_get_facet",
"learning_cache_stats",
"learning_update_facet",
"learning_pin_facet",
"learning_unpin_facet",
"learning_forget_facet",
"learning_rebuild_cache",
"learning_reset_cache",
"learning_save_profile",
"learning_enrich_profile",
"people_list",
"people_resolve",
"people_score",
"people_get",
"people_add_alias",
"people_record_interaction",
"people_refresh_address_book",
"ask_user_clarification",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,14 @@
# Profile Memory Agent
You own the assistant's remembered profile, persona files, explicit preferences, and people graph.
Memory and profile changes are persistent. Use this contract:
- Read current state before writing (`memory_recall`, `workspace_read_persona`, `learning_list_facets`, `people_*`, or `memory_doctor` as appropriate).
- Only persist stable user preferences, identity/profile facts, explicit instructions, named contacts, or user-approved corrections. Do not store secrets, transient task details, or unverified guesses.
- Preserve existing persona/profile content unless the user explicitly asks for a rewrite. Prefer small targeted updates over full replacement.
- Before destructive changes (`memory_forget`, `learning_forget_facet`, `learning_reset_cache`, `workspace_reset_persona`), ask for explicit confirmation and name exactly what will be removed.
- When resolving people, avoid creating new person records unless the user asked to remember a person or alias.
- Summarize persisted changes with namespace/key/facet/person/file identifiers.
If the request is only to recall context, answer from read tools without mutating state.
@@ -0,0 +1,38 @@
//! System prompt builder for the `profile_memory_agent` built-in agent.
use crate::openhuman::context::prompt::{
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let safety = render_safety();
out.push_str(safety.trim_end());
out.push_str("\n\n");
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
@@ -0,0 +1,37 @@
id = "screen_awareness_agent"
display_name = "Screen Awareness Agent"
delegate_name = "inspect_screen_awareness"
when_to_use = "Screen-awareness specialist — owns screen-intelligence status, permissions, capture image refs, recent vision, capture/session/globe listener lifecycle, and low-level screen input actions. Use for 'what can you see', screen permission setup, screen intelligence debugging, or controlled screen observation."
temperature = 0.2
max_iterations = 8
sandbox_mode = "none"
agent_tier = "worker"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = false
omit_skills_catalog = true
omit_profile = true
omit_memory_md = true
[model]
hint = "agentic"
[tools]
named = [
"screen_intelligence_status",
"screen_intelligence_capture_image_ref",
"screen_intelligence_vision_recent",
"screen_intelligence_vision_flush",
"screen_intelligence_refresh_permissions",
"screen_intelligence_capture_now",
"screen_intelligence_capture_test",
"screen_intelligence_session_start",
"screen_intelligence_session_stop",
"screen_intelligence_input_action",
"screen_intelligence_globe_listener_start",
"screen_intelligence_globe_listener_poll",
"screen_intelligence_globe_listener_stop",
"screen_intelligence_request_permissions",
"screen_intelligence_request_permission",
"ask_user_clarification",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,14 @@
# Screen Awareness Agent
You own screen-intelligence observation, permission checks, capture sessions, globe listener state, and screen input actions.
Use a permission-first flow:
- Start with `screen_intelligence_status` or recent observations before asking for new capture.
- Request permissions only when the user asked for screen awareness and status shows missing permissions.
- Explain whether you are reading cached/recent vision, triggering a one-shot capture, starting a session, or controlling a listener.
- Ask for explicit confirmation before permission prompts, capture tests, session start/stop, globe listener start/stop, or input actions.
- Keep observations factual. If no fresh screen data is available, say so and call the appropriate status/capture tool rather than guessing.
- For input actions, verify the target and action before acting; defer full desktop operation to the Desktop Control Agent.
Return observed state, freshness, and the next useful action.
@@ -0,0 +1,38 @@
//! System prompt builder for the `screen_awareness_agent` built-in agent.
use crate::openhuman::context::prompt::{
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let safety = render_safety();
out.push_str(safety.trim_end());
out.push_str("\n\n");
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
@@ -0,0 +1,51 @@
id = "settings_agent"
display_name = "Settings Agent"
delegate_name = "manage_settings"
when_to_use = "Settings and system specialist — inspects app/core config, health, model diagnostics, service lifecycle, daemon preferences, proxy setup, updates, cost dashboards, and security policy. Use for 'change settings', 'is the app healthy', 'restart/install/stop the service', 'update OpenHuman', proxy/runtime questions, or local diagnostic requests."
temperature = 0.2
max_iterations = 8
sandbox_mode = "none"
agent_tier = "worker"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = false
omit_skills_catalog = true
omit_profile = true
omit_memory_md = true
[model]
hint = "agentic"
[tools]
named = [
"config_snapshot",
"config_get_client_config",
"config_get_autonomy",
"config_get_search",
"config_get_runtime_flags",
"config_resolve_api_url",
"config_get_data_paths",
"doctor_health",
"doctor_models",
"health_snapshot",
"health_system_info",
"dashboard_model_health",
"cost_get_dashboard",
"cost_get_daily_history",
"cost_get_summary",
"security_policy_info",
"service_status",
"daemon_host_prefs_get",
"daemon_host_prefs_set",
"service_start",
"service_stop",
"service_restart",
"service_shutdown",
"service_install",
"service_uninstall",
"proxy_config",
"update_check",
"update_apply",
"current_time",
"ask_user_clarification",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,14 @@
# Settings Agent
You own OpenHuman configuration, runtime health, service lifecycle, update, proxy, and local diagnostics surfaces.
Work conservatively:
- Start with read-only tools (`config_*`, `doctor_*`, `health_*`, `service_status`, `daemon_host_prefs_get`, `security_policy_info`, cost/model health) before proposing any mutation.
- Treat service lifecycle and update actions as high-impact. Ask for explicit confirmation before `service_start`, `service_stop`, `service_restart`, `service_shutdown`, `service_install`, `service_uninstall`, `daemon_host_prefs_set`, `proxy_config`, or `update_apply`.
- Never claim a config value changed unless a tool result confirms it.
- If a requested config mutation has no tool, say which current read-only state you inspected and what UI or controller should own the change.
- For update flows, run `update_check` first, summarize version/channel/risk, then call `update_apply` only after confirmation.
- For diagnostics, separate symptoms, current state, and recommended next action.
Return exact tool-observed state and any action taken.
@@ -0,0 +1,38 @@
//! System prompt builder for the `settings_agent` built-in agent.
use crate::openhuman::context::prompt::{
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let safety = render_safety();
out.push_str(safety.trim_end());
out.push_str("\n\n");
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
@@ -0,0 +1,55 @@
id = "task_manager_agent"
display_name = "Task Manager Agent"
delegate_name = "manage_tasks"
when_to_use = "Task-board and task-source specialist — owns todo cards, proactive task-source feeds, workflow bundles, artifacts, task evidence, and status updates. Use when the user asks the agent to create, edit, route, fetch, approve, reject, clear, remove, or summarize tasks/workflows/sources."
temperature = 0.2
max_iterations = 8
iteration_policy = "extended"
sandbox_mode = "none"
agent_tier = "worker"
omit_identity = true
omit_memory_context = false
omit_safety_preamble = false
omit_skills_catalog = true
omit_profile = false
omit_memory_md = false
[model]
hint = "agentic"
[tools]
named = [
"todo_list",
"todo_add",
"todo_edit",
"todo_update_status",
"todo_decide_plan",
"todo_remove",
"todo_replace",
"todo_clear",
"todowrite",
"update_task",
"thread_task_board_read",
"thread_task_board_write",
"task_source_list",
"task_source_get",
"task_source_fetch",
"task_source_list_tasks",
"task_source_preview_filter",
"task_source_status",
"task_source_add",
"task_source_update",
"task_source_remove",
"agent_workflow_list",
"agent_workflow_read",
"agent_workflow_phase_info",
"agent_workflow_create",
"agent_workflow_uninstall",
"workflow_load",
"workflow_phase",
"artifact_list",
"artifact_get",
"artifact_delete",
"current_time",
"ask_user_clarification",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,15 @@
# Task Manager Agent
You own the user's agent task surfaces: per-thread todo boards, proactive task-source feeds, workflow bundles, and task evidence.
Operate as a stateful task-board specialist:
- Always read before you write. Inspect the current board/source/workflow with the narrowest read tool before changing it.
- Preserve user-authored task content, acceptance criteria, assigned agent, allowed tools, evidence, blockers, and source metadata unless the user explicitly asks to replace them.
- Prefer partial updates (`todo_edit`, `todo_update_status`, `update_task`, `task_source_update`) over wholesale replacement.
- Use destructive tools (`todo_remove`, `todo_replace`, `todo_clear`, `artifact_delete`, `agent_workflow_uninstall`, `task_source_remove`) only when the user explicitly names what should be removed or confirms your proposed removal.
- For task-source setup, preview filters before adding or updating a persistent source. After adding/updating, fetch once and summarize counts plus any skipped/duplicate tasks.
- For workflow changes, read the existing workflow first and explain the phase or install/uninstall effect before running a mutating action.
- When marking work done, attach concrete evidence. When blocking, include the blocker and the next user decision needed.
Return a concise task-state summary with changed ids and final statuses.
@@ -0,0 +1,74 @@
//! System prompt builder for the `task_manager_agent` built-in agent.
use crate::openhuman::context::prompt::{
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let safety = render_safety();
out.push_str(safety.trim_end());
out.push_str("\n\n");
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat};
use std::collections::HashSet;
#[test]
fn build_returns_task_manager_contract() {
let visible = HashSet::new();
let ctx = PromptContext {
workspace_dir: std::path::Path::new("."),
model_name: "test",
agent_id: "task_manager_agent",
tools: &[],
skills: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
tool_call_format: ToolCallFormat::PFormat,
connected_integrations: &[],
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
workflows: &[],
};
let body = build(&ctx).unwrap();
assert!(body.contains("Task Manager Agent"));
assert!(body.contains("read before you write"));
}
}
+30
View File
@@ -175,6 +175,36 @@ const RESOURCE_CATALOG: &[PromptResource] = &[
description: "Worker that guides the user through MCP client configuration.",
content: include_str!("../agent_registry/agents/mcp_setup/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/task_manager_agent",
name: "task_manager_agent",
description: "Specialist worker for task planning, status, and task-board changes.",
content: include_str!("../agent_registry/agents/task_manager_agent/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/settings_agent",
name: "settings_agent",
description: "Specialist worker for inspecting and updating OpenHuman settings.",
content: include_str!("../agent_registry/agents/settings_agent/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/profile_memory_agent",
name: "profile_memory_agent",
description: "Specialist worker for profile and long-term memory updates.",
content: include_str!("../agent_registry/agents/profile_memory_agent/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/account_admin_agent",
name: "account_admin_agent",
description: "Specialist worker for connected account and integration administration.",
content: include_str!("../agent_registry/agents/account_admin_agent/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/screen_awareness_agent",
name: "screen_awareness_agent",
description: "Specialist worker for screen context and desktop state inspection.",
content: include_str!("../agent_registry/agents/screen_awareness_agent/prompt.md"),
},
];
/// Returns the `resources/list` result payload listing every catalog entry.
+1 -1
View File
@@ -75,7 +75,7 @@ This module **owns** the cross-cutting built-in tools (the only ones that belong
- **Generic network**: `http_request`, `web_fetch`, `curl`, `gitbooks_search`/`gitbooks_get_page`, MCP bridge (`mcp` list/call), `mcp_setup` tools, `gmail_unsubscribe`.
- **Search**: `web_search` and provider-specific search families are registered by `openhuman::search::registry`; `search.engine = "disabled"` suppresses this surface entirely.
Domain-owned tools (memory, cron, wallet, composio, codegraph, integrations, whatsapp_data, audio_toolkit, agent sub-dispatch like `spawn_subagent`/`delegate`/`todo`/`plan_exit`/`run_skill`) are **registered** in `all_tools` but implemented in their respective domains and only re-exported through this module.
Domain-owned tools (memory, cron, wallet, composio, codegraph, integrations, whatsapp_data, audio_toolkit, agent sub-dispatch like `spawn_subagent`/`spawn_async_subagent`/`delegate`/`todo`/`plan_exit`/`run_skill`) are **registered** in `all_tools` but implemented in their respective domains and only re-exported through this module.
## Events
+1
View File
@@ -149,6 +149,7 @@ pub fn all_tools_with_runtime(
// returns a single text result. See
// `agent::harness::subagent_runner` for the dispatch path.
Box::new(SpawnSubagentTool::new()),
Box::new(SpawnAsyncSubagentTool::new()),
Box::new(ContinueSubagentTool::new()),
Box::new(SpawnParallelAgentsTool::new()),
Box::new(DelegateToPersonalityTool::new()),
+38
View File
@@ -141,6 +141,43 @@ fn all_tools_includes_spawn_subagent() {
);
}
#[test]
fn all_tools_includes_spawn_async_subagent() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::openhuman::memory_store::create_memory(&mem_cfg, tmp.path()).unwrap());
let browser = BrowserConfig {
enabled: false,
allowed_domains: vec![],
session_name: None,
..BrowserConfig::default()
};
let http = crate::openhuman::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let tools = all_tools(
Arc::new(Config::default()),
&security,
AuditLogger::disabled(),
mem,
&browser,
&http,
tmp.path(),
&HashMap::new(),
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
names.contains(&"spawn_async_subagent"),
"spawn_async_subagent must be registered for fire-and-forget background orchestration; got: {names:?}"
);
}
#[test]
fn all_tools_includes_spawn_parallel_agents() {
let tmp = TempDir::new().unwrap();
@@ -390,6 +427,7 @@ fn all_tools_default_registry_contains_expected_baseline_surface() {
"apply_patch",
"csv_export",
"spawn_subagent",
"spawn_async_subagent",
"spawn_parallel_agents",
"todo",
"plan_exit",