diff --git a/CHANGELOG.md b/CHANGELOG.md index 153301fa1..0c5364449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Orchestrator (Issue #930)**: Dedicated worker threads for long, complex + delegated sub-tasks + - Extended `spawn_subagent` with an opt-in `dedicated_thread: boolean` flag. + When true, the sub-agent's prompt and final summary land in a fresh + `worker`-labeled conversation thread the user can open from the thread + list, and the parent thread receives a compact `[worker_thread_ref]` + envelope instead of the full sub-agent transcript. + - Added `WorkerThreadRefCard` to render the envelope as a clickable card in + the parent thread's tool timeline; the card swaps the active thread on + click so the user can read the full sub-agent transcript without losing + the parent conversation. + - Worker threads are one level deep by construction — sub-agents never see + `spawn_subagent`, so a worker cannot itself spawn another worker. + - Updated the orchestrator system prompt with guidance on when to opt in to + `dedicated_thread` (multi-step research, multi-file refactors, batch + integration work) vs. the default inline path. + - Unit tests cover the schema flag, the worker thread title builder, the + parent-visible `[worker_thread_ref]` envelope, the thread/message + persistence shape, and the TypeScript envelope parser. + - **Config (Issue #933)**: Bootstrap from config.toml RPC URL with runtime derivation - Added "Configure RPC URL" option on Welcome screen for self-hosted/internal deployments - Users can now set core JSON-RPC URL on login screen without build-time configuration diff --git a/app/src/pages/conversations/components/ToolTimelineBlock.tsx b/app/src/pages/conversations/components/ToolTimelineBlock.tsx index 78a2d9ec4..0c436a48b 100644 --- a/app/src/pages/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/pages/conversations/components/ToolTimelineBlock.tsx @@ -1,5 +1,7 @@ import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; +import { parseWorkerThreadRef } from '../utils/workerThreadRef'; +import { WorkerThreadRefCard } from './WorkerThreadRefCard'; export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) { const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; @@ -18,6 +20,7 @@ export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) const formatted = formatTimelineEntry(entry); const detailContent = normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer); + const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail); const shouldAutoExpand = latestRunningEntryId != null && latestRunningEntryId === entry.id; const statusTone = entry.status === 'running' @@ -55,7 +58,14 @@ export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) {entry.status} - {formatted.detail ? ( + {workerRef ? ( +
+ {workerRef.before} + + {workerRef.after ?
{workerRef.after}
: null} +
+ ) : formatted.detail ? (
{formatted.detail} diff --git a/app/src/pages/conversations/components/WorkerThreadRefCard.tsx b/app/src/pages/conversations/components/WorkerThreadRefCard.tsx new file mode 100644 index 000000000..3b3aca502 --- /dev/null +++ b/app/src/pages/conversations/components/WorkerThreadRefCard.tsx @@ -0,0 +1,53 @@ +import { useDispatch } from 'react-redux'; + +import { setActiveThread } from '../../../store/threadSlice'; +import type { WorkerThreadRef } from '../utils/workerThreadRef'; + +/** + * Compact card rendered inside a parent thread's tool timeline when the + * orchestrator delegated a sub-task into a dedicated worker thread. + * Clicking the card swaps the active thread so the user can read the + * sub-agent's full transcript without losing the parent conversation. + */ +export function WorkerThreadRefCard({ ref }: { ref: WorkerThreadRef }) { + const dispatch = useDispatch(); + const meta: string[] = []; + if (ref.agentId) meta.push(ref.agentId); + if (typeof ref.iterations === 'number') { + meta.push(`${ref.iterations} ${ref.iterations === 1 ? 'turn' : 'turns'}`); + } + if (typeof ref.elapsedMs === 'number') { + meta.push(`${Math.round(ref.elapsedMs)}ms`); + } + + return ( + + ); +} diff --git a/app/src/pages/conversations/utils/workerThreadRef.test.ts b/app/src/pages/conversations/utils/workerThreadRef.test.ts new file mode 100644 index 000000000..8a16aee07 --- /dev/null +++ b/app/src/pages/conversations/utils/workerThreadRef.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { parseWorkerThreadRef } from './workerThreadRef'; + +describe('parseWorkerThreadRef', () => { + it('extracts the envelope and surrounding prose for a well-formed payload', () => { + const input = + 'Spawned worker thread `worker-abc` for the delegated task. ' + + 'Continue from a brief summary in this thread instead of relaying the entire run.\n\n' + + '[worker_thread_ref]\n' + + '{"thread_id":"worker-abc","label":"worker","agent_id":"researcher",' + + '"task_id":"sub-1","elapsed_ms":120,"iterations":3}\n' + + '[/worker_thread_ref]'; + + const parsed = parseWorkerThreadRef(input); + expect(parsed).not.toBeNull(); + expect(parsed!.before).toContain('Spawned worker thread'); + expect(parsed!.after).toBe(''); + expect(parsed!.ref).toEqual({ + threadId: 'worker-abc', + label: 'worker', + agentId: 'researcher', + taskId: 'sub-1', + elapsedMs: 120, + iterations: 3, + }); + }); + + it('returns null when no envelope is present', () => { + expect(parseWorkerThreadRef('plain tool result with no card')).toBeNull(); + }); + + it('returns null when the envelope payload is not valid JSON', () => { + const input = '[worker_thread_ref]\nnot really json\n[/worker_thread_ref]'; + expect(parseWorkerThreadRef(input)).toBeNull(); + }); + + it('returns null when thread_id is missing or blank', () => { + const input = '[worker_thread_ref]\n{"label":"worker"}\n[/worker_thread_ref]'; + expect(parseWorkerThreadRef(input)).toBeNull(); + }); + + it('falls back to "worker" when label is missing', () => { + const input = '[worker_thread_ref]\n{"thread_id":"worker-x"}\n[/worker_thread_ref]'; + const parsed = parseWorkerThreadRef(input); + expect(parsed!.ref.label).toBe('worker'); + expect(parsed!.ref.threadId).toBe('worker-x'); + expect(parsed!.ref.agentId).toBeUndefined(); + }); + + it('handles null and empty input safely', () => { + expect(parseWorkerThreadRef(null)).toBeNull(); + expect(parseWorkerThreadRef(undefined)).toBeNull(); + expect(parseWorkerThreadRef('')).toBeNull(); + }); +}); diff --git a/app/src/pages/conversations/utils/workerThreadRef.ts b/app/src/pages/conversations/utils/workerThreadRef.ts new file mode 100644 index 000000000..4bdfa6f80 --- /dev/null +++ b/app/src/pages/conversations/utils/workerThreadRef.ts @@ -0,0 +1,63 @@ +/** + * Parses the `[worker_thread_ref]…[/worker_thread_ref]` envelope the + * Rust core's `spawn_subagent` tool emits when it spawns a sub-agent + * with `dedicated_thread: true`. The envelope is appended to the parent + * thread's tool_result text so the UI can render a clickable card + * linking to the new worker thread instead of dumping the sub-agent's + * full transcript inline. + */ + +export interface WorkerThreadRef { + threadId: string; + label: string; + agentId?: string; + taskId?: string; + elapsedMs?: number; + iterations?: number; +} + +export interface ParsedWorkerThreadRef { + /** The text that appeared before the envelope (model-readable summary). */ + before: string; + /** The decoded reference, if the envelope parsed cleanly. */ + ref: WorkerThreadRef; + /** The text that appeared after the envelope (rare but supported). */ + after: string; +} + +const ENVELOPE_RE = /\[worker_thread_ref\]\s*\n?([\s\S]*?)\n?\s*\[\/worker_thread_ref\]/; + +export function parseWorkerThreadRef( + input: string | undefined | null +): ParsedWorkerThreadRef | null { + if (!input) return null; + const match = ENVELOPE_RE.exec(input); + if (!match) return null; + + let payload: unknown; + try { + payload = JSON.parse(match[1].trim()); + } catch { + return null; + } + if (!payload || typeof payload !== 'object') return null; + const obj = payload as Record; + + const threadId = typeof obj.thread_id === 'string' ? obj.thread_id.trim() : ''; + if (!threadId) return null; + + const label = typeof obj.label === 'string' && obj.label.trim().length > 0 ? obj.label : 'worker'; + + return { + before: input.slice(0, match.index).trim(), + after: input.slice(match.index + match[0].length).trim(), + ref: { + threadId, + label, + agentId: typeof obj.agent_id === 'string' ? obj.agent_id : undefined, + taskId: typeof obj.task_id === 'string' ? obj.task_id : undefined, + elapsedMs: typeof obj.elapsed_ms === 'number' ? obj.elapsed_ms : undefined, + iterations: typeof obj.iterations === 'number' ? obj.iterations : undefined, + }, + }; +} diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 34280f353..28346f6ba 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -206,6 +206,16 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, + Capability { + id: "intelligence.orchestrator_worker_thread", + name: "Worker Thread Delegation", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "When a delegated sub-task is long or complex, the orchestrator can route it into a fresh worker-labeled conversation thread instead of flooding the parent thread. The user opens the worker thread from the thread list (or via the reference card in the parent) to read the sub-agent's full transcript.", + how_to: "Conversations > tap the worker reference card in the parent thread, or open the worker-labeled thread from the thread list", + status: CapabilityStatus::Beta, + privacy: DERIVED_TO_BACKEND, + }, Capability { id: "intelligence.slack_memory_ingest", name: "Slack Memory Ingestion", diff --git a/src/openhuman/agent/agents/orchestrator/prompt.md b/src/openhuman/agent/agents/orchestrator/prompt.md index 04db4ebe2..1fbe94ac2 100644 --- a/src/openhuman/agent/agents/orchestrator/prompt.md +++ b/src/openhuman/agent/agents/orchestrator/prompt.md @@ -76,6 +76,21 @@ or a manual fallback. - **Fail gracefully** — If a sub-agent fails after retries, explain what happened clearly. - **Escalate when appropriate** — If orchestration is the wrong mode or a specialist cannot make progress, hand control back to OpenHuman Core with a concise explanation and let Core handle general interactions. +## Dedicated worker threads + +`spawn_subagent` accepts an optional `dedicated_thread: true` flag. When set, the +sub-agent's run is persisted into a fresh **worker**-labeled thread the user can +open from the thread list, and you receive a compact reference (worker thread id ++ brief summary) instead of the full sub-agent transcript. Use this **only** +when the sub-task is genuinely long or complex and the parent thread should not +be flooded with the sub-agent's output — for example multi-step research, +multi-file refactors, or batch integration work that produces a large +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. + ## Connecting external services When the user asks to connect a service (Gmail, Notion, WhatsApp, Calendar, Drive, etc.) or a sub-agent reports `Connection error, try to authenticate`: diff --git a/src/openhuman/tools/impl/agent/spawn_subagent.rs b/src/openhuman/tools/impl/agent/spawn_subagent.rs index 811e0e72f..d984e47d1 100644 --- a/src/openhuman/tools/impl/agent/spawn_subagent.rs +++ b/src/openhuman/tools/impl/agent/spawn_subagent.rs @@ -21,10 +21,16 @@ use crate::core::event_bus::{publish_global, DomainEvent}; 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::agent::harness::subagent_runner::{ + run_subagent, SubagentRunOptions, SubagentRunOutcome, +}; +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; +use std::path::PathBuf; /// Spawns a sub-agent of the requested type to handle a delegated task. /// @@ -129,6 +135,10 @@ impl Tool for SpawnSubagentTool { "type": "string", "enum": ["typed", "fork"], "description": "`typed` (default) builds a narrow prompt + filtered tools. `fork` replays the parent's exact prompt for prefix-cache reuse on the inference backend." + }, + "dedicated_thread": { + "type": "boolean", + "description": "Default `false`. Set `true` ONLY for long, complex sub-tasks where the parent thread should not be flooded with sub-agent output. The sub-agent's prompt and final summary land in a fresh worker-labeled thread the user can open from the thread list, and the parent receives a compact reference (worker thread id + brief summary) instead of the full transcript. Worker threads cannot themselves spawn another worker (sub-agents never see this tool), so this is a one-level-deep escape hatch." } } }) @@ -168,6 +178,11 @@ impl Tool for SpawnSubagentTool { let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("typed"); + let dedicated_thread = args + .get("dedicated_thread") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // ── Validation ───────────────────────────────────────────────── if agent_id.is_empty() { return Ok(ToolResult::error( @@ -329,6 +344,44 @@ impl Tool for SpawnSubagentTool { output_chars: outcome.output.chars().count(), iterations: outcome.iterations, }); + + if dedicated_thread { + let workspace_dir = current_parent() + .map(|p| p.workspace_dir.clone()) + .unwrap_or_else(|| PathBuf::from(".")); + let parent_visible = match persist_worker_thread( + &workspace_dir, + &definition.id, + &prompt, + &outcome, + ) { + Ok(thread_id) => { + render_worker_thread_result(&thread_id, &definition.id, &outcome) + } + Err(error) => { + // Persistence failure must not silently swallow the + // sub-agent's work — return the full output and + // surface the worker-thread error so the parent + // model can mention it. We deliberately fall + // through to a `success` ToolResult so the agent + // loop doesn't prepend "Error:" to text the + // sub-agent produced legitimately. + tracing::error!( + target: "spawn_subagent", + agent_id = %definition.id, + error = %error, + "[spawn_subagent] dedicated_thread persistence failed; \ + returning full sub-agent output inline" + ); + format!( + "{}\n\n[worker_thread_error] failed to persist worker thread: {}", + outcome.output, error + ) + } + }; + return Ok(ToolResult::success(parent_visible)); + } + Ok(ToolResult::success(outcome.output)) } Err(err) => { @@ -362,9 +415,219 @@ impl Tool for SpawnSubagentTool { } } +/// Trim a raw prompt down to a thread-list-friendly title. +/// +/// Mirrors the visible-character cap the UI threads list uses so titles +/// stay readable when the orchestrator hands in a multi-paragraph prompt. +const WORKER_THREAD_TITLE_MAX_CHARS: usize = 80; + +fn build_worker_thread_title(prompt: &str) -> String { + let collapsed: String = prompt.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return "Worker task".to_string(); + } + let mut iter = collapsed.chars(); + let truncated: String = iter.by_ref().take(WORKER_THREAD_TITLE_MAX_CHARS).collect(); + if iter.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +fn persist_worker_thread( + workspace_dir: &std::path::Path, + agent_id: &str, + prompt: &str, + outcome: &SubagentRunOutcome, +) -> Result { + let thread_id = format!("worker-{}", uuid::Uuid::new_v4()); + let title = build_worker_thread_title(prompt); + let now = chrono::Utc::now().to_rfc3339(); + + conversations::ensure_thread( + workspace_dir.to_path_buf(), + CreateConversationThread { + id: thread_id.clone(), + title, + created_at: now.clone(), + labels: Some(vec!["worker".to_string()]), + }, + ) + .map_err(|err| format!("ensure_thread: {err}"))?; + + conversations::append_message( + workspace_dir.to_path_buf(), + &thread_id, + ConversationMessage { + id: format!("user:{}", outcome.task_id), + content: prompt.to_string(), + message_type: "text".to_string(), + extra_metadata: json!({ + "scope": "worker_thread", + "agent_id": agent_id, + "task_id": outcome.task_id, + }), + sender: "user".to_string(), + created_at: now.clone(), + }, + ) + .map_err(|err| format!("append user message: {err}"))?; + + conversations::append_message( + workspace_dir.to_path_buf(), + &thread_id, + ConversationMessage { + id: format!("agent:{}", outcome.task_id), + content: outcome.output.clone(), + message_type: "text".to_string(), + extra_metadata: json!({ + "scope": "worker_thread", + "agent_id": outcome.agent_id, + "task_id": outcome.task_id, + "elapsed_ms": outcome.elapsed.as_millis() as u64, + "iterations": outcome.iterations, + }), + sender: "agent".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ) + .map_err(|err| format!("append agent message: {err}"))?; + + Ok(thread_id) +} + +/// Build a parent-thread tool_result that refers the user to the worker +/// thread instead of dumping the sub-agent's full transcript inline. +/// +/// The `[worker_thread_ref] … [/worker_thread_ref]` envelope carries +/// machine-readable metadata the UI parses to render a clickable card; the +/// surrounding prose stays informative for the LLM that reads the result. +fn render_worker_thread_result( + thread_id: &str, + agent_id: &str, + outcome: &SubagentRunOutcome, +) -> String { + let payload = json!({ + "thread_id": thread_id, + "label": "worker", + "agent_id": agent_id, + "task_id": outcome.task_id, + "elapsed_ms": outcome.elapsed.as_millis() as u64, + "iterations": outcome.iterations, + }); + format!( + "Spawned worker thread `{thread_id}` for the delegated task. The \ + user can open it from the thread list (label: `worker`) to see \ + the sub-agent's full transcript. Continue from a brief summary \ + in this thread instead of relaying the entire run.\n\n\ + [worker_thread_ref]\n{payload}\n[/worker_thread_ref]", + thread_id = thread_id, + payload = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()), + ) +} + #[cfg(test)] mod tests { use super::*; + use crate::openhuman::agent::harness::subagent_runner::SubagentMode; + use std::time::Duration; + use tempfile::TempDir; + + fn sample_outcome(output: &str) -> SubagentRunOutcome { + SubagentRunOutcome { + agent_id: "researcher".into(), + task_id: "sub-test-1".into(), + output: output.to_string(), + elapsed: Duration::from_millis(120), + iterations: 3, + mode: SubagentMode::Typed, + } + } + + #[test] + fn build_worker_thread_title_collapses_whitespace_and_caps_length() { + let prompt = " draft\n a very long\tplan that\nrambles ".to_string() + &"x".repeat(200); + let title = build_worker_thread_title(&prompt); + assert!(title.starts_with("draft a very long plan")); + assert!(title.chars().count() <= WORKER_THREAD_TITLE_MAX_CHARS + 1); + assert!(title.ends_with('…')); + } + + #[test] + fn build_worker_thread_title_falls_back_when_empty() { + assert_eq!(build_worker_thread_title(" \n\t "), "Worker task"); + } + + #[test] + fn parameters_schema_advertises_dedicated_thread_flag() { + let tool = SpawnSubagentTool; + let schema = tool.parameters_schema(); + let props = schema.get("properties").expect("schema has properties"); + let flag = props + .get("dedicated_thread") + .expect("dedicated_thread advertised"); + assert_eq!(flag.get("type").and_then(|v| v.as_str()), Some("boolean")); + // Must be off by default — workers are an opt-in escape hatch, not + // a free upgrade for every spawn. + assert!(schema + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().all(|s| s.as_str() != Some("dedicated_thread"))) + .unwrap_or(true)); + } + + #[test] + fn render_worker_thread_result_carries_machine_readable_envelope() { + let outcome = sample_outcome("done"); + let rendered = render_worker_thread_result("worker-abc", "researcher", &outcome); + assert!(rendered.contains("Spawned worker thread `worker-abc`")); + assert!(rendered.contains("[worker_thread_ref]")); + assert!(rendered.contains("[/worker_thread_ref]")); + // The JSON payload between the markers must round-trip. + let start = rendered.find("[worker_thread_ref]\n").unwrap() + "[worker_thread_ref]\n".len(); + let end = rendered.find("\n[/worker_thread_ref]").unwrap(); + let payload: serde_json::Value = + serde_json::from_str(&rendered[start..end]).expect("valid json envelope"); + assert_eq!(payload["thread_id"], "worker-abc"); + assert_eq!(payload["label"], "worker"); + assert_eq!(payload["agent_id"], "researcher"); + assert_eq!(payload["task_id"], "sub-test-1"); + assert_eq!(payload["iterations"], 3); + } + + #[test] + fn persist_worker_thread_creates_thread_with_worker_label_and_messages() { + let temp = TempDir::new().expect("tempdir"); + let outcome = sample_outcome("the answer is 42"); + let thread_id = persist_worker_thread( + temp.path(), + "researcher", + "draft a long research plan", + &outcome, + ) + .expect("worker thread persisted"); + + assert!(thread_id.starts_with("worker-")); + + let threads = conversations::list_threads(temp.path().to_path_buf()).expect("list threads"); + let worker = threads + .iter() + .find(|t| t.id == thread_id) + .expect("worker thread present"); + assert!(worker.labels.contains(&"worker".to_string())); + assert!(worker.title.starts_with("draft a long research plan")); + + let messages = + conversations::get_messages(temp.path().to_path_buf(), &thread_id).expect("messages"); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].sender, "user"); + assert_eq!(messages[0].content, "draft a long research plan"); + assert_eq!(messages[1].sender, "agent"); + assert_eq!(messages[1].content, "the answer is 42"); + assert_eq!(messages[1].extra_metadata["iterations"], 3); + assert_eq!(messages[1].extra_metadata["scope"], "worker_thread"); + } #[tokio::test] async fn missing_agent_id_returns_error() {