From e075320d8343b03f62ad5aa5b3018b8a0152042e Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:42:15 -0400 Subject: [PATCH] feat(agent): add control specialists and async subagent spawning (#3379) --- app/src/providers/ChatRuntimeProvider.tsx | 41 +- .../__tests__/ChatRuntimeProvider.test.tsx | 69 +++- .../agent/harness/builtin_definitions.rs | 5 + src/openhuman/agent_orchestration/tools.rs | 3 + .../tools/spawn_async_subagent.rs | 380 ++++++++++++++++++ .../agents/account_admin_agent/agent.toml | 59 +++ .../agents/account_admin_agent/mod.rs | 1 + .../agents/account_admin_agent/prompt.md | 14 + .../agents/account_admin_agent/prompt.rs | 38 ++ src/openhuman/agent_registry/agents/loader.rs | 90 ++++- src/openhuman/agent_registry/agents/mod.rs | 5 + .../agents/orchestrator/agent.toml | 22 + .../agents/orchestrator/prompt.md | 12 + .../agents/profile_memory_agent/agent.toml | 54 +++ .../agents/profile_memory_agent/mod.rs | 1 + .../agents/profile_memory_agent/prompt.md | 14 + .../agents/profile_memory_agent/prompt.rs | 38 ++ .../agents/screen_awareness_agent/agent.toml | 37 ++ .../agents/screen_awareness_agent/mod.rs | 1 + .../agents/screen_awareness_agent/prompt.md | 14 + .../agents/screen_awareness_agent/prompt.rs | 38 ++ .../agents/settings_agent/agent.toml | 51 +++ .../agents/settings_agent/mod.rs | 1 + .../agents/settings_agent/prompt.md | 14 + .../agents/settings_agent/prompt.rs | 38 ++ .../agents/task_manager_agent/agent.toml | 55 +++ .../agents/task_manager_agent/mod.rs | 1 + .../agents/task_manager_agent/prompt.md | 15 + .../agents/task_manager_agent/prompt.rs | 74 ++++ src/openhuman/mcp_server/resources.rs | 30 ++ src/openhuman/tools/README.md | 2 +- src/openhuman/tools/ops.rs | 1 + src/openhuman/tools/ops_tests.rs | 38 ++ 33 files changed, 1234 insertions(+), 22 deletions(-) create mode 100644 src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs create mode 100644 src/openhuman/agent_registry/agents/account_admin_agent/agent.toml create mode 100644 src/openhuman/agent_registry/agents/account_admin_agent/mod.rs create mode 100644 src/openhuman/agent_registry/agents/account_admin_agent/prompt.md create mode 100644 src/openhuman/agent_registry/agents/account_admin_agent/prompt.rs create mode 100644 src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml create mode 100644 src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs create mode 100644 src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md create mode 100644 src/openhuman/agent_registry/agents/profile_memory_agent/prompt.rs create mode 100644 src/openhuman/agent_registry/agents/screen_awareness_agent/agent.toml create mode 100644 src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs create mode 100644 src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.md create mode 100644 src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.rs create mode 100644 src/openhuman/agent_registry/agents/settings_agent/agent.toml create mode 100644 src/openhuman/agent_registry/agents/settings_agent/mod.rs create mode 100644 src/openhuman/agent_registry/agents/settings_agent/prompt.md create mode 100644 src/openhuman/agent_registry/agents/settings_agent/prompt.rs create mode 100644 src/openhuman/agent_registry/agents/task_manager_agent/agent.toml create mode 100644 src/openhuman/agent_registry/agents/task_manager_agent/mod.rs create mode 100644 src/openhuman/agent_registry/agents/task_manager_agent/prompt.md create mode 100644 src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index ce031c69e..616253860 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -156,6 +156,27 @@ function chatDoneExtraMetadata(event: ChatDoneEvent): Record | 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 diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 0c5835397..932703631 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -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('../../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[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(); diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index ebef49d20..b4417dede 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -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", diff --git a/src/openhuman/agent_orchestration/tools.rs b/src/openhuman/agent_orchestration/tools.rs index c77259de3..5ec5c3d93 100644 --- a/src/openhuman/agent_orchestration/tools.rs +++ b/src/openhuman/agent_orchestration/tools.rs @@ -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; diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs new file mode 100644 index 000000000..a88fedb99 --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -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 = 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 { + 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")); + } +} diff --git a/src/openhuman/agent_registry/agents/account_admin_agent/agent.toml b/src/openhuman/agent_registry/agents/account_admin_agent/agent.toml new file mode 100644 index 000000000..f1b9c5075 --- /dev/null +++ b/src/openhuman/agent_registry/agents/account_admin_agent/agent.toml @@ -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", +] diff --git a/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs b/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/account_admin_agent/prompt.md b/src/openhuman/agent_registry/agents/account_admin_agent/prompt.md new file mode 100644 index 000000000..bce067f2f --- /dev/null +++ b/src/openhuman/agent_registry/agents/account_admin_agent/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/account_admin_agent/prompt.rs b/src/openhuman/agent_registry/agents/account_admin_agent/prompt.rs new file mode 100644 index 000000000..77b51bf4d --- /dev/null +++ b/src/openhuman/agent_registry/agents/account_admin_agent/prompt.rs @@ -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 { + 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) +} diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 2132fadd0..a3f8418fc 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -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 // ───────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent_registry/agents/mod.rs b/src/openhuman/agent_registry/agents/mod.rs index ca1179cb7..f6e6accc9 100644 --- a/src/openhuman/agent_registry/agents/mod.rs +++ b/src/openhuman/agent_registry/agents/mod.rs @@ -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` 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; diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index a70cef059..ac0eea36d 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -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` diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.md b/src/openhuman/agent_registry/agents/orchestrator/prompt.md index b99504800..b6f44447b 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.md +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.md @@ -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`: diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml b/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml new file mode 100644 index 000000000..1f0ece4e7 --- /dev/null +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml @@ -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", +] diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs b/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md b/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md new file mode 100644 index 000000000..1f38cb6ac --- /dev/null +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.rs b/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.rs new file mode 100644 index 000000000..5cb887ee5 --- /dev/null +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.rs @@ -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 { + 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) +} diff --git a/src/openhuman/agent_registry/agents/screen_awareness_agent/agent.toml b/src/openhuman/agent_registry/agents/screen_awareness_agent/agent.toml new file mode 100644 index 000000000..07e4e2206 --- /dev/null +++ b/src/openhuman/agent_registry/agents/screen_awareness_agent/agent.toml @@ -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", +] diff --git a/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs b/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.md b/src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.md new file mode 100644 index 000000000..ad59bfacb --- /dev/null +++ b/src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.rs b/src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.rs new file mode 100644 index 000000000..896dfed70 --- /dev/null +++ b/src/openhuman/agent_registry/agents/screen_awareness_agent/prompt.rs @@ -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 { + 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) +} diff --git a/src/openhuman/agent_registry/agents/settings_agent/agent.toml b/src/openhuman/agent_registry/agents/settings_agent/agent.toml new file mode 100644 index 000000000..88e3c3e3f --- /dev/null +++ b/src/openhuman/agent_registry/agents/settings_agent/agent.toml @@ -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", +] diff --git a/src/openhuman/agent_registry/agents/settings_agent/mod.rs b/src/openhuman/agent_registry/agents/settings_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/settings_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/settings_agent/prompt.md b/src/openhuman/agent_registry/agents/settings_agent/prompt.md new file mode 100644 index 000000000..4918d1373 --- /dev/null +++ b/src/openhuman/agent_registry/agents/settings_agent/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/settings_agent/prompt.rs b/src/openhuman/agent_registry/agents/settings_agent/prompt.rs new file mode 100644 index 000000000..7ecadcd63 --- /dev/null +++ b/src/openhuman/agent_registry/agents/settings_agent/prompt.rs @@ -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 { + 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) +} diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/agent.toml b/src/openhuman/agent_registry/agents/task_manager_agent/agent.toml new file mode 100644 index 000000000..e77519eff --- /dev/null +++ b/src/openhuman/agent_registry/agents/task_manager_agent/agent.toml @@ -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", +] diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs b/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/prompt.md b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.md new file mode 100644 index 000000000..277a7f3d5 --- /dev/null +++ b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs new file mode 100644 index 000000000..5dea632b5 --- /dev/null +++ b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs @@ -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 { + 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")); + } +} diff --git a/src/openhuman/mcp_server/resources.rs b/src/openhuman/mcp_server/resources.rs index e3385407d..f9a2272d6 100644 --- a/src/openhuman/mcp_server/resources.rs +++ b/src/openhuman/mcp_server/resources.rs @@ -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. diff --git a/src/openhuman/tools/README.md b/src/openhuman/tools/README.md index 7a1a9b9a5..84584880e 100644 --- a/src/openhuman/tools/README.md +++ b/src/openhuman/tools/README.md @@ -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 diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 8ce5445a2..f83a42470 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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()), diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 76cb93160..36f4a67ae 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -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 = + 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",