mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Claude Opus 4.7
parent
08d5d1a88e
commit
a274adc81e
@@ -6,6 +6,7 @@ export interface Thread {
|
||||
messageCount: number;
|
||||
lastMessageAt: string;
|
||||
createdAt: string;
|
||||
parentThreadId?: string;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ named = [
|
||||
"read_workspace_state",
|
||||
"ask_user_clarification",
|
||||
"spawn_subagent",
|
||||
"spawn_worker_thread",
|
||||
"composio_list_connections",
|
||||
# Time + scheduling — lets the orchestrator answer "what time is it",
|
||||
# "remind me in 10 minutes", "every morning at 8" directly rather than
|
||||
|
||||
@@ -56,7 +56,8 @@ to a sub-agent wastes a turn. Use these directly:
|
||||
| `read_workspace_state` | Get git status + file tree before planning a code task. |
|
||||
| `composio_list_connections` | Check which external integrations (Gmail, Notion, GitHub, …) the user has authorised *right now*. Session-start list may be stale. |
|
||||
| `ask_user_clarification` | Ask one focused question when the request is ambiguous — don't guess. |
|
||||
| `spawn_subagent` | Escape hatch for agent ids not listed in the delegation table above; only use when direct handling is not sufficient. |
|
||||
| `spawn_subagent` | Inline delegation: the sub-agent's work is collapsed into a single result in this thread. Use for quick tasks. |
|
||||
| `spawn_worker_thread` | Dedicated delegation: creates a fresh 'worker' thread for the sub-agent. Use for long, complex, or multi-step tasks to avoid cluttering the parent thread. |
|
||||
|
||||
**Scheduling rule of thumb.** To "remind me in 10 minutes", call `current_time`
|
||||
first. If `cron_add` is available and enabled for this runtime, then call
|
||||
@@ -89,7 +90,7 @@ transcript. For everyday delegation keep `dedicated_thread` off (the default)
|
||||
and surface the result inline.
|
||||
|
||||
Worker threads are one level deep by design: a sub-agent never sees
|
||||
`spawn_subagent`, so a worker cannot itself spawn another worker.
|
||||
`spawn_subagent` or `spawn_worker_thread`, so a worker cannot itself spawn another worker.
|
||||
|
||||
## Connecting external services
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::context::prompt::{
|
||||
render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions,
|
||||
};
|
||||
use crate::openhuman::memory::conversations::ConversationMessage;
|
||||
use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall};
|
||||
use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec};
|
||||
|
||||
@@ -254,7 +255,10 @@ async fn run_typed_mode(
|
||||
// should not list these tools either, but we enforce it here so a
|
||||
// misconfigured TOML can't bypass the rule.
|
||||
let before = allowed_indices.len();
|
||||
allowed_indices.retain(|&i| !is_subagent_spawn_tool(parent.all_tools[i].name()));
|
||||
allowed_indices.retain(|&i| {
|
||||
let name = parent.all_tools[i].name();
|
||||
!is_subagent_spawn_tool(name) && name != "spawn_worker_thread"
|
||||
});
|
||||
let stripped = before - allowed_indices.len();
|
||||
if stripped > 0 {
|
||||
tracing::debug!(
|
||||
@@ -734,6 +738,7 @@ async fn run_typed_mode(
|
||||
definition.max_iterations,
|
||||
task_id,
|
||||
&definition.id,
|
||||
options.worker_thread_id.clone(),
|
||||
handoff_cache.as_deref(),
|
||||
parent,
|
||||
)
|
||||
@@ -813,7 +818,7 @@ async fn run_fork_mode(
|
||||
.all_tools
|
||||
.iter()
|
||||
.map(|t| t.name().to_string())
|
||||
.filter(|name| !is_subagent_spawn_tool(name))
|
||||
.filter(|name| !is_subagent_spawn_tool(name) && name != "spawn_worker_thread")
|
||||
.collect();
|
||||
|
||||
let model = parent.model_name.clone();
|
||||
@@ -840,6 +845,7 @@ async fn run_fork_mode(
|
||||
task_id,
|
||||
&definition.id,
|
||||
None,
|
||||
None,
|
||||
parent,
|
||||
)
|
||||
.await?;
|
||||
@@ -891,6 +897,7 @@ async fn run_inner_loop(
|
||||
max_iterations: usize,
|
||||
task_id: &str,
|
||||
agent_id: &str,
|
||||
worker_thread_id: Option<String>,
|
||||
handoff_cache: Option<&ResultHandoffCache>,
|
||||
parent: &ParentExecutionContext,
|
||||
) -> Result<(String, usize, AggregatedUsage), SubagentRunError> {
|
||||
@@ -1040,6 +1047,32 @@ async fn run_inner_loop(
|
||||
}
|
||||
};
|
||||
|
||||
let append_worker_message =
|
||||
|content: String, sender: String, extra_metadata: serde_json::Value| {
|
||||
if let Some(ref thread_id) = worker_thread_id {
|
||||
let message = ConversationMessage {
|
||||
id: format!("{}:{}", sender, uuid::Uuid::new_v4()),
|
||||
content,
|
||||
message_type: "text".to_string(),
|
||||
extra_metadata,
|
||||
sender,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
if let Err(err) = crate::openhuman::memory::conversations::append_message(
|
||||
parent.workspace_dir.clone(),
|
||||
thread_id,
|
||||
message,
|
||||
) {
|
||||
tracing::debug!(
|
||||
agent_id = %agent_id,
|
||||
thread_id = %thread_id,
|
||||
error = %err,
|
||||
"[subagent_runner] failed to append message to worker thread"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Per-turn progress sink shared with the parent — `None` for runs
|
||||
// that don't have a subscriber (CLI / triage / tests). Cloned upfront
|
||||
// so the inner loop body doesn't repeatedly re-resolve `parent.on_progress`.
|
||||
@@ -1126,6 +1159,17 @@ async fn run_inner_loop(
|
||||
"[subagent_runner] no tool calls — returning final response"
|
||||
);
|
||||
history.push(ChatMessage::assistant(response_text.clone()));
|
||||
append_worker_message(
|
||||
response_text.clone(),
|
||||
"agent".to_string(),
|
||||
serde_json::json!({
|
||||
"scope": "worker_thread",
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"iteration": iteration + 1,
|
||||
"final": true,
|
||||
}),
|
||||
);
|
||||
// Persist the final response before returning so the
|
||||
// transcript always captures the last provider reply.
|
||||
persist_transcript(history, &usage);
|
||||
@@ -1146,6 +1190,18 @@ async fn run_inner_loop(
|
||||
history.push(ChatMessage::assistant(assistant_history_content));
|
||||
}
|
||||
|
||||
append_worker_message(
|
||||
response_text.clone(),
|
||||
"agent".to_string(),
|
||||
serde_json::json!({
|
||||
"scope": "worker_thread",
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"iteration": iteration + 1,
|
||||
"tool_calls": native_calls.len(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Persist the assistant response + tool-call intents **before**
|
||||
// executing tools. If the session crashes mid-tool-call we
|
||||
// still have what the model emitted on disk.
|
||||
@@ -1309,9 +1365,21 @@ async fn run_inner_loop(
|
||||
} else {
|
||||
let tool_msg = serde_json::json!({
|
||||
"tool_call_id": call.id,
|
||||
"content": result_text,
|
||||
"content": result_text.clone(),
|
||||
});
|
||||
history.push(ChatMessage::tool(tool_msg.to_string()));
|
||||
append_worker_message(
|
||||
result_text.clone(),
|
||||
"user".to_string(),
|
||||
serde_json::json!({
|
||||
"scope": "worker_thread",
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"iteration": iteration + 1,
|
||||
"tool_call_id": call.id,
|
||||
"tool_name": call.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref tx) = progress_sink {
|
||||
@@ -1331,9 +1399,19 @@ async fn run_inner_loop(
|
||||
}
|
||||
|
||||
if force_text_mode && !text_mode_result_block.is_empty() {
|
||||
history.push(ChatMessage::user(format!(
|
||||
"[Tool results]\n{text_mode_result_block}"
|
||||
)));
|
||||
let content = format!("[Tool results]\n{text_mode_result_block}");
|
||||
history.push(ChatMessage::user(content.clone()));
|
||||
append_worker_message(
|
||||
content,
|
||||
"user".to_string(),
|
||||
serde_json::json!({
|
||||
"scope": "worker_thread",
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"iteration": iteration + 1,
|
||||
"mode": "text",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Persist again after tool results have been appended so the
|
||||
|
||||
@@ -334,6 +334,7 @@ async fn typed_mode_returns_text_through_runner() {
|
||||
toolkit_override: None,
|
||||
context: None,
|
||||
task_id: Some("t1".into()),
|
||||
worker_thread_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -439,6 +440,7 @@ async fn typed_mode_filters_tools_by_skill_filter() {
|
||||
toolkit_override: None,
|
||||
context: None,
|
||||
task_id: None,
|
||||
worker_thread_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -31,6 +31,11 @@ pub struct SubagentRunOptions {
|
||||
|
||||
/// Stable id for tracing / DomainEvents (defaults to a UUID).
|
||||
pub task_id: Option<String>,
|
||||
|
||||
/// Optional thread ID for persistent worker threads. When set,
|
||||
/// every assistant message and tool result in the inner loop is
|
||||
/// appended to this thread in the global ConversationStore.
|
||||
pub worker_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Outcome of a single sub-agent run, returned to the parent.
|
||||
|
||||
@@ -173,6 +173,7 @@ fn persist_channel_turn(
|
||||
id: thread_id.clone(),
|
||||
title,
|
||||
created_at: created_at.clone(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["work".to_string()]),
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -57,6 +57,8 @@ enum ThreadLogEntry {
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
parent_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
labels: Option<Vec<String>>,
|
||||
},
|
||||
Delete {
|
||||
@@ -87,6 +89,7 @@ impl ConversationStore {
|
||||
title: request.title.clone(),
|
||||
created_at: request.created_at.clone(),
|
||||
updated_at: now,
|
||||
parent_thread_id: request.parent_thread_id.clone(),
|
||||
labels: request.labels.clone(),
|
||||
},
|
||||
)?;
|
||||
@@ -159,6 +162,7 @@ impl ConversationStore {
|
||||
title: title.to_string(),
|
||||
created_at: entry.created_at.clone(),
|
||||
updated_at: updated_at.to_string(),
|
||||
parent_thread_id: entry.parent_thread_id.clone(),
|
||||
labels: Some(entry.labels.clone()),
|
||||
},
|
||||
)?;
|
||||
@@ -192,6 +196,7 @@ impl ConversationStore {
|
||||
title: entry.title.clone(),
|
||||
created_at: entry.created_at.clone(),
|
||||
updated_at: updated_at.to_string(),
|
||||
parent_thread_id: entry.parent_thread_id.clone(),
|
||||
labels: Some(labels),
|
||||
},
|
||||
)?;
|
||||
@@ -352,6 +357,7 @@ impl ConversationStore {
|
||||
message_count,
|
||||
last_message_at,
|
||||
created_at: entry.created_at.clone(),
|
||||
parent_thread_id: entry.parent_thread_id.clone(),
|
||||
labels: entry.labels.clone(),
|
||||
}))
|
||||
}
|
||||
@@ -370,24 +376,28 @@ impl ConversationStore {
|
||||
thread_id,
|
||||
title,
|
||||
created_at,
|
||||
parent_thread_id,
|
||||
labels,
|
||||
..
|
||||
} => {
|
||||
let (created_at_value, labels_value) = match index.get(&thread_id) {
|
||||
Some(existing) => (
|
||||
existing.created_at.clone(),
|
||||
labels.unwrap_or_else(|| existing.labels.clone()),
|
||||
),
|
||||
None => {
|
||||
let inferred = labels.unwrap_or_else(|| infer_labels(&thread_id));
|
||||
(created_at, inferred)
|
||||
}
|
||||
};
|
||||
let (created_at_value, parent_thread_id_value, labels_value) =
|
||||
match index.get(&thread_id) {
|
||||
Some(existing) => (
|
||||
existing.created_at.clone(),
|
||||
parent_thread_id.or_else(|| existing.parent_thread_id.clone()),
|
||||
labels.unwrap_or_else(|| existing.labels.clone()),
|
||||
),
|
||||
None => {
|
||||
let inferred = labels.unwrap_or_else(|| infer_labels(&thread_id));
|
||||
(created_at, parent_thread_id, inferred)
|
||||
}
|
||||
};
|
||||
index.insert(
|
||||
thread_id,
|
||||
ThreadIndexEntry {
|
||||
title,
|
||||
created_at: created_at_value,
|
||||
parent_thread_id: parent_thread_id_value,
|
||||
labels: labels_value,
|
||||
},
|
||||
);
|
||||
@@ -414,6 +424,7 @@ impl ConversationStore {
|
||||
struct ThreadIndexEntry {
|
||||
title: String,
|
||||
created_at: String,
|
||||
parent_thread_id: Option<String>,
|
||||
labels: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ fn store_roundtrips_threads_and_messages() {
|
||||
let created_at = "2026-04-10T12:00:00Z".to_string();
|
||||
let thread = store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "default-thread".to_string(),
|
||||
title: "Conversation".to_string(),
|
||||
created_at: created_at.clone(),
|
||||
@@ -55,6 +56,7 @@ fn store_updates_message_metadata() {
|
||||
let (_temp, store) = make_store();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "default-thread".to_string(),
|
||||
title: "Conversation".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -95,6 +97,7 @@ fn purge_removes_threads_and_messages() {
|
||||
let (_temp, store) = make_store();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "default-thread".to_string(),
|
||||
title: "Conversation".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -125,6 +128,7 @@ fn purge_removes_threads_and_messages() {
|
||||
fn ensure_thread_is_idempotent() {
|
||||
let (_temp, store) = make_store();
|
||||
let req = CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "t1".to_string(),
|
||||
title: "Thread".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -141,6 +145,7 @@ fn delete_thread_removes_thread_and_messages() {
|
||||
let (_temp, store) = make_store();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "t1".to_string(),
|
||||
title: "Thread".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -179,6 +184,7 @@ fn get_messages_empty_thread() {
|
||||
let (_temp, store) = make_store();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "t1".to_string(),
|
||||
title: "Empty".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -202,6 +208,7 @@ fn multiple_threads_and_messages() {
|
||||
for i in 0..3 {
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: format!("t{i}"),
|
||||
title: format!("Thread {i}"),
|
||||
created_at: format!("2026-04-10T12:0{i}:00Z"),
|
||||
@@ -239,6 +246,7 @@ fn update_message_nonexistent_returns_error() {
|
||||
let (_temp, store) = make_store();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "t1".to_string(),
|
||||
title: "Thread".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -260,6 +268,7 @@ fn update_thread_title_persists_latest_title() {
|
||||
let (_temp, store) = make_store();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "t1".to_string(),
|
||||
title: "Chat Apr 10 12:00 PM".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -284,6 +293,7 @@ fn store_handles_labels_and_inference() {
|
||||
// 1. Explicit labels on ensure
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "t1".to_string(),
|
||||
title: "Thread 1".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -294,6 +304,7 @@ fn store_handles_labels_and_inference() {
|
||||
// 2. Inferred labels for morning briefing
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "proactive:morning_briefing".to_string(),
|
||||
title: "Morning Briefing".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -304,6 +315,7 @@ fn store_handles_labels_and_inference() {
|
||||
// 3. Inferred labels for other proactive
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "proactive:system".to_string(),
|
||||
title: "System Notification".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
@@ -314,6 +326,7 @@ fn store_handles_labels_and_inference() {
|
||||
// 4. Default inferred labels (work)
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "user-thread".to_string(),
|
||||
title: "User Chat".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct ConversationThread {
|
||||
pub message_count: usize,
|
||||
pub last_message_at: String,
|
||||
pub created_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
@@ -42,6 +44,8 @@ pub struct CreateConversationThread {
|
||||
pub title: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub parent_thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ pub struct ConversationThreadSummary {
|
||||
pub message_count: usize,
|
||||
pub last_message_at: String,
|
||||
pub created_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
@@ -136,6 +138,8 @@ pub struct UpsertConversationThreadRequest {
|
||||
pub title: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub parent_thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ fn thread_to_summary(thread: ConversationThread) -> ConversationThreadSummary {
|
||||
message_count: thread.message_count,
|
||||
last_message_at: thread.last_message_at,
|
||||
created_at: thread.created_at,
|
||||
parent_thread_id: thread.parent_thread_id,
|
||||
labels: thread.labels,
|
||||
}
|
||||
}
|
||||
@@ -132,6 +133,7 @@ pub async fn thread_upsert(
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
created_at: request.created_at,
|
||||
parent_thread_id: request.parent_thread_id,
|
||||
labels: request.labels,
|
||||
},
|
||||
)?;
|
||||
@@ -157,6 +159,7 @@ pub async fn thread_create_new(
|
||||
id,
|
||||
title,
|
||||
created_at,
|
||||
parent_thread_id: None,
|
||||
// Pass labels through as-is; the store's infer_labels() applies
|
||||
// the same default on index rebuild, so this is the single source
|
||||
// of truth for default labels.
|
||||
|
||||
@@ -288,6 +288,7 @@ fn sample_thread() -> ConversationThread {
|
||||
message_count: 5,
|
||||
last_message_at: "2026-01-01T00:00:00Z".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
parent_thread_id: None,
|
||||
labels: vec!["work".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(crate) async fn dispatch_subagent(
|
||||
toolkit_override: skill_filter.map(str::to_string),
|
||||
context: None,
|
||||
task_id: Some(task_id.clone()),
|
||||
worker_thread_id: None,
|
||||
};
|
||||
|
||||
match run_subagent(definition, prompt, options).await {
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(crate) mod onboarding_status;
|
||||
mod plan_exit;
|
||||
mod skill_delegation;
|
||||
mod spawn_subagent;
|
||||
pub mod spawn_worker_thread;
|
||||
mod todo_write;
|
||||
|
||||
pub(crate) use dispatch::dispatch_subagent;
|
||||
@@ -20,4 +21,5 @@ pub use delegate::DelegateTool;
|
||||
pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER};
|
||||
pub use skill_delegation::SkillDelegationTool;
|
||||
pub use spawn_subagent::SpawnSubagentTool;
|
||||
pub use spawn_worker_thread::SpawnWorkerThreadTool;
|
||||
pub use todo_write::{global_todo_store, TodoItem, TodoStatus, TodoStore, TodoWriteTool};
|
||||
|
||||
@@ -404,6 +404,7 @@ impl Tool for SpawnSubagentTool {
|
||||
toolkit_override,
|
||||
context,
|
||||
task_id: Some(task_id.clone()),
|
||||
worker_thread_id: None,
|
||||
};
|
||||
|
||||
let progress_sink = current_parent().and_then(|p| p.on_progress.clone());
|
||||
@@ -547,6 +548,7 @@ fn persist_worker_thread(
|
||||
id: thread_id.clone(),
|
||||
title,
|
||||
created_at: now.clone(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["worker".to_string()]),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Tool: `spawn_worker_thread` — spawn a dedicated worker thread for a complex delegated task.
|
||||
//!
|
||||
//! Unlike `spawn_subagent`, which collapses sub-agent work into a single
|
||||
//! tool result in the current thread, `spawn_worker_thread` creates a new
|
||||
//! persisted thread with label `worker`. The sub-agent's full transcript
|
||||
//! is recorded into that thread, and the parent receives a compact
|
||||
//! reference (worker thread id) instead of the full output.
|
||||
//!
|
||||
//! Worker threads carry a hard cap on depth: a worker thread cannot spawn
|
||||
//! another worker thread.
|
||||
|
||||
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::harness::fork_context::current_parent;
|
||||
use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions};
|
||||
use crate::openhuman::memory::conversations::{
|
||||
self, ConversationMessage, CreateConversationThread,
|
||||
};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
/// Spawns a sub-agent in a dedicated worker thread.
|
||||
pub struct SpawnWorkerThreadTool;
|
||||
|
||||
impl Default for SpawnWorkerThreadTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnWorkerThreadTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SpawnWorkerThreadTool {
|
||||
fn name(&self) -> &str {
|
||||
"spawn_worker_thread"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Spawn a dedicated worker thread for a complex delegated task. \
|
||||
Use this when the task is long or involves many steps that would \
|
||||
clutter the current conversation. The sub-agent runs in a fresh \
|
||||
thread labeled 'worker', and you receive the thread ID and a \
|
||||
summary. Worker threads cannot spawn other worker threads."
|
||||
}
|
||||
|
||||
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. code_executor, researcher, planner)."
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": "string",
|
||||
"enum": agent_ids,
|
||||
"description": "Sub-agent id from the registry."
|
||||
})
|
||||
};
|
||||
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["agent_id", "prompt", "task_title"],
|
||||
"properties": {
|
||||
"agent_id": agent_id_schema,
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Clear, specific instruction for the sub-agent. The sub-agent has no memory of the parent's conversation, so include all context the sub-agent needs to act."
|
||||
},
|
||||
"task_title": {
|
||||
"type": "string",
|
||||
"description": "A short, descriptive title for the worker thread (e.g. 'Researching Rust async patterns')."
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Optional context blob from prior task results. Rendered as a `[Context]` block before the prompt."
|
||||
},
|
||||
"toolkit": {
|
||||
"type": "string",
|
||||
"description": "Composio toolkit slug to scope this spawn to (e.g. `gmail`, `notion`)."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Execute
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
let agent_id = args
|
||||
.get("agent_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let prompt = args
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let task_title = args
|
||||
.get("task_title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Worker Task")
|
||||
.to_string();
|
||||
let context = args
|
||||
.get("context")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let toolkit_override = args
|
||||
.get("toolkit")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if agent_id.is_empty() || prompt.is_empty() {
|
||||
tracing::warn!(
|
||||
agent_id = %agent_id,
|
||||
prompt_empty = prompt.is_empty(),
|
||||
"[spawn_worker_thread] rejected: agent_id and prompt are required"
|
||||
);
|
||||
return Ok(ToolResult::error("agent_id and prompt are required"));
|
||||
}
|
||||
|
||||
let parent = current_parent().ok_or_else(|| anyhow::anyhow!("no parent context"))?;
|
||||
|
||||
// ── Depth Guard ────────────────────────────────────────────────
|
||||
// Check if the current thread is already a worker thread.
|
||||
let current_thread_id = crate::openhuman::providers::thread_context::current_thread_id()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
tracing::info!(
|
||||
agent_id = %agent_id,
|
||||
task_title = %task_title,
|
||||
current_thread_id = %current_thread_id,
|
||||
toolkit_override = ?toolkit_override,
|
||||
has_context = context.is_some(),
|
||||
"[spawn_worker_thread] invoked"
|
||||
);
|
||||
|
||||
let threads = conversations::list_threads(parent.workspace_dir.clone())
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
if let Some(current_thread) = threads.iter().find(|t| t.id == current_thread_id) {
|
||||
if current_thread.labels.contains(&"worker".to_string())
|
||||
|| current_thread.parent_thread_id.is_some()
|
||||
{
|
||||
tracing::warn!(
|
||||
agent_id = %agent_id,
|
||||
current_thread_id = %current_thread_id,
|
||||
is_worker_label = current_thread.labels.contains(&"worker".to_string()),
|
||||
has_parent_thread_id = current_thread.parent_thread_id.is_some(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"[spawn_worker_thread] depth guard blocked spawn from worker thread"
|
||||
);
|
||||
return Ok(ToolResult::error("Worker threads cannot spawn other worker threads. Depth is capped at 1. Use spawn_subagent for inline delegation instead."));
|
||||
}
|
||||
}
|
||||
|
||||
let registry = AgentDefinitionRegistry::global()
|
||||
.ok_or_else(|| anyhow::anyhow!("AgentDefinitionRegistry not initialised"))?;
|
||||
|
||||
let definition = registry
|
||||
.get(&agent_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("agent_id '{}' not found", agent_id))?;
|
||||
|
||||
// ── Create Worker Thread ───────────────────────────────────────
|
||||
let worker_thread_id = format!("worker-{}", uuid::Uuid::new_v4());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
conversations::ensure_thread(
|
||||
parent.workspace_dir.clone(),
|
||||
CreateConversationThread {
|
||||
id: worker_thread_id.clone(),
|
||||
title: task_title.clone(),
|
||||
created_at: now.clone(),
|
||||
parent_thread_id: Some(current_thread_id.clone()),
|
||||
labels: Some(vec!["worker".to_string()]),
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
tracing::info!(
|
||||
agent_id = %agent_id,
|
||||
worker_thread_id = %worker_thread_id,
|
||||
parent_thread_id = %current_thread_id,
|
||||
task_title = %task_title,
|
||||
created_at = %now,
|
||||
"[spawn_worker_thread] created worker thread"
|
||||
);
|
||||
|
||||
// Append initial user message to the worker thread
|
||||
conversations::append_message(
|
||||
parent.workspace_dir.clone(),
|
||||
&worker_thread_id,
|
||||
ConversationMessage {
|
||||
id: format!("user:{}", uuid::Uuid::new_v4()),
|
||||
content: prompt.clone(),
|
||||
message_type: "text".to_string(),
|
||||
extra_metadata: json!({
|
||||
"scope": "worker_thread",
|
||||
"agent_id": agent_id,
|
||||
}),
|
||||
sender: "user".to_string(),
|
||||
created_at: now,
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// We don't have an easy way to append a system message to the parent
|
||||
// thread here without triggering a re-render of the history the model
|
||||
// sees. Instead, we return the info in the tool result.
|
||||
|
||||
// ── Run Subagent ──────────────────────────────────────────────
|
||||
let options = SubagentRunOptions {
|
||||
skill_filter_override: None,
|
||||
toolkit_override,
|
||||
context,
|
||||
task_id: None,
|
||||
worker_thread_id: Some(worker_thread_id.clone()),
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
agent_id = %agent_id,
|
||||
worker_thread_id = %worker_thread_id,
|
||||
"[spawn_worker_thread] dispatching run_subagent"
|
||||
);
|
||||
|
||||
match run_subagent(definition, &prompt, options).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
agent_id = %agent_id,
|
||||
worker_thread_id = %worker_thread_id,
|
||||
task_id = %outcome.task_id,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"[spawn_worker_thread] completed successfully"
|
||||
);
|
||||
let parent_visible = format!(
|
||||
"Spawned worker thread `{worker_thread_id}` for the task: {task_title}. \
|
||||
The sub-agent has completed its work. You can find the full transcript \
|
||||
in the worker thread.\n\n\
|
||||
[worker_thread_ref]\n{}\n[/worker_thread_ref]",
|
||||
json!({
|
||||
"thread_id": worker_thread_id,
|
||||
"label": "worker",
|
||||
"agent_id": agent_id,
|
||||
"task_id": outcome.task_id,
|
||||
"status": "completed"
|
||||
})
|
||||
);
|
||||
Ok(ToolResult::success(parent_visible))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
agent_id = %agent_id,
|
||||
worker_thread_id = %worker_thread_id,
|
||||
error = %err,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"[spawn_worker_thread] execution failed"
|
||||
);
|
||||
Ok(ToolResult::error(format!(
|
||||
"Worker thread execution failed: {err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::agent::harness::definition::{
|
||||
AgentDefinition, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope,
|
||||
};
|
||||
use crate::openhuman::agent::harness::fork_context::with_parent_context;
|
||||
use crate::openhuman::agent::harness::ParentExecutionContext;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct MockProvider;
|
||||
#[async_trait]
|
||||
impl crate::openhuman::providers::Provider for MockProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("".into())
|
||||
}
|
||||
async fn chat(
|
||||
&self,
|
||||
_: crate::openhuman::providers::ChatRequest<'_>,
|
||||
_: &str,
|
||||
_: f64,
|
||||
) -> anyhow::Result<crate::openhuman::providers::ChatResponse> {
|
||||
Ok(crate::openhuman::providers::ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
})
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct MockMemory;
|
||||
#[async_trait]
|
||||
impl crate::openhuman::memory::Memory for MockMemory {
|
||||
async fn store(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: crate::openhuman::memory::MemoryCategory,
|
||||
_: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn recall(
|
||||
&self,
|
||||
_: &str,
|
||||
_: usize,
|
||||
_: crate::openhuman::memory::RecallOpts<'_>,
|
||||
) -> anyhow::Result<Vec<crate::openhuman::memory::MemoryEntry>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
) -> anyhow::Result<Option<crate::openhuman::memory::MemoryEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<&crate::openhuman::memory::MemoryCategory>,
|
||||
_: Option<&str>,
|
||||
) -> anyhow::Result<Vec<crate::openhuman::memory::MemoryEntry>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn forget(&self, _: &str, _: &str) -> anyhow::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn namespace_summaries(
|
||||
&self,
|
||||
) -> anyhow::Result<Vec<crate::openhuman::memory::NamespaceSummary>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count(&self) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn health_check(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parent_ctx(workspace_dir: PathBuf) -> ParentExecutionContext {
|
||||
ParentExecutionContext {
|
||||
session_id: "test".into(),
|
||||
session_key: "test".into(),
|
||||
session_parent_prefix: None,
|
||||
model_name: "test".into(),
|
||||
temperature: 0.4,
|
||||
workspace_dir,
|
||||
provider: Arc::new(MockProvider),
|
||||
memory: Arc::new(MockMemory),
|
||||
channel: "test".into(),
|
||||
all_tools: Arc::new(vec![]),
|
||||
all_tool_specs: Arc::new(vec![]),
|
||||
skills: Arc::new(vec![]),
|
||||
memory_context: None,
|
||||
connected_integrations: vec![],
|
||||
composio_client: None,
|
||||
on_progress: None,
|
||||
agent_config: crate::openhuman::config::AgentConfig::default(),
|
||||
tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::Native,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_if_already_worker_thread() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let thread_id = "worker-123";
|
||||
conversations::ensure_thread(
|
||||
temp.path().to_path_buf(),
|
||||
CreateConversationThread {
|
||||
id: thread_id.to_string(),
|
||||
title: "Worker".into(),
|
||||
created_at: "now".into(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["worker".to_string()]),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::openhuman::providers::thread_context::with_thread_id(thread_id.to_string(), async {
|
||||
let parent = test_parent_ctx(temp.path().to_path_buf());
|
||||
with_parent_context(parent, async {
|
||||
let tool = SpawnWorkerThreadTool::new();
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"agent_id": "researcher",
|
||||
"prompt": "do it",
|
||||
"task_title": "Task"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(result
|
||||
.output()
|
||||
.contains("cannot spawn other worker threads"));
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_if_has_parent_thread_id() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let thread_id = "sub-123";
|
||||
conversations::ensure_thread(
|
||||
temp.path().to_path_buf(),
|
||||
CreateConversationThread {
|
||||
id: thread_id.to_string(),
|
||||
title: "Sub".into(),
|
||||
created_at: "now".into(),
|
||||
parent_thread_id: Some("parent".into()),
|
||||
labels: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::openhuman::providers::thread_context::with_thread_id(thread_id.to_string(), async {
|
||||
let parent = test_parent_ctx(temp.path().to_path_buf());
|
||||
with_parent_context(parent, async {
|
||||
let tool = SpawnWorkerThreadTool::new();
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"agent_id": "researcher",
|
||||
"prompt": "do it",
|
||||
"task_title": "Task"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_error);
|
||||
assert!(result
|
||||
.output()
|
||||
.contains("cannot spawn other worker threads"));
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ use crate::openhuman::agent::harness::definition::{
|
||||
};
|
||||
use crate::openhuman::context::prompt::ConnectedIntegration;
|
||||
|
||||
use super::{ArchetypeDelegationTool, SkillDelegationTool, Tool};
|
||||
use super::{ArchetypeDelegationTool, SkillDelegationTool, SpawnWorkerThreadTool, Tool};
|
||||
|
||||
/// Synthesise the delegation tool list for an agent based on its
|
||||
/// declarative `subagents` field.
|
||||
@@ -65,6 +65,11 @@ pub fn collect_orchestrator_tools(
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
|
||||
// Orchestrator-only tool: spawn_worker_thread.
|
||||
if definition.id == "orchestrator" {
|
||||
tools.push(Box::new(SpawnWorkerThreadTool::new()));
|
||||
}
|
||||
|
||||
for entry in &definition.subagents {
|
||||
match entry {
|
||||
SubagentEntry::AgentId(agent_id) => {
|
||||
@@ -278,6 +283,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"spawn_worker_thread", // orchestrator-only, prepended in collect_orchestrator_tools
|
||||
"research", // researcher's delegate_name override
|
||||
"delegate_archivist", // archivist has no delegate_name → default
|
||||
"delegate_gmail",
|
||||
@@ -308,7 +314,10 @@ mod tests {
|
||||
let reg = registry_with_targets();
|
||||
let tools = collect_orchestrator_tools(&orch, ®, &[]);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert_eq!(names, vec!["research", "delegate_archivist"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["spawn_worker_thread", "research", "delegate_archivist"]
|
||||
);
|
||||
}
|
||||
|
||||
/// An AgentId entry that points at an id not present in the registry
|
||||
@@ -324,7 +333,7 @@ mod tests {
|
||||
let reg = registry_with_targets();
|
||||
let tools = collect_orchestrator_tools(&orch, ®, &[]);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert_eq!(names, vec!["research"]);
|
||||
assert_eq!(names, vec!["spawn_worker_thread", "research"]);
|
||||
}
|
||||
|
||||
/// An empty `subagents` list should produce zero tools — regular
|
||||
|
||||
Reference in New Issue
Block a user