mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): add control specialists and async subagent spawning (#3379)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user