From 2d71eb3ab16fe328d94735876b0124f062e0a915 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:51:48 -0700 Subject: [PATCH] Make subagents async and reusable by default (#3887) --- Cargo.toml | 4 + docs/DELEGATION_POLICY.md | 12 +- .../developing/architecture/agent-harness.md | 17 +- .../native-tools/agent-coordination.md | 13 +- scripts/debug/harness-live-audit-cases.md | 29 + scripts/debug/harness-subagent-audit.sh | 16 + scripts/debug/harness-subagent-rpc-audit.mjs | 1083 +++++++++++++++++ src/bin/harness_subagent_audit.rs | 1030 ++++++++++++++++ src/openhuman/agent/harness/session/tests.rs | 3 +- .../harness/subagent_runner/ops/runner.rs | 1 + .../agent/harness/subagent_runner/types.rs | 4 + src/openhuman/agent_orchestration/mod.rs | 1 + .../agent_orchestration/running_subagents.rs | 161 +++ .../agent_orchestration/subagent_control.rs | 129 +- .../subagent_sessions/mod.rs | 12 + .../subagent_sessions/ops.rs | 425 +++++++ .../subagent_sessions/store.rs | 49 + .../subagent_sessions/types.rs | 163 +++ src/openhuman/agent_orchestration/tools.rs | 6 + .../tools/close_subagent.rs | 336 +++++ .../tools/list_subagents.rs | 126 ++ .../tools/spawn_async_subagent.rs | 382 +++++- .../tools/spawn_subagent.rs | 68 ++ .../tools/steer_subagent.rs | 62 +- .../tools/tools_e2e_tests.rs | 3 +- .../tools/wait_subagent.rs | 68 +- .../tools/worker_thread.rs | 53 +- src/openhuman/tools/ops.rs | 9 +- tests/agent_harness_raw_coverage_e2e.rs | 1 + tests/inference_agent_raw_coverage_e2e.rs | 1 + 30 files changed, 4190 insertions(+), 77 deletions(-) create mode 100644 scripts/debug/harness-live-audit-cases.md create mode 100755 scripts/debug/harness-subagent-audit.sh create mode 100755 scripts/debug/harness-subagent-rpc-audit.mjs create mode 100644 src/bin/harness_subagent_audit.rs create mode 100644 src/openhuman/agent_orchestration/subagent_sessions/mod.rs create mode 100644 src/openhuman/agent_orchestration/subagent_sessions/ops.rs create mode 100644 src/openhuman/agent_orchestration/subagent_sessions/store.rs create mode 100644 src/openhuman/agent_orchestration/subagent_sessions/types.rs create mode 100644 src/openhuman/agent_orchestration/tools/close_subagent.rs create mode 100644 src/openhuman/agent_orchestration/tools/list_subagents.rs diff --git a/Cargo.toml b/Cargo.toml index bc5195315..879251379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,10 @@ path = "src/bin/memory_tree_init_smoke.rs" name = "inference-probe" path = "src/bin/inference_probe.rs" +[[bin]] +name = "harness-subagent-audit" +path = "src/bin/harness_subagent_audit.rs" + [[bin]] name = "test-mcp-stub" path = "src/bin/test_mcp_stub.rs" diff --git a/docs/DELEGATION_POLICY.md b/docs/DELEGATION_POLICY.md index 7b163a60b..ecfe727c7 100644 --- a/docs/DELEGATION_POLICY.md +++ b/docs/DELEGATION_POLICY.md @@ -14,18 +14,18 @@ Apply when: the task needs a tool but not specialised execution (time lookup, me Cost: 1 tool call + parse overhead (~200-400 tokens). Rule: prefer `current_time`, `cron_*`, `memory_*`, `memory_tree`, `read_workspace_state`, `composio_list_connections`, `ask_user_clarification`. -## Tier 3 — Spawn a sub-agent (inline) +## Tier 3 — Delegate to a reusable async sub-agent Apply when: the task requires specialised execution (writing code, crawling docs, running shell, calling an external integration) that the orchestrator cannot do directly. Cost: full sub-agent turn (~1-5k tokens depending on archetype). -Rule: spawn the narrowest archetype that can complete the task. Prefer inline spawn (`spawn_worker_thread` with no dedicated thread) for tasks that complete in <5 turns. +Rule: spawn the narrowest archetype that can complete the task. `spawn_subagent` is reusable and asynchronous by default: it first looks for a compatible durable worker for the same parent thread, agent id, toolkit/model/sandbox/action-root shape, and deterministic task key. If one is running, the new instruction is steered into it; if one is idle, it resumes from saved history; otherwise a new durable worker session is created. -## Tier 4 — Spawn a dedicated worker thread -Apply when: the task is long (>5 turns estimated), produces a large transcript, or the user explicitly wants it tracked as a separate thread. +## Tier 4 — Explicit worker lifecycle control +Apply when: the task is long (>5 turns estimated), produces a large transcript, needs manual inspection, or the user explicitly wants it tracked/closed separately. Cost: same as Tier 3 but the parent thread is not flooded. -Rule: use `spawn_worker_thread` and surface a brief summary back to the parent. Do not chain workers (workers cannot spawn workers). +Rule: use `list_subagents`, `steer_subagent`, `wait_subagent`, and `close_subagent` with `subagent_session_id` for durable control. Use `fresh: true` only when the prior worker is materially incompatible or the user asks for a clean worker. Use `blocking: true` only when the parent must synchronously wait for the child result before replying. Do not chain workers (workers cannot spawn workers). ## Anti-patterns to avoid - Spawning a sub-agent to answer a question the orchestrator already has context for. - Delegating a tool call to a sub-agent when `current_tier <= 2` applies. -- Using `spawn_subagent` when `delegate_{archetype}` covers the task — `delegate_*` tools carry the full archetype definition and have correct tool filtering pre-configured. +- Repeatedly forcing fresh sub-agents for the same logical job, which loses worker-local context and cache affinity. - Passing the entire parent conversation as context to a sub-agent — pass only the task-relevant slice. diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index d1b9b6d68..7e68466f7 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -157,18 +157,24 @@ Each archetype lives under `agents//` with an `agent.toml` (metadata, tool Custom archetypes ship as TOML files under `$OPENHUMAN_WORKSPACE/agents/*.toml` (or `~/.openhuman/agents/*.toml` for user-global specialists). Custom definitions override built-ins on id collision. -### Running a sub-agent +### Running a reusable sub-agent -When the orchestrator calls `spawn_subagent` (or one of the `delegate_*` convenience tools), the runner: +When the orchestrator calls `spawn_subagent`, the default contract is durable and asynchronous. The tool builds a deterministic compatibility selector from the parent session/thread, agent id, toolkit scope, model override, sandbox mode, action root, and normalized task key/title. It then checks `agent_orchestration::subagent_sessions` before spawning: + +* If a compatible worker is already running, the instruction is injected through its `RunQueue` and the parent gets a quick `subagent_session_id` / `task_id` reference. +* If a compatible worker is idle or paused with reusable history, the harness starts a new transient run for the same durable `subagent_session_id` and passes the saved child history through `SubagentRunOptions.initial_history`, with the new instruction appended as a user-visible follow-up. +* If the shape is incompatible, the worker was closed, `fresh: true` was passed, or no session exists, the harness creates a new durable session and worker thread. + +The child run itself still uses the same runner: 1. Reads the parent's execution context from a task-local - the parent's provider, sandbox mode, cancellation fence, transcript root. 2. Resolves the sub-agent's model - inline `model` override first, then config-level pins (`[orchestrator].model`, `[teams.*].lead_model`, `[teams.*].agent_model`), then the archetype hint or inherited parent model. 3. Filters the parent's tool registry per the definition's `tools`, `disallowed_tools`, and `skill_filter`. In `fork` mode, the parent's full registry is inherited verbatim. 4. Builds a narrow system prompt, omitting the sections the definition asks to strip. 5. Runs an inner tool-call loop using the same machinery as the parent. -6. Returns one compact text result. The intra-sub-agent history is never spliced back into the parent - the orchestrator sees a single tool result and moves on. +6. Persists the child history and worker thread pointer under the durable `subagent_session_id` so later turns can resume or inspect it. -For tasks that don't need to block the orchestrator's turn, `spawn_worker_thread` runs the sub-agent in the background and the orchestrator continues immediately. +`wait_subagent` and `steer_subagent` accept either the durable `subagent_session_id` or the transient `task_id`; durable ids are preferred across turns. `list_subagents` shows reusable children for the current parent thread, and `close_subagent` marks a worker non-reusable and cancels it if it is still running. Inline blocking is explicit via `blocking: true`; it is no longer the default. ### Spawn hierarchy and tiers @@ -295,7 +301,8 @@ The harness lives entirely under `src/openhuman/agent/`. The README in that dire | ----------------------------- | ----------------------------------------------------------------- | | `harness/session/turn.rs` | `Agent::turn` - the lifecycle described above. | | `harness/tool_loop.rs` | The inner tool-call loop. | -| `harness/subagent_runner/` | `run_subagent`, fork-mode, oversized-result handoff. | +| `harness/subagent_runner/` | `run_subagent`, history replay, fork-mode, oversized-result handoff. | +| `agent_orchestration/subagent_sessions/` | Durable reusable sub-agent identity, compatibility matching, persisted status/history. | | `harness/definition.rs` | `AgentDefinition` - what an archetype declares. | | `harness/tool_filter.rs` | Toolkit-action ranking for integrations sub-agents. | | `harness/payload_summarizer.rs` | Oversized-tool-result detour. | diff --git a/gitbooks/features/native-tools/agent-coordination.md b/gitbooks/features/native-tools/agent-coordination.md index 419a7d663..82e4e7c29 100644 --- a/gitbooks/features/native-tools/agent-coordination.md +++ b/gitbooks/features/native-tools/agent-coordination.md @@ -12,8 +12,11 @@ Beyond doing the work, the agent has tools for *organising* the work - planning | Tool | What it does | | ----------------------- | --------------------------------------------------------------------------------------------- | | `todo_write` | Maintain a structured TODO list across a long task. Marked done as work progresses. | -| `spawn_subagent` | Spin up a fresh agent with its own context window for a self-contained subtask. | -| `spawn_worker_thread` | Background work that doesn't need to block the main conversation. | +| `spawn_subagent` | Delegate to a reusable async specialist by default; creates a fresh worker only when incompatible or requested. | +| `spawn_async_subagent` | Lower-level reusable async delegation surface with the same durable session identity. | +| `steer_subagent` / `wait_subagent` | Message or collect a running worker by durable `subagent_session_id` or transient `task_id`. | +| `list_subagents` / `close_subagent` | Inspect reusable workers for the parent thread or explicitly retire one. | +| `spawn_worker_thread` | Explicit background work tracked as a separate worker thread. | | `delegate` | Hand a task to a specialist (e.g. an archetype with different prompts/tools/permissions). | | `archetype_delegation` | Route to a named archetype - coder, researcher, planner, etc. | | `skill_delegation` | Hand off to a [skill](../integrations/README.md#skills) installed in the workspace. | @@ -21,13 +24,15 @@ Beyond doing the work, the agent has tools for *organising* the work - planning | `plan_exit` | Exit a planning phase and start executing. | | `check_onboarding_status` / `complete_onboarding` | Gate behaviour on whether the user has finished onboarding. | -`spawn_subagent` and archetype delegation calls accept an optional `model` field for a one-off exact model pin. If it is omitted, the harness uses config-level per-agent pins when present and otherwise falls back to the normal model-routing hints. +`spawn_subagent` and archetype delegation calls accept an optional `model` field for a one-off exact model pin. If it is omitted, the harness uses config-level per-agent pins when present and otherwise falls back to the normal model-routing hints. Model, toolkit, sandbox mode, parent thread, action root, and task key are part of reusable sub-agent compatibility, so materially different work gets a separate worker. + +Reusable delegation returns both a transient `task_id` and a durable `subagent_session_id`. Prefer the durable id for cross-turn follow-ups. Pass `fresh: true` only when the user or task needs a clean worker; pass `blocking: true` only when the parent must wait inline for the child result. ## Why these are tools, not implicit behaviour Long tasks fall apart when the agent tries to keep everything in one head. Splitting work via TODOs and subagents means: -* Each subagent gets a clean context - fewer tokens, fewer distractions. +* Each subagent keeps useful local context for the same logical job instead of being respawned every turn. * The main thread keeps a high-level view of progress. * Failures in one branch don't poison the rest. diff --git a/scripts/debug/harness-live-audit-cases.md b/scripts/debug/harness-live-audit-cases.md new file mode 100644 index 000000000..d3f1e55f6 --- /dev/null +++ b/scripts/debug/harness-live-audit-cases.md @@ -0,0 +1,29 @@ +# Harness Live Audit Cases + +These checks exercise the real harness through JSON-RPC with live model credentials. They are debug audits, not CI tests. They intentionally avoid printing prompt bodies, response bodies, tokens, or transcript contents. + +Use an isolated spawned core when validating managed OpenHuman backend behavior without relying on a running desktop core: + +```bash +node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --model agentic-v1 --scenario all +``` + +`--isolated-workspace` defaults to `--provider-mode openhuman-backend`. It writes a temporary backend config, seeds the temp auth store from `JWT_TOKEN`, starts `openhuman-core`, and removes the temp workspace by default. Only pass `--keep-workspace` when you need to inspect artifacts locally. + +The optional `--provider-mode direct-openai` control path writes a temporary direct-provider config using `OPENAI_API_KEY` or `OPENAI_KEY`. + +## Cases + +- `async-steer`: starts a durable async subagent with `spawn_subagent`, waits until the live registry shows it running, then sends `openhuman.subagent_steer` mid-run and verifies the steer was accepted. +- `parallel-research-code`: asks the orchestrator to call `spawn_parallel_agents` with two workers. One researches `https://example.com`; the other writes a small Python code snippet. The audit checks that the parent turn completed and at least two subagent transcripts changed. +- `reuse-parent-comm`: runs two parent prompts. The first prompt spawns two blocking durable subagents that return parent-facing status updates. The second prompt uses the same durable task keys and verifies that the same subagent sessions are reused. Each turn prints transcript usage deltas: input tokens, cached input tokens, output tokens, and charged USD. +- `all`: runs every case against the same spawned core. + +Useful focused commands: + +```bash +node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --model agentic-v1 --scenario async-steer +node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --model agentic-v1 --scenario parallel-research-code +node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --model agentic-v1 --scenario reuse-parent-comm +node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --provider-mode direct-openai --model gpt-4.1-mini --scenario reuse-parent-comm +``` diff --git a/scripts/debug/harness-subagent-audit.sh b/scripts/debug/harness-subagent-audit.sh new file mode 100755 index 000000000..f249dddfc --- /dev/null +++ b/scripts/debug/harness-subagent-audit.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$ROOT_DIR" + +if [[ -f "$ROOT_DIR/.env" ]]; then + # shellcheck source=../load-dotenv.sh + source "$ROOT_DIR/scripts/load-dotenv.sh" "$ROOT_DIR/.env" +fi + +export RUST_LOG="${RUST_LOG:-info,spawn_subagent=debug,openhuman_core::openhuman::agent=debug,openhuman_core::openhuman::agent_orchestration=debug}" + +echo "[harness_subagent_audit] running live audit; requires configured provider/backend credentials" >&2 +exec cargo run --manifest-path "$ROOT_DIR/Cargo.toml" --bin harness-subagent-audit -- "$@" diff --git a/scripts/debug/harness-subagent-rpc-audit.mjs b/scripts/debug/harness-subagent-rpc-audit.mjs new file mode 100755 index 000000000..66b76eb6b --- /dev/null +++ b/scripts/debug/harness-subagent-rpc-audit.mjs @@ -0,0 +1,1083 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { once } from "node:events"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:net"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_RPC_URL = "http://127.0.0.1:7788/rpc"; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); + +function usage() { + return `Usage: node scripts/debug/harness-subagent-rpc-audit.mjs [options] + +Runs a live JSON-RPC harness turn, waits for the async subagent to register, +then steers it through openhuman.subagent_steer while the parent/core process is live. +No prompt, response, credential, or transcript bodies are printed. + +Options: + --scenario async-steer, parallel-research-code, reuse-parent-comm, or all (default: async-steer) + --core-url JSON-RPC endpoint (default: OPENHUMAN_CORE_RPC_URL or ${DEFAULT_RPC_URL}) + --token RPC bearer (default: OPENHUMAN_CORE_TOKEN or /core.token) + --workspace Workspace containing .openhuman/subagent_sessions.json + --task-key Durable task key (default: audit-subagent-rpc-) + --agent-id Subagent id to request (default: researcher) + --model Optional model_override for openhuman.agent_chat + --provider-mode Isolated provider config: openhuman-backend or direct-openai (default: openhuman-backend) + --rpc-timeout-ms Parent agent_chat timeout (default: 600000) + --spawn-wait-ms Time to wait for a running durable session (default: 120000) + --settle-wait-ms Time to wait for final session status after parent returns (default: 60000) + --spawn-core Start openhuman-core run --jsonrpc-only for the audit + --isolated-workspace With --spawn-core, use a temp workspace and custom audit agent definitions + --keep-workspace Do not remove an isolated temp workspace after the run; this can leave a temp config with a live API key + --verbose Print response char counts and spawned core logs + -h, --help Show this help + +Examples: + node scripts/debug/harness-subagent-rpc-audit.mjs + node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --model agentic-v1 + node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --scenario parallel-research-code --model agentic-v1 + node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --scenario reuse-parent-comm --model agentic-v1 + node scripts/debug/harness-subagent-rpc-audit.mjs --spawn-core --isolated-workspace --provider-mode direct-openai --scenario reuse-parent-comm --model gpt-4.1-mini +`; +} + +function parseArgs(argv) { + const opts = { + scenario: "async-steer", + coreUrl: process.env.OPENHUMAN_CORE_RPC_URL || DEFAULT_RPC_URL, + token: process.env.OPENHUMAN_CORE_TOKEN || "", + workspace: process.env.OPENHUMAN_WORKSPACE || "", + taskKey: `audit-subagent-rpc-${Date.now().toString(36)}`, + agentId: "researcher", + model: "", + providerMode: "openhuman-backend", + rpcTimeoutMs: 600_000, + spawnWaitMs: 120_000, + settleWaitMs: 60_000, + spawnCore: false, + isolatedWorkspace: false, + keepWorkspace: false, + verbose: false, + coreUrlExplicit: Boolean(process.env.OPENHUMAN_CORE_RPC_URL), + agentIdExplicit: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (!value) throw new Error(`missing value for ${arg}`); + return value; + }; + switch (arg) { + case "--scenario": + opts.scenario = next(); + break; + case "--core-url": + opts.coreUrl = next(); + opts.coreUrlExplicit = true; + break; + case "--token": + opts.token = next(); + break; + case "--workspace": + opts.workspace = next(); + break; + case "--task-key": + opts.taskKey = next(); + break; + case "--agent-id": + opts.agentId = next(); + opts.agentIdExplicit = true; + break; + case "--model": + opts.model = next(); + break; + case "--provider-mode": + opts.providerMode = next(); + break; + case "--rpc-timeout-ms": + opts.rpcTimeoutMs = parsePositiveInt(next(), "--rpc-timeout-ms"); + break; + case "--spawn-wait-ms": + opts.spawnWaitMs = parsePositiveInt(next(), "--spawn-wait-ms"); + break; + case "--settle-wait-ms": + opts.settleWaitMs = parsePositiveInt(next(), "--settle-wait-ms"); + break; + case "--spawn-core": + opts.spawnCore = true; + break; + case "--isolated-workspace": + opts.isolatedWorkspace = true; + break; + case "--keep-workspace": + opts.keepWorkspace = true; + break; + case "--verbose": + opts.verbose = true; + break; + case "-h": + case "--help": + console.log(usage()); + process.exit(0); + default: + throw new Error(`unknown option: ${arg}`); + } + } + const scenarios = new Set([ + "async-steer", + "parallel-research-code", + "reuse-parent-comm", + "all", + ]); + if (!scenarios.has(opts.scenario)) { + throw new Error( + `--scenario must be one of ${Array.from(scenarios).join(", ")}`, + ); + } + const providerModes = new Set(["direct-openai", "openhuman-backend"]); + if (!providerModes.has(opts.providerMode)) { + throw new Error( + `--provider-mode must be one of ${Array.from(providerModes).join(", ")}`, + ); + } + return opts; +} + +function parsePositiveInt(raw, label) { + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function defaultOpenhumanDir() { + return process.env.OPENHUMAN_APP_ENV === "staging" + ? path.join(homedir(), ".openhuman-staging") + : path.join(homedir(), ".openhuman"); +} + +async function defaultWorkspace() { + if (process.env.OPENHUMAN_WORKSPACE) return process.env.OPENHUMAN_WORKSPACE; + const openhumanDir = defaultOpenhumanDir(); + try { + const active = await readFile( + path.join(openhumanDir, "active_user.toml"), + "utf8", + ); + const match = active.match(/^\s*user_id\s*=\s*"([^"]+)"\s*$/m); + if (match?.[1]) { + return path.join(openhumanDir, "users", match[1], "workspace"); + } + } catch { + // Fall through to legacy root workspace. + } + return openhumanDir; +} + +async function readToken(opts) { + if (opts.token.trim()) return opts.token.trim(); + const tokenPath = path.join(opts.workspace, "core.token"); + try { + return (await readFile(tokenPath, "utf8")).trim(); + } catch { + throw new Error( + `RPC token not provided and ${tokenPath} could not be read. Pass --token or set OPENHUMAN_CORE_TOKEN.`, + ); + } +} + +async function rpc(coreUrl, token, method, params, timeoutMs = 600_000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let res; + try { + res = await fetch(coreUrl, { + method: "POST", + signal: controller.signal, + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `subagent-audit-${Date.now()}-${Math.random().toString(16).slice(2)}`, + method, + params, + }), + }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`RPC ${method} timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timeout); + } + + const bodyText = await res.text(); + let body; + try { + body = JSON.parse(bodyText); + } catch { + throw new Error(`RPC ${method} returned non-JSON HTTP ${res.status}`); + } + if (!res.ok) throw new Error(`RPC ${method} HTTP ${res.status}`); + if (body.error) { + throw new Error( + `RPC ${method} error: ${body.error.message || body.error.code || "unknown"}`, + ); + } + return body.result; +} + +function sessionStorePath(workspace) { + return path.join(workspace, ".openhuman", "subagent_sessions.json"); +} + +async function readSessions(workspace, taskKey) { + let raw; + try { + raw = await readFile(sessionStorePath(workspace), "utf8"); + } catch { + return []; + } + let sessions; + try { + sessions = JSON.parse(raw); + } catch { + return []; + } + return sessions + .filter((session) => session?.taskKey === taskKey) + .map((session) => ({ + subagentSessionId: String(session.subagentSessionId || ""), + parentSession: String(session.parentSession || ""), + workerThreadId: session.workerThreadId || null, + agentId: String(session.agentId || ""), + taskKey: String(session.taskKey || ""), + currentTaskId: session.currentTaskId || null, + status: String(session.status || ""), + reusable: Boolean(session.reusable), + updatedAt: String(session.updatedAt || ""), + lastUsedAt: String(session.lastUsedAt || ""), + })); +} + +async function waitForRunningSession( + workspace, + taskKey, + waitMs, + parentPromise, +) { + const deadline = Date.now() + waitMs; + let last = []; + while (Date.now() < deadline) { + const parentState = await Promise.race([ + parentPromise.then( + () => ({ done: true }), + (err) => ({ error: err }), + ), + sleep(0).then(() => ({ pending: true })), + ]); + if (parentState.error) throw parentState.error; + if (parentState.done) { + throw new Error( + `parent agent_chat completed before a running subagent session appeared; last_count=${last.length}`, + ); + } + + last = await readSessions(workspace, taskKey); + const running = last.find( + (session) => session.currentTaskId && session.status === "running", + ); + if (running) return running; + await sleep(200); + } + throw new Error( + `timed out waiting for running subagent session; last_count=${last.length}`, + ); +} + +async function waitForSettledSessions(workspace, taskKey, waitMs) { + const deadline = Date.now() + waitMs; + let last = []; + while (Date.now() < deadline) { + last = await readSessions(workspace, taskKey); + if ( + last.length > 0 && + last.some((session) => session.status !== "running") + ) { + return last; + } + await sleep(500); + } + return last; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function listJsonlFiles(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const files = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listJsonlFiles(full))); + } else if (entry.isFile() && full.endsWith(".jsonl")) { + files.push(full); + } + } + return files; +} + +async function transcriptSnapshot(workspace) { + const transcriptDir = path.join(workspace, "session_raw"); + const files = await listJsonlFiles(transcriptDir); + const snapshot = new Map(); + for (const file of files) { + try { + const metadata = await stat(file); + snapshot.set(file, { + size: metadata.size, + mtimeMs: metadata.mtimeMs, + }); + } catch { + // Ignore racing transcript writes during live audit sampling. + } + } + return snapshot; +} + +function num(value) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +async function usageSnapshot(workspace) { + const transcriptDir = path.join(workspace, "session_raw"); + const files = await listJsonlFiles(transcriptDir); + const snapshot = new Map(); + await Promise.all( + files.map(async (file) => { + try { + const data = await readFile(file, "utf8"); + const firstLine = data.split(/\r?\n/, 1)[0]; + const parsed = JSON.parse(firstLine); + const meta = parsed._meta || {}; + snapshot.set(file, { + file, + agent: String(meta.agent || "(unknown)"), + input: num(meta.input_tokens), + output: num(meta.output_tokens), + cached: num(meta.cached_input_tokens), + charged: num(meta.charged_amount_usd), + isSubagent: path.basename(file).includes("__"), + }); + } catch { + // Ignore malformed or partially-written transcripts during live audit sampling. + } + }), + ); + return snapshot; +} + +function diffUsageSnapshots(before, after) { + const rows = []; + for (const [file, current] of after.entries()) { + const prior = before.get(file); + const row = { + file, + agent: current.agent, + isSubagent: current.isSubagent, + input: Math.max(0, current.input - (prior?.input || 0)), + output: Math.max(0, current.output - (prior?.output || 0)), + cached: Math.max(0, current.cached - (prior?.cached || 0)), + charged: Math.max(0, current.charged - (prior?.charged || 0)), + }; + if (row.input || row.output || row.cached || row.charged || !prior) { + rows.push(row); + } + } + return rows; +} + +function summarizeUsage(rows) { + return rows.reduce( + (acc, row) => { + acc.input += row.input; + acc.output += row.output; + acc.cached += row.cached; + acc.charged += row.charged; + acc.sessions += 1; + if (row.isSubagent) acc.subagentSessions += 1; + return acc; + }, + { + input: 0, + output: 0, + cached: 0, + charged: 0, + sessions: 0, + subagentSessions: 0, + }, + ); +} + +function printUsageReport(label, rows) { + const totals = summarizeUsage(rows); + const cacheRate = totals.input > 0 ? (totals.cached / totals.input) * 100 : 0; + console.log( + `[harness-subagent-rpc-audit] usage ${label} total in=${totals.input} cache=${totals.cached} out=${totals.output} cost=$${totals.charged.toFixed(6)} cache_rate=${cacheRate.toFixed(2)}% sessions=${totals.sessions} subagents=${totals.subagentSessions}`, + ); + for (const row of rows.sort((a, b) => { + if (a.isSubagent !== b.isSubagent) return a.isSubagent ? 1 : -1; + return a.agent.localeCompare(b.agent); + })) { + console.log( + ` ${row.isSubagent ? "subagent" : "parent"} agent=${row.agent} in=${row.input} cache=${row.cached} out=${row.output} cost=$${row.charged.toFixed(6)}`, + ); + } + return totals; +} + +function changedTranscriptFiles(before, after) { + return [...after.entries()] + .filter(([file, current]) => { + const previous = before.get(file); + return ( + !previous || + previous.size !== current.size || + previous.mtimeMs !== current.mtimeMs + ); + }) + .map(([file]) => file); +} + +function subagentTranscriptFiles(files) { + return files.filter((file) => path.basename(file).includes("__")); +} + +function spawnPrompt(opts) { + return `Harness async subagent RPC audit. +Call spawn_subagent exactly once with agent_id \`${opts.agentId}\`, task_key \`${opts.taskKey}\`, blocking false, and fresh false. +Ask the sub-agent to produce a concise confirmation for audit marker \`${opts.taskKey}\`. +After the tool returns, reply with one short sentence saying the async worker was started. +Do not call wait_subagent.`; +} + +function parallelPrompt(opts) { + return `Harness parallel subagent audit. +Call spawn_parallel_agents exactly once with these two tasks: +1. agent_id "researcher", ownership "website research", prompt "Research https://example.com and return a concise factual note with the page title or domain purpose. Include one short evidence phrase. Do not browse unrelated sites." +2. agent_id "code_executor", ownership "code draft", prompt "Write a small Python function normalize_title(title: str) -> str that trims whitespace, collapses internal whitespace, and title-cases the result. Include one tiny assert-style example. Return only the code block; do not modify files." +After spawn_parallel_agents returns, reply with one concise sentence summarizing that both parallel workers completed. +Audit marker: ${opts.taskKey}.`; +} + +function reusePrompt(opts, turn) { + const base = `${opts.taskKey}-reuse`; + const agentId = opts.agentId; + if (turn === 1) { + return `Harness reusable subagent parent communication audit, turn 1. +Call spawn_subagent exactly twice, both with blocking false and fresh false: +1. agent_id "${agentId}", task_key "${base}-alpha", prompt "You are alpha. Send a concise parent-facing status update with marker ${base}-alpha and remember that the topic is cache-aware reuse." +2. agent_id "${agentId}", task_key "${base}-beta", prompt "You are beta. Send a concise parent-facing status update with marker ${base}-beta and remember that the topic is durable worker reuse." +Then call wait_subagent for each returned task_id and collect both final updates. +After both workers are collected, reply with one concise sentence saying both worker updates were collected.`; + } + return `Harness reusable subagent parent communication audit, turn 2. +Call spawn_subagent exactly twice again with the same agent_id, same task_key values, blocking false, fresh false: +1. agent_id "${agentId}", task_key "${base}-alpha", prompt "Continue alpha's prior work. Mention the remembered cache-aware reuse topic and send a new concise parent-facing update." +2. agent_id "${agentId}", task_key "${base}-beta", prompt "Continue beta's prior work. Mention the remembered durable worker reuse topic and send a new concise parent-facing update." +Then call wait_subagent for each returned task_id and collect both final updates. +After both workers are collected, reply with one concise sentence saying both reusable worker updates were collected.`; +} + +function steerMessage(opts) { + return `Mid-run RPC steering audit for marker \`${opts.taskKey}\`: acknowledge that this instruction arrived through the async steering queue, then keep the final answer concise.`; +} + +function responseText(result) { + if (typeof result === "string") return result; + if (typeof result?.result === "string") return result.result; + if (typeof result?.response === "string") return result.response; + if (typeof result?.data === "string") return result.data; + return JSON.stringify(result); +} + +async function pickFreePort() { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +async function writeAuditDefinitions(workspace) { + const agentsDir = path.join(workspace, "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + path.join(agentsDir, "orchestrator.toml"), + `id = "orchestrator" +display_name = "Subagent RPC Audit Orchestrator" +when_to_use = "Deterministic live harness async subagent RPC steering audit orchestrator." +temperature = 0.0 +max_iterations = 4 +sandbox_mode = "none" +agent_tier = "chat" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = true +omit_skills_catalog = true +omit_profile = true +omit_memory_md = true + +[system_prompt] +inline = """ +You are the OpenHuman async subagent RPC audit orchestrator. +For async steering audit messages, call spawn_subagent exactly once with agent_id "async_audit_worker", blocking false, fresh false, and the task_key provided by the user. +For parallel audit messages, call spawn_parallel_agents exactly once with the task list provided by the user. +For reusable subagent parent communication audit messages, call spawn_subagent exactly as many times as requested, preserving each requested task_key, blocking setting, and fresh setting. When asked to collect workers, call wait_subagent for the returned task_id values. +After the requested tool call or calls return, provide one concise sentence. Do not call wait_subagent for async steering audits. Do not call any tools other than the requested audit tools. +""" + +[tools] +named = ["spawn_subagent", "spawn_parallel_agents", "wait_subagent"] + +[subagents] +allowlist = ["async_audit_worker", "researcher", "code_executor"] +`, + ); + await writeFile( + path.join(agentsDir, "async_audit_worker.toml"), + `id = "async_audit_worker" +display_name = "Async Audit Worker" +delegate_name = "delegate_async_audit_worker" +when_to_use = "Tiny worker used only by harness async subagent RPC steering audit runs." +temperature = 0.0 +max_iterations = 2 +sandbox_mode = "none" +agent_tier = "worker" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = true +omit_skills_catalog = true +omit_profile = true +omit_memory_md = true + +[system_prompt] +inline = "Return one short sentence confirming the async audit worker ran and mention whether a steering instruction was received. Do not call tools." + +[tools] +named = [] +`, + ); +} + +async function writeIsolatedDirectProviderConfig(workspace, model) { + const apiKey = + process.env.OPENAI_API_KEY?.trim() || process.env.OPENAI_KEY?.trim() || ""; + if (!apiKey) { + throw new Error( + "--isolated-workspace requires OPENAI_API_KEY or OPENAI_KEY for direct OpenAI provider routing", + ); + } + const providerModel = model?.trim() || "gpt-4.1-mini"; + const providerRoute = `openai:${providerModel}`; + await writeFile( + path.join(workspace, "config.toml"), + `api_key = ${JSON.stringify(apiKey)} +inference_url = "https://api.openai.com/v1" +default_model = ${JSON.stringify(providerModel)} +chat_provider = ${JSON.stringify(providerRoute)} +reasoning_provider = ${JSON.stringify(providerRoute)} +agentic_provider = ${JSON.stringify(providerRoute)} +coding_provider = ${JSON.stringify(providerRoute)} +memory_provider = "openhuman" +embedding_provider = "none" + +[[cloud_providers]] +id = "audit_openai" +slug = "openai" +label = "OpenAI" +endpoint = "https://api.openai.com/v1" +auth_style = "bearer" +default_model = ${JSON.stringify(providerModel)} +`, + { mode: 0o600 }, + ); +} + +function backendApiUrl() { + const explicit = + process.env.BACKEND_URL?.trim() || + process.env.VITE_BACKEND_URL?.trim() || + ""; + if (explicit) return explicit.replace(/\/+$/, ""); + return process.env.OPENHUMAN_APP_ENV === "staging" + ? "https://staging-api.tinyhumans.ai" + : "https://api.tinyhumans.ai"; +} + +async function writeIsolatedOpenHumanBackendConfig(workspace, model) { + const providerModel = model?.trim() || "agentic-v1"; + await writeFile( + path.join(workspace, "config.toml"), + `api_url = ${JSON.stringify(backendApiUrl())} +default_model = ${JSON.stringify(providerModel)} +chat_provider = "openhuman" +reasoning_provider = "openhuman" +agentic_provider = "openhuman" +coding_provider = "openhuman" +memory_provider = "openhuman" +embedding_provider = "none" + +[secrets] +encrypt = false +`, + { mode: 0o600 }, + ); +} + +async function writeIsolatedAppSessionAuth(workspace) { + const token = process.env.JWT_TOKEN?.trim() || ""; + if (!token) { + throw new Error( + "--provider-mode openhuman-backend with --isolated-workspace requires JWT_TOKEN from scripts/load-dotenv.sh or your shell", + ); + } + const now = new Date().toISOString(); + await writeFile( + path.join(workspace, "auth-profiles.json"), + JSON.stringify( + { + schema_version: 1, + updated_at: now, + active_profiles: { + "app-session": "app-session:default", + }, + profiles: { + "app-session:default": { + provider: "app-session", + profile_name: "default", + kind: "token", + token, + created_at: now, + updated_at: now, + metadata: {}, + }, + }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); +} + +async function startCore(opts) { + const token = opts.token || `audit-${randomBytes(24).toString("hex")}`; + const env = { ...process.env, OPENHUMAN_CORE_TOKEN: token }; + if (opts.workspace) env.OPENHUMAN_WORKSPACE = opts.workspace; + if (opts.isolatedWorkspace) env.OPENHUMAN_AGENTBOX_MODE = "1"; + const port = new URL(opts.coreUrl).port || "7788"; + env.OPENHUMAN_CORE_PORT = port; + env.OPENHUMAN_CORE_RPC_URL = opts.coreUrl; + const child = spawn( + "cargo", + [ + "run", + "--quiet", + "--bin", + "openhuman-core", + "--", + "run", + "--host", + "127.0.0.1", + "--port", + port, + "--jsonrpc-only", + ], + { + cwd: path.resolve(SCRIPT_DIR, "../.."), + env, + stdio: opts.verbose + ? ["ignore", "inherit", "inherit"] + : ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + if (child.stderr) { + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + if (stderr.length > 8000) stderr = stderr.slice(-8000); + }); + } + await waitForCore(opts.coreUrl, token, child, () => stderr); + return { child, token }; +} + +async function waitForCore(coreUrl, token, child, stderrFn) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `spawned core exited with ${child.exitCode}\n${stderrFn()}`, + ); + } + try { + await rpc(coreUrl, token, "core.ping", {}, 10_000); + return; + } catch { + await sleep(750); + } + } + throw new Error(`timed out waiting for spawned core\n${stderrFn()}`); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + const exited = await Promise.race([ + once(child, "exit").then(() => true), + sleep(5_000).then(() => false), + ]); + if (exited || child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGKILL"); + await Promise.race([once(child, "exit"), sleep(2_000)]); +} + +function unwrapData(result) { + return result?.data && typeof result.data === "object" ? result.data : result; +} + +async function runAsyncSteerScenario(opts) { + const params = { message: spawnPrompt(opts) }; + if (opts.model) params.model_override = opts.model; + + const parentStarted = Date.now(); + const parentPromise = rpc( + opts.coreUrl, + opts.token, + "openhuman.agent_chat", + params, + opts.rpcTimeoutMs, + ); + + const runningSession = await waitForRunningSession( + opts.sessionWorkspace || opts.workspace, + opts.taskKey, + opts.spawnWaitMs, + parentPromise, + ); + console.log( + `[harness-subagent-rpc-audit] running session task_id=${runningSession.currentTaskId} subagent_session_id=${runningSession.subagentSessionId}`, + ); + + const steerResult = unwrapData( + await rpc( + opts.coreUrl, + opts.token, + "openhuman.subagent_steer", + { + taskId: runningSession.currentTaskId, + message: steerMessage(opts), + mode: "steer", + }, + 30_000, + ), + ); + console.log( + `[harness-subagent-rpc-audit] steer result steered=${Boolean(steerResult.steered)} reason=${steerResult.reason || "none"}`, + ); + + const parentResult = await parentPromise; + const response = responseText(parentResult); + console.log( + `[harness-subagent-rpc-audit] parent turn completed in ${Date.now() - parentStarted}ms${ + opts.verbose ? ` response_chars=${response.length}` : "" + }`, + ); + + const sessions = await waitForSettledSessions( + opts.sessionWorkspace || opts.workspace, + opts.taskKey, + opts.settleWaitMs, + ); + + console.log("[harness-subagent-rpc-audit] sessions"); + if (sessions.length === 0) { + console.log(" none"); + } else { + for (const session of sessions) { + console.log( + ` subagent_session_id=${session.subagentSessionId} task_id=${session.currentTaskId || "none"} status=${session.status} reusable=${session.reusable} updated_at=${session.updatedAt}`, + ); + } + } + + const failures = []; + if (!runningSession?.currentTaskId) + failures.push("no running subagent task observed"); + if (!steerResult?.steered) { + failures.push( + `subagent steer was not accepted (${steerResult?.reason || "unknown"})`, + ); + } + const uniqueSessions = new Set( + sessions.map((session) => session.subagentSessionId), + ); + if (uniqueSessions.size !== 1) { + failures.push( + `expected one durable session for task key, observed ${uniqueSessions.size}`, + ); + } + if (sessions.length === 0) + failures.push("no durable session remained after audit"); + return failures; +} + +async function runParallelResearchCodeScenario(opts) { + const transcriptWorkspace = opts.sessionWorkspace || opts.workspace; + const before = await transcriptSnapshot(transcriptWorkspace); + const params = { message: parallelPrompt(opts) }; + if (opts.model) params.model_override = opts.model; + + const started = Date.now(); + const result = await rpc( + opts.coreUrl, + opts.token, + "openhuman.agent_chat", + params, + opts.rpcTimeoutMs, + ); + const response = responseText(result); + const after = await transcriptSnapshot(transcriptWorkspace); + const changed = changedTranscriptFiles(before, after); + const changedSubagents = subagentTranscriptFiles(changed); + + console.log( + `[harness-subagent-rpc-audit] parallel turn completed in ${Date.now() - started}ms${ + opts.verbose ? ` response_chars=${response.length}` : "" + }`, + ); + console.log( + `[harness-subagent-rpc-audit] transcript changes changed=${changed.length} subagent_changed=${changedSubagents.length}`, + ); + + const failures = []; + if (response.length === 0) + failures.push("parallel parent response was empty"); + if (changedSubagents.length < 2) { + failures.push( + `expected at least two changed subagent transcripts, observed ${changedSubagents.length}`, + ); + } + return failures; +} + +async function runReuseParentCommScenario(opts) { + const transcriptWorkspace = opts.sessionWorkspace || opts.workspace; + const sessionWorkspace = opts.sessionWorkspace || opts.workspace; + const failures = []; + const turnSummaries = []; + const sessionSets = []; + + for (let turn = 1; turn <= 2; turn += 1) { + const before = await usageSnapshot(transcriptWorkspace); + const params = { message: reusePrompt(opts, turn) }; + if (opts.model) params.model_override = opts.model; + + const started = Date.now(); + const result = await rpc( + opts.coreUrl, + opts.token, + "openhuman.agent_chat", + params, + opts.rpcTimeoutMs, + ); + const response = responseText(result); + const after = await usageSnapshot(transcriptWorkspace); + const rows = diffUsageSnapshots(before, after); + const totals = printUsageReport(`reuse-parent-comm turn=${turn}`, rows); + const sessions = [ + ...(await readSessions(sessionWorkspace, `${opts.taskKey}-reuse-alpha`)), + ...(await readSessions(sessionWorkspace, `${opts.taskKey}-reuse-beta`)), + ]; + const durableIds = sessions + .map((session) => session.subagentSessionId) + .filter(Boolean) + .sort(); + sessionSets.push(durableIds); + turnSummaries.push({ + ms: Date.now() - started, + responseChars: response.length, + totals, + durableIds, + }); + console.log( + `[harness-subagent-rpc-audit] reuse turn ${turn} completed in ${turnSummaries.at(-1).ms}ms sessions=${durableIds.join(",") || "none"}${ + opts.verbose ? ` response_chars=${response.length}` : "" + }`, + ); + if (response.length === 0) { + failures.push(`reuse turn ${turn} parent response was empty`); + } + if (totals.input === 0) { + failures.push(`reuse turn ${turn} had no transcript usage metadata`); + } + if (totals.subagentSessions < 2) { + failures.push( + `reuse turn ${turn} expected at least two subagent usage deltas, observed ${totals.subagentSessions}`, + ); + } + if (durableIds.length !== 2) { + failures.push( + `reuse turn ${turn} expected two durable subagent sessions, observed ${durableIds.length}`, + ); + } + } + + if ( + sessionSets.length === 2 && + JSON.stringify(sessionSets[0]) !== JSON.stringify(sessionSets[1]) + ) { + failures.push( + `durable subagent sessions were not reused across turns: first=${sessionSets[0].join(",") || "none"} second=${sessionSets[1].join(",") || "none"}`, + ); + } + return failures; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!opts.workspace) opts.workspace = await defaultWorkspace(); + + let tempWorkspace = ""; + let spawned; + if (opts.isolatedWorkspace) { + if (!opts.spawnCore) { + throw new Error("--isolated-workspace requires --spawn-core"); + } + tempWorkspace = await mkdtemp( + path.join(tmpdir(), "openhuman-harness-subagent-rpc-audit-"), + ); + opts.workspace = path.join(tempWorkspace, "workspace"); + await mkdir(opts.workspace, { recursive: true }); + await writeAuditDefinitions(opts.workspace); + await writeAuditDefinitions(path.join(opts.workspace, "workspace")); + if (opts.providerMode === "openhuman-backend") { + await writeIsolatedOpenHumanBackendConfig(opts.workspace, opts.model); + await writeIsolatedAppSessionAuth(opts.workspace); + } else { + await writeIsolatedDirectProviderConfig(opts.workspace, opts.model); + } + opts.sessionWorkspace = path.join(opts.workspace, "workspace"); + if (!opts.agentIdExplicit) opts.agentId = "async_audit_worker"; + if (!opts.model) { + opts.model = + opts.providerMode === "openhuman-backend" + ? "agentic-v1" + : "gpt-4.1-mini"; + } + } + + if (opts.spawnCore) { + if (!opts.coreUrlExplicit) { + const port = await pickFreePort(); + opts.coreUrl = `http://127.0.0.1:${port}/rpc`; + } + spawned = await startCore(opts); + opts.token = spawned.token; + } else { + opts.token = await readToken(opts); + } + + console.log("[harness-subagent-rpc-audit] starting live audit"); + console.log(` rpc: ${opts.coreUrl}`); + console.log(` workspace: ${opts.workspace}`); + if (opts.sessionWorkspace) { + console.log(` session_workspace: ${opts.sessionWorkspace}`); + } + console.log(` task_key: ${opts.taskKey}`); + console.log(` scenario: ${opts.scenario}`); + if (opts.scenario !== "parallel-research-code") { + console.log(` agent_id: ${opts.agentId}`); + } + console.log(` mode: ${opts.spawnCore ? "spawned-core" : "attached-core"}`); + if (opts.isolatedWorkspace) { + console.log(" definitions: isolated audit overrides enabled"); + console.log(` provider_mode: ${opts.providerMode}`); + } + + const failures = []; + try { + const scenarios = + opts.scenario === "all" + ? ["async-steer", "parallel-research-code", "reuse-parent-comm"] + : [opts.scenario]; + for (const scenario of scenarios) { + console.log(`[harness-subagent-rpc-audit] scenario ${scenario}`); + const scenarioOpts = { + ...opts, + taskKey: + scenarios.length > 1 ? `${opts.taskKey}-${scenario}` : opts.taskKey, + }; + if (scenario === "async-steer") { + failures.push(...(await runAsyncSteerScenario(scenarioOpts))); + } else if (scenario === "parallel-research-code") { + failures.push(...(await runParallelResearchCodeScenario(scenarioOpts))); + } else if (scenario === "reuse-parent-comm") { + failures.push(...(await runReuseParentCommScenario(scenarioOpts))); + } + } + } finally { + if (spawned?.child) await stopChild(spawned.child); + if (tempWorkspace && !opts.keepWorkspace) { + await rm(tempWorkspace, { recursive: true, force: true }); + } + } + + if (failures.length > 0) { + console.error("\n[harness-subagent-rpc-audit] FAIL"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); + } + if (tempWorkspace && opts.keepWorkspace) { + console.log( + `[harness-subagent-rpc-audit] kept isolated workspace: ${opts.workspace}`, + ); + } + console.log("\n[harness-subagent-rpc-audit] PASS"); +} + +main().catch((err) => { + console.error(`[harness-subagent-rpc-audit] ERROR: ${err.message}`); + process.exit(1); +}); diff --git a/src/bin/harness_subagent_audit.rs b/src/bin/harness_subagent_audit.rs new file mode 100644 index 000000000..fa0940766 --- /dev/null +++ b/src/bin/harness_subagent_audit.rs @@ -0,0 +1,1030 @@ +//! Live harness audit for reusable async sub-agent delegation. +//! +//! This binary intentionally uses the user's real OpenHuman config and live +//! provider/backend credentials. It records only sanitized progress metadata: +//! tool names, task/session ids, statuses, character counts, and elapsed times. +//! It does not print prompts, tool arguments, assistant replies, transcripts, +//! credentials, or integration payloads. +//! +//! Typical usage: +//! +//! ```sh +//! scripts/debug/harness-subagent-audit.sh --turns 2 +//! ``` + +use std::collections::BTreeSet; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use clap::Parser; +use openhuman_core::openhuman::agent::harness::run_queue::QueueMode; +use openhuman_core::openhuman::agent::progress::AgentProgress; +use openhuman_core::openhuman::agent::Agent; +use openhuman_core::openhuman::agent_orchestration::{ + running_subagents, + subagent_sessions::{DurableSubagentSession, DurableSubagentStatus, SubagentSessionStore}, +}; +use openhuman_core::openhuman::config::Config; +use serde::Serialize; +use tokio::sync::mpsc; + +#[derive(Parser, Debug)] +#[command(name = "harness-subagent-audit")] +struct Args { + /// Sub-agent archetype to request from the orchestrator. + #[arg(long, default_value = "researcher")] + agent_id: String, + + /// Stable reusable task key. Defaults to audit-subagent-. + #[arg(long)] + task_key: Option, + + /// Number of parent turns to run. Use 2 to audit same-key reuse. + #[arg(long, default_value_t = 2)] + turns: usize, + + /// Seconds to wait for the durable session to appear or settle. + #[arg(long, default_value_t = 45)] + wait_secs: u64, + + /// Require the final durable session status to leave running. + #[arg(long)] + require_completion: bool, + + /// Override the first parent prompt. The audit task key is not appended. + #[arg(long)] + prompt: Option, + + /// Override the second parent prompt. Only used when --turns is at least 2. + #[arg(long)] + follow_up: Option, + + /// Print sanitized JSON summary in addition to the human summary. + #[arg(long)] + json: bool, + + /// After the first async sub-agent spawn, steer the running child through its run queue. + #[arg(long)] + steer_mid_run: bool, + + /// Delay after SubagentSpawned before attempting the steer. + #[arg(long, default_value_t = 250)] + steer_delay_ms: u64, + + /// Seconds to retry resolving/registering the running child before steering fails. + #[arg(long, default_value_t = 10)] + steer_wait_secs: u64, + + /// Override the steering message. The message itself is never printed. + #[arg(long)] + steer_message: Option, +} + +#[derive(Debug, Default, Serialize)] +struct ProgressStats { + parent_tool_started: Vec, + parent_tool_completed: Vec, + subagent_spawned: Vec, + subagent_completed: Vec, + subagent_failed: Vec, + subagent_tool_started: Vec, + subagent_tool_completed: Vec, + steer_attempts: Vec, + turn_completed: usize, +} + +#[derive(Debug, Serialize)] +struct ParentToolStarted { + turn: usize, + call_id: String, + tool_name: String, + iteration: u32, + argument_keys: Vec, +} + +#[derive(Debug, Serialize)] +struct ParentToolCompleted { + turn: usize, + call_id: String, + tool_name: String, + success: bool, + output_chars: usize, + elapsed_ms: u64, + iteration: u32, +} + +#[derive(Debug, Clone, Serialize)] +struct SubagentSpawnedEvent { + turn: usize, + agent_id: String, + task_id: String, + mode: String, + dedicated_thread: bool, + prompt_chars: usize, + worker_thread_id: Option, + display_name: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SteerAttemptEvent { + turn: usize, + agent_id: String, + task_id: String, + subagent_session_id: Option, + delivered: bool, + error: Option, + attempts: usize, + elapsed_ms: u128, + message_chars: usize, +} + +#[derive(Clone)] +struct SteerAuditConfig { + store: SubagentSessionStore, + task_key: String, + message: String, + delay: Duration, + wait_for: Duration, + fired: Arc, +} + +#[derive(Debug, Serialize)] +struct SubagentCompletedEvent { + turn: usize, + agent_id: String, + task_id: String, + elapsed_ms: u64, + iterations: u32, + output_chars: usize, +} + +#[derive(Debug, Serialize)] +struct SubagentFailedEvent { + turn: usize, + agent_id: String, + task_id: String, + error_chars: usize, +} + +#[derive(Debug, Serialize)] +struct SubagentToolEvent { + turn: usize, + agent_id: String, + task_id: String, + call_id: String, + tool_name: String, + iteration: u32, +} + +#[derive(Debug, Serialize)] +struct SubagentToolCompletedEvent { + turn: usize, + agent_id: String, + task_id: String, + call_id: String, + tool_name: String, + success: bool, + output_chars: usize, + elapsed_ms: u64, + iteration: u32, +} + +#[derive(Debug, Clone, Serialize)] +struct SessionSummary { + subagent_session_id: String, + parent_session: String, + parent_thread_id: Option, + worker_thread_id: Option, + agent_id: String, + display_name: Option, + task_key: String, + current_task_id: Option, + status: DurableSubagentStatus, + reusable: bool, + created_at: String, + updated_at: String, + last_used_at: String, +} + +#[derive(Debug, Serialize)] +struct AuditSummary { + task_key: String, + agent_id: String, + turns_requested: usize, + assistant_reply_chars: Vec, + progress: ProgressStats, + sessions: Vec, + checks: Vec, + passed: bool, +} + +#[derive(Debug, Serialize)] +struct AuditCheck { + name: &'static str, + passed: bool, + detail: String, +} + +#[tokio::main] +async fn main() { + if let Err(err) = run().await { + eprintln!("[harness_subagent_audit] ERROR: {err:#}"); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let args = Args::parse(); + let task_key = args + .task_key + .clone() + .unwrap_or_else(|| format!("audit-subagent-{}", unix_seconds())); + + eprintln!("[harness_subagent_audit] loading live OpenHuman config"); + let config = Config::load_or_init() + .await + .context("loading user config (Config::load_or_init)")?; + let store = SubagentSessionStore::new(config.workspace_dir.clone()); + eprintln!( + "[harness_subagent_audit] workspace_dir={} session_store={}", + config.workspace_dir.display(), + store.path().display() + ); + eprintln!( + "[harness_subagent_audit] default_model={:?} dispatcher={:?}", + config.default_model, config.agent.tool_dispatcher + ); + + let before_sessions = load_matching_sessions(&store, &task_key) + .context("loading existing matching durable subagent sessions")?; + if !before_sessions.is_empty() { + eprintln!( + "[harness_subagent_audit] found {} pre-existing session(s) for task_key={}; reuse checks may include prior state", + before_sessions.len(), + task_key + ); + } + + let mut agent = Agent::from_config(&config).context("Agent::from_config failed")?; + eprintln!("[harness_subagent_audit] fetching connected integrations"); + agent.fetch_connected_integrations().await; + let refreshed = agent.refresh_delegation_tools(); + eprintln!( + "[harness_subagent_audit] connected_integrations={} delegation_tools_refreshed={} visible_tools={} model={}", + agent.connected_integrations().len(), + refreshed, + agent.tools().len(), + agent.model_name() + ); + + let stats = Arc::new(Mutex::new(ProgressStats::default())); + let current_turn = Arc::new(AtomicUsize::new(0)); + let (tx, rx) = mpsc::channel(512); + agent.set_on_progress(Some(tx)); + let steer_config = args.steer_mid_run.then(|| SteerAuditConfig { + store: store.clone(), + task_key: task_key.clone(), + message: args + .steer_message + .clone() + .unwrap_or_else(|| default_steer_message(&task_key)), + delay: Duration::from_millis(args.steer_delay_ms), + wait_for: Duration::from_secs(args.steer_wait_secs), + fired: Arc::new(AtomicBool::new(false)), + }); + let progress_task = tokio::spawn(drain_progress( + rx, + stats.clone(), + current_turn.clone(), + steer_config, + )); + + let turns = args.turns.clamp(1, 2); + let mut assistant_reply_chars = Vec::new(); + for turn in 1..=turns { + current_turn.store(turn, Ordering::SeqCst); + let prompt = if turn == 1 { + args.prompt + .clone() + .unwrap_or_else(|| first_turn_prompt(&args.agent_id, &task_key)) + } else { + args.follow_up + .clone() + .unwrap_or_else(|| second_turn_prompt(&args.agent_id, &task_key)) + }; + eprintln!( + "[harness_subagent_audit] >>> parent_turn={} prompt_chars={} task_key={}", + turn, + prompt.chars().count(), + task_key + ); + let started = std::time::Instant::now(); + let reply = agent + .run_single(&prompt) + .await + .with_context(|| format!("agent.run_single failed on turn {turn}"))?; + eprintln!( + "[harness_subagent_audit] <<< parent_turn={} elapsed_ms={} assistant_reply_chars={}", + turn, + started.elapsed().as_millis(), + reply.chars().count() + ); + assistant_reply_chars.push(reply.chars().count()); + } + + let sessions = poll_matching_sessions( + &store, + &task_key, + Duration::from_secs(args.wait_secs), + args.require_completion, + ) + .await + .context("polling durable subagent sessions")?; + + agent.set_on_progress(None); + drop(agent); + let _ = tokio::time::timeout(Duration::from_secs(2), progress_task).await; + + let progress = take_stats(stats); + let checks = evaluate_checks( + &args.agent_id, + turns, + args.require_completion, + &progress, + &sessions, + ); + let passed = checks.iter().all(|check| check.passed); + let summary = AuditSummary { + task_key, + agent_id: args.agent_id, + turns_requested: turns, + assistant_reply_chars, + progress, + sessions, + checks, + passed, + }; + + print_human_summary(&summary); + if args.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } + + if !summary.passed { + std::process::exit(1); + } + Ok(()) +} + +async fn drain_progress( + mut rx: mpsc::Receiver, + stats: Arc>, + current_turn: Arc, + steer_config: Option, +) { + while let Some(event) = rx.recv().await { + let turn = current_turn.load(Ordering::SeqCst); + match event { + AgentProgress::ToolCallStarted { + call_id, + tool_name, + arguments, + iteration, + } => { + let argument_keys = argument_keys(&arguments); + eprintln!( + "[harness_subagent_audit] progress turn={} parent_tool_started tool={} call_id={} iteration={} argument_keys={:?}", + turn, tool_name, call_id, iteration, argument_keys + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .parent_tool_started + .push(ParentToolStarted { + turn, + call_id, + tool_name, + iteration, + argument_keys, + }); + } + AgentProgress::ToolCallCompleted { + call_id, + tool_name, + success, + output_chars, + elapsed_ms, + iteration, + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} parent_tool_completed tool={} call_id={} success={} output_chars={} elapsed_ms={} iteration={}", + turn, tool_name, call_id, success, output_chars, elapsed_ms, iteration + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .parent_tool_completed + .push(ParentToolCompleted { + turn, + call_id, + tool_name, + success, + output_chars, + elapsed_ms, + iteration, + }); + } + AgentProgress::SubagentSpawned { + agent_id, + task_id, + mode, + dedicated_thread, + prompt_chars, + worker_thread_id, + display_name, + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} subagent_spawned agent_id={} task_id={} mode={} dedicated_thread={} prompt_chars={} worker_thread_id={}", + turn, + agent_id, + task_id, + mode, + dedicated_thread, + prompt_chars, + worker_thread_id.as_deref().unwrap_or("none") + ); + let spawned = SubagentSpawnedEvent { + turn, + agent_id, + task_id, + mode, + dedicated_thread, + prompt_chars, + worker_thread_id, + display_name, + }; + stats + .lock() + .expect("progress stats mutex poisoned") + .subagent_spawned + .push(spawned.clone()); + if let Some(config) = steer_config.as_ref() { + if config + .fired + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let config = config.clone(); + let stats = stats.clone(); + tokio::spawn(async move { + let attempt = steer_after_spawn(config, spawned).await; + eprintln!( + "[harness_subagent_audit] steer_attempt turn={} task_id={} delivered={} attempts={} elapsed_ms={} message_chars={} error={}", + attempt.turn, + attempt.task_id, + attempt.delivered, + attempt.attempts, + attempt.elapsed_ms, + attempt.message_chars, + attempt.error.as_deref().unwrap_or("none") + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .steer_attempts + .push(attempt); + }); + } + } + } + AgentProgress::SubagentCompleted { + agent_id, + task_id, + elapsed_ms, + iterations, + output_chars, + .. + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} subagent_completed agent_id={} task_id={} elapsed_ms={} iterations={} output_chars={}", + turn, agent_id, task_id, elapsed_ms, iterations, output_chars + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .subagent_completed + .push(SubagentCompletedEvent { + turn, + agent_id, + task_id, + elapsed_ms, + iterations, + output_chars, + }); + } + AgentProgress::SubagentFailed { + agent_id, + task_id, + error, + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} subagent_failed agent_id={} task_id={} error_chars={}", + turn, + agent_id, + task_id, + error.chars().count() + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .subagent_failed + .push(SubagentFailedEvent { + turn, + agent_id, + task_id, + error_chars: error.chars().count(), + }); + } + AgentProgress::SubagentToolCallStarted { + agent_id, + task_id, + call_id, + tool_name, + iteration, + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} subagent_tool_started agent_id={} task_id={} tool={} call_id={} iteration={}", + turn, agent_id, task_id, tool_name, call_id, iteration + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .subagent_tool_started + .push(SubagentToolEvent { + turn, + agent_id, + task_id, + call_id, + tool_name, + iteration, + }); + } + AgentProgress::SubagentToolCallCompleted { + agent_id, + task_id, + call_id, + tool_name, + success, + output_chars, + elapsed_ms, + iteration, + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} subagent_tool_completed agent_id={} task_id={} tool={} call_id={} success={} output_chars={} elapsed_ms={} iteration={}", + turn, agent_id, task_id, tool_name, call_id, success, output_chars, elapsed_ms, iteration + ); + stats + .lock() + .expect("progress stats mutex poisoned") + .subagent_tool_completed + .push(SubagentToolCompletedEvent { + turn, + agent_id, + task_id, + call_id, + tool_name, + success, + output_chars, + elapsed_ms, + iteration, + }); + } + AgentProgress::TurnCompleted { .. } => { + stats + .lock() + .expect("progress stats mutex poisoned") + .turn_completed += 1; + } + AgentProgress::SubagentAwaitingUser { + agent_id, + task_id, + question, + worker_thread_id, + } => { + eprintln!( + "[harness_subagent_audit] progress turn={} subagent_awaiting_user agent_id={} task_id={} question_chars={} worker_thread_id={}", + turn, + agent_id, + task_id, + question.chars().count(), + worker_thread_id.as_deref().unwrap_or("none") + ); + } + AgentProgress::IterationStarted { .. } + | AgentProgress::SubagentIterationStarted { .. } + | AgentProgress::TextDelta { .. } + | AgentProgress::ThinkingDelta { .. } + | AgentProgress::SubagentTextDelta { .. } + | AgentProgress::SubagentThinkingDelta { .. } + | AgentProgress::ToolCallArgsDelta { .. } + | AgentProgress::TaskBoardUpdated { .. } + | AgentProgress::TurnCostUpdated { .. } + | AgentProgress::TurnStarted => {} + } + } +} + +async fn steer_after_spawn( + config: SteerAuditConfig, + spawned: SubagentSpawnedEvent, +) -> SteerAttemptEvent { + tokio::time::sleep(config.delay).await; + let started = std::time::Instant::now(); + let mut attempts = 0; + loop { + attempts += 1; + let attempt_error; + match find_session_for_task(&config.store, &config.task_key, &spawned.task_id) { + Ok(Some(session)) => { + match running_subagents::steer( + &spawned.task_id, + &session.parent_session, + config.message.clone(), + QueueMode::Steer, + ) + .await + { + Ok(()) => { + return SteerAttemptEvent { + turn: spawned.turn, + agent_id: spawned.agent_id, + task_id: spawned.task_id, + subagent_session_id: Some(session.subagent_session_id), + delivered: true, + error: None, + attempts, + elapsed_ms: started.elapsed().as_millis(), + message_chars: config.message.chars().count(), + }; + } + Err(err) => { + attempt_error = Some(format!("{err:?}")); + if !matches!(err, running_subagents::SteerError::Unknown) { + return failed_steer_attempt( + spawned, + attempt_error, + attempts, + started.elapsed().as_millis(), + config.message.chars().count(), + ); + } + } + } + } + Ok(None) => { + attempt_error = Some("durable session not found yet".to_string()); + } + Err(err) => { + attempt_error = Some(err.to_string()); + } + } + + if started.elapsed() >= config.wait_for { + return failed_steer_attempt( + spawned, + attempt_error, + attempts, + started.elapsed().as_millis(), + config.message.chars().count(), + ); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn failed_steer_attempt( + spawned: SubagentSpawnedEvent, + error: Option, + attempts: usize, + elapsed_ms: u128, + message_chars: usize, +) -> SteerAttemptEvent { + SteerAttemptEvent { + turn: spawned.turn, + agent_id: spawned.agent_id, + task_id: spawned.task_id, + subagent_session_id: None, + delivered: false, + error, + attempts, + elapsed_ms, + message_chars, + } +} + +fn find_session_for_task( + store: &SubagentSessionStore, + task_key: &str, + task_id: &str, +) -> Result> { + Ok(store + .load() + .map_err(anyhow::Error::msg)? + .into_iter() + .filter(|session| session.task_key == task_key) + .find(|session| session.current_task_id.as_deref() == Some(task_id)) + .map(SessionSummary::from)) +} + +fn argument_keys(value: &serde_json::Value) -> Vec { + value + .as_object() + .map(|object| object.keys().cloned().collect()) + .unwrap_or_default() +} + +fn first_turn_prompt(agent_id: &str, task_key: &str) -> String { + format!( + "Harness audit run. Call spawn_subagent exactly once with agent_id `{agent_id}`, \ + task_key `{task_key}`, blocking false, and fresh false. The delegated prompt should ask \ + the sub-agent to return a concise confirmation for audit marker `{task_key}` without \ + asking for clarification. After the tool returns, answer with a brief note that the \ + async reusable worker was started. Do not call wait_subagent in this turn." + ) +} + +fn second_turn_prompt(agent_id: &str, task_key: &str) -> String { + format!( + "Harness audit follow-up. Continue the same reusable sub-agent by calling spawn_subagent \ + exactly once with agent_id `{agent_id}`, the same task_key `{task_key}`, blocking false, \ + and fresh false. The delegated prompt should add one short follow-up instruction for \ + audit marker `{task_key}`. After the tool returns, answer briefly. Do not call \ + wait_subagent in this turn." + ) +} + +fn default_steer_message(task_key: &str) -> String { + format!( + "Mid-run steering audit for marker `{task_key}`: acknowledge that this instruction arrived through the async steering queue, then keep the final answer concise." + ) +} + +async fn poll_matching_sessions( + store: &SubagentSessionStore, + task_key: &str, + wait_for: Duration, + require_completion: bool, +) -> Result> { + let started = std::time::Instant::now(); + loop { + let sessions = load_matching_sessions(store, task_key)?; + let settled = sessions.iter().any(|session| { + !require_completion || !matches!(session.status, DurableSubagentStatus::Running) + }); + if !sessions.is_empty() && settled { + return Ok(sessions); + } + if started.elapsed() >= wait_for { + return Ok(sessions); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +fn load_matching_sessions( + store: &SubagentSessionStore, + task_key: &str, +) -> Result> { + let sessions = store.load().map_err(anyhow::Error::msg).with_context(|| { + format!( + "loading durable subagent store at {}", + store.path().display() + ) + })?; + Ok(sessions + .into_iter() + .filter(|session| session.task_key == task_key) + .map(SessionSummary::from) + .collect()) +} + +impl From for SessionSummary { + fn from(session: DurableSubagentSession) -> Self { + Self { + subagent_session_id: session.subagent_session_id, + parent_session: session.parent_session, + parent_thread_id: session.parent_thread_id, + worker_thread_id: session.worker_thread_id, + agent_id: session.agent_id, + display_name: session.display_name, + task_key: session.task_key, + current_task_id: session.current_task_id, + status: session.status, + reusable: session.reusable, + created_at: session.created_at, + updated_at: session.updated_at, + last_used_at: session.last_used_at, + } + } +} + +fn evaluate_checks( + agent_id: &str, + turns: usize, + require_completion: bool, + progress: &ProgressStats, + sessions: &[SessionSummary], +) -> Vec { + let mut checks = Vec::new(); + let parent_spawn_calls = progress + .parent_tool_started + .iter() + .filter(|event| is_spawn_tool(&event.tool_name)) + .count(); + checks.push(AuditCheck { + name: "parent_called_spawn_tool", + passed: parent_spawn_calls >= turns, + detail: format!( + "observed {parent_spawn_calls} spawn_subagent/spawn_async_subagent start event(s)" + ), + }); + + if !progress.steer_attempts.is_empty() { + let delivered = progress + .steer_attempts + .iter() + .filter(|attempt| attempt.delivered) + .count(); + checks.push(AuditCheck { + name: "mid_run_steer_delivered", + passed: delivered > 0, + detail: format!( + "observed {delivered} delivered steer attempt(s) out of {}", + progress.steer_attempts.len() + ), + }); + } + + let completed_spawn_calls = progress + .parent_tool_completed + .iter() + .filter(|event| is_spawn_tool(&event.tool_name) && event.success) + .count(); + checks.push(AuditCheck { + name: "spawn_tool_completed_successfully", + passed: completed_spawn_calls >= turns, + detail: format!( + "observed {completed_spawn_calls} successful spawn tool completion event(s)" + ), + }); + + let spawned_events = progress + .subagent_spawned + .iter() + .filter(|event| event.agent_id == agent_id && event.mode == "async") + .count(); + checks.push(AuditCheck { + name: "async_subagent_registered", + passed: spawned_events > 0 || !sessions.is_empty(), + detail: format!( + "observed {spawned_events} async SubagentSpawned event(s), {} persisted matching session(s)", + sessions.len() + ), + }); + + checks.push(AuditCheck { + name: "durable_session_persisted", + passed: !sessions.is_empty(), + detail: format!("persisted matching session count={}", sessions.len()), + }); + + let unique_session_ids: BTreeSet<_> = sessions + .iter() + .map(|session| session.subagent_session_id.as_str()) + .collect(); + checks.push(AuditCheck { + name: "single_reusable_session_for_task_key", + passed: turns < 2 || unique_session_ids.len() == 1, + detail: format!( + "unique matching subagent_session_id count={}", + unique_session_ids.len() + ), + }); + + let session_agent_ok = sessions + .iter() + .all(|session| session.agent_id == agent_id && session.reusable); + checks.push(AuditCheck { + name: "session_agent_and_reusable_flag_match", + passed: !sessions.is_empty() && session_agent_ok, + detail: format!( + "all sessions match agent_id={agent_id} and reusable=true: {session_agent_ok}" + ), + }); + + let running_count = sessions + .iter() + .filter(|session| matches!(session.status, DurableSubagentStatus::Running)) + .count(); + checks.push(AuditCheck { + name: "completion_requirement", + passed: !require_completion || running_count == 0, + detail: if require_completion { + format!("running matching sessions after wait={running_count}") + } else { + "not required".to_string() + }, + }); + + checks +} + +fn is_spawn_tool(tool_name: &str) -> bool { + tool_name == "spawn_subagent" || tool_name == "spawn_async_subagent" +} + +fn take_stats(stats: Arc>) -> ProgressStats { + match Arc::try_unwrap(stats) { + Ok(mutex) => mutex.into_inner().expect("progress stats mutex poisoned"), + Err(stats) => std::mem::take(&mut *stats.lock().expect("progress stats mutex poisoned")), + } +} + +fn print_human_summary(summary: &AuditSummary) { + println!("=== Harness Subagent Audit ==="); + println!("task_key: {}", summary.task_key); + println!("agent_id: {}", summary.agent_id); + println!("turns_requested: {}", summary.turns_requested); + println!("assistant_reply_chars: {:?}", summary.assistant_reply_chars); + println!( + "progress: parent_spawn_started={} parent_spawn_completed={} subagent_spawned={} subagent_completed={} subagent_failed={} subagent_tool_started={} subagent_tool_completed={}", + summary + .progress + .parent_tool_started + .iter() + .filter(|event| is_spawn_tool(&event.tool_name)) + .count(), + summary + .progress + .parent_tool_completed + .iter() + .filter(|event| is_spawn_tool(&event.tool_name)) + .count(), + summary.progress.subagent_spawned.len(), + summary.progress.subagent_completed.len(), + summary.progress.subagent_failed.len(), + summary.progress.subagent_tool_started.len(), + summary.progress.subagent_tool_completed.len() + ); + if !summary.progress.steer_attempts.is_empty() { + for attempt in &summary.progress.steer_attempts { + println!( + "steer: task_id={} delivered={} attempts={} elapsed_ms={} message_chars={} error={}", + attempt.task_id, + attempt.delivered, + attempt.attempts, + attempt.elapsed_ms, + attempt.message_chars, + attempt.error.as_deref().unwrap_or("none") + ); + } + } + println!("sessions:"); + if summary.sessions.is_empty() { + println!(" none"); + } else { + for session in &summary.sessions { + println!( + " subagent_session_id={} task_id={} status={:?} reusable={} worker_thread_id={} updated_at={}", + session.subagent_session_id, + session.current_task_id.as_deref().unwrap_or("none"), + session.status, + session.reusable, + session.worker_thread_id.as_deref().unwrap_or("none"), + session.updated_at + ); + } + } + println!("checks:"); + for check in &summary.checks { + println!( + " [{}] {} - {}", + if check.passed { "pass" } else { "fail" }, + check.name, + check.detail + ); + } + println!("verdict: {}", if summary.passed { "PASS" } else { "FAIL" }); +} + +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index afefe0570..f96405ca2 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -846,7 +846,8 @@ async fn turn_dispatches_spawn_subagent_through_full_path() { name: "spawn_subagent".into(), arguments: serde_json::json!({ "agent_id": "__test_inherit_echo", - "prompt": "find out about X" + "prompt": "find out about X", + "blocking": true }) .to_string(), extra_content: None, diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 55e485532..8404830ff 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -772,5 +772,6 @@ async fn run_typed_mode( elapsed: started.elapsed(), mode: SubagentMode::Typed, status, + final_history: history, }) } diff --git a/src/openhuman/agent/harness/subagent_runner/types.rs b/src/openhuman/agent/harness/subagent_runner/types.rs index a91e9dac3..c67f45d2a 100644 --- a/src/openhuman/agent/harness/subagent_runner/types.rs +++ b/src/openhuman/agent/harness/subagent_runner/types.rs @@ -107,6 +107,10 @@ pub struct SubagentRunOutcome { pub mode: SubagentMode, /// Whether the run completed or paused for user input. pub status: SubagentRunStatus, + /// Final in-memory history after the run loop exits. Durable sub-agent + /// sessions persist this so an idle worker can resume without rebuilding + /// its context from only the parent transcript. + pub final_history: Vec, } /// Which prompt-construction path the runner took for a sub-agent. diff --git a/src/openhuman/agent_orchestration/mod.rs b/src/openhuman/agent_orchestration/mod.rs index 4c5ebc456..3444abe7f 100644 --- a/src/openhuman/agent_orchestration/mod.rs +++ b/src/openhuman/agent_orchestration/mod.rs @@ -13,6 +13,7 @@ mod ops; pub(crate) mod parent_context; pub mod running_subagents; pub mod subagent_control; +pub mod subagent_sessions; pub mod tools; pub mod types; pub mod workflow_runs; diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index c9584b00a..5691cfc47 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -19,6 +19,7 @@ //! mode — openai/codex#18335). use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -50,6 +51,8 @@ impl SubagentStatus { struct RunningSubagentEntry { agent_id: String, parent_session: String, + subagent_session_id: Option, + workspace_dir: PathBuf, /// Parent chat thread that spawned this sub-agent, captured at registration. /// `None` for a headless spawn with no originating thread. Used to abort the /// sub-agent when its parent thread is deleted (see [`cancel_for_thread`]). @@ -93,6 +96,8 @@ pub fn register( task_id: String, agent_id: String, parent_session: String, + subagent_session_id: Option, + workspace_dir: PathBuf, parent_thread_id: Option, run_queue: Arc, abort: AbortHandle, @@ -101,6 +106,8 @@ pub fn register( let entry = RunningSubagentEntry { agent_id, parent_session, + subagent_session_id, + workspace_dir, parent_thread_id, run_queue, abort, @@ -121,6 +128,37 @@ pub fn register( ); } +/// Resolve a durable `subagent_session_id` to the currently-running transient +/// `task_id`, enforcing parent-session ownership. +pub fn task_id_for_session( + subagent_session_id: &str, + parent_session: &str, +) -> Result { + let map = registry().lock().expect("running_subagents mutex poisoned"); + let mut saw_unowned = false; + let mut owned_terminal: Option = None; + for (task_id, entry) in map + .iter() + .filter(|(_, entry)| entry.subagent_session_id.as_deref() == Some(subagent_session_id)) + { + if entry.parent_session != parent_session { + saw_unowned = true; + continue; + } + if !entry.status.borrow().is_terminal() { + return Ok(task_id.clone()); + } + owned_terminal.get_or_insert_with(|| task_id.clone()); + } + if let Some(task_id) = owned_terminal { + return Ok(task_id); + } + if saw_unowned { + return Err(WaitError::NotOwned); + } + Err(WaitError::Unknown) +} + /// Why a steer could not be delivered. #[derive(Debug, PartialEq, Eq)] pub enum SteerError { @@ -173,6 +211,43 @@ pub async fn steer( Ok(()) } +/// Trusted-control variant used by JSON-RPC sub-agent controls. +/// +/// This intentionally does not require the caller to provide `parent_session`: +/// the RPC layer is already bearer-protected and mirrors the existing +/// `subagent_cancel` control surface, which can abort a task by id. The function +/// still refuses unknown or terminal tasks and never logs the steered text. +pub async fn steer_control(task_id: &str, text: String, mode: QueueMode) -> Result<(), SteerError> { + let run_queue = { + let map = registry().lock().expect("running_subagents mutex poisoned"); + let entry = map.get(task_id).ok_or(SteerError::Unknown)?; + if entry.status.borrow().is_terminal() { + return Err(SteerError::AlreadyDone); + } + entry.run_queue.clone() + }; + + run_queue + .push(QueuedMessage { + text, + mode, + client_id: "subagent_control_rpc".to_string(), + thread_id: task_id.to_string(), + queued_at_ms: now_ms(), + model_override: None, + temperature: None, + profile_id: None, + locale: None, + }) + .await; + log::info!( + "[running_subagents] control_steered task_id={} mode={}", + task_id, + mode + ); + Ok(()) +} + /// Why a wait could not be set up. #[derive(Debug, PartialEq, Eq)] pub enum WaitError { @@ -243,6 +318,8 @@ pub async fn wait( pub struct CancelledSubagent { pub agent_id: String, pub parent_session: String, + pub subagent_session_id: Option, + pub workspace_dir: PathBuf, pub parent_thread_id: Option, } @@ -267,10 +344,20 @@ pub fn cancel_by_task(task_id: &str) -> Option { Some(CancelledSubagent { agent_id: entry.agent_id, parent_session: entry.parent_session, + subagent_session_id: entry.subagent_session_id, + workspace_dir: entry.workspace_dir, parent_thread_id: entry.parent_thread_id, }) } +pub fn cancel_by_session( + subagent_session_id: &str, + parent_session: &str, +) -> Option { + let task_id = task_id_for_session(subagent_session_id, parent_session).ok()?; + cancel_by_task(&task_id) +} + /// Abort a running sub-agent and drop its registry entry. Kept for a future /// `close_agent` tool; the abort handle is stored at spawn time. pub fn close(task_id: &str, parent_session: &str) -> bool { @@ -396,6 +483,8 @@ mod tests { task_id.into(), "researcher".into(), parent_session.into(), + None, + std::env::temp_dir(), parent_thread_id.map(Into::into), rq, dummy_abort(), @@ -404,6 +493,78 @@ mod tests { tx } + #[tokio::test] + async fn task_id_for_session_enforces_parent_ownership() { + let _guard = test_guard(); + let rq = RunQueue::new(); + let (tx, rx) = status_channel(); + register( + "task-session".into(), + "researcher".into(), + "session-owner".into(), + Some("subsess-1".into()), + std::env::temp_dir(), + Some("thread-1".into()), + rq, + dummy_abort(), + rx, + ); + + assert_eq!( + task_id_for_session("subsess-1", "session-owner").unwrap(), + "task-session" + ); + assert!(matches!( + task_id_for_session("subsess-1", "session-other"), + Err(WaitError::NotOwned) + )); + let _ = tx.send(SubagentStatus::Completed { + output: "done".into(), + iterations: 1, + }); + prune("task-session"); + } + + #[tokio::test] + async fn task_id_for_session_prefers_live_task_over_terminal_task() { + let _guard = test_guard(); + let (old_tx, old_rx) = status_channel(); + register( + "task-old".into(), + "researcher".into(), + "session-owner".into(), + Some("subsess-live".into()), + std::env::temp_dir(), + Some("thread-1".into()), + RunQueue::new(), + dummy_abort(), + old_rx, + ); + let _ = old_tx.send(SubagentStatus::Completed { + output: "old".into(), + iterations: 1, + }); + let (_new_tx, new_rx) = status_channel(); + register( + "task-new".into(), + "researcher".into(), + "session-owner".into(), + Some("subsess-live".into()), + std::env::temp_dir(), + Some("thread-1".into()), + RunQueue::new(), + dummy_abort(), + new_rx, + ); + + assert_eq!( + task_id_for_session("subsess-live", "session-owner").unwrap(), + "task-new" + ); + prune("task-old"); + prune("task-new"); + } + #[tokio::test] async fn steer_pushes_into_the_subagent_queue() { let _guard = test_guard(); diff --git a/src/openhuman/agent_orchestration/subagent_control.rs b/src/openhuman/agent_orchestration/subagent_control.rs index 8ef5391e7..32f92e5da 100644 --- a/src/openhuman/agent_orchestration/subagent_control.rs +++ b/src/openhuman/agent_orchestration/subagent_control.rs @@ -18,20 +18,30 @@ use serde_json::{json, Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::agent_orchestration::{background_completions, running_subagents}; +use crate::openhuman::agent::harness::run_queue::QueueMode; +use crate::openhuman::agent_orchestration::running_subagents::SteerError; +use crate::openhuman::agent_orchestration::{ + background_completions, running_subagents, subagent_sessions, +}; use crate::rpc::RpcOutcome; /// Controller schemas exposed for detached sub-agent control. pub fn all_controller_schemas() -> Vec { - vec![schema_for("subagent_cancel")] + vec![schema_for("subagent_cancel"), schema_for("subagent_steer")] } /// Registered controllers (schema + handler) for detached sub-agent control. pub fn all_registered_controllers() -> Vec { - vec![RegisteredController { - schema: schema_for("subagent_cancel"), - handler: handle_subagent_cancel, - }] + vec![ + RegisteredController { + schema: schema_for("subagent_cancel"), + handler: handle_subagent_cancel, + }, + RegisteredController { + schema: schema_for("subagent_steer"), + handler: handle_subagent_steer, + }, + ] } fn schema_for(function: &str) -> ControllerSchema { @@ -58,6 +68,28 @@ fn schema_for(function: &str) -> ControllerSchema { "{ cancelled: bool, taskId: string } — cancelled=false if nothing was running.", )], }, + "subagent_steer" => ControllerSchema { + namespace: "subagent", + function: "steer", + description: "Inject a message into a still-running detached background sub-agent by \ + spawn task id. This trusted RPC control mirrors the steer_subagent agent \ + tool and returns immediately after the message is queued.", + inputs: vec![ + required_str( + "taskId", + "Spawn task id (`sub-…`) of the running background sub-agent.", + ), + required_str( + "message", + "Instruction or context to queue for the running sub-agent.", + ), + optional_str("mode", "Optional queue mode: steer (default) or collect."), + ], + outputs: vec![json_output( + "result", + "{ steered: bool, taskId: string, mode: string }.", + )], + }, _ => ControllerSchema { namespace: "subagent", function: "unknown", @@ -92,12 +124,26 @@ fn handle_subagent_cancel(params: Map) -> ControllerFuture { // record a completion that flows through the same idle-gated // delivery and surfaces the cancellation in chat. background_completions::record_completion( - meta.parent_session, + meta.parent_session.clone(), &task_id, - meta.agent_id, + meta.agent_id.clone(), summary, - meta.parent_thread_id, + meta.parent_thread_id.clone(), ); + if let Some(subagent_session_id) = meta.subagent_session_id { + let store = subagent_sessions::SubagentSessionStore::new(meta.workspace_dir); + if let Err(err) = subagent_sessions::mark_failed( + &store, + &subagent_session_id, + &task_id, + "cancelled by user".to_string(), + ) { + log::warn!( + target: "subagent_control_rpc", + "[subagent_control_rpc][{cid}] cancel.mark_failed_failed task_id={task_id} subagent_session_id={subagent_session_id} error={err}" + ); + } + } true } None => false, @@ -111,6 +157,53 @@ fn handle_subagent_cancel(params: Map) -> ControllerFuture { }) } +fn handle_subagent_steer(params: Map) -> ControllerFuture { + Box::pin(async move { + let cid = new_correlation_id(); + let task_id = require_str(¶ms, "taskId")?; + let message = require_str(¶ms, "message")?; + let mode = match opt_str(¶ms, "mode").as_deref() { + Some("collect") => QueueMode::Collect, + _ => QueueMode::Steer, + }; + log::debug!( + target: "subagent_control_rpc", + "[subagent_control_rpc][{cid}] steer.entry task_id={task_id} mode={mode} chars={}", + message.chars().count() + ); + + match running_subagents::steer_control(&task_id, message, mode).await { + Ok(()) => { + log::debug!( + target: "subagent_control_rpc", + "[subagent_control_rpc][{cid}] steer.done task_id={task_id} mode={mode} steered=true" + ); + to_json(json!({ "steered": true, "taskId": task_id, "mode": mode.to_string() })) + } + Err(err) => { + let reason = match err { + SteerError::Unknown => "unknown", + SteerError::AlreadyDone => "already_done", + // `steer_control` is trusted UI/RPC control and currently + // does not perform parent ownership checks. Keep the arm + // for future trust-model changes that may return NotOwned. + SteerError::NotOwned => "not_owned", + }; + log::debug!( + target: "subagent_control_rpc", + "[subagent_control_rpc][{cid}] steer.done task_id={task_id} mode={mode} steered=false reason={reason}" + ); + to_json(json!({ + "steered": false, + "taskId": task_id, + "mode": mode.to_string(), + "reason": reason, + })) + } + } + }) +} + fn to_json(value: T) -> Result { RpcOutcome::new(value, vec![]).into_cli_compatible_json() } @@ -177,9 +270,11 @@ mod tests { let schemas = all_controller_schemas(); let registered = all_registered_controllers(); assert_eq!(schemas.len(), registered.len()); - assert_eq!(schemas.len(), 1); + assert_eq!(schemas.len(), 2); assert_eq!(schema_for("subagent_cancel").namespace, "subagent"); assert_eq!(schema_for("subagent_cancel").function, "cancel"); + assert_eq!(schema_for("subagent_steer").namespace, "subagent"); + assert_eq!(schema_for("subagent_steer").function, "steer"); } #[test] @@ -211,4 +306,18 @@ mod tests { .and_then(Value::as_bool); assert_eq!(cancelled, Some(false)); } + + #[tokio::test] + async fn steer_unknown_task_is_a_noop_false() { + let _lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut params = Map::new(); + params.insert("taskId".into(), json!("sub-does-not-exist")); + params.insert("message".into(), json!("redirect")); + let out = handle_subagent_steer(params).await.expect("handler ok"); + let data = out.get("data").unwrap_or(&out); + assert_eq!(data.get("steered").and_then(Value::as_bool), Some(false)); + assert_eq!(data.get("reason").and_then(Value::as_str), Some("unknown")); + } } diff --git a/src/openhuman/agent_orchestration/subagent_sessions/mod.rs b/src/openhuman/agent_orchestration/subagent_sessions/mod.rs new file mode 100644 index 000000000..b72077b9b --- /dev/null +++ b/src/openhuman/agent_orchestration/subagent_sessions/mod.rs @@ -0,0 +1,12 @@ +mod ops; +mod store; +mod types; + +pub use ops::{ + action_root_key, close, find_reusable, list_for_parent, mark_failed, mark_finished, + normalize_task_key, reuse_decision, task_title_from_prompt, upsert_running, +}; +pub use types::{ + DurableSubagentSession, DurableSubagentSessionSummary, DurableSubagentStatus, ReuseDecision, + SubagentSessionSelector, SubagentSessionStore, SubagentSessionUpsert, +}; diff --git a/src/openhuman/agent_orchestration/subagent_sessions/ops.rs b/src/openhuman/agent_orchestration/subagent_sessions/ops.rs new file mode 100644 index 000000000..4af0b988b --- /dev/null +++ b/src/openhuman/agent_orchestration/subagent_sessions/ops.rs @@ -0,0 +1,425 @@ +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; + +use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus; +use crate::openhuman::inference::provider::ChatMessage; + +use super::types::{ + DurableSubagentSession, DurableSubagentStatus, ReuseDecision, SubagentSessionSelector, + SubagentSessionStore, SubagentSessionUpsert, +}; + +pub fn normalize_task_key(input: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + for ch in input.trim().to_lowercase().chars() { + if ch.is_alphanumeric() { + out.push(ch); + last_dash = false; + } else if !last_dash && !out.is_empty() { + out.push('-'); + last_dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + if out.is_empty() { + if input.trim().is_empty() { + "untitled-task".to_string() + } else { + format!("task-{:016x}", task_key_hash(input)) + } + } else if out.len() > 96 { + let hash = format!("{:016x}", task_key_hash(input)); + let mut prefix = String::new(); + for ch in out.chars() { + if prefix.len() + ch.len_utf8() + hash.len() + 1 > 96 { + break; + } + prefix.push(ch); + } + while prefix.ends_with('-') { + prefix.pop(); + } + format!("{prefix}-{hash}") + } else { + out + } +} + +fn task_key_hash(input: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + input.hash(&mut hasher); + hasher.finish() +} + +pub fn task_title_from_prompt(prompt: &str) -> String { + let collapsed = prompt.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= 80 { + collapsed + } else { + let mut title: String = collapsed.chars().take(77).collect(); + title.push_str("..."); + title + } +} + +pub fn action_root_key(path: Option<&Path>) -> Option { + path.map(|p| p.to_string_lossy().to_string()) +} + +pub fn find_reusable( + store: &SubagentSessionStore, + selector: &SubagentSessionSelector, +) -> Result, String> { + let mut matches: Vec = store + .load()? + .into_iter() + .filter(|session| session.matches_selector(selector)) + .collect(); + matches.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at)); + Ok(matches.into_iter().next()) +} + +pub fn upsert_running( + store: &SubagentSessionStore, + upsert: SubagentSessionUpsert, + reuse: Option<&DurableSubagentSession>, +) -> Result { + let _guard = store_write_lock() + .lock() + .map_err(|_| "subagent session store lock poisoned".to_string())?; + let mut sessions = store.load()?; + let now = chrono::Utc::now().to_rfc3339(); + let session_id = reuse + .map(|session| session.subagent_session_id.clone()) + .unwrap_or_else(|| format!("subsess-{}", uuid::Uuid::new_v4())); + + let mut session = if let Some(existing) = sessions + .iter() + .find(|session| session.subagent_session_id == session_id) + .cloned() + { + existing + } else { + DurableSubagentSession { + subagent_session_id: session_id.clone(), + parent_session: upsert.selector.parent_session.clone(), + parent_thread_id: upsert.selector.parent_thread_id.clone(), + worker_thread_id: upsert.worker_thread_id.clone(), + agent_id: upsert.selector.agent_id.clone(), + display_name: upsert.display_name.clone(), + toolkit: upsert.selector.toolkit.clone(), + model: upsert.selector.model.clone(), + sandbox_mode: upsert.selector.sandbox_mode.clone(), + action_root: upsert.selector.action_root.clone(), + task_key: upsert.selector.task_key.clone(), + task_title: upsert.task_title.clone(), + current_task_id: None, + status: DurableSubagentStatus::Running, + reusable: true, + latest_history: None, + latest_error: None, + created_at: now.clone(), + updated_at: now.clone(), + last_used_at: now.clone(), + } + }; + + session.parent_session = upsert.selector.parent_session; + session.parent_thread_id = upsert.selector.parent_thread_id; + session.worker_thread_id = upsert.worker_thread_id.or(session.worker_thread_id); + session.display_name = upsert.display_name.or(session.display_name); + session.current_task_id = Some(upsert.task_id); + session.status = DurableSubagentStatus::Running; + session.reusable = true; + session.latest_error = None; + session.updated_at = now.clone(); + session.last_used_at = now; + + sessions.retain(|existing| existing.subagent_session_id != session_id); + sessions.push(session.clone()); + store.save(&sessions)?; + Ok(session) +} + +pub fn mark_finished( + store: &SubagentSessionStore, + subagent_session_id: &str, + task_id: &str, + run_status: &SubagentRunStatus, + history: Vec, +) -> Result<(), String> { + let updated = update_session(store, subagent_session_id, |session, now| { + session.current_task_id = Some(task_id.to_string()); + session.status = DurableSubagentStatus::from_run_status(run_status); + session.latest_history = Some(history); + session.latest_error = None; + session.updated_at = now.clone(); + session.last_used_at = now; + })?; + if updated { + Ok(()) + } else { + Err(format!( + "sub-agent session not found: {subagent_session_id}" + )) + } +} + +pub fn mark_failed( + store: &SubagentSessionStore, + subagent_session_id: &str, + task_id: &str, + error: String, +) -> Result<(), String> { + let updated = update_session(store, subagent_session_id, |session, now| { + session.current_task_id = Some(task_id.to_string()); + session.status = DurableSubagentStatus::Failed; + session.latest_error = Some(error); + session.updated_at = now.clone(); + session.last_used_at = now; + })?; + if updated { + Ok(()) + } else { + Err(format!( + "sub-agent session not found: {subagent_session_id}" + )) + } +} + +pub fn close(store: &SubagentSessionStore, subagent_session_id: &str) -> Result { + update_session(store, subagent_session_id, |session, now| { + session.status = DurableSubagentStatus::Closed; + session.reusable = false; + session.updated_at = now.clone(); + session.last_used_at = now; + }) +} + +pub fn list_for_parent( + store: &SubagentSessionStore, + parent_session: &str, + parent_thread_id: Option<&str>, +) -> Result, String> { + let mut sessions: Vec = store + .load()? + .into_iter() + .filter(|session| { + session.parent_session == parent_session + && parent_thread_id + .map(|thread_id| session.parent_thread_id.as_deref() == Some(thread_id)) + .unwrap_or(true) + }) + .collect(); + sessions.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at)); + Ok(sessions) +} + +pub fn reuse_decision(reuse: Option<&DurableSubagentSession>, force_fresh: bool) -> ReuseDecision { + if force_fresh { + return ReuseDecision::ForcedFresh; + } + match reuse.map(|session| session.status) { + Some(DurableSubagentStatus::Running) => ReuseDecision::ReusedRunning, + Some(_) => ReuseDecision::ReusedIdle, + None => ReuseDecision::SpawnedNew, + } +} + +fn update_session( + store: &SubagentSessionStore, + subagent_session_id: &str, + update: F, +) -> Result +where + F: FnOnce(&mut DurableSubagentSession, String), +{ + let _guard = store_write_lock() + .lock() + .map_err(|_| "subagent session store lock poisoned".to_string())?; + let mut sessions = store.load()?; + let now = chrono::Utc::now().to_rfc3339(); + if let Some(session) = sessions + .iter_mut() + .find(|session| session.subagent_session_id == subagent_session_id) + { + update(session, now); + store.save(&sessions)?; + return Ok(true); + } + Ok(false) +} + +fn store_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::agent_orchestration::subagent_sessions::types::SubagentSessionUpsert; + + fn selector(task_key: &str) -> SubagentSessionSelector { + SubagentSessionSelector { + parent_session: "parent-a".into(), + parent_thread_id: Some("thread-a".into()), + agent_id: "researcher".into(), + toolkit: Some("github".into()), + model: Some("oh-1".into()), + sandbox_mode: "read_only".into(), + action_root: Some("/tmp/work".into()), + task_key: task_key.into(), + } + } + + #[test] + fn normalize_task_key_is_deterministic() { + assert_eq!( + normalize_task_key(" Review: GitHub PR #123!! "), + "review-github-pr-123" + ); + assert_eq!(normalize_task_key(" "), "untitled-task"); + } + + #[test] + fn normalize_task_key_preserves_non_latin_words() { + assert_ne!(normalize_task_key("研究 caching"), "untitled-task"); + assert_ne!( + normalize_task_key("研究 caching"), + normalize_task_key("調査 caching") + ); + } + + #[test] + fn normalize_task_key_hashes_empty_or_long_colliding_slugs() { + assert_ne!(normalize_task_key("🙂🙂🙂"), "untitled-task"); + let prefix = "research ".repeat(40); + let first = normalize_task_key(&(prefix.clone() + "alpha")); + let second = normalize_task_key(&(prefix + "beta")); + assert!(first.len() <= 96, "{first}"); + assert!(second.len() <= 96, "{second}"); + assert_ne!(first, second); + } + + #[test] + fn compatible_session_reuses_and_incompatible_shape_spawns_new() { + let dir = tempfile::tempdir().unwrap(); + let store = SubagentSessionStore::new(dir.path().to_path_buf()); + let upsert = SubagentSessionUpsert { + selector: selector("same-task"), + display_name: Some("Researcher".into()), + task_title: "Same task".into(), + worker_thread_id: Some("worker-1".into()), + task_id: "sub-1".into(), + }; + let session = upsert_running(&store, upsert, None).unwrap(); + mark_finished( + &store, + &session.subagent_session_id, + "sub-1", + &SubagentRunStatus::Completed, + vec![ChatMessage::user("done")], + ) + .unwrap(); + + let reusable = find_reusable(&store, &selector("same-task")) + .unwrap() + .expect("same selector reuses"); + assert_eq!(reusable.subagent_session_id, session.subagent_session_id); + + let mut different = selector("same-task"); + different.action_root = Some("/tmp/other".into()); + assert!(find_reusable(&store, &different).unwrap().is_none()); + } + + #[test] + fn closed_session_is_not_reusable() { + let dir = tempfile::tempdir().unwrap(); + let store = SubagentSessionStore::new(dir.path().to_path_buf()); + let session = upsert_running( + &store, + SubagentSessionUpsert { + selector: selector("task"), + display_name: None, + task_title: "Task".into(), + worker_thread_id: None, + task_id: "sub-1".into(), + }, + None, + ) + .unwrap(); + + assert!(close(&store, &session.subagent_session_id).unwrap()); + assert!(find_reusable(&store, &selector("task")).unwrap().is_none()); + } + + #[test] + fn list_for_parent_without_thread_id_does_not_filter_by_thread() { + let dir = tempfile::tempdir().unwrap(); + let store = SubagentSessionStore::new(dir.path().to_path_buf()); + let first = upsert_running( + &store, + SubagentSessionUpsert { + selector: selector("first"), + display_name: None, + task_title: "First".into(), + worker_thread_id: None, + task_id: "sub-1".into(), + }, + None, + ) + .unwrap(); + + let mut second_selector = selector("second"); + second_selector.parent_thread_id = Some("thread-b".into()); + let second = upsert_running( + &store, + SubagentSessionUpsert { + selector: second_selector, + display_name: None, + task_title: "Second".into(), + worker_thread_id: None, + task_id: "sub-2".into(), + }, + None, + ) + .unwrap(); + + let all = list_for_parent(&store, "parent-a", None).unwrap(); + assert_eq!(all.len(), 2); + assert!(all + .iter() + .any(|session| session.subagent_session_id == first.subagent_session_id)); + assert!(all + .iter() + .any(|session| session.subagent_session_id == second.subagent_session_id)); + + let thread_a = list_for_parent(&store, "parent-a", Some("thread-a")).unwrap(); + assert_eq!(thread_a.len(), 1); + assert_eq!(thread_a[0].subagent_session_id, first.subagent_session_id); + } + + #[test] + fn missing_session_updates_return_errors() { + let dir = tempfile::tempdir().unwrap(); + let store = SubagentSessionStore::new(dir.path().to_path_buf()); + assert!(mark_finished( + &store, + "missing", + "sub-1", + &SubagentRunStatus::Completed, + vec![] + ) + .is_err()); + assert!(mark_failed(&store, "missing", "sub-1", "boom".into()).is_err()); + assert!(!close(&store, "missing").unwrap()); + } +} diff --git a/src/openhuman/agent_orchestration/subagent_sessions/store.rs b/src/openhuman/agent_orchestration/subagent_sessions/store.rs new file mode 100644 index 000000000..b6533b5b8 --- /dev/null +++ b/src/openhuman/agent_orchestration/subagent_sessions/store.rs @@ -0,0 +1,49 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::types::{DurableSubagentSession, SubagentSessionStore}; + +impl SubagentSessionStore { + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } + + pub fn path(&self) -> PathBuf { + self.workspace_dir + .join(".openhuman") + .join("subagent_sessions.json") + } + + pub fn load(&self) -> Result, String> { + load_from_path(&self.path()) + } + + pub fn save(&self, sessions: &[DurableSubagentSession]) -> Result<(), String> { + save_to_path(&self.path(), sessions) + } +} + +pub(crate) fn load_from_path(path: &Path) -> Result, String> { + match fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw) + .map_err(|err| format!("failed to parse subagent session store: {err}")), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(err) => Err(format!("failed to read subagent session store: {err}")), + } +} + +pub(crate) fn save_to_path(path: &Path, sessions: &[DurableSubagentSession]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create subagent session dir: {err}"))?; + } + let raw = serde_json::to_string_pretty(sessions) + .map_err(|err| format!("failed to encode subagent session store: {err}"))?; + let tmp_path = path.with_extension(format!("json.tmp-{}", uuid::Uuid::new_v4().simple())); + fs::write(&tmp_path, raw) + .map_err(|err| format!("failed to write temporary subagent session store: {err}"))?; + fs::rename(&tmp_path, path).map_err(|err| { + let _ = fs::remove_file(&tmp_path); + format!("failed to commit subagent session store: {err}") + }) +} diff --git a/src/openhuman/agent_orchestration/subagent_sessions/types.rs b/src/openhuman/agent_orchestration/subagent_sessions/types.rs new file mode 100644 index 000000000..a2c2e40cd --- /dev/null +++ b/src/openhuman/agent_orchestration/subagent_sessions/types.rs @@ -0,0 +1,163 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus; +use crate::openhuman::inference::provider::ChatMessage; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DurableSubagentStatus { + Running, + Idle, + AwaitingUser, + Failed, + Closed, +} + +impl DurableSubagentStatus { + pub fn from_run_status(status: &SubagentRunStatus) -> Self { + match status { + SubagentRunStatus::Completed => Self::Idle, + SubagentRunStatus::AwaitingUser { .. } => Self::AwaitingUser, + } + } + + pub fn reusable(self) -> bool { + matches!(self, Self::Running | Self::Idle) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubagentSessionSelector { + pub parent_session: String, + pub parent_thread_id: Option, + pub agent_id: String, + pub toolkit: Option, + pub model: Option, + pub sandbox_mode: String, + pub action_root: Option, + pub task_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DurableSubagentSession { + pub subagent_session_id: String, + pub parent_session: String, + pub parent_thread_id: Option, + pub worker_thread_id: Option, + pub agent_id: String, + pub display_name: Option, + pub toolkit: Option, + pub model: Option, + pub sandbox_mode: String, + pub action_root: Option, + pub task_key: String, + pub task_title: String, + pub current_task_id: Option, + pub status: DurableSubagentStatus, + pub reusable: bool, + pub latest_history: Option>, + pub latest_error: Option, + pub created_at: String, + pub updated_at: String, + pub last_used_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DurableSubagentSessionSummary { + pub subagent_session_id: String, + pub parent_thread_id: Option, + pub worker_thread_id: Option, + pub agent_id: String, + pub display_name: Option, + pub toolkit: Option, + pub model: Option, + pub sandbox_mode: String, + pub action_root: Option, + pub task_key: String, + pub task_title: String, + pub current_task_id: Option, + pub status: DurableSubagentStatus, + pub reusable: bool, + pub latest_error: Option, + pub created_at: String, + pub updated_at: String, + pub last_used_at: String, +} + +impl From<&DurableSubagentSession> for DurableSubagentSessionSummary { + fn from(session: &DurableSubagentSession) -> Self { + Self { + subagent_session_id: session.subagent_session_id.clone(), + parent_thread_id: session.parent_thread_id.clone(), + worker_thread_id: session.worker_thread_id.clone(), + agent_id: session.agent_id.clone(), + display_name: session.display_name.clone(), + toolkit: session.toolkit.clone(), + model: session.model.clone(), + sandbox_mode: session.sandbox_mode.clone(), + action_root: session.action_root.clone(), + task_key: session.task_key.clone(), + task_title: session.task_title.clone(), + current_task_id: session.current_task_id.clone(), + status: session.status, + reusable: session.reusable, + latest_error: session.latest_error.clone(), + created_at: session.created_at.clone(), + updated_at: session.updated_at.clone(), + last_used_at: session.last_used_at.clone(), + } + } +} + +impl DurableSubagentSession { + pub fn matches_selector(&self, selector: &SubagentSessionSelector) -> bool { + self.reusable + && self.status.reusable() + && self.parent_session == selector.parent_session + && self.parent_thread_id == selector.parent_thread_id + && self.agent_id == selector.agent_id + && self.toolkit == selector.toolkit + && self.model == selector.model + && self.sandbox_mode == selector.sandbox_mode + && self.action_root == selector.action_root + && self.task_key == selector.task_key + } +} + +#[derive(Debug, Clone)] +pub struct SubagentSessionUpsert { + pub selector: SubagentSessionSelector, + pub display_name: Option, + pub task_title: String, + pub worker_thread_id: Option, + pub task_id: String, +} + +#[derive(Debug, Clone)] +pub struct SubagentSessionStore { + pub workspace_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReuseDecision { + ReusedRunning, + ReusedIdle, + SpawnedNew, + ForcedFresh, +} + +impl ReuseDecision { + pub fn as_str(&self) -> &'static str { + match self { + Self::ReusedRunning => "reused_running", + Self::ReusedIdle => "reused_idle", + Self::SpawnedNew => "spawned_new", + Self::ForcedFresh => "forced_fresh", + } + } +} diff --git a/src/openhuman/agent_orchestration/tools.rs b/src/openhuman/agent_orchestration/tools.rs index ab4c3dbca..9f8656c6b 100644 --- a/src/openhuman/agent_orchestration/tools.rs +++ b/src/openhuman/agent_orchestration/tools.rs @@ -1,9 +1,13 @@ #[path = "tools/archetype_delegation.rs"] mod archetype_delegation; +#[path = "tools/close_subagent.rs"] +mod close_subagent; #[path = "tools/continue_subagent.rs"] mod continue_subagent; #[path = "tools/dispatch.rs"] mod dispatch; +#[path = "tools/list_subagents.rs"] +mod list_subagents; #[path = "tools/skill_delegation.rs"] mod skill_delegation; #[path = "tools/spawn_async_subagent.rs"] @@ -27,7 +31,9 @@ mod worker_thread; pub(crate) use dispatch::dispatch_subagent; pub use archetype_delegation::ArchetypeDelegationTool; +pub use close_subagent::CloseSubagentTool; pub use continue_subagent::ContinueSubagentTool; +pub use list_subagents::ListSubagentsTool; pub use skill_delegation::{SkillDelegationTool, INTEGRATIONS_DELEGATE_TOOL_NAME}; pub use spawn_async_subagent::SpawnAsyncSubagentTool; pub use spawn_parallel_agents::SpawnParallelAgentsTool; diff --git a/src/openhuman/agent_orchestration/tools/close_subagent.rs b/src/openhuman/agent_orchestration/tools/close_subagent.rs new file mode 100644 index 000000000..dc83b29f2 --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/close_subagent.rs @@ -0,0 +1,336 @@ +//! Tool: `close_subagent` - retire a reusable durable sub-agent session. + +use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent_orchestration::subagent_sessions::SubagentSessionStore; +use crate::openhuman::agent_orchestration::{running_subagents, subagent_sessions}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +pub struct CloseSubagentTool; + +impl CloseSubagentTool { + pub fn new() -> Self { + Self + } +} + +impl Default for CloseSubagentTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for CloseSubagentTool { + fn name(&self) -> &str { + "close_subagent" + } + + fn description(&self) -> &str { + "Close a reusable sub-agent session so future delegation creates a fresh worker. \ + If that session is currently running, it is cancelled first." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["subagent_session_id"], + "properties": { + "subagent_session_id": { + "type": "string", + "description": "Durable subagent_session_id returned by reusable async delegation." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let subagent_session_id = args + .get("subagent_session_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if subagent_session_id.is_empty() { + return Ok(ToolResult::error( + "close_subagent: `subagent_session_id` is required", + )); + } + let parent = match current_parent() { + Some(parent) => parent, + None => { + return Ok(ToolResult::error( + "close_subagent called outside of an agent turn", + )); + } + }; + let store = SubagentSessionStore::new(parent.workspace_dir.clone()); + let parent_thread_id = + crate::openhuman::inference::provider::thread_context::current_thread_id(); + let owned = match subagent_sessions::list_for_parent( + &store, + &parent.session_id, + parent_thread_id.as_deref(), + ) { + Ok(sessions) => sessions + .iter() + .any(|session| session.subagent_session_id == subagent_session_id), + Err(err) => { + return Ok(ToolResult::error(format!( + "close_subagent: failed to read sub-agent sessions: {err}" + ))); + } + }; + if !owned { + log::warn!( + "[subagent_reuse] close rejected parent_session={} parent_thread_id={} subagent_session_id={}", + parent.session_id, + parent_thread_id.as_deref().unwrap_or("none"), + subagent_session_id + ); + return Ok(ToolResult::error( + "close_subagent: sub-agent session not found for this parent thread", + )); + } + let cancelled = + running_subagents::cancel_by_session(&subagent_session_id, &parent.session_id) + .is_some(); + match subagent_sessions::close(&store, &subagent_session_id) { + Ok(closed) => { + log::info!( + "[subagent_reuse] close subagent_session_id={} parent_session={} closed={} cancelled_running={}", + subagent_session_id, + parent.session_id, + closed, + cancelled + ); + Ok(ToolResult::success(format!( + "Closed reusable sub-agent session `{subagent_session_id}` (closed={closed}, cancelled_running={cancelled})." + ))) + } + Err(err) => Ok(ToolResult::error(format!( + "close_subagent: failed to update sub-agent session: {err}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::agent::harness::fork_context::{ + with_parent_context, ParentExecutionContext, + }; + use crate::openhuman::config::AgentConfig; + use crate::openhuman::context::prompt::ToolCallFormat; + use crate::openhuman::inference::provider::Provider; + use crate::openhuman::memory::{ + Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts, + }; + use std::collections::HashSet; + use std::path::Path; + use std::sync::Arc; + + #[tokio::test] + async fn missing_session_id_is_rejected() { + let res = CloseSubagentTool::new().execute(json!({})).await.unwrap(); + assert!(res.is_error); + assert!(res.output().contains("subagent_session_id")); + } + + #[tokio::test] + async fn rejects_session_from_different_parent_thread() { + let workspace = tempfile::TempDir::new().expect("workspace"); + let store = SubagentSessionStore::new(workspace.path().to_path_buf()); + let session = seed_session(&store, "thread-b"); + + let res = with_parent_context(parent_context(workspace.path()), async { + crate::openhuman::inference::provider::thread_context::with_thread_id( + "thread-a", + async { + CloseSubagentTool::new() + .execute(json!({ + "subagent_session_id": session.subagent_session_id, + })) + .await + }, + ) + .await + }) + .await + .unwrap(); + + assert!(res.is_error); + assert!(res.output().contains("not found for this parent thread")); + assert!( + subagent_sessions::find_reusable(&store, &selector("thread-b")) + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn closes_session_owned_by_current_parent_thread() { + let workspace = tempfile::TempDir::new().expect("workspace"); + let store = SubagentSessionStore::new(workspace.path().to_path_buf()); + let session = seed_session(&store, "thread-a"); + + let res = with_parent_context(parent_context(workspace.path()), async { + crate::openhuman::inference::provider::thread_context::with_thread_id( + "thread-a", + async { + CloseSubagentTool::new() + .execute(json!({ + "subagent_session_id": session.subagent_session_id, + })) + .await + }, + ) + .await + }) + .await + .unwrap(); + + assert!(!res.is_error, "{}", res.output()); + assert!(res.output().contains("closed=true")); + assert!( + subagent_sessions::find_reusable(&store, &selector("thread-a")) + .unwrap() + .is_none() + ); + } + + fn seed_session( + store: &SubagentSessionStore, + parent_thread_id: &str, + ) -> subagent_sessions::DurableSubagentSession { + subagent_sessions::upsert_running( + store, + subagent_sessions::SubagentSessionUpsert { + selector: selector(parent_thread_id), + display_name: Some("Researcher".into()), + task_title: "Task".into(), + worker_thread_id: Some("worker-1".into()), + task_id: "sub-1".into(), + }, + None, + ) + .unwrap() + } + + fn selector(parent_thread_id: &str) -> subagent_sessions::SubagentSessionSelector { + subagent_sessions::SubagentSessionSelector { + parent_session: "parent-session".into(), + parent_thread_id: Some(parent_thread_id.into()), + agent_id: "researcher".into(), + toolkit: None, + model: None, + sandbox_mode: "workspace".into(), + action_root: None, + task_key: "task".into(), + } + } + + fn parent_context(workspace_dir: &Path) -> ParentExecutionContext { + ParentExecutionContext { + agent_definition_id: "orchestrator".into(), + allowed_subagent_ids: HashSet::new(), + provider: Arc::new(NoopProvider), + all_tools: Arc::new(Vec::new()), + all_tool_specs: Arc::new(Vec::new()), + model_name: "test-model".into(), + temperature: 0.0, + workspace_dir: workspace_dir.to_path_buf(), + memory: Arc::new(NoopMemory), + agent_config: AgentConfig::default(), + workflows: Arc::new(Vec::new()), + memory_context: Arc::new(None), + session_id: "parent-session".into(), + channel: "test".into(), + connected_integrations: Vec::new(), + tool_call_format: ToolCallFormat::Native, + session_key: "parent-key".into(), + session_parent_prefix: None, + on_progress: None, + run_queue: None, + } + } + + struct NoopProvider; + + #[async_trait::async_trait] + impl Provider for NoopProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + } + + struct NoopMemory; + + #[async_trait::async_trait] + impl Memory for NoopMemory { + fn name(&self) -> &str { + "noop" + } + + async fn store( + &self, + _namespace: &str, + _key: &str, + _content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall( + &self, + _query: &str, + _limit: usize, + _opts: RecallOpts<'_>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn get(&self, _namespace: &str, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list( + &self, + _namespace: Option<&str>, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn forget(&self, _namespace: &str, _key: &str) -> anyhow::Result { + Ok(false) + } + + async fn namespace_summaries(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } + } +} diff --git a/src/openhuman/agent_orchestration/tools/list_subagents.rs b/src/openhuman/agent_orchestration/tools/list_subagents.rs new file mode 100644 index 000000000..183d029c0 --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/list_subagents.rs @@ -0,0 +1,126 @@ +//! Tool: `list_subagents` - inspect reusable sub-agent sessions for this parent. + +use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent_orchestration::subagent_sessions::{ + self, DurableSubagentSessionSummary, SubagentSessionStore, +}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +pub struct ListSubagentsTool; + +impl ListSubagentsTool { + pub fn new() -> Self { + Self + } +} + +impl Default for ListSubagentsTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for ListSubagentsTool { + fn name(&self) -> &str { + "list_subagents" + } + + fn description(&self) -> &str { + "List active or reusable durable sub-agents owned by the current parent thread." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": [], + "properties": {} + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + let parent = match current_parent() { + Some(parent) => parent, + None => { + return Ok(ToolResult::error( + "list_subagents called outside of an agent turn", + )); + } + }; + let parent_thread_id = + crate::openhuman::inference::provider::thread_context::current_thread_id(); + let store = SubagentSessionStore::new(parent.workspace_dir.clone()); + match subagent_sessions::list_for_parent( + &store, + &parent.session_id, + parent_thread_id.as_deref(), + ) { + Ok(sessions) => { + let summaries: Vec = sessions + .iter() + .map(DurableSubagentSessionSummary::from) + .collect(); + log::debug!( + "[subagent_reuse] list parent_thread_id={} parent_session={} count={}", + parent_thread_id.as_deref().unwrap_or("none"), + parent.session_id, + summaries.len() + ); + Ok(ToolResult::success(format!( + "[subagent_sessions]\n{}\n[/subagent_sessions]", + serde_json::to_string_pretty(&summaries).unwrap_or_else(|_| "[]".to_string()) + ))) + } + Err(err) => Ok(ToolResult::error(format!( + "list_subagents: failed to read sub-agent sessions: {err}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_has_no_required_fields() { + let schema = ListSubagentsTool::new().parameters_schema(); + assert!(schema + .get("required") + .and_then(|v| v.as_array()) + .expect("required") + .is_empty()); + } + + #[test] + fn summary_projection_does_not_include_history() { + let raw = serde_json::to_string(&DurableSubagentSessionSummary { + subagent_session_id: "subsess-1".into(), + parent_thread_id: Some("thread-1".into()), + worker_thread_id: Some("worker-1".into()), + agent_id: "researcher".into(), + display_name: Some("Researcher".into()), + toolkit: None, + model: Some("agentic-v1".into()), + sandbox_mode: "workspace".into(), + action_root: None, + task_key: "task".into(), + task_title: "Task".into(), + current_task_id: Some("sub-1".into()), + status: subagent_sessions::DurableSubagentStatus::Idle, + reusable: true, + latest_error: None, + created_at: "now".into(), + updated_at: "now".into(), + last_used_at: "now".into(), + }) + .unwrap(); + assert!(!raw.contains("latestHistory")); + } +} diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs index 592af786a..fd132fc5f 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -13,6 +13,11 @@ use crate::openhuman::agent::harness::subagent_runner::{ }; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::agent_orchestration::running_subagents::{self, SubagentStatus}; +use crate::openhuman::agent_orchestration::subagent_sessions::{ + self, DurableSubagentStatus, SubagentSessionSelector, SubagentSessionStore, + SubagentSessionUpsert, +}; +use crate::openhuman::inference::provider::ChatMessage; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; @@ -87,6 +92,14 @@ impl Tool for SpawnAsyncSubagentTool { "task_title": { "type": "string", "description": "Optional short title for the persisted background worker thread." + }, + "task_key": { + "type": "string", + "description": "Optional deterministic identity key for reusable delegation. Defaults to a normalized task_title/prompt." + }, + "fresh": { + "type": "boolean", + "description": "When true, bypass reusable subagent matching and create a fresh durable worker." } } }) @@ -130,6 +143,9 @@ impl Tool for SpawnAsyncSubagentTool { .filter(|s| !s.is_empty()) .unwrap_or("Background subagent") .to_string(); + let task_key_source = durable_task_key_source(&args, &prompt, context.as_deref()); + let task_key = subagent_sessions::normalize_task_key(&task_key_source); + let force_fresh = args.get("fresh").and_then(|v| v.as_bool()).unwrap_or(false); if agent_id.is_empty() { return Ok(ToolResult::error( @@ -191,20 +207,209 @@ impl Tool for SpawnAsyncSubagentTool { let parent_session = parent.session_id.clone(); let progress_sink = parent.on_progress.clone(); + let parent_thread_id = + crate::openhuman::inference::provider::thread_context::current_thread_id(); + let store = SubagentSessionStore::new(parent.workspace_dir.clone()); + let effective_action_root = crate::openhuman::agent::harness::current_action_dir_override() + .or_else(|| { + crate::openhuman::security::live_policy::current() + .map(|policy| policy.action_dir.clone()) + }); + let selector = SubagentSessionSelector { + parent_session: parent_session.clone(), + parent_thread_id: parent_thread_id.clone(), + agent_id: definition.id.clone(), + toolkit: toolkit_override.clone(), + model: model_override.clone(), + sandbox_mode: format!("{:?}", definition.sandbox_mode), + action_root: subagent_sessions::action_root_key(effective_action_root.as_deref()), + task_key: task_key.clone(), + }; + + let reusable = if force_fresh { + match subagent_sessions::find_reusable(&store, &selector) { + Ok(Some(session)) => { + let _ = running_subagents::cancel_by_session( + &session.subagent_session_id, + &parent_session, + ); + if let Err(err) = subagent_sessions::close(&store, &session.subagent_session_id) + { + log::warn!( + "[subagent_reuse] fresh close failed parent_thread_id={} subagent_session_id={} agent_id={} task_key={} error={}", + parent_thread_id.as_deref().unwrap_or("none"), + session.subagent_session_id, + definition.id, + task_key, + err + ); + } + } + Ok(None) => {} + Err(err) => { + log::warn!( + "[subagent_reuse] fresh lookup failed parent_thread_id={} agent_id={} task_key={} error={}", + parent_thread_id.as_deref().unwrap_or("none"), + definition.id, + task_key, + err + ); + } + } + None + } else { + match subagent_sessions::find_reusable(&store, &selector) { + Ok(session) => session, + Err(err) => { + log::warn!( + "[subagent_reuse] lookup failed parent_thread_id={} agent_id={} task_key={} error={}", + parent_thread_id.as_deref().unwrap_or("none"), + definition.id, + task_key, + err + ); + None + } + } + }; + let reuse_decision = subagent_sessions::reuse_decision(reusable.as_ref(), force_fresh); + let follow_up_prompt = reusable_follow_up_message(&prompt, context.as_deref()); + + if let Some(session) = reusable.as_ref() { + if session.status == DurableSubagentStatus::Running { + if let Some(ref running_task_id) = session.current_task_id { + match running_subagents::steer( + running_task_id, + &parent_session, + follow_up_prompt.clone(), + crate::openhuman::agent::harness::run_queue::QueueMode::Steer, + ) + .await + { + Ok(()) => { + log::info!( + "[subagent_reuse] parent_thread_id={} subagent_session_id={} task_id={} agent_id={} reuse_decision={}", + parent_thread_id.as_deref().unwrap_or("none"), + session.subagent_session_id, + running_task_id, + definition.id, + reuse_decision.as_str() + ); + let payload = json!({ + "task_id": running_task_id, + "subagent_session_id": session.subagent_session_id, + "agent_id": definition.id, + "mode": "async", + "worker_thread_id": session.worker_thread_id, + "reused": true, + "reuse_decision": reuse_decision.as_str(), + }); + return Ok(ToolResult::success(format!( + "Continued reusable sub-agent `{}` (subagent_session_id `{}`, task_id `{}`). \ + It is already running and will pick up the new instruction at its next step.\n\n[async_subagent_ref]\n{}\n[/async_subagent_ref]", + payload["agent_id"].as_str().unwrap_or("subagent"), + payload["subagent_session_id"].as_str().unwrap_or(""), + running_task_id, + serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()) + ))); + } + Err(err) => { + log::warn!( + "[subagent_reuse] running steer failed parent_thread_id={} subagent_session_id={} task_id={} agent_id={} error={:?}", + parent_thread_id.as_deref().unwrap_or("none"), + session.subagent_session_id, + running_task_id, + definition.id, + err + ); + } + } + } + } + } + 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| { + let worker_thread_id = reusable + .as_ref() + .and_then(|session| session.worker_thread_id.clone()) + .or_else(|| { + parent_thread_id.as_ref().and_then(|parent_thread_id| { super::worker_thread::create_worker_thread( parent.workspace_dir.clone(), - &parent_thread_id, + parent_thread_id, &definition.id, &task_title, &prompt, ) .ok() - }, - ); + }) + }); + if let (Some(session), Some(worker_thread_id)) = + (reusable.as_ref(), worker_thread_id.as_ref()) + { + if session.worker_thread_id.as_deref() == Some(worker_thread_id.as_str()) { + if let Err(err) = super::worker_thread::append_worker_user_message( + parent.workspace_dir.clone(), + worker_thread_id, + &definition.id, + &task_id, + &follow_up_prompt, + ) { + log::warn!( + "[subagent_reuse] worker follow-up append failed parent_thread_id={} subagent_session_id={} worker_thread_id={} task_id={} error={}", + parent_thread_id.as_deref().unwrap_or("none"), + session.subagent_session_id, + worker_thread_id, + task_id, + err + ); + } + } + } + let durable_session = match subagent_sessions::upsert_running( + &store, + SubagentSessionUpsert { + selector, + display_name: Some(definition.display_name().to_string()), + task_title: task_title.clone(), + worker_thread_id: worker_thread_id.clone(), + task_id: task_id.clone(), + }, + reusable.as_ref(), + ) { + Ok(session) => session, + Err(err) => { + log::warn!( + "[subagent_reuse] upsert failed parent_thread_id={} task_id={} agent_id={} reuse_decision={} error={}", + parent_thread_id.as_deref().unwrap_or("none"), + task_id, + definition.id, + reuse_decision.as_str(), + err + ); + return Ok(ToolResult::error(format!( + "spawn_async_subagent: failed to persist reusable sub-agent session: {err}" + ))); + } + }; + + let initial_history = reusable + .as_ref() + .and_then(|session| session.latest_history.clone()) + .map(|mut history| { + history.push(ChatMessage::user(follow_up_prompt.clone())); + history + }); + + log::info!( + "[subagent_reuse] parent_thread_id={} subagent_session_id={} task_id={} agent_id={} reuse_decision={} task_key={}", + parent_thread_id.as_deref().unwrap_or("none"), + durable_session.subagent_session_id, + task_id, + definition.id, + reuse_decision.as_str(), + task_key + ); publish_global(DomainEvent::SubagentSpawned { parent_session: parent_session.clone(), @@ -242,10 +447,15 @@ impl Tool for SpawnAsyncSubagentTool { let background_parent_session = parent_session.clone(); let background_progress = progress_sink.clone(); let background_worker_thread_id = worker_thread_id.clone(); + let background_store = store.clone(); + let background_subagent_session_id = durable_session.subagent_session_id.clone(); + let background_thread_affinity_id = background_worker_thread_id + .clone() + .unwrap_or_else(|| background_subagent_session_id.clone()); + let background_initial_history = initial_history; // Capture the parent chat thread NOW (the spawning turn's thread) so the // finished result can be delivered back into it as a system turn. - let background_parent_thread_id = - crate::openhuman::inference::provider::thread_context::current_thread_id(); + let background_parent_thread_id = parent_thread_id.clone(); // Kept on this side (the closure moves its own clone) so the registry // entry knows which parent thread owns this sub-agent — that's how // `cancel_for_thread` aborts it when the thread is deleted. @@ -268,21 +478,41 @@ impl Tool for SpawnAsyncSubagentTool { model_override, task_id: Some(background_task_id.clone()), worker_thread_id: background_worker_thread_id.clone(), - initial_history: None, + initial_history: background_initial_history, checkpoint_dir: None, worktree_action_dir: None, run_queue: Some(task_queue), }; let result = with_parent_context(background_parent, async move { - run_subagent(&background_definition, &background_prompt, options).await + crate::openhuman::inference::provider::thread_context::with_thread_id( + background_thread_affinity_id, + async move { + run_subagent(&background_definition, &background_prompt, options).await + }, + ) + .await }) .await; match result { Ok(outcome) => match outcome.status { SubagentRunStatus::Completed => { - // Unblock `wait_subagent` with the final output first. + if let Err(err) = subagent_sessions::mark_finished( + &background_store, + &background_subagent_session_id, + &outcome.task_id, + &outcome.status, + outcome.final_history.clone(), + ) { + log::warn!( + "[subagent_reuse] mark_completed failed subagent_session_id={} task_id={} agent_id={} error={}", + background_subagent_session_id, + outcome.task_id, + outcome.agent_id, + err + ); + } let _ = status_tx.send(SubagentStatus::Completed { output: outcome.output.clone(), iterations: outcome.iterations, @@ -320,7 +550,22 @@ impl Tool for SpawnAsyncSubagentTool { .await; } } - SubagentRunStatus::AwaitingUser { question, .. } => { + SubagentRunStatus::AwaitingUser { ref question, .. } => { + if let Err(err) = subagent_sessions::mark_finished( + &background_store, + &background_subagent_session_id, + &outcome.task_id, + &outcome.status, + outcome.final_history.clone(), + ) { + log::warn!( + "[subagent_reuse] mark_awaiting_user failed subagent_session_id={} task_id={} agent_id={} error={}", + background_subagent_session_id, + outcome.task_id, + outcome.agent_id, + err + ); + } let _ = status_tx.send(SubagentStatus::AwaitingUser { question: question.clone(), }); @@ -346,6 +591,20 @@ impl Tool for SpawnAsyncSubagentTool { }, Err(err) => { let error = err.to_string(); + if let Err(store_err) = subagent_sessions::mark_failed( + &background_store, + &background_subagent_session_id, + &background_task_id, + error.clone(), + ) { + log::warn!( + "[subagent_reuse] mark_failed failed subagent_session_id={} task_id={} agent_id={} error={}", + background_subagent_session_id, + background_task_id, + background_agent_id, + store_err + ); + } let _ = status_tx.send(SubagentStatus::Failed { error: error.clone(), }); @@ -374,6 +633,8 @@ impl Tool for SpawnAsyncSubagentTool { task_id.clone(), definition.id.clone(), parent_session.clone(), + Some(durable_session.subagent_session_id.clone()), + parent.workspace_dir.clone(), register_parent_thread_id, steer_queue, join.abort_handle(), @@ -382,16 +643,21 @@ impl Tool for SpawnAsyncSubagentTool { let payload = json!({ "task_id": task_id, + "subagent_session_id": durable_session.subagent_session_id, "agent_id": definition.id, "mode": "async", "worker_thread_id": worker_thread_id, + "reused": reusable.is_some(), + "reuse_decision": reuse_decision.as_str(), }); Ok(ToolResult::success(format!( - "Accepted background sub-agent `{}` (task_id `{}`). Do not block on it before answering the user. \ - You may redirect it mid-run with `steer_subagent {{ task_id, message }}` and collect its result \ - with `wait_subagent {{ task_id }}`.\n\n[async_subagent_ref]\n{}\n[/async_subagent_ref]", + "Accepted reusable async sub-agent `{}` (subagent_session_id `{}`, task_id `{}`, reuse_decision `{}`). Do not block on it before answering the user. \ + You may redirect it mid-run with `steer_subagent {{ subagent_session_id, message }}` and collect its result \ + with `wait_subagent {{ subagent_session_id }}`.\n\n[async_subagent_ref]\n{}\n[/async_subagent_ref]", payload["agent_id"].as_str().unwrap_or("subagent"), + payload["subagent_session_id"].as_str().unwrap_or(""), task_id, + reuse_decision.as_str(), serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()) ))) } @@ -407,6 +673,38 @@ fn add_background_contract(prompt: &str) -> String { ) } +fn durable_task_key_source( + args: &serde_json::Value, + prompt: &str, + context: Option<&str>, +) -> String { + if let Some(task_key) = args + .get("task_key") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return task_key.to_string(); + } + + match context.map(str::trim).filter(|s| !s.is_empty()) { + Some(context) => format!("{prompt}\n\n[Context]\n{context}"), + None => prompt.to_string(), + } +} + +fn reusable_follow_up_message(prompt: &str, context: Option<&str>) -> String { + let mut message = String::from("[Follow-up instruction for reusable sub-agent]\n"); + if let Some(context) = context.map(str::trim).filter(|s| !s.is_empty()) { + message.push_str("\n[Context]\n"); + message.push_str(context); + message.push_str("\n\n"); + } + message.push_str("[Task]\n"); + message.push_str(prompt); + message +} + #[cfg(test)] mod tests { use super::*; @@ -439,6 +737,60 @@ mod tests { assert!(wrapped.contains("[Task]\narchive this fact")); } + #[test] + fn durable_task_key_defaults_to_prompt_not_display_title() { + let args = json!({ + "task_title": "Research", + "prompt": "Research the async subagent cache behavior for example.com" + }); + assert_eq!( + durable_task_key_source(&args, args["prompt"].as_str().unwrap(), None), + "Research the async subagent cache behavior for example.com" + ); + } + + #[test] + fn durable_task_key_includes_context_when_no_explicit_key() { + let args = json!({ + "prompt": "Analyze this issue" + }); + let source = durable_task_key_source( + &args, + args["prompt"].as_str().unwrap(), + Some("issue body A"), + ); + assert!(source.contains("Analyze this issue")); + assert!(source.contains("[Context]\nissue body A")); + assert_ne!( + subagent_sessions::normalize_task_key(&source), + subagent_sessions::normalize_task_key(&durable_task_key_source( + &args, + args["prompt"].as_str().unwrap(), + Some("issue body B") + )) + ); + } + + #[test] + fn durable_task_key_uses_explicit_task_key_when_present() { + let args = json!({ + "task_key": "audit:example.com", + "task_title": "Research", + "prompt": "Research the async subagent cache behavior for example.com" + }); + assert_eq!( + durable_task_key_source(&args, args["prompt"].as_str().unwrap(), Some("ignored")), + "audit:example.com" + ); + } + + #[test] + fn reusable_follow_up_message_preserves_context() { + let rendered = reusable_follow_up_message("Continue the audit", Some("prior result: 42")); + assert!(rendered.contains("[Context]\nprior result: 42")); + assert!(rendered.contains("[Task]\nContinue the audit")); + } + #[tokio::test] async fn missing_agent_id_returns_error() { let tool = SpawnAsyncSubagentTool::new(); diff --git a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs index 2e718e275..492b1db76 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs @@ -133,6 +133,18 @@ impl Tool for SpawnSubagentTool { "dedicated_thread": { "type": "boolean", "description": "Legacy compatibility flag. Delegations now always create a persistent worker thread when parent context is available, so this flag no longer gates thread creation." + }, + "blocking": { + "type": "boolean", + "description": "Explicitly run the sub-agent inline and return its final output. Defaults to false; reusable async delegation is the default." + }, + "task_key": { + "type": "string", + "description": "Optional deterministic identity key for reusable async delegation. Defaults to a normalized prompt/title." + }, + "fresh": { + "type": "boolean", + "description": "When true, bypass reusable subagent matching and create a fresh durable worker." } } }) @@ -185,6 +197,10 @@ impl Tool for SpawnSubagentTool { .get("dedicated_thread") .and_then(|v| v.as_bool()) .unwrap_or(false); + let blocking = args + .get("blocking") + .and_then(|v| v.as_bool()) + .unwrap_or(false); // ── Validation ───────────────────────────────────────────────── if agent_id.is_empty() { @@ -396,6 +412,31 @@ impl Tool for SpawnSubagentTool { } } + if !blocking { + let mut async_args = args; + if let Some(obj) = async_args.as_object_mut() { + obj.insert( + "agent_id".to_string(), + serde_json::Value::String(definition.id.clone()), + ); + if obj.get("task_title").is_none() { + let title = + crate::openhuman::agent_orchestration::subagent_sessions::task_title_from_prompt( + &prompt, + ); + obj.insert("task_title".to_string(), serde_json::Value::String(title)); + } + } + tracing::info!( + target: "spawn_subagent", + agent_id = %definition.id, + "[spawn_subagent] routing to reusable async sub-agent by default" + ); + return super::spawn_async_subagent::SpawnAsyncSubagentTool::new() + .execute(async_args) + .await; + } + // ── Publish SubagentSpawned event ────────────────────────────── let parent_session = current_parent() .map(|p| p.session_id.clone()) @@ -817,6 +858,7 @@ mod tests { iterations: 3, mode: SubagentMode::Typed, status: SubagentRunStatus::Completed, + final_history: Vec::new(), } } @@ -1022,6 +1064,32 @@ mod tests { .contains("unknown agent_id 'totally_made_up'")); } + #[tokio::test] + async fn legacy_archetype_alias_is_forwarded_to_async_default_path() { + let _ = AgentDefinitionRegistry::init_global_builtins(); + let tool = SpawnSubagentTool; + let result = tool + .execute(json!({ + "archetype": "researcher", + "prompt": "research the reusable async default path", + })) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result + .output() + .contains("spawn_async_subagent called outside of an agent turn"), + "{}", + result.output() + ); + assert!( + !result.output().contains("agent_id is required"), + "{}", + result.output() + ); + } + #[tokio::test] async fn integrations_agent_requires_toolkit_argument() { let _ = AgentDefinitionRegistry::init_global_builtins(); diff --git a/src/openhuman/agent_orchestration/tools/steer_subagent.rs b/src/openhuman/agent_orchestration/tools/steer_subagent.rs index baf4813a1..cb08ee03e 100644 --- a/src/openhuman/agent_orchestration/tools/steer_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/steer_subagent.rs @@ -45,11 +45,15 @@ impl Tool for SteerSubagentTool { fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", - "required": ["task_id", "message"], + "required": ["message"], "properties": { "task_id": { "type": "string", - "description": "The task_id returned by spawn_async_subagent (see its [async_subagent_ref] block)." + "description": "Transient task_id returned by reusable async delegation." + }, + "subagent_session_id": { + "type": "string", + "description": "Durable subagent_session_id returned by reusable async delegation. Preferred over task_id for cross-turn messaging." }, "message": { "type": "string", @@ -76,6 +80,12 @@ impl Tool for SteerSubagentTool { .unwrap_or("") .trim() .to_string(); + let subagent_session_id = args + .get("subagent_session_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); let message = args .get("message") .and_then(|v| v.as_str()) @@ -87,8 +97,10 @@ impl Tool for SteerSubagentTool { _ => QueueMode::Steer, }; - if task_id.is_empty() { - return Ok(ToolResult::error("steer_subagent: `task_id` is required")); + if task_id.is_empty() && subagent_session_id.is_empty() { + return Ok(ToolResult::error( + "steer_subagent: `subagent_session_id` or `task_id` is required", + )); } if message.is_empty() { return Ok(ToolResult::error("steer_subagent: `message` is required")); @@ -103,27 +115,50 @@ impl Tool for SteerSubagentTool { } }; + let resolved_task_id = if task_id.is_empty() { + match running_subagents::task_id_for_session(&subagent_session_id, &parent_session) { + Ok(id) => id, + Err(running_subagents::WaitError::Unknown) => { + return Ok(ToolResult::error(format!( + "steer_subagent: no running sub-agent with subagent_session_id `{subagent_session_id}`." + ))); + } + Err(running_subagents::WaitError::NotOwned) => { + return Ok(ToolResult::error(format!( + "steer_subagent: sub-agent session `{subagent_session_id}` was not started by this agent." + ))); + } + } + } else { + task_id.clone() + }; + log::info!( - "[steer_subagent] task_id={} mode={} chars={}", - task_id, + "[steer_subagent] task_id={} subagent_session_id={} mode={} chars={}", + resolved_task_id, + if subagent_session_id.is_empty() { + "none" + } else { + &subagent_session_id + }, mode, message.chars().count() ); - match running_subagents::steer(&task_id, &parent_session, message, mode).await { + match running_subagents::steer(&resolved_task_id, &parent_session, message, mode).await { Ok(()) => Ok(ToolResult::success(format!( - "Steered sub-agent `{task_id}` ({mode}). It will pick this up at its next step. \ - Use wait_subagent {{ task_id: \"{task_id}\" }} to collect its result." + "Steered sub-agent `{resolved_task_id}` ({mode}). It will pick this up at its next step. \ + Use wait_subagent with its subagent_session_id or task_id to collect its result." ))), Err(SteerError::Unknown) => Ok(ToolResult::error(format!( - "steer_subagent: no running sub-agent with task_id `{task_id}`. It may have already \ + "steer_subagent: no running sub-agent with task_id `{resolved_task_id}`. It may have already \ finished — use wait_subagent to collect its result, or check the task_id." ))), Err(SteerError::NotOwned) => Ok(ToolResult::error(format!( - "steer_subagent: sub-agent `{task_id}` was not started by this agent and cannot be steered." + "steer_subagent: sub-agent `{resolved_task_id}` was not started by this agent and cannot be steered." ))), Err(SteerError::AlreadyDone) => Ok(ToolResult::error(format!( - "steer_subagent: sub-agent `{task_id}` has already finished. Use wait_subagent to collect its result." + "steer_subagent: sub-agent `{resolved_task_id}` has already finished. Use wait_subagent to collect its result." ))), } } @@ -140,7 +175,6 @@ mod tests { .get("required") .and_then(|v| v.as_array()) .expect("required list"); - assert!(required.iter().any(|v| v.as_str() == Some("task_id"))); assert!(required.iter().any(|v| v.as_str() == Some("message"))); } @@ -149,7 +183,7 @@ mod tests { let tool = SteerSubagentTool::new(); let res = tool.execute(json!({ "message": "go" })).await.unwrap(); assert!(res.is_error); - assert!(res.output().contains("task_id")); + assert!(res.output().contains("subagent_session_id")); } #[tokio::test] diff --git a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs index df2e871a0..2a1aa85c4 100644 --- a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs +++ b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs @@ -36,7 +36,8 @@ async fn spawn_subagent_tool_runs_child_agent_e2e() { "agent_id": "researcher", "prompt": format!("Investigate {SPAWN_SUBAGENT_CANARY}"), "context": "parent supplied context", - "model": "test-model" + "model": "test-model", + "blocking": true })) .await }, diff --git a/src/openhuman/agent_orchestration/tools/wait_subagent.rs b/src/openhuman/agent_orchestration/tools/wait_subagent.rs index a659b080a..be3dde496 100644 --- a/src/openhuman/agent_orchestration/tools/wait_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/wait_subagent.rs @@ -48,11 +48,15 @@ impl Tool for WaitSubagentTool { fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", - "required": ["task_id"], + "required": [], "properties": { "task_id": { "type": "string", - "description": "The task_id returned by spawn_async_subagent." + "description": "Transient task_id returned by reusable async delegation." + }, + "subagent_session_id": { + "type": "string", + "description": "Durable subagent_session_id returned by reusable async delegation. Preferred for cross-turn waits." }, "timeout_secs": { "type": "integer", @@ -75,8 +79,16 @@ impl Tool for WaitSubagentTool { .unwrap_or("") .trim() .to_string(); - if task_id.is_empty() { - return Ok(ToolResult::error("wait_subagent: `task_id` is required")); + let subagent_session_id = args + .get("subagent_session_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if task_id.is_empty() && subagent_session_id.is_empty() { + return Ok(ToolResult::error( + "wait_subagent: `subagent_session_id` or `task_id` is required", + )); } let timeout_secs = args @@ -94,14 +106,37 @@ impl Tool for WaitSubagentTool { } }; + let resolved_task_id = if task_id.is_empty() { + match running_subagents::task_id_for_session(&subagent_session_id, &parent_session) { + Ok(id) => id, + Err(WaitError::Unknown) => { + return Ok(ToolResult::error(format!( + "wait_subagent: no running sub-agent with subagent_session_id `{subagent_session_id}`." + ))); + } + Err(WaitError::NotOwned) => { + return Ok(ToolResult::error(format!( + "wait_subagent: sub-agent session `{subagent_session_id}` was not started by this agent." + ))); + } + } + } else { + task_id.clone() + }; + log::info!( - "[wait_subagent] task_id={} timeout_secs={}", - task_id, + "[wait_subagent] task_id={} subagent_session_id={} timeout_secs={}", + resolved_task_id, + if subagent_session_id.is_empty() { + "none" + } else { + &subagent_session_id + }, timeout_secs ); match running_subagents::wait( - &task_id, + &resolved_task_id, &parent_session, Duration::from_secs(timeout_secs), ) @@ -109,30 +144,33 @@ impl Tool for WaitSubagentTool { { Ok(WaitOutcome::Terminal(SubagentStatus::Completed { output, iterations })) => { Ok(ToolResult::success(format!( - "Sub-agent `{task_id}` completed in {iterations} iteration(s):\n\n{output}" + "Sub-agent `{task_id}` completed in {iterations} iteration(s):\n\n{output}", + task_id = resolved_task_id ))) } Ok(WaitOutcome::Terminal(SubagentStatus::AwaitingUser { question })) => { Ok(ToolResult::success(format!( - "Sub-agent `{task_id}` paused for clarification and did not finish: {question}\n\n\ + "Sub-agent `{}` paused for clarification and did not finish: {question}\n\n\ It cannot proceed unattended. Resume it with continue_subagent once you have an answer." + , resolved_task_id ))) } Ok(WaitOutcome::Terminal(SubagentStatus::Failed { error })) => Ok(ToolResult::error( - format!("Sub-agent `{task_id}` failed: {error}"), + format!("Sub-agent `{resolved_task_id}` failed: {error}"), )), // `Running` is never terminal; treat defensively as a timeout-style result. Ok(WaitOutcome::Terminal(SubagentStatus::Running)) | Ok(WaitOutcome::TimedOut(_)) => Ok(ToolResult::success(format!( "Sub-agent `{task_id}` is still running after {timeout_secs}s. \ - Continue with other work and call wait_subagent again later, or steer_subagent to redirect it." + Continue with other work and call wait_subagent again later, or steer_subagent to redirect it.", + task_id = resolved_task_id ))), Err(WaitError::Unknown) => Ok(ToolResult::error(format!( - "wait_subagent: no sub-agent with task_id `{task_id}`. It may have already finished and \ + "wait_subagent: no sub-agent with task_id `{resolved_task_id}`. It may have already finished and \ been collected, or the task_id is wrong." ))), Err(WaitError::NotOwned) => Ok(ToolResult::error(format!( - "wait_subagent: sub-agent `{task_id}` was not started by this agent." + "wait_subagent: sub-agent `{resolved_task_id}` was not started by this agent." ))), } } @@ -149,14 +187,14 @@ mod tests { .get("required") .and_then(|v| v.as_array()) .expect("required list"); - assert!(required.iter().any(|v| v.as_str() == Some("task_id"))); + assert!(required.is_empty()); } #[tokio::test] async fn missing_task_id_is_rejected() { let res = WaitSubagentTool::new().execute(json!({})).await.unwrap(); assert!(res.is_error); - assert!(res.output().contains("task_id")); + assert!(res.output().contains("subagent_session_id")); } #[tokio::test] diff --git a/src/openhuman/agent_orchestration/tools/worker_thread.rs b/src/openhuman/agent_orchestration/tools/worker_thread.rs index f03952ae8..586dca7d2 100644 --- a/src/openhuman/agent_orchestration/tools/worker_thread.rs +++ b/src/openhuman/agent_orchestration/tools/worker_thread.rs @@ -76,6 +76,33 @@ pub(crate) fn create_worker_thread( Ok(worker_thread_id) } +pub(crate) fn append_worker_user_message( + workspace_dir: PathBuf, + worker_thread_id: &str, + agent_id: &str, + task_id: &str, + prompt: &str, +) -> Result<(), String> { + conversations::append_message( + workspace_dir, + worker_thread_id, + ConversationMessage { + id: format!("user:{task_id}:{}", uuid::Uuid::new_v4()), + content: prompt.to_string(), + message_type: "text".to_string(), + extra_metadata: json!({ + "scope": "worker_thread", + "agent_id": agent_id, + "task_id": task_id, + "reused_worker": true, + }), + sender: "user".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ) + .map(|_| ()) +} + #[cfg(test)] mod tests { use super::*; @@ -106,9 +133,33 @@ mod tests { // It opens with the delegation prompt as the user message, so the // drawer can render the parent↔subagent chat from memory on reopen. - let messages = conversations::get_messages(dir, &id).unwrap(); + let messages = conversations::get_messages(dir.clone(), &id).unwrap(); assert_eq!(messages.len(), 1); assert_eq!(messages[0].sender, "user"); assert_eq!(messages[0].content, "Find Q3"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn appends_follow_up_prompt_to_existing_worker_thread() { + let dir = std::env::temp_dir().join(format!("wt-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let id = create_worker_thread( + dir.clone(), + "parent-thread-1", + "researcher", + "Initial", + "Initial prompt", + ) + .unwrap(); + + append_worker_user_message(dir.clone(), &id, "researcher", "sub-2", "Follow-up prompt") + .unwrap(); + + let messages = conversations::get_messages(dir.clone(), &id).unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1].sender, "user"); + assert_eq!(messages[1].content, "Follow-up prompt"); + let _ = std::fs::remove_dir_all(dir); } } diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 2ee952698..299da44b5 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -167,13 +167,12 @@ pub fn all_tools_with_runtime( // `agent::harness::subagent_runner` for the dispatch path. Box::new(SpawnSubagentTool::new()), Box::new(SpawnAsyncSubagentTool::new()), - // Steer a running async sub-agent mid-flight and collect its result: - // `steer_subagent { task_id, message }` injects into the child's - // run-queue (drained at its next iteration boundary), `wait_subagent - // { task_id }` blocks for the final output. See - // `agent_orchestration::running_subagents`. + // Steer/list/close reusable async sub-agents and collect results by + // durable `subagent_session_id` (preferred) or transient `task_id`. + Box::new(ListSubagentsTool::new()), Box::new(SteerSubagentTool::new()), Box::new(WaitSubagentTool::new()), + Box::new(CloseSubagentTool::new()), Box::new(ContinueSubagentTool::new()), Box::new(SpawnParallelAgentsTool::new()), Box::new(DelegateToPersonalityTool::new()), diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index ecc5711b0..3c844217e 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -588,6 +588,7 @@ inline = "Answer the delegated cache probe directly." "agent_id": "cache_probe_child", "prompt": "Inspect whether the child turn can answer a cache probe.", "context": "Parent observed request id cache-42.", + "blocking": true, }), )], 140, diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index d9ba0dd04..c0bee3f7f 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -4698,6 +4698,7 @@ async fn agent_subagent_public_types_cover_task_local_and_error_display_paths() elapsed: Duration::from_millis(12), mode: SubagentMode::Typed, status: SubagentRunStatus::Completed, + final_history: Vec::new(), }; assert_eq!(outcome.mode.as_str(), "typed"); assert_eq!(outcome.elapsed.as_millis(), 12);