diff --git a/.env.example b/.env.example index 4af824a5c..6b432ccdb 100644 --- a/.env.example +++ b/.env.example @@ -76,8 +76,8 @@ OPENHUMAN_REASONING_ENABLED= # --------------------------------------------------------------------------- # Web search # --------------------------------------------------------------------------- -# [optional] Default: false -OPENHUMAN_WEB_SEARCH_ENABLED=false +# Web search is always enabled — no opt-in flag. Configure the +# provider / API keys / budgets below. # [optional] Search provider name OPENHUMAN_WEB_SEARCH_PROVIDER= # [optional] Required if web search provider is Brave diff --git a/app/src/lib/ai/skillsAgentContext.ts b/app/src/lib/ai/skillsAgentContext.ts index 43c530339..01c0d0d15 100644 --- a/app/src/lib/ai/skillsAgentContext.ts +++ b/app/src/lib/ai/skillsAgentContext.ts @@ -1,4 +1,4 @@ -// Source of truth: src/openhuman/agent/agents/skills_agent/prompt.md +// Source of truth: src/openhuman/agent/agents/integrations_agent/prompt.md // Keep in sync when the Rust-side prompt changes. export const SKILLS_AGENT_PROMPT = `# Skills Agent — Service Integration Specialist diff --git a/scripts/debug-agent-prompts.sh b/scripts/debug-agent-prompts.sh index 75d70530a..550f203a3 100755 --- a/scripts/debug-agent-prompts.sh +++ b/scripts/debug-agent-prompts.sh @@ -10,13 +10,14 @@ # to stderr. Useful workflow: # # bash scripts/debug-agent-prompts.sh -# diff -u prompts.before/skills_agent.md prompts.after/skills_agent.md +# diff -u prompts.before/integrations_agent.md prompts.after/integrations_agent.md # -# When run with `--stub-composio` (the default) the dumper injects the -# five Composio meta-tools into the registry even on machines that are -# not signed in, so `skills_agent` always renders the full Composio -# surface. Pass `--no-stub-composio` to see the raw on-disk state instead -# (useful for sanity-checking the unauthed onboarding path). +# The dumper runs against the real session construction path +# (`Agent::from_config_for_agent` → `Agent::build_system_prompt`), so the +# Composio surface reflects the signed-in user's actual integrations. +# If you need the toolkit list populated, sign in via the desktop app or +# point `OPENHUMAN_WORKSPACE` at a workspace that already holds the +# connection state. # # The dumper runs against the currently-logged-in user's workspace # (`$OPENHUMAN_WORKSPACE`, falling back to `~/.openhuman/workspace`) so @@ -25,16 +26,16 @@ # different workspace. # # Usage: -# bash scripts/debug-agent-prompts.sh [--out ] [--no-stub-composio] [--with-tools] [-v] +# bash scripts/debug-agent-prompts.sh [--out ] [--with-tools] [-v] # # The output directory is wiped and recreated at the start of each run # so the snapshot only reflects the current agent set — stale files from # an earlier run cannot hide a regression. # # Defaults: -# --out ./prompt-dumps (deleted + recreated each run) -# --stub-composio ON (override with --no-stub-composio) -# --with-tools OFF (pass to also list each agent's tool names) +# --out ./prompt-dumps (deleted + recreated each run) +# --with-tools DEPRECATED / no-op — tool names are always recorded in +# the per-agent `.meta.txt` files emitted by dump-all. # set -euo pipefail @@ -44,6 +45,25 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" BIN="${REPO_ROOT}/target/debug/openhuman-core" +# Load the repo .env so staging/prod backend URLs, API keys, and the +# Composio toggle reach the dumped prompts. `Config::load_or_init` +# calls `apply_env_overrides` after reading from disk, so any variable +# exported here wins over whatever is baked into the workspace config. +# Mirrors `yarn tauri dev`, which sources the same file via +# `scripts/load-dotenv.sh` before launching the sidecar. +if [[ -f "${REPO_ROOT}/.env" ]]; then + echo "[debug-agent-prompts] loading env from ${REPO_ROOT}/.env" >&2 + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/load-dotenv.sh" "${REPO_ROOT}/.env" +fi + +# The project's CLI logger writes to stdout (not stderr), so any +# `RUST_LOG` value inherited from `.env` (typically `info`) would +# interleave log lines into the JSON/prompt payloads this script +# expects on stdout. Force quiet unless the caller passed `-v` — in +# which case the later `--verbose` flag restores debug logging. +export RUST_LOG=error + # Always run `cargo build` — it no-ops when the binary is already # up-to-date, and re-links quickly when it isn't. The old `-x` existence # check let a stale debug binary survive across agent-registry changes @@ -54,7 +74,6 @@ echo "[debug-agent-prompts] building openhuman-core (no-op if up-to-date) …" > # ── Parse flags ─────────────────────────────────────────────────────────── OUT_DIR="" -STUB_COMPOSIO=1 WITH_TOOLS=0 VERBOSE_FLAG=() @@ -68,14 +87,6 @@ while [[ $# -gt 0 ]]; do OUT_DIR="$2" shift 2 ;; - --stub-composio) - STUB_COMPOSIO=1 - shift - ;; - --no-stub-composio) - STUB_COMPOSIO=0 - shift - ;; --with-tools) WITH_TOOLS=1 shift @@ -85,7 +96,7 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -158,127 +169,58 @@ OUT_DIR="${resolved_out}" rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" -# Point the binary at the real, currently-logged-in user's workspace so -# onboarding-generated files like `PROFILE.md` appear in the dump. A -# throwaway mktemp workspace would silently hide them. +# Workspace resolution is owned by `Config::load_or_init` inside the +# binary: it reads `~/.openhuman/active_user.toml`, falls back to the +# persisted workspace marker, then to the pre-login user directory. We +# only pass `--workspace` when the caller has explicitly exported one +# (an empty `OPENHUMAN_WORKSPACE=` in `.env` counts as unset — the +# binary's resolver is what we want in that case). # -# Resolution order (matches what the desktop app does): -# 1. $OPENHUMAN_WORKSPACE if explicitly exported. -# 2. ~/.openhuman/users//workspace, where active_user_id -# is read from ~/.openhuman/active_user.toml. This is the path the -# Tauri app writes PROFILE.md into after onboarding. -# 3. ~/.openhuman/workspace as a last-resort fallback for pre-multi-user -# installs. -# -# The binary auto-seeds SOUL.md/IDENTITY.md/HEARTBEAT.md when they are -# missing, so pointing at the real workspace is read-mostly — the only -# writes are the same self-healing writes that happen on every normal -# agent run. +# Previously this script duplicated the resolution in shell and guessed +# wrong when the user's active install used a multi-user layout under +# `~/.openhuman/users//workspace` without a top-level +# `active_user.toml`, causing the dumper to bail with "workspace not +# found". Delegating to the binary removes that divergence and makes +# `.env` (including `OPENHUMAN_APP_ENV=staging`) take effect +# automatically. +WORKSPACE_OVERRIDE="" if [[ -n "${OPENHUMAN_WORKSPACE:-}" ]]; then - WORKSPACE="${OPENHUMAN_WORKSPACE}" -else - ACTIVE_USER_TOML="${HOME}/.openhuman/active_user.toml" - ACTIVE_USER_ID="" - if [[ -f "${ACTIVE_USER_TOML}" ]]; then - # Extract `user_id = "…"` without pulling in a TOML parser — the file - # is one or two lines and the shape is stable (config/ops.rs writes - # it with a plain quoted string). - ACTIVE_USER_ID="$(sed -n 's/^[[:space:]]*user_id[[:space:]]*=[[:space:]]*"\([^"]*\)"[[:space:]]*$/\1/p' "${ACTIVE_USER_TOML}" | head -n 1)" - fi - if [[ -n "${ACTIVE_USER_ID}" && -d "${HOME}/.openhuman/users/${ACTIVE_USER_ID}/workspace" ]]; then - WORKSPACE="${HOME}/.openhuman/users/${ACTIVE_USER_ID}/workspace" - else - WORKSPACE="${HOME}/.openhuman/workspace" - fi -fi - -if [[ ! -d "${WORKSPACE}" ]]; then - echo "[debug-agent-prompts] workspace not found: ${WORKSPACE}" >&2 - echo "[debug-agent-prompts] complete onboarding in the app first, or export OPENHUMAN_WORKSPACE=." >&2 - exit 66 -fi - -if [[ -f "${WORKSPACE}/PROFILE.md" ]]; then - PROFILE_STATE="present" -else - PROFILE_STATE="MISSING (onboarding enrichment has not run)" + WORKSPACE_OVERRIDE="${OPENHUMAN_WORKSPACE}" fi echo "[debug-agent-prompts] output dir : ${OUT_DIR}" >&2 -echo "[debug-agent-prompts] workspace : ${WORKSPACE}" >&2 -echo "[debug-agent-prompts] PROFILE.md : ${PROFILE_STATE}" >&2 -echo "[debug-agent-prompts] stub composio: $([[ ${STUB_COMPOSIO} -eq 1 ]] && echo on || echo off)" >&2 +if [[ -n "${WORKSPACE_OVERRIDE}" ]]; then + echo "[debug-agent-prompts] workspace : ${WORKSPACE_OVERRIDE} (OPENHUMAN_WORKSPACE override)" >&2 +else + echo "[debug-agent-prompts] workspace : " >&2 +fi +if [[ -n "${OPENHUMAN_APP_ENV:-}" ]]; then + echo "[debug-agent-prompts] app env : ${OPENHUMAN_APP_ENV}" >&2 +fi +if [[ -n "${OPENHUMAN_BASE_URL:-}" ]]; then + echo "[debug-agent-prompts] base url : ${OPENHUMAN_BASE_URL}" >&2 +fi echo >&2 -# ── Discover agent ids from `agent list --json` ─────────────────────────── -# `mapfile` is bash 4+, but macOS ships bash 3 — use a portable -# read-while-IFS loop instead so the script works out of the box on a -# vanilla `/bin/bash`. -AGENT_LIST_JSON="$("${BIN}" agent list --workspace "${WORKSPACE}" --json 2>/dev/null)" -AGENT_IDS=() -while IFS= read -r line; do - [[ -n "${line}" ]] && AGENT_IDS+=("${line}") -done < <(printf '%s' "${AGENT_LIST_JSON}" | python3 -c ' -import json, sys -for entry in json.load(sys.stdin): - aid = entry.get("id", "") - # The synthetic `fork` definition replays the parent verbatim and - # has no standalone prompt — skip it. - if aid and aid != "fork": - print(aid) -') - -# Always include the main / orchestrator prompt as the first dump. -TARGETS=("main" "${AGENT_IDS[@]}") - -# ── Build common dump-prompt flag list ──────────────────────────────────── -DUMP_FLAGS=(--workspace "${WORKSPACE}") -if [[ ${STUB_COMPOSIO} -eq 1 ]]; then - DUMP_FLAGS+=(--stub-composio) -fi -if [[ ${WITH_TOOLS} -eq 1 ]]; then - DUMP_FLAGS+=(--with-tools) +# ── Delegate to `openhuman-core agent dump-all` ────────────────────────── +# All the per-agent iteration + `integrations_agent`-per-toolkit +# expansion now lives in Rust (`debug_dump::dump_all_agent_prompts`). +# The shell script just supplies the output directory and passes +# through workspace / verbose toggles. +DUMP_ARGS=(agent dump-all --out "${OUT_DIR}") +if [[ -n "${WORKSPACE_OVERRIDE}" ]]; then + DUMP_ARGS+=(--workspace "${WORKSPACE_OVERRIDE}") fi if [[ ${#VERBOSE_FLAG[@]} -gt 0 ]]; then - DUMP_FLAGS+=("${VERBOSE_FLAG[@]}") + DUMP_ARGS+=("${VERBOSE_FLAG[@]}") fi -# ── Dump every target ───────────────────────────────────────────────────── -INDEX=0 -SUMMARY="" -for AGENT in "${TARGETS[@]}"; do - INDEX=$((INDEX + 1)) - SAFE_NAME="$(printf '%s' "${AGENT}" | tr -c 'A-Za-z0-9._-' '_')" - PROMPT_PATH="${OUT_DIR}/${INDEX}_${SAFE_NAME}.md" - META_PATH="${OUT_DIR}/${INDEX}_${SAFE_NAME}.meta.txt" +"${BIN}" "${DUMP_ARGS[@]}" - printf '[debug-agent-prompts] %-20s → %s\n' "${AGENT}" "${PROMPT_PATH}" >&2 - if "${BIN}" agent dump-prompt --agent "${AGENT}" "${DUMP_FLAGS[@]}" \ - > "${PROMPT_PATH}" 2> "${META_PATH}"; then - LINES="$(wc -l < "${PROMPT_PATH}" | tr -d ' ')" - TOOL_COUNT="$(grep -E '^tool_count:' "${META_PATH}" | awk '{print $2}')" - SKILL_COUNT="$(grep -E '^skill_tools:' "${META_PATH}" | awk '{print $2}')" - SUMMARY+="$(printf '%-20s lines=%-5s tools=%-4s skill=%-4s\n' \ - "${AGENT}" "${LINES}" "${TOOL_COUNT:-?}" "${SKILL_COUNT:-?}") -" - else - echo "[debug-agent-prompts] ✘ failed to dump ${AGENT} (see ${META_PATH})" >&2 - SUMMARY+="$(printf '%-20s FAILED — see %s\n' "${AGENT}" "${META_PATH}") -" - fi -done - -# ── Write a summary index file alongside the dumps ──────────────────────── -SUMMARY_PATH="${OUT_DIR}/SUMMARY.txt" -{ - echo "OpenHuman agent prompt dump summary" - echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "Workspace: ${WORKSPACE}" - echo "Stub composio: $([[ ${STUB_COMPOSIO} -eq 1 ]] && echo on || echo off)" - echo - echo "${SUMMARY}" -} > "${SUMMARY_PATH}" +if [[ ${WITH_TOOLS} -eq 1 ]]; then + echo "[debug-agent-prompts] NOTE: --with-tools is no longer honoured by dump-all" >&2 + echo "[debug-agent-prompts] (tool names are always recorded in the .meta.txt files)" >&2 +fi echo >&2 -echo "[debug-agent-prompts] done — ${INDEX} prompts dumped" >&2 -echo "[debug-agent-prompts] summary : ${SUMMARY_PATH}" >&2 +echo "[debug-agent-prompts] done — see ${OUT_DIR}/SUMMARY.txt" >&2 diff --git a/src/core/agent_cli.rs b/src/core/agent_cli.rs index f706c3188..6f3885e8a 100644 --- a/src/core/agent_cli.rs +++ b/src/core/agent_cli.rs @@ -6,24 +6,27 @@ //! agent definitions / tool registry and printing something. //! //! Usage: -//! openhuman agent dump-prompt --agent [--skill ] [--workspace ] [--json] [--with-tools] [-v] +//! openhuman agent dump-prompt --agent [--toolkit ] [--workspace ] [--json] [--with-tools] [-v] +//! (--toolkit is REQUIRED when --agent is `integrations_agent`.) +//! openhuman agent dump-all --out [--workspace ] [--model ] [-v] //! openhuman agent list [--json] [-v] //! //! `dump-prompt` is the main tool: it renders the exact system prompt the -//! context engine would hand to the LLM when that agent is spawned. Pass -//! `--agent main` for the orchestrator / main-agent prompt; otherwise pass -//! any built-in or workspace-custom agent id (e.g. `skills_agent`, -//! `orchestrator`, `code_executor`). -//! -//! `--skill` mirrors the `spawn_subagent { skill_filter: "…" }` runtime -//! argument — pair it with `skills_agent` to scope the dump to a single -//! integration (e.g. `--skill notion`). +//! context engine would hand to the LLM when that agent is spawned. The +//! dump routes through [`Agent::from_config_for_agent`] and calls +//! [`Agent::build_system_prompt`] on the live session, so the output is +//! byte-identical to what the LLM sees on turn 1. Pass +//! `--agent orchestrator` for the orchestrator prompt; otherwise pass +//! any built-in or workspace-custom agent id (e.g. `integrations_agent`, +//! `welcome`, `code_executor`). -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use std::path::PathBuf; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; -use crate::openhuman::context::debug_dump::{dump_agent_prompt, DumpPromptOptions, DumpedPrompt}; +use crate::openhuman::context::debug_dump::{ + dump_agent_prompt, dump_all_agent_prompts, DumpPromptOptions, DumpedPrompt, +}; /// Entry point for `openhuman agent `. pub fn run_agent_command(args: &[String]) -> Result<()> { @@ -34,6 +37,7 @@ pub fn run_agent_command(args: &[String]) -> Result<()> { match args[0].as_str() { "dump-prompt" => run_dump_prompt(&args[1..]), + "dump-all" => run_dump_all(&args[1..]), "list" => run_list(&args[1..]), other => Err(anyhow!( "unknown agent subcommand '{other}'. Run `openhuman agent --help`." @@ -41,30 +45,193 @@ pub fn run_agent_command(args: &[String]) -> Result<()> { } } +// --------------------------------------------------------------------------- +// dump-all +// --------------------------------------------------------------------------- + +struct DumpAllFlags { + out: PathBuf, + workspace: Option, + model: Option, + verbose: bool, +} + +fn parse_dump_all_flags(args: &[String]) -> Result { + let mut out: Option = None; + let mut workspace: Option = None; + let mut model: Option = None; + let mut verbose = false; + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--out" | "-o" => { + out = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --out"))?, + )); + i += 2; + } + "--workspace" | "-w" => { + workspace = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --workspace"))?, + )); + i += 2; + } + "--model" | "-m" => { + model = Some( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --model"))? + .clone(), + ); + i += 2; + } + "-v" | "--verbose" => { + verbose = true; + i += 1; + } + "-h" | "--help" => { + println!("Usage: openhuman agent dump-all --out [--workspace ] [--model ] [-v]"); + println!(); + println!("Render every registered agent's turn-1 system prompt into ."); + println!("`integrations_agent` is expanded into one file per currently-connected"); + println!("Composio toolkit; if no toolkit is connected, it is skipped."); + std::process::exit(0); + } + other => return Err(anyhow!("unknown dump-all arg: {other}")), + } + } + Ok(DumpAllFlags { + out: out.ok_or_else(|| anyhow!("--out is required"))?, + workspace, + model, + verbose, + }) +} + +fn run_dump_all(args: &[String]) -> Result<()> { + let flags = parse_dump_all_flags(args)?; + init_quiet_logging(flags.verbose); + + log::debug!( + "[agent-cli] run_dump_all entry: out={} workspace={:?} model={:?}", + flags.out.display(), + flags.workspace, + flags.model + ); + + std::fs::create_dir_all(&flags.out) + .with_context(|| format!("creating output dir {}", flags.out.display()))?; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + log::debug!("[agent-cli] run_dump_all: calling dump_all_agent_prompts"); + let dumps = rt.block_on(async { + dump_all_agent_prompts(flags.workspace.clone(), flags.model.clone()).await + })?; + log::debug!( + "[agent-cli] run_dump_all: dump_all_agent_prompts returned {} prompt(s)", + dumps.len() + ); + + let mut summary = String::new(); + for (idx, dumped) in dumps.iter().enumerate() { + let safe_agent = sanitise_filename_component(&dumped.agent_id); + let stem = match &dumped.toolkit { + Some(tk) => format!( + "{}_{}_{}", + idx + 1, + safe_agent, + sanitise_filename_component(tk) + ), + None => format!("{}_{}", idx + 1, safe_agent), + }; + let prompt_path = flags.out.join(format!("{stem}.md")); + let meta_path = flags.out.join(format!("{stem}.meta.txt")); + + log::trace!( + "[agent-cli] run_dump_all: writing prompt file {} ({} bytes)", + prompt_path.display(), + dumped.text.len() + ); + std::fs::write(&prompt_path, &dumped.text) + .with_context(|| format!("writing {}", prompt_path.display()))?; + + let mut meta = String::new(); + use std::fmt::Write as _; + let _ = writeln!(meta, "agent: {}", dumped.agent_id); + if let Some(tk) = &dumped.toolkit { + let _ = writeln!(meta, "toolkit: {tk}"); + } + let _ = writeln!(meta, "mode: {}", dumped.mode); + let _ = writeln!(meta, "model: {}", dumped.model); + let _ = writeln!(meta, "workspace: {}", dumped.workspace_dir.display()); + let _ = writeln!(meta, "tool_count: {}", dumped.tool_names.len()); + let _ = writeln!(meta, "skill_tools: {}", dumped.skill_tool_count); + std::fs::write(&meta_path, meta) + .with_context(|| format!("writing {}", meta_path.display()))?; + + let label = match &dumped.toolkit { + Some(tk) => format!("{}@{}", dumped.agent_id, tk), + None => dumped.agent_id.clone(), + }; + let _ = writeln!( + summary, + "{:<32} tools={:<4} skill={:<4}", + label, + dumped.tool_names.len(), + dumped.skill_tool_count + ); + eprintln!("[dump-all] {label:<32} → {}", prompt_path.display()); + } + + let summary_path = flags.out.join("SUMMARY.txt"); + std::fs::write(&summary_path, &summary) + .with_context(|| format!("writing {}", summary_path.display()))?; + eprintln!("[dump-all] wrote summary → {}", summary_path.display()); + log::debug!( + "[agent-cli] run_dump_all exit: wrote {} prompt(s) + SUMMARY.txt", + dumps.len() + ); + Ok(()) +} + +fn sanitise_filename_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect() +} + // --------------------------------------------------------------------------- // dump-prompt // --------------------------------------------------------------------------- struct DumpFlags { agent: Option, - skill: Option, + toolkit: Option, workspace: Option, model: Option, json: bool, with_tools: bool, - stub_composio: bool, verbose: bool, } fn parse_dump_flags(args: &[String]) -> Result { let mut out = DumpFlags { agent: None, - skill: None, + toolkit: None, workspace: None, model: None, json: false, with_tools: false, - stub_composio: false, verbose: false, }; let mut i = 0usize; @@ -78,10 +245,10 @@ fn parse_dump_flags(args: &[String]) -> Result { ); i += 2; } - "--skill" | "-s" => { - out.skill = Some( + "--toolkit" | "-t" => { + out.toolkit = Some( args.get(i + 1) - .ok_or_else(|| anyhow!("missing value for --skill"))? + .ok_or_else(|| anyhow!("missing value for --toolkit"))? .clone(), ); i += 2; @@ -109,10 +276,6 @@ fn parse_dump_flags(args: &[String]) -> Result { out.with_tools = true; i += 1; } - "--stub-composio" => { - out.stub_composio = true; - i += 1; - } "-v" | "--verbose" => { out.verbose = true; i += 1; @@ -130,25 +293,45 @@ fn parse_dump_flags(args: &[String]) -> Result { fn run_dump_prompt(args: &[String]) -> Result<()> { let flags = parse_dump_flags(args)?; - let agent = flags - .agent - .clone() - .ok_or_else(|| anyhow!("--agent is required (use `main` for the orchestrator)"))?; + let agent = flags.agent.clone().ok_or_else(|| { + anyhow!("--agent is required (e.g. `orchestrator`, `integrations_agent`, `welcome`)") + })?; + + if agent == "integrations_agent" && flags.toolkit.is_none() { + return Err(anyhow!( + "--toolkit is required when --agent is `integrations_agent` \ + (e.g. `--toolkit gmail`). Run `composio list_connection` to see active slugs." + )); + } init_quiet_logging(flags.verbose); + log::debug!( + "[agent-cli] run_dump_prompt entry: agent={} toolkit={:?} workspace={:?} model={:?}", + agent, + flags.toolkit, + flags.workspace, + flags.model + ); + let options = DumpPromptOptions { agent_id: agent, - skill_filter: flags.skill.clone(), + toolkit: flags.toolkit.clone(), workspace_dir_override: flags.workspace.clone(), model_override: flags.model.clone(), - stub_composio: flags.stub_composio, }; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; + log::debug!("[agent-cli] run_dump_prompt: calling dump_agent_prompt"); let dumped = rt.block_on(async { dump_agent_prompt(options).await })?; + log::debug!( + "[agent-cli] run_dump_prompt: dump returned (tools={}, skill_tools={}, prompt_bytes={})", + dumped.tool_names.len(), + dumped.skill_tool_count, + dumped.text.len() + ); if flags.json { print_json(&dumped, flags.with_tools)?; @@ -165,15 +348,14 @@ fn print_human(dumped: &DumpedPrompt, with_tools: bool) { // in `core/cli.rs` (banner to stderr, JSON result to stdout). eprintln!("# Agent prompt dump"); eprintln!("agent: {}", dumped.agent_id); + if let Some(tk) = &dumped.toolkit { + eprintln!("toolkit: {tk}"); + } eprintln!("mode: {}", dumped.mode); eprintln!("model: {}", dumped.model); eprintln!("workspace: {}", dumped.workspace_dir.display()); eprintln!("tool_count: {}", dumped.tool_names.len()); eprintln!("skill_tools: {}", dumped.skill_tool_count); - match dumped.cache_boundary { - Some(offset) => eprintln!("cache_boundary: byte offset {offset}"), - None => eprintln!("cache_boundary: none"), - } if with_tools { eprintln!("tools:"); for name in &dumped.tool_names { @@ -195,6 +377,13 @@ fn print_json(dumped: &DumpedPrompt, with_tools: bool) -> Result<()> { "agent_id".into(), serde_json::Value::String(dumped.agent_id.clone()), ); + obj.insert( + "toolkit".into(), + match &dumped.toolkit { + Some(tk) => serde_json::Value::String(tk.clone()), + None => serde_json::Value::Null, + }, + ); obj.insert( "mode".into(), serde_json::Value::String(dumped.mode.to_string()), @@ -215,13 +404,6 @@ fn print_json(dumped: &DumpedPrompt, with_tools: bool) -> Result<()> { "skill_tool_count".into(), serde_json::Value::Number(dumped.skill_tool_count.into()), ); - obj.insert( - "cache_boundary".into(), - match dumped.cache_boundary { - Some(offset) => serde_json::Value::Number(offset.into()), - None => serde_json::Value::Null, - }, - ); obj.insert( "system_prompt".into(), serde_json::Value::String(dumped.text.clone()), @@ -327,13 +509,6 @@ fn run_list(args: &[String]) -> Result<()> { "omit_skills_catalog".into(), serde_json::Value::Bool(def.omit_skills_catalog), ); - obj.insert( - "category_filter".into(), - match def.category_filter { - Some(cat) => serde_json::Value::String(format!("{cat:?}")), - None => serde_json::Value::Null, - }, - ); arr.push(serde_json::Value::Object(obj)); } println!( @@ -341,15 +516,11 @@ fn run_list(args: &[String]) -> Result<()> { serde_json::to_string_pretty(&serde_json::Value::Array(arr))? ); } else { - println!("{:<20} {:<22} WHEN TO USE", "ID", "CATEGORY FILTER"); + println!("{:<20} WHEN TO USE", "ID"); println!("{}", "-".repeat(90)); for def in registry.list() { - let cat = def - .category_filter - .map(|c| format!("{c:?}")) - .unwrap_or_else(|| "-".into()); - let when = def.when_to_use.chars().take(46).collect::(); - println!("{:<20} {:<22} {}", def.id, cat, when); + let when = def.when_to_use.chars().take(68).collect::(); + println!("{:<20} {}", def.id, when); } println!(); println!("{} agent(s) registered.", registry.len()); @@ -366,7 +537,8 @@ fn print_agent_help() { println!(); println!("Usage:"); println!(" openhuman agent list [--workspace ] [--json]"); - println!(" openhuman agent dump-prompt --agent [--skill ] [--workspace ] [--model ] [--with-tools] [--json] [-v]"); + println!(" openhuman agent dump-prompt --agent [--workspace ] [--model ] [--with-tools] [--json] [-v]"); + println!(" openhuman agent dump-all --out [--workspace ] [--model ] [-v]"); println!(); println!("Run `openhuman agent --help` for details."); } @@ -378,34 +550,30 @@ fn print_dump_prompt_help() { println!(" openhuman agent dump-prompt --agent [options]"); println!(); println!("Required:"); - println!(" --agent, -a Target agent id — any built-in or workspace-custom id."); - println!(" Use `main` for the orchestrator / main-agent prompt."); + println!(" --agent, -a Target agent id — any built-in or workspace-custom id"); + println!(" (e.g. `orchestrator`, `integrations_agent`, `welcome`)."); println!(); println!("Options:"); - println!(" --skill, -s Skill filter override — scopes the tool list to"); - println!(" tools named `__*`. Mirrors `spawn_subagent`'s"); - println!(" per-call `skill_filter` argument."); + println!(" --toolkit, -t REQUIRED when `--agent integrations_agent`. Names the"); + println!(" Composio toolkit to bind this dump to (e.g. `gmail`,"); + println!(" `notion`). Must match a currently-connected integration —"); + println!(" run `composio list_connection` to see the active slugs."); println!(" --workspace, -w

Override the workspace directory (defaults to"); println!(" Config::workspace_dir / ~/.openhuman/workspace)."); println!(" --model, -m Override the resolved model name (affects only the"); println!(" `## Runtime` section)."); println!(" --with-tools Also print the full list of tool names the agent sees."); - println!(" --stub-composio Inject the five Composio meta-tool stubs into the dump"); - println!(" even if the user is not signed in. Debug-only — do not"); - println!(" run agents against the resulting registry (stubs have no"); - println!(" real backend)."); println!(" --json Emit a machine-readable JSON object on stdout."); println!(" -v, --verbose Enable debug logging on stderr."); println!(); println!("Examples:"); - println!(" # Full skills_agent dump (includes Composio meta-tools when enabled)."); - println!(" openhuman agent dump-prompt --agent skills_agent --with-tools"); + println!(" # Orchestrator prompt, JSON for scripting."); + println!(" openhuman agent dump-prompt --agent orchestrator --json"); println!(); - println!(" # Narrow the skills_agent prompt to just the Notion integration."); - println!(" openhuman agent dump-prompt --agent skills_agent --skill notion"); - println!(); - println!(" # Main orchestrator prompt, JSON for scripting."); - println!(" openhuman agent dump-prompt --agent main --json"); + println!(" # integrations_agent bound to the user's gmail connection."); + println!( + " openhuman agent dump-prompt --agent integrations_agent --toolkit gmail --with-tools" + ); } fn is_help(value: &str) -> bool { diff --git a/src/core/cli.rs b/src/core/cli.rs index 7135ed791..5d4e97d09 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -47,6 +47,8 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { // Print the welcome banner to stderr to keep stdout clean for JSON output. eprint!("{CLI_BANNER}"); + load_dotenv_for_cli()?; + let grouped = grouped_schemas(); if args.is_empty() || is_help(&args[0]) { print_general_help(&grouped); @@ -81,13 +83,14 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { /// Loads key/value pairs from a `.env` file into the process environment. /// -/// This is used during the `run` command to load sensitive configurations. +/// This is used for all CLI entrypoints so direct namespace commands pick up +/// the same repo-local configuration as `run` / `serve`. /// /// Precedence: /// 1. Variables already set in the process environment are **not** overwritten. /// 2. If `OPENHUMAN_DOTENV_PATH` is set, that file is loaded. /// 3. Otherwise, it searches for `.env` in the current working directory. -fn load_dotenv_for_server() -> Result<()> { +fn load_dotenv_for_cli() -> Result<()> { match std::env::var("OPENHUMAN_DOTENV_PATH") { Ok(path) if !path.trim().is_empty() => { dotenvy::from_path(&path).map_err(|e| { @@ -110,8 +113,6 @@ fn load_dotenv_for_server() -> Result<()> { /// /// * `args` - Command-line arguments for the `run` command (e.g., `--port`). fn run_server_command(args: &[String]) -> Result<()> { - load_dotenv_for_server()?; - let mut port: Option = None; let mut host: Option = None; let mut socketio_enabled = true; @@ -564,8 +565,19 @@ fn is_help(value: &str) -> bool { #[cfg(test)] mod tests { - use super::{grouped_schemas, parse_function_params, parse_input_value}; + use super::{grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + use std::sync::{Mutex, OnceLock}; + use tempfile::tempdir; + + static CLI_ENV_LOCK: OnceLock> = OnceLock::new(); + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + CLI_ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } #[test] fn grouped_schemas_contains_migrated_namespaces() { @@ -612,4 +624,56 @@ mod tests { .expect_err("invalid bool should fail"); assert!(err.contains("expected bool")); } + + #[test] + fn load_dotenv_for_cli_reads_cwd_dotenv_without_overwriting_existing_env() { + let _guard = env_lock(); + let tmp = tempdir().expect("tempdir"); + let env_path = tmp.path().join(".env"); + std::fs::write( + &env_path, + "BACKEND_URL=https://staging-api.example.test\nOPENHUMAN_APP_ENV=staging\n", + ) + .expect("write .env"); + + let original_dir = std::env::current_dir().expect("current dir"); + let prior_backend = std::env::var("BACKEND_URL").ok(); + let prior_app_env = std::env::var("OPENHUMAN_APP_ENV").ok(); + let prior_dotenv_path = std::env::var("OPENHUMAN_DOTENV_PATH").ok(); + + unsafe { + std::env::remove_var("BACKEND_URL"); + std::env::set_var("OPENHUMAN_APP_ENV", "production"); + std::env::remove_var("OPENHUMAN_DOTENV_PATH"); + } + std::env::set_current_dir(tmp.path()).expect("set current dir"); + + let result = load_dotenv_for_cli(); + + let loaded_backend = std::env::var("BACKEND_URL").ok(); + let loaded_app_env = std::env::var("OPENHUMAN_APP_ENV").ok(); + + std::env::set_current_dir(&original_dir).expect("restore current dir"); + unsafe { + match prior_backend { + Some(value) => std::env::set_var("BACKEND_URL", value), + None => std::env::remove_var("BACKEND_URL"), + } + match prior_app_env { + Some(value) => std::env::set_var("OPENHUMAN_APP_ENV", value), + None => std::env::remove_var("OPENHUMAN_APP_ENV"), + } + match prior_dotenv_path { + Some(value) => std::env::set_var("OPENHUMAN_DOTENV_PATH", value), + None => std::env::remove_var("OPENHUMAN_DOTENV_PATH"), + } + } + + result.expect("dotenv load should succeed"); + assert_eq!( + loaded_backend.as_deref(), + Some("https://staging-api.example.test") + ); + assert_eq!(loaded_app_env.as_deref(), Some("production")); + } } diff --git a/src/openhuman/agent/agents/archivist/mod.rs b/src/openhuman/agent/agents/archivist/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/archivist/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/archivist/prompt.rs b/src/openhuman/agent/agents/archivist/prompt.rs new file mode 100644 index 000000000..1dbcfe35c --- /dev/null +++ b/src/openhuman/agent/agents/archivist/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `archivist` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "archivist", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/code_executor/mod.rs b/src/openhuman/agent/agents/code_executor/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/code_executor/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/code_executor/prompt.rs b/src/openhuman/agent/agents/code_executor/prompt.rs new file mode 100644 index 000000000..6639ba72e --- /dev/null +++ b/src/openhuman/agent/agents/code_executor/prompt.rs @@ -0,0 +1,71 @@ +//! System prompt builder for the `code_executor` built-in agent. +//! +//! Returns the fully-assembled system prompt, including the standard +//! `## Safety` block (this agent has `omit_safety_preamble = false` +//! in its TOML — it executes code or external actions and needs the +//! guard rails inlined). + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "code_executor", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/critic/mod.rs b/src/openhuman/agent/agents/critic/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/critic/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/critic/prompt.rs b/src/openhuman/agent/agents/critic/prompt.rs new file mode 100644 index 000000000..a5af3e081 --- /dev/null +++ b/src/openhuman/agent/agents/critic/prompt.rs @@ -0,0 +1,66 @@ +//! System prompt builder for the `critic` built-in agent. +//! +//! Returns the final, fully-assembled system prompt — archetype body +//! (from the sibling `prompt.md`) plus the same section helpers the +//! runtime uses for every other agent. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "critic", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/integrations_agent/agent.toml b/src/openhuman/agent/agents/integrations_agent/agent.toml new file mode 100644 index 000000000..5d0e2eba6 --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/agent.toml @@ -0,0 +1,16 @@ +id = "integrations_agent" +display_name = "Integrations Agent" +when_to_use = "Service integration specialist — drives a SINGLE Composio toolkit per spawn (gmail, notion, github, slack, …). The `toolkit` argument is mandatory. Use when a task should be completed via a managed OAuth integration rather than raw HTTP / file I/O." +temperature = 0.4 +max_iterations = 10 +sandbox_mode = "none" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = false +omit_skills_catalog = true + +[model] +hint = "agentic" + +[tools] +named = ["composio_list_tools", "file_read"] diff --git a/src/openhuman/agent/agents/integrations_agent/mod.rs b/src/openhuman/agent/agents/integrations_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.md b/src/openhuman/agent/agents/integrations_agent/prompt.md new file mode 100644 index 000000000..3d04374dc --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/prompt.md @@ -0,0 +1,45 @@ +# Integrations Agent — Service Integration Specialist + +You are the **Integrations Agent**. You interact with one connected external service at a time via **Composio** (a managed OAuth gateway). Each spawn is scoped to a single toolkit — the one your caller passed in the `toolkit` argument (e.g. `gmail`, `notion`, `github`, `slack`). + +## Your tool surface + +- **`composio_list_tools`** — inspect the action catalogue for your bound toolkit. Returns the `function.name` slug + JSON schema for each action. +- **`composio_execute`** — run a Composio action: `{ tool: "", arguments: {...} }`. +- **Per-action tools** — the toolkit's individual action tools are already registered in your tool list with typed schemas (e.g. `GMAIL_SEND_EMAIL`, `NOTION_CREATE_PAGE`). Prefer calling these directly over the generic `composio_execute`. + +You do **not** have `composio_list_toolkits`, `composio_list_connections`, `composio_authorize`, shell, file I/O, or any other capability. Stay inside this surface. + +## Typical flow + +1. You already have the toolkit's action tools in your tool list — start there. If you need a schema reminder or a slug you don't see, call `composio_list_tools`. +2. Call the per-action tool (or `composio_execute` with the slug) using the caller's task as your guide. +3. If the call fails with an authentication / authorization / connection error, stop and return: **"Connection error, try to authenticate"** — the orchestrator will take over and route the user to settings. + +## Rules + +- **Never fabricate action slugs.** Pull them from `composio_list_tools` or use the per-action tools already in your list. +- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. +- **Auth errors bubble up.** On any auth / connection failure reply exactly: `Connection error, try to authenticate`. Do not retry, do not attempt to re-authorise yourself — you have no tools for that. +- **Be precise** — every action expects a specific argument shape. Validate against the schema before calling. +- **Report results** — state what action was taken and the outcome, including any cost reported by Composio. + +## Handling oversized tool results + +When an action returns a very large payload (~100 KB or more), decide based on what the caller asked for. + +### Path A — caller wants an answer, not the raw data + +Examples: "how many unread emails do I have?", "which issues are labeled P0?", "what's the most recent message?" + +Scan the result for the specific facts that answer the question, then synthesise a concise answer referencing identifiers (issue numbers, email subjects, message timestamps). Do **not** dump raw output. + +### Path B — caller wants the dataset itself + +Examples: "show me all open issues", "export my contacts", "give me the full thread". + +You cannot write files from this agent. Return a concise summary inline (count, key highlights, representative identifiers) and tell the caller you are returning the structured data so the orchestrator can persist it — the orchestrator, not you, owns file I/O. + +### Hard cap + +Never paste more than ~2000 characters of raw tool output directly in your response. diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.rs b/src/openhuman/agent/agents/integrations_agent/prompt.rs new file mode 100644 index 000000000..8dc805e58 --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/prompt.rs @@ -0,0 +1,204 @@ +//! System prompt builder for the `integrations_agent` built-in agent. +//! +//! `integrations_agent` is the one sub-agent that executes Composio actions +//! directly — every other agent delegates to it via `spawn_subagent`. +//! That means the prompt owns two blocks nobody else renders: +//! +//! * `## Available Skills` — the QuickJS skill catalogue it can invoke +//! through the runtime. +//! * `## Connected Integrations` — the list of Composio toolkits the +//! user has connected, framed as "you have direct access to the +//! action tools in your tool list" rather than "delegate to integrations_agent". +//! +//! Both blocks live here (not in the shared prompts module) so the +//! delegator agents stay lean and the integrations_agent-specific wording +//! isn't a branch on `agent_id` somewhere else. + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, ConnectedIntegration, + PromptContext, +}; +use crate::openhuman::skills::Skill; +use anyhow::Result; +use std::fmt::Write; +use std::path::Path; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let skills = render_available_skills(ctx.skills, ctx.workspace_dir); + if !skills.trim().is_empty() { + out.push_str(skills.trim_end()); + out.push_str("\n\n"); + } + + let integrations = render_connected_integrations(ctx.connected_integrations); + if !integrations.trim().is_empty() { + out.push_str(integrations.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +/// Render the `## Available Skills` XML catalogue of QuickJS skills +/// this agent can invoke through the host runtime. Empty when no skills +/// are registered. +fn render_available_skills(skills: &[Skill], workspace_dir: &Path) -> String { + if skills.is_empty() { + return String::new(); + } + let mut out = String::from("## Available Skills\n\n\n"); + for skill in skills { + let location = skill.location.clone().unwrap_or_else(|| { + workspace_dir + .join("skills") + .join(&skill.name) + .join("SKILL.md") + }); + let _ = writeln!( + out, + " \n {}\n {}\n {}\n ", + xml_escape(&skill.name), + xml_escape(&skill.description), + xml_escape(&location.display().to_string()), + ); + } + out.push_str(""); + out +} + +/// Escape XML-sensitive characters so skill metadata can't break the +/// surrounding `` block if a name or description +/// contains `<`, `>`, or `&`. +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// Render the skill-executor-flavoured `## Connected Integrations` +/// block. Tells the model that the action tools for each toolkit are +/// already in its tool list and to call them directly — no delegation +/// wording, because `integrations_agent` IS the delegation target. +fn render_connected_integrations(integrations: &[ConnectedIntegration]) -> String { + let connected: Vec<&ConnectedIntegration> = + integrations.iter().filter(|ci| ci.connected).collect(); + if connected.is_empty() { + return String::new(); + } + let mut out = String::from( + "## Connected Integrations\n\n\ + You have direct access to the following external services. \ + The corresponding action tools are in your tool list with \ + their typed parameter schemas — call them by name.\n\n", + ); + for ci in connected { + let _ = writeln!(out, "- **{}** — {}", ci.toolkit, ci.description); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn ctx_with<'a>( + integrations: &'a [ConnectedIntegration], + skills: &'a [Skill], + ) -> PromptContext<'a> { + // Leak a HashSet so the returned context borrows a 'static-ish + // reference — the test owns the value for its lifetime. + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "integrations_agent", + tools: &[], + skills, + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: integrations, + include_profile: false, + include_memory_md: false, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with(&[], &[])).unwrap(); + assert!(!body.is_empty()); + assert!(!body.contains("## Connected Integrations")); + assert!(!body.contains("## Available Skills")); + } + + #[test] + fn build_includes_connected_integrations_in_executor_voice() { + let integrations = vec![ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + connected: true, + }]; + let body = build(&ctx_with(&integrations, &[])).unwrap(); + assert!(body.contains("## Connected Integrations")); + assert!(body.contains("You have direct access")); + assert!(body.contains("- **gmail** — Email access.")); + // `integrations_agent` must NOT render the delegator spawn snippet — + // that belongs on the orchestrator/welcome side. + assert!(!body.contains("Delegation Guide")); + assert!(!body.contains("spawn_subagent")); + } + + #[test] + fn build_skips_unconnected_integrations() { + let integrations = vec![ConnectedIntegration { + toolkit: "notion".into(), + description: "Pages.".into(), + tools: Vec::new(), + connected: false, + }]; + let body = build(&ctx_with(&integrations, &[])).unwrap(); + assert!(!body.contains("## Connected Integrations")); + } +} diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index 261f28c11..38722962f 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -6,12 +6,15 @@ //! * `agent.toml` — id, when_to_use, model, tool allowlist, sandbox, //! iteration cap, and the `omit_*` flags. Parsed //! directly into [`AgentDefinition`] via serde. -//! * `prompt.md` — the sub-agent's system prompt body. +//! * `prompt.rs` — a Rust module exporting `pub fn build(ctx: &PromptContext) +//! -> anyhow::Result` that returns the sub-agent's system +//! prompt body. Dynamic: may branch on available tools, user profile, +//! connected integrations, model hint, etc. //! //! Adding a new built-in agent = creating a new subfolder with those two -//! files and appending one entry to [`BUILTINS`] below. There are no -//! match arms to update, no enum variants to add, and no `include_str!` -//! paths scattered across the harness. +//! files, declaring the module, and appending one entry to [`BUILTINS`] +//! below. There are no match arms to update, no enum variants to add, +//! and no `include_str!` paths scattered across the harness. //! //! ## Flow //! @@ -32,18 +35,22 @@ //! collision. use crate::openhuman::agent::harness::definition::{ - AgentDefinition, DefinitionSource, PromptSource, + AgentDefinition, DefinitionSource, PromptBuilder, PromptSource, }; use anyhow::{Context, Result}; -/// A single built-in agent: its id plus the two files that define it. +/// A single built-in agent: its id plus the metadata TOML and a +/// function-driven prompt builder. /// /// Kept as a static slice (rather than e.g. `include_dir!`) so the /// compile-time file-existence check is explicit and grep-friendly. pub struct BuiltinAgent { pub id: &'static str, pub toml: &'static str, - pub prompt: &'static str, + /// Prompt builder. Invoked at spawn time by the sub-agent runner + /// with a populated [`crate::openhuman::agent::harness::definition::PromptContext`] + /// so the returned body can branch on runtime state. + pub prompt_fn: PromptBuilder, } /// Every built-in agent, in stable display order. @@ -53,67 +60,72 @@ pub const BUILTINS: &[BuiltinAgent] = &[ BuiltinAgent { id: "orchestrator", toml: include_str!("orchestrator/agent.toml"), - prompt: include_str!("orchestrator/prompt.md"), + prompt_fn: super::orchestrator::prompt::build, }, BuiltinAgent { id: "planner", toml: include_str!("planner/agent.toml"), - prompt: include_str!("planner/prompt.md"), + prompt_fn: super::planner::prompt::build, }, BuiltinAgent { id: "code_executor", toml: include_str!("code_executor/agent.toml"), - prompt: include_str!("code_executor/prompt.md"), + prompt_fn: super::code_executor::prompt::build, }, BuiltinAgent { - id: "skills_agent", - toml: include_str!("skills_agent/agent.toml"), - prompt: include_str!("skills_agent/prompt.md"), + id: "integrations_agent", + toml: include_str!("integrations_agent/agent.toml"), + prompt_fn: super::integrations_agent::prompt::build, + }, + BuiltinAgent { + id: "tools_agent", + toml: include_str!("tools_agent/agent.toml"), + prompt_fn: super::tools_agent::prompt::build, }, BuiltinAgent { id: "tool_maker", toml: include_str!("tool_maker/agent.toml"), - prompt: include_str!("tool_maker/prompt.md"), + prompt_fn: super::tool_maker::prompt::build, }, BuiltinAgent { id: "researcher", toml: include_str!("researcher/agent.toml"), - prompt: include_str!("researcher/prompt.md"), + prompt_fn: super::researcher::prompt::build, }, BuiltinAgent { id: "critic", toml: include_str!("critic/agent.toml"), - prompt: include_str!("critic/prompt.md"), + prompt_fn: super::critic::prompt::build, }, BuiltinAgent { id: "archivist", toml: include_str!("archivist/agent.toml"), - prompt: include_str!("archivist/prompt.md"), + prompt_fn: super::archivist::prompt::build, }, BuiltinAgent { id: "trigger_triage", toml: include_str!("trigger_triage/agent.toml"), - prompt: include_str!("trigger_triage/prompt.md"), + prompt_fn: super::trigger_triage::prompt::build, }, BuiltinAgent { id: "trigger_reactor", toml: include_str!("trigger_reactor/agent.toml"), - prompt: include_str!("trigger_reactor/prompt.md"), + prompt_fn: super::trigger_reactor::prompt::build, }, BuiltinAgent { id: "morning_briefing", toml: include_str!("morning_briefing/agent.toml"), - prompt: include_str!("morning_briefing/prompt.md"), + prompt_fn: super::morning_briefing::prompt::build, }, BuiltinAgent { id: "welcome", toml: include_str!("welcome/agent.toml"), - prompt: include_str!("welcome/prompt.md"), + prompt_fn: super::welcome::prompt::build, }, BuiltinAgent { id: "summarizer", toml: include_str!("summarizer/agent.toml"), - prompt: include_str!("summarizer/prompt.md"), + prompt_fn: super::summarizer::prompt::build, }, ]; @@ -134,8 +146,8 @@ fn parse_builtin(b: &BuiltinAgent) -> Result { let mut def: AgentDefinition = toml::from_str(b.toml) .with_context(|| format!("parsing built-in agent `{}` TOML", b.id))?; - // Inject the prompt body and stamp the source. - def.system_prompt = PromptSource::Inline(b.prompt.to_string()); + // Install the function-driven prompt builder and stamp the source. + def.system_prompt = PromptSource::Dynamic(b.prompt_fn); def.source = DefinitionSource::Builtin; // Sanity check: file layout id must match declared TOML id. This @@ -160,7 +172,7 @@ mod tests { fn all_builtins_parse() { let defs = load_builtins().expect("built-in TOML must parse"); assert_eq!(defs.len(), BUILTINS.len()); - assert_eq!(defs.len(), 13, "expected 13 built-in agents"); + assert_eq!(defs.len(), 14, "expected 14 built-in agents"); } #[test] @@ -226,13 +238,35 @@ mod tests { #[test] fn every_builtin_has_a_prompt_body() { + use crate::openhuman::context::prompt::{ + ConnectedIntegration, LearnedContextData, PromptContext, PromptTool, ToolCallFormat, + }; + let empty_tools: Vec> = Vec::new(); + let empty_integrations: Vec = Vec::new(); + let empty_visible: std::collections::HashSet = std::collections::HashSet::new(); for def in load_builtins().unwrap() { match &def.system_prompt { - PromptSource::Inline(body) => { + PromptSource::Dynamic(build) => { + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: &def.id, + tools: &empty_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &empty_visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &empty_integrations, + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx) + .unwrap_or_else(|e| panic!("{} prompt build failed: {e}", def.id)); assert!(!body.is_empty(), "{} has empty prompt", def.id); } - PromptSource::File { .. } => { - panic!("{} should use inline prompt, not File", def.id); + PromptSource::Inline(_) | PromptSource::File { .. } => { + panic!("{} should use dynamic prompt builder", def.id); } } } @@ -292,16 +326,26 @@ mod tests { } #[test] - fn skills_agent_is_wildcard_with_skill_category_filter() { - let def = find("skills_agent"); - assert!(matches!(def.tools, ToolScope::Wildcard)); - assert_eq!( - def.category_filter, - Some(crate::openhuman::tools::ToolCategory::Skill) - ); + fn integrations_agent_tool_scope_honours_toml() { + let def = find("integrations_agent"); + // Current TOML: `named = ["composio_list_tools", "file_read"]`. + // Sub-agent runner additionally injects per-toolkit + // ComposioActionTools at spawn time. + match &def.tools { + ToolScope::Named(names) => { + assert!(names.iter().any(|n| n == "composio_list_tools")); + } + other => panic!("expected Named scope, got {other:?}"), + } assert!(!def.omit_safety_preamble); } + #[test] + fn tools_agent_is_registered() { + let def = find("tools_agent"); + assert!(matches!(def.tools, ToolScope::Wildcard)); + } + #[test] fn archivist_runs_in_background() { let def = find("archivist"); @@ -310,14 +354,10 @@ mod tests { } #[test] - fn morning_briefing_is_read_only_with_skill_filter() { + fn morning_briefing_is_read_only() { let def = find("morning_briefing"); assert_eq!(def.sandbox_mode, SandboxMode::ReadOnly); assert!(matches!(def.tools, ToolScope::Wildcard)); - assert_eq!( - def.category_filter, - Some(crate::openhuman::tools::ToolCategory::Skill) - ); assert!(!def.omit_memory_context); assert!(def.omit_identity); assert!(def.omit_safety_preamble); diff --git a/src/openhuman/agent/agents/mod.rs b/src/openhuman/agent/agents/mod.rs index ca9184dcb..9d519e4b5 100644 --- a/src/openhuman/agent/agents/mod.rs +++ b/src/openhuman/agent/agents/mod.rs @@ -1,3 +1,22 @@ mod loader; +// Built-in agents. Each module owns an `agent.toml` (metadata), the +// legacy `prompt.md` (kept alongside for reference / workspace +// overrides), and a `prompt.rs` exposing a `pub fn build(&PromptContext) +// -> Result` that the loader wires into `PromptSource::Dynamic`. +pub mod archivist; +pub mod code_executor; +pub mod critic; +pub mod integrations_agent; +pub mod morning_briefing; +pub mod orchestrator; +pub mod planner; +pub mod researcher; +pub mod summarizer; +pub mod tool_maker; +pub mod tools_agent; +pub mod trigger_reactor; +pub mod trigger_triage; +pub mod welcome; + pub use loader::{load_builtins, BuiltinAgent, BUILTINS}; diff --git a/src/openhuman/agent/agents/morning_briefing/agent.toml b/src/openhuman/agent/agents/morning_briefing/agent.toml index 198f1dfc4..4001b191c 100644 --- a/src/openhuman/agent/agents/morning_briefing/agent.toml +++ b/src/openhuman/agent/agents/morning_briefing/agent.toml @@ -12,10 +12,6 @@ omit_memory_context = false omit_safety_preamble = true omit_skills_catalog = true -# Skill-category tools so it can pull calendar, email, task data from -# connected integrations (Composio: Gmail, Google Calendar, Notion, etc.). -category_filter = "skill" - [model] hint = "agentic" diff --git a/src/openhuman/agent/agents/morning_briefing/mod.rs b/src/openhuman/agent/agents/morning_briefing/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/morning_briefing/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/morning_briefing/prompt.rs b/src/openhuman/agent/agents/morning_briefing/prompt.rs new file mode 100644 index 000000000..e6c9c42a8 --- /dev/null +++ b/src/openhuman/agent/agents/morning_briefing/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `morning_briefing` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "morning_briefing", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/orchestrator/agent.toml b/src/openhuman/agent/agents/orchestrator/agent.toml index 704bf274d..1c70affb0 100644 --- a/src/openhuman/agent/agents/orchestrator/agent.toml +++ b/src/openhuman/agent/agents/orchestrator/agent.toml @@ -26,7 +26,7 @@ omit_memory_md = false # LLM-visible tool description. # # * `{ skills = "*" }` → one `SkillDelegationTool` per connected -# Composio toolkit, all routing to the generic `skills_agent` with +# Composio toolkit, all routing to the generic `integrations_agent` with # the toolkit slug pre-filled as `skill_filter`. # # The orchestrator LLM sees these as first-class entries in its @@ -41,6 +41,7 @@ subagents = [ "researcher", "planner", "code_executor", + "tools_agent", "critic", "archivist", # Runtime-dispatched only — the runtime calls the summarizer sub-agent @@ -63,9 +64,18 @@ hint = "reasoning" # delegating. `spawn_subagent` is retained as an advanced fallback so # power users can still spawn arbitrary agent ids that are not listed # in `subagents` above (e.g. workspace-override custom agents). +# +# `composio_list_connections` is the orchestrator's only composio_* +# tool: it exists so the agent can detect newly-authorised integrations +# mid-session (the session-start fetch froze the Delegation Guide's +# connected list). Authorisation, toolkit discovery, action listing, +# and action execution all live downstream in `integrations_agent` — +# the orchestrator never calls composio_authorize / composio_list_tools +# / composio_execute directly. named = [ "query_memory", "read_workspace_state", "ask_user_clarification", "spawn_subagent", + "composio_list_connections", ] diff --git a/src/openhuman/agent/agents/orchestrator/mod.rs b/src/openhuman/agent/agents/orchestrator/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/orchestrator/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/orchestrator/prompt.rs b/src/openhuman/agent/agents/orchestrator/prompt.rs new file mode 100644 index 000000000..d3a493fcc --- /dev/null +++ b/src/openhuman/agent/agents/orchestrator/prompt.rs @@ -0,0 +1,169 @@ +//! System prompt builder for the `orchestrator` built-in agent. +//! +//! The orchestrator is a pure delegator — it never executes Composio +//! actions itself. Its integration block is a `## Delegation Guide` +//! that tells the model to `spawn_subagent(integrations_agent, toolkit=…)` +//! for anything touching an external service. That prose lives here +//! (not in the shared prompts module) so the skill-executor voice +//! stays in `integrations_agent/prompt.rs` and nobody has to branch on +//! `agent_id` in a shared section impl. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, ConnectedIntegration, PromptContext, +}; +use anyhow::Result; +use std::fmt::Write; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let integrations = render_delegation_guide(ctx.connected_integrations); + if !integrations.trim().is_empty() { + out.push_str(integrations.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +/// Render the delegator-voice `## Delegation Guide — Integrations` +/// block. Only toolkits the user has actively connected are listed — +/// unauthorised toolkits are hidden so the orchestrator can't hallucinate +/// a spawn against an integration the `spawn_subagent` pre-flight will +/// immediately reject. When every toolkit is unconnected, the whole +/// section is omitted. +fn render_delegation_guide(integrations: &[ConnectedIntegration]) -> String { + let connected: Vec<&ConnectedIntegration> = + integrations.iter().filter(|ci| ci.connected).collect(); + if connected.is_empty() { + return String::new(); + } + let mut out = String::from( + "## Delegation Guide — Integrations\n\n\ + For any task that touches one of these external services, \ + delegate to `integrations_agent` with the matching `toolkit` \ + argument. The sub-agent receives the full action catalogue \ + for that integration as native tool schemas — do not attempt \ + to call integration actions directly from this agent.\n\n\ + Only the integrations listed below are currently authorised. \ + If the user asks about another service, tell them to connect \ + it in **Skills** page before retrying.\n\n", + ); + for ci in connected { + let _ = writeln!( + out, + "- **{}** — {}\n Delegate with: `spawn_subagent(agent_id=\"integrations_agent\", toolkit=\"{}\", prompt=)`", + ci.toolkit, ci.description, ci.toolkit, + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn ctx_with<'a>(integrations: &'a [ConnectedIntegration]) -> PromptContext<'a> { + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "orchestrator", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: integrations, + include_profile: false, + include_memory_md: false, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with(&[])).unwrap(); + assert!(!body.is_empty()); + assert!(!body.contains("## Delegation Guide")); + } + + #[test] + fn build_emits_delegation_guide_with_spawn_snippet() { + let integrations = vec![ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + connected: true, + }]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(body.contains("## Delegation Guide — Integrations")); + assert!(body.contains( + "spawn_subagent(agent_id=\"integrations_agent\", toolkit=\"gmail\", prompt=)" + )); + // Delegator voice must NOT use the skill-executor wording. + assert!(!body.contains("You have direct access")); + } + + #[test] + fn build_hides_unconnected_integrations() { + // Only connected toolkits make it into the Delegation Guide + // — unconnected entries would just trigger a spawn_subagent + // pre-flight rejection, so keeping them out keeps the prompt + // focused on what the orchestrator can actually delegate. + let integrations = vec![ + ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email.".into(), + tools: Vec::new(), + connected: true, + }, + ConnectedIntegration { + toolkit: "linear".into(), + description: "Tracker.".into(), + tools: Vec::new(), + connected: false, + }, + ]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(body.contains("- **gmail**")); + assert!(!body.contains("- **linear**")); + } + + #[test] + fn build_omits_guide_when_no_integrations_connected() { + let integrations = vec![ConnectedIntegration { + toolkit: "linear".into(), + description: "Tracker.".into(), + tools: Vec::new(), + connected: false, + }]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(!body.contains("## Delegation Guide")); + } +} diff --git a/src/openhuman/agent/agents/planner/agent.toml b/src/openhuman/agent/agents/planner/agent.toml index 24e6e9374..b8ce6d39d 100644 --- a/src/openhuman/agent/agents/planner/agent.toml +++ b/src/openhuman/agent/agents/planner/agent.toml @@ -14,4 +14,8 @@ omit_skills_catalog = true hint = "reasoning" [tools] -named = ["file_read", "memory_recall", "memory_store", "memory_forget", "web_search_tool"] +# Read + research only. The planner produces plans — it never mutates +# the workspace, memory, or state. Any writes the plan requires get +# executed by downstream agents (code_executor, archivist, …) at the +# orchestrator's direction. +named = ["file_read", "memory_recall", "web_search_tool"] diff --git a/src/openhuman/agent/agents/planner/mod.rs b/src/openhuman/agent/agents/planner/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/planner/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/planner/prompt.md b/src/openhuman/agent/agents/planner/prompt.md index 02e081e47..26027729f 100644 --- a/src/openhuman/agent/agents/planner/prompt.md +++ b/src/openhuman/agent/agents/planner/prompt.md @@ -33,7 +33,7 @@ Return **only** valid JSON matching this schema: ## Available Agent IDs - `code_executor` — Writes and runs code. Use for implementation tasks. -- `skills_agent` — Executes skill tools (Notion, Gmail, etc.). Use for service interactions. +- `integrations_agent` — Executes skill tools (Notion, Gmail, etc.). Use for service interactions. - `tool_maker` — Writes polyfill scripts. Rarely needed in planning. - `researcher` — Reads docs, web searches. Use for information gathering. - `critic` — Reviews code quality and security. Use after code changes. @@ -48,4 +48,4 @@ Return **only** valid JSON matching this schema: 6. **Simple goals = single node** — If the goal is straightforward, return exactly 1 node. 7. **No cycles** — The graph must be a DAG (directed acyclic graph). 8. **Max 8 nodes** — Keep plans manageable. Split larger projects into multiple plans. -9. **Store insights** — If you discover something during research that future plans would benefit from, use `memory_store` to save it. +9. **Read-only** — You have no write tools. If a plan depends on saving an insight, facts, or artefacts, capture that as an explicit node (e.g. "archivist: store X") in the DAG so a downstream agent performs the write. diff --git a/src/openhuman/agent/agents/planner/prompt.rs b/src/openhuman/agent/agents/planner/prompt.rs new file mode 100644 index 000000000..8e3ce9b57 --- /dev/null +++ b/src/openhuman/agent/agents/planner/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `planner` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "planner", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/researcher/mod.rs b/src/openhuman/agent/agents/researcher/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/researcher/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/researcher/prompt.rs b/src/openhuman/agent/agents/researcher/prompt.rs new file mode 100644 index 000000000..4861389d9 --- /dev/null +++ b/src/openhuman/agent/agents/researcher/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `researcher` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "researcher", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/skills_agent/agent.toml b/src/openhuman/agent/agents/skills_agent/agent.toml deleted file mode 100644 index 03b9883f7..000000000 --- a/src/openhuman/agent/agents/skills_agent/agent.toml +++ /dev/null @@ -1,24 +0,0 @@ -id = "skills_agent" -display_name = "Skills Agent" -when_to_use = "Service integration specialist — executes skill-category tools that reach external SaaS. This primarily covers Composio (1000+ OAuth integrations: Gmail, Notion, GitHub, Slack, …). Use when the task should be completed via a managed integration rather than raw HTTP/file I/O." -temperature = 0.4 -max_iterations = 10 -sandbox_mode = "none" -omit_identity = true -omit_memory_context = true -omit_safety_preamble = false -omit_skills_catalog = true -category_filter = "skill" - -# `file_write` bypasses category_filter so the agent can save -# oversized tool payloads to workspace files (Path B in prompt.md). -# `csv_export` was previously included but removed — it was triggering -# superfluous export calls even after the answer had already been -# synthesised. Re-add here only if a typed CSV path is needed again. -extra_tools = ["file_write"] - -[model] -hint = "agentic" - -[tools] -wildcard = {} diff --git a/src/openhuman/agent/agents/skills_agent/prompt.md b/src/openhuman/agent/agents/skills_agent/prompt.md deleted file mode 100644 index 56de78152..000000000 --- a/src/openhuman/agent/agents/skills_agent/prompt.md +++ /dev/null @@ -1,56 +0,0 @@ -# Skills Agent — Service Integration Specialist - -You are the **Skills Agent**. You interact with connected external services primarily through **Composio** (a managed OAuth gateway for 1000+ apps like Gmail, Notion, GitHub, Slack). - -## Available tool surfaces - -1. **Composio tools** — a small meta-surface that discovers and executes Composio actions on the user's behalf: - - `composio_list_toolkits` — what integrations the backend allows (e.g. `gmail`, `notion`). - - `composio_list_connections` — which of those the user has already authorised. - - `composio_authorize` — start an OAuth handoff for a toolkit; returns a `connectUrl`. - - `composio_list_tools` — list available action schemas (optionally filtered by toolkit). Use the returned `function.name` slug as the `tool` argument to `composio_execute`. - - `composio_execute` — run a Composio action with `{ tool, arguments }` (e.g. `tool = "GMAIL_SEND_EMAIL"`). -## Typical Composio flow - -1. Call `composio_list_connections` to see what the user already has connected. -2. If the required toolkit is missing, call `composio_authorize` and return the `connectUrl` so the user can complete OAuth. -3. Once connected, call `composio_list_tools` (optionally scoped to one or two toolkits) to discover the action slug and its JSON schema. -4. Call `composio_execute` with the slug and argument object. - -## Rules - -- **Never fabricate action slugs.** Always pull them from `composio_list_tools` before calling `composio_execute`. -- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. -- **Handle OAuth expiry** — if an action fails with an auth error, surface the need to re-authorise rather than looping. -- **Use memory context** — consult the injected memory context for details about the user's integrations and preferences. -- **Be precise** — every tool expects a specific argument shape. Validate against the schema from `composio_list_tools` before calling. -- **Report results** — state what action was taken and the outcome, including any cost reported by Composio. - -## Handling Oversized Tool Results - -When a tool returns a very large result (roughly 100 KB or more — you'll recognize it by the sheer volume of data in the response), decide which path to take based on what the user actually asked for: - -### Path A — User wants an answer, not the raw data - -Examples: "how many unread emails do I have?", "which GitHub issues are labeled P0?", "what's the most recent Slack message in #general?" - -The data is a means to an answer. Do NOT dump the raw output. Instead: -1. Scan the tool result for the specific facts that answer the user's question. -2. Synthesize a concise answer referencing specific identifiers (issue numbers, email subjects, message timestamps). -3. If you can't find the answer in one pass, use your remaining iterations to refine. - -### Path B — User wants the actual data - -Examples: "show me all open issues", "export my contacts", "give me the full email thread", "list all files in the drive folder" - -The user wants the dataset itself, not a derivative. Do NOT try to paste it all inline — it won't fit. Instead: -1. Call `file_write` to save the content as `.md` (e.g. full email bodies, document content, long threads, or a markdown-formatted list of items). Example: - ``` - file_write(path="exports/slack-thread-general-2026-04-16.md", content=) - ``` -2. Return to the user: a brief summary of what's in the file (count of items, key highlights) plus the file path so they can access it. - -### Important - -- Never paste more than ~2000 characters of raw tool output directly in your response. If the output is larger, always use Path A or Path B. -- File paths are relative to the workspace root. The `exports/` directory will be created automatically. diff --git a/src/openhuman/agent/agents/summarizer/mod.rs b/src/openhuman/agent/agents/summarizer/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/summarizer/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/summarizer/prompt.rs b/src/openhuman/agent/agents/summarizer/prompt.rs new file mode 100644 index 000000000..62f7a80c9 --- /dev/null +++ b/src/openhuman/agent/agents/summarizer/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `summarizer` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "summarizer", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/tool_maker/mod.rs b/src/openhuman/agent/agents/tool_maker/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/tool_maker/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/tool_maker/prompt.rs b/src/openhuman/agent/agents/tool_maker/prompt.rs new file mode 100644 index 000000000..c96a5fd35 --- /dev/null +++ b/src/openhuman/agent/agents/tool_maker/prompt.rs @@ -0,0 +1,71 @@ +//! System prompt builder for the `tool_maker` built-in agent. +//! +//! Returns the fully-assembled system prompt, including the standard +//! `## Safety` block (this agent has `omit_safety_preamble = false` +//! in its TOML — it executes code or external actions and needs the +//! guard rails inlined). + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "tool_maker", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/tools_agent/agent.toml b/src/openhuman/agent/agents/tools_agent/agent.toml new file mode 100644 index 000000000..4c0087f1f --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/agent.toml @@ -0,0 +1,21 @@ +id = "tools_agent" +display_name = "Tools Agent" +when_to_use = "Generalist specialist for ad-hoc work that uses only built-in OpenHuman tools (shell, file I/O, HTTP, web search, memory). Use when a task does NOT require a managed Composio OAuth integration — for external SaaS, spawn `integrations_agent` with a `toolkit` argument instead." +temperature = 0.4 +max_iterations = 10 +sandbox_mode = "none" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = false +omit_skills_catalog = true + +[model] +hint = "agentic" + +[tools] +# Wildcard — the agent inherits the orchestrator's full built-in tool +# surface. Composio meta-tools and dynamic `_*` action tools +# are stripped at runtime (see `filter_non_composio_indices` in the +# subagent runner), so the LLM never sees integration-specific tools +# here; those belong to `integrations_agent`. +wildcard = {} diff --git a/src/openhuman/agent/agents/tools_agent/mod.rs b/src/openhuman/agent/agents/tools_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/tools_agent/prompt.md b/src/openhuman/agent/agents/tools_agent/prompt.md new file mode 100644 index 000000000..e28356261 --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/prompt.md @@ -0,0 +1,16 @@ +# Tools Agent — Built-in Tool Specialist + +You are the **Tools Agent**. You complete ad-hoc tasks using only OpenHuman's built-in tool surface: shell commands, file I/O, HTTP requests, web search, memory lookups, and the rest of the system-category tools in your tool list. + +## Scope + +- You do **NOT** have access to Composio / managed OAuth integrations. If a task requires acting on an external SaaS account (Gmail, Notion, GitHub, Slack, …), stop and report back — the orchestrator will spawn `integrations_agent` with the correct toolkit. +- You **DO** handle: running commands, reading and writing files in the workspace, scraping the web, searching the user's memory, querying structured data, chaining simple transformations. + +## Operating rules + +1. Plan briefly, then act. Prefer one well-chosen tool call over exploratory flailing. +2. Read before you write. Inspect the workspace or remote state first when the task touches existing data. +3. Keep tool output tight. Don't paste huge file bodies back to the caller — summarise, or save to a workspace file and return the path. +4. Surface blockers early. If a required tool isn't in your list, say so in the final response rather than faking progress. +5. When the task is done, reply with a concise summary of what you did and any relevant paths / identifiers. Don't repeat tool output verbatim. diff --git a/src/openhuman/agent/agents/tools_agent/prompt.rs b/src/openhuman/agent/agents/tools_agent/prompt.rs new file mode 100644 index 000000000..824767f59 --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/prompt.rs @@ -0,0 +1,69 @@ +//! System prompt builder for the `tools_agent` built-in agent. +//! +//! `tools_agent` is the counterpart to [`super::integrations_agent`]: +//! Composio-free specialist that only ever sees OpenHuman's built-in +//! (system-category) tools — shell, file I/O, HTTP, web search, memory. +//! Composio action tools are filtered out at tool-list construction +//! time in the subagent runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "tools_agent", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + assert!(body.contains("Tools Agent")); + } +} diff --git a/src/openhuman/agent/agents/trigger_reactor/mod.rs b/src/openhuman/agent/agents/trigger_reactor/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/trigger_reactor/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/trigger_reactor/prompt.rs b/src/openhuman/agent/agents/trigger_reactor/prompt.rs new file mode 100644 index 000000000..2fe490135 --- /dev/null +++ b/src/openhuman/agent/agents/trigger_reactor/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `trigger_reactor` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "trigger_reactor", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/trigger_triage/mod.rs b/src/openhuman/agent/agents/trigger_triage/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/trigger_triage/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/trigger_triage/prompt.rs b/src/openhuman/agent/agents/trigger_triage/prompt.rs new file mode 100644 index 000000000..5b7c28985 --- /dev/null +++ b/src/openhuman/agent/agents/trigger_triage/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `trigger_triage` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "trigger_triage", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/welcome/mod.rs b/src/openhuman/agent/agents/welcome/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/welcome/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/welcome/prompt.rs b/src/openhuman/agent/agents/welcome/prompt.rs new file mode 100644 index 000000000..83644a202 --- /dev/null +++ b/src/openhuman/agent/agents/welcome/prompt.rs @@ -0,0 +1,150 @@ +//! System prompt builder for the `welcome` built-in agent. +//! +//! Welcome runs onboarding — it surfaces which integrations the user +//! has already connected and pitches the ones that are still pending. +//! Like the orchestrator, it delegates any integration work rather +//! than executing Composio actions directly, so it renders the same +//! delegator-voice block (inlined here rather than shared, so the +//! skill-executor wording stays scoped to `integrations_agent/prompt.rs`). + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, ConnectedIntegration, PromptContext, +}; +use anyhow::Result; +use std::fmt::Write; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let integrations = render_connected_integrations(ctx.connected_integrations); + if !integrations.trim().is_empty() { + out.push_str(integrations.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +/// Render welcome's connected-integrations block — a compact list of +/// the toolkits the user has already authorised. Unconnected entries +/// are skipped (welcome's job during onboarding is to pitch them, not +/// to treat them as usable yet). +fn render_connected_integrations(integrations: &[ConnectedIntegration]) -> String { + let connected: Vec<&ConnectedIntegration> = + integrations.iter().filter(|ci| ci.connected).collect(); + if connected.is_empty() { + return String::new(); + } + let mut out = String::from("## Connected Integrations\n\n"); + for ci in connected { + let _ = writeln!( + out, + "- **{}** — {}", + sanitize_bullet(&ci.toolkit), + sanitize_bullet(&ci.description), + ); + } + out +} + +/// Normalise a string for safe inclusion in a single markdown bullet: +/// replace newlines/carriage returns with spaces, collapse runs of +/// whitespace, and trim leading/trailing whitespace so a description +/// with embedded linebreaks can't split the bullet. +fn sanitize_bullet(s: &str) -> String { + let replaced: String = s + .chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .collect(); + let mut out = String::with_capacity(replaced.len()); + let mut prev_space = false; + for ch in replaced.chars() { + if ch.is_whitespace() { + if !prev_space { + out.push(' '); + } + prev_space = true; + } else { + out.push(ch); + prev_space = false; + } + } + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn ctx_with<'a>(integrations: &'a [ConnectedIntegration]) -> PromptContext<'a> { + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "welcome", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: integrations, + include_profile: false, + include_memory_md: false, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with(&[])).unwrap(); + assert!(!body.is_empty()); + assert!(!body.contains("## Connected Integrations")); + } + + #[test] + fn build_lists_only_connected_integrations() { + let integrations = vec![ + ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + connected: true, + }, + ConnectedIntegration { + toolkit: "notion".into(), + description: "Pitch during onboarding.".into(), + tools: Vec::new(), + connected: false, + }, + ]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(body.contains("## Connected Integrations")); + assert!(body.contains("- **gmail**")); + assert!(!body.contains("- **notion**")); + } +} diff --git a/src/openhuman/agent/harness/archivist.rs b/src/openhuman/agent/harness/archivist.rs index 48d3f50ae..a81e35961 100644 --- a/src/openhuman/agent/harness/archivist.rs +++ b/src/openhuman/agent/harness/archivist.rs @@ -17,6 +17,8 @@ use crate::openhuman::memory::store::segments::{ use async_trait::async_trait; use parking_lot::Mutex; use rusqlite::Connection; +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -418,8 +420,6 @@ fn uuid_v4() -> String { /// Simple random u32 from system entropy. fn rand_u32() -> u32 { - use std::collections::hash_map::RandomState; - use std::hash::{BuildHasher, Hasher}; let state = RandomState::new(); let mut hasher = state.build_hasher(); hasher.write_u64( diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index fdd87458e..33d0c262e 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -63,7 +63,6 @@ pub fn fork_definition() -> AgentDefinition { tools: ToolScope::Wildcard, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], // Fork inherits the parent's max iterations from the runtime. max_iterations: 15, @@ -119,23 +118,6 @@ mod tests { assert!(!def.omit_memory_md); } - #[test] - fn skills_agent_has_extra_tools_for_export() { - let defs = all(); - let skills = defs.iter().find(|d| d.id == "skills_agent").unwrap(); - assert!( - skills.extra_tools.contains(&"file_write".to_string()), - "skills_agent must include file_write in extra_tools" - ); - // csv_export was removed from extra_tools — it triggered - // superfluous export calls after extraction had already - // answered. file_write alone covers the Path B export flow. - assert!( - !skills.extra_tools.contains(&"csv_export".to_string()), - "csv_export should not be present in extra_tools" - ); - } - #[test] fn expected_builtin_ids_are_present() { let ids: Vec = all().into_iter().map(|d| d.id).collect(); @@ -143,7 +125,7 @@ mod tests { "orchestrator", "planner", "code_executor", - "skills_agent", + "integrations_agent", "tool_maker", "researcher", "critic", diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index 89f115ddd..186ab2923 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -21,7 +21,7 @@ //! runtime — it is pure data so the model can be unit-tested in isolation //! and serialised straight from disk. -use crate::openhuman::tools::ToolCategory; +use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -113,18 +113,12 @@ pub struct AgentDefinition { #[serde(default)] pub skill_filter: Option, - /// Filter to only tools belonging to a specific high-level category. - #[serde(default)] - pub category_filter: Option, - - /// Additional system tool names to include even when `category_filter` - /// restricts to a different category. This allows an agent that is - /// primarily scoped to `Skill` tools (e.g. `skills_agent`) to also - /// access a handful of named system tools (e.g. `file_write`, - /// `csv_export`) without removing the category filter entirely. + /// Named tools that should always be visible to this agent in + /// addition to its [`ToolScope`]. Historically this was a bypass + /// list for the now-removed `category_filter`; kept as a generic + /// "also include these" hook for custom definitions. /// - /// Tools listed here bypass the `category_filter` check but are still - /// subject to `disallowed_tools` and `ToolScope` restrictions. + /// Entries are still subject to [`AgentDefinition::disallowed_tools`]. #[serde(default)] pub extra_tools: Vec, @@ -161,7 +155,7 @@ pub struct AgentDefinition { /// /// * [`SubagentEntry::Skills`] — one [`SkillDelegationTool`] per /// connected Composio toolkit, each named `delegate_{toolkit}`, - /// all routing to the generic `skills_agent` with an appropriate + /// all routing to the generic `integrations_agent` with an appropriate /// `skill_filter` pre-populated. /// /// `subagents` is intentionally separate from [`AgentDefinition::tools`] @@ -211,7 +205,7 @@ pub enum SubagentEntry { AgentId(String), /// Expand at build time to one `delegate_{toolkit}` tool per /// connected Composio toolkit, each routing to the generic - /// `skills_agent` with `skill_filter` pre-set. + /// `integrations_agent` with `skill_filter` pre-set. Skills(SkillsWildcard), } @@ -248,9 +242,18 @@ impl AgentDefinition { // Prompt source // ───────────────────────────────────────────────────────────────────────────── +/// Builder function signature for [`PromptSource::Dynamic`]. Takes the +/// full runtime [`crate::openhuman::context::prompt::PromptContext`] +/// (tools, skills, memory, connected integrations, dispatcher, model, +/// …) and returns the final system prompt body — typically assembled +/// by calling the `render_*` section helpers in +/// [`crate::openhuman::context::prompt`] in the order the builder +/// wants. +pub type PromptBuilder = + fn(&crate::openhuman::context::prompt::PromptContext<'_>) -> anyhow::Result; + /// Where the sub-agent's core system prompt comes from. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[derive(Clone)] pub enum PromptSource { /// Inline prompt string (custom TOML-defined agents). Inline(String), @@ -258,6 +261,61 @@ pub enum PromptSource { /// `src/openhuman/agent/prompts/` for built-ins. Resolved by the runner /// at spawn time. File { path: String }, + /// Function-driven prompt: the builder is invoked at spawn time with + /// a [`PromptContext`] so the returned body can depend on runtime + /// state (available tools, user profile, connected skills, etc.). + /// + /// Only constructed in-process (by built-in agent loaders). Not + /// deserializable from TOML — TOML-authored agents must use `inline` + /// or `file`. + Dynamic(PromptBuilder), +} + +impl std::fmt::Debug for PromptSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromptSource::Inline(s) => f.debug_tuple("Inline").field(&s).finish(), + PromptSource::File { path } => f.debug_struct("File").field("path", path).finish(), + PromptSource::Dynamic(_) => f.debug_tuple("Dynamic").field(&"").finish(), + } + } +} + +impl Serialize for PromptSource { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(1))?; + match self { + PromptSource::Inline(s) => map.serialize_entry("inline", s)?, + PromptSource::File { path } => { + #[derive(Serialize)] + struct FileBody<'a> { + path: &'a str, + } + map.serialize_entry("file", &FileBody { path })?; + } + // Opaque marker — runtime-only. Round-trips back through + // Deserialize would produce an error (Dynamic is unsupported + // there) which is intentional: RPC consumers treat Dynamic + // sources as "built-in, runtime-generated". + PromptSource::Dynamic(_) => map.serialize_entry("dynamic", &serde_json::Value::Null)?, + } + map.end() + } +} + +impl<'de> Deserialize<'de> for PromptSource { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + enum Shape { + Inline(String), + File { path: String }, + } + Shape::deserialize(deserializer).map(|s| match s { + Shape::Inline(body) => PromptSource::Inline(body), + Shape::File { path } => PromptSource::File { path }, + }) + } } // ───────────────────────────────────────────────────────────────────────────── @@ -519,7 +577,6 @@ mod tests { tools: ToolScope::Wildcard, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 8, timeout_secs: None, diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index 8923f0cb8..5d8cf0a73 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -90,7 +90,7 @@ pub struct ParentExecutionContext { /// when the parent agent fetches its integration list. Used by the /// sub-agent runner to dynamically construct per-action /// [`ComposioActionTool`](crate::openhuman::composio::ComposioActionTool) - /// entries at spawn time when `skills_agent` is scoped to a + /// entries at spawn time when `integrations_agent` is scoped to a /// specific toolkit. `None` when the user isn't signed in to /// Composio or the backend was unreachable. pub composio_client: Option, @@ -103,6 +103,21 @@ pub struct ParentExecutionContext { /// runtime uses native function-calling, and the model emits /// uncallable P-Format tool_call blocks. pub tool_call_format: crate::openhuman::context::prompt::ToolCallFormat, + + /// Parent's own session-transcript key, formatted as + /// `"{unix_ts}_{agent_id}"`. Sub-agents chain this (plus any + /// ancestor prefixes on the parent) into their own transcript + /// filename so the hierarchy `orchestrator → planner → critic` + /// lands on disk as a single flat file name — + /// `{orch_key}__{planner_key}__{critic_key}.jsonl`. + pub session_key: String, + + /// Parent's ancestor-chain of session keys (already joined with + /// `__`), or `None` when the parent is itself a root session. + /// A sub-agent spawned from a root parent observes + /// `Some(parent.session_key)`. A grand-child observes + /// `Some("{grandparent_key}__{parent_key}")`. + pub session_parent_prefix: Option, } tokio::task_local! { @@ -161,12 +176,6 @@ pub struct ForkContext { /// verbatim. Includes the system message at index 0. pub message_prefix: Arc>, - /// Optional system-prompt cache boundary the parent passed in its - /// own [`crate::openhuman::providers::ChatRequest`]. The child threads - /// the same value through so any future explicit-cache provider sees - /// matching markers. - pub cache_boundary: Option, - /// The actual instruction the model issued for *this* fork — appears /// as the new user message appended after `message_prefix`. pub fork_task_prompt: String, diff --git a/src/openhuman/agent/harness/payload_summarizer.rs b/src/openhuman/agent/harness/payload_summarizer.rs index 8e2f7de77..9923b7738 100644 --- a/src/openhuman/agent/harness/payload_summarizer.rs +++ b/src/openhuman/agent/harness/payload_summarizer.rs @@ -47,7 +47,7 @@ //! //! Only the orchestrator session gets a `PayloadSummarizer` wired in //! ([`crate::openhuman::agent::harness::session::builder::AgentBuilder`] -//! checks `agent_id == "orchestrator"`). Welcome, skills_agent, +//! checks `agent_id == "orchestrator"`). Welcome, integrations_agent, //! researcher, planner, archivist, and every other typed sub-agent get //! `None` and their tool results are untouched. The summarizer itself //! is also `None` so it can never recursively summarize its own input. @@ -357,7 +357,6 @@ mod tests { tools: ToolScope::Named(vec![]), disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 1, timeout_secs: None, diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index c360561cc..3ab7bc590 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -10,6 +10,9 @@ use super::types::{Agent, AgentBuilder}; use crate::openhuman::agent::dispatcher::{ NativeToolDispatcher, PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher, }; +use crate::openhuman::agent::harness::definition::{ + AgentDefinitionRegistry, PromptSource, ToolScope, +}; use crate::openhuman::agent::host_runtime; use crate::openhuman::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader}; use crate::openhuman::config::{Config, ContextConfig}; @@ -45,6 +48,7 @@ impl AgentBuilder { event_session_id: None, event_channel: None, agent_definition_name: None, + session_parent_prefix: None, omit_profile: None, omit_memory_md: None, payload_summarizer: None, @@ -188,7 +192,7 @@ impl AgentBuilder { } /// Sets the agent definition id this session is running - /// (`welcome`, `orchestrator`, `skills_agent`, …). + /// (`welcome`, `orchestrator`, `integrations_agent`, …). /// /// This value is stamped onto the built [`Agent`] and surfaces in /// the following places: @@ -209,13 +213,13 @@ impl AgentBuilder { /// * **[`PromptContext::agent_id`]** at prompt-build time (see /// `turn.rs`). Today only one prompt section reads this field — /// the `Connected Integrations` branch in `context/prompt.rs` - /// that special-cases `skills_agent` vs every other agent — so + /// that special-cases `integrations_agent` vs every other agent — so /// the current user-visible impact of a wrong id is limited to /// the two bullets above. The stamped `prompt_builder` injected /// by [`Agent::from_config_for_agent`] is what actually drives /// prompt flavour per archetype, independent of this field. That /// said, any future prompt section that branches on a - /// non-`skills_agent` id (e.g. welcome-specific banner, planner- + /// non-`integrations_agent` id (e.g. welcome-specific banner, planner- /// specific rubric) would silently never fire if the field were /// left at `"main"`, so keeping it correctly stamped closes a /// latent foot-gun for code that hasn't been written yet. @@ -229,6 +233,18 @@ impl AgentBuilder { self } + /// Set the parent session-key chain for a sub-agent. Passing + /// `Some("1713000000_orchestrator")` produces a sub-agent whose + /// transcript filename is prefixed with the parent's session key, + /// yielding a flat hierarchy on disk + /// (`session_raw/DDMMYYYY/{parent}__{child}.jsonl`). Nested + /// delegations chain further prefixes with `__`. Leave `None` + /// (default) for root sessions. + pub fn session_parent_prefix(mut self, prefix: Option) -> Self { + self.session_parent_prefix = prefix; + self + } + /// Forward the target agent definition's `omit_profile` flag so /// [`Agent::build_system_prompt`] can decide whether to inject /// `PROFILE.md`. Only opt-in agents (welcome, orchestrator, the @@ -351,7 +367,6 @@ impl AgentBuilder { skills: self.skills.unwrap_or_default(), auto_save: self.auto_save.unwrap_or(false), last_memory_context: None, - system_prompt_cache_boundary: None, history: Vec::new(), post_turn_hooks: self.post_turn_hooks, learning_enabled: self.learning_enabled, @@ -361,8 +376,28 @@ impl AgentBuilder { event_channel: self.event_channel.unwrap_or_else(|| "internal".to_string()), agent_definition_name: self .agent_definition_name + .clone() .unwrap_or_else(|| "main".to_string()), session_transcript_path: None, + session_key: { + let unix_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let agent_id = self.agent_definition_name.as_deref().unwrap_or("main"); + let sanitized: String = agent_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + format!("{unix_ts}_{sanitized}") + }, + session_parent_prefix: self.session_parent_prefix, cached_transcript_messages: None, context, on_progress: None, @@ -427,8 +462,6 @@ impl Agent { /// The welcome agent uses this entry point when routed from the /// Tauri web channel (see `channels::providers::web::build_session_agent`). pub fn from_config_for_agent(config: &Config, agent_id: &str) -> Result { - use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, ToolScope}; - // Look up the target definition up front so we can fail fast // with a clear error instead of building half an agent and then // discovering the id is unknown. The registry is a singleton @@ -511,7 +544,6 @@ impl Agent { agent_id: &str, target_def: Option<&crate::openhuman::agent::harness::definition::AgentDefinition>, ) -> Result { - use crate::openhuman::agent::harness::definition::{PromptSource, ToolScope}; let runtime: Arc = Arc::from(host_runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( @@ -553,6 +585,17 @@ impl Agent { config, ); + // `complete_onboarding` is the terminal step of the welcome + // flow and must never be callable from any other session. + // Stripping it here (before prompt + delegation assembly) keeps + // it out of both the LLM's function-calling schema and the + // rendered `## Tools` section. + if agent_id != "welcome" { + tools.retain(|t| { + !crate::openhuman::agent::harness::subagent_runner::is_welcome_only_tool(t.name()) + }); + } + let model_name = config .default_model .as_deref() @@ -601,46 +644,55 @@ impl Agent { // prompt stays byte-identical to the legacy CLI/REPL behaviour // except for the tool-scope tightening we already landed in // earlier commits. + // Every agent with a resolved definition (built-in or workspace + // override) goes through the per-agent pipeline — the legacy + // `with_defaults()` branch only fires when the registry is + // unavailable (pre-startup, tests). `PromptSource::Dynamic` + // agents install a [`DynamicPromptSection`] that re-runs the + // builder against the live [`PromptContext`] at + // `build_system_prompt` time, so `connected_integrations` + // fetched asynchronously on session start land in the prompt. + // `Inline`/`File` sources still resolve to just the archetype + // body and get wrapped by [`SystemPromptBuilder::for_subagent`]. let mut prompt_builder = match target_def { - Some(def) if agent_id != "orchestrator" => { - // Resolve the prompt body. For built-in agents, - // `system_prompt` is `PromptSource::Inline(...)` populated - // at crate-build time from the sibling `prompt.md` via - // `include_str!` in `agents/mod.rs`. File-based prompts - // (custom workspace overrides) read from disk. - let body = match &def.system_prompt { - PromptSource::Inline(text) => text.clone(), - PromptSource::File { path } => { - let workspace_path = config - .workspace_dir - .join("agent") - .join("prompts") - .join(path); - if workspace_path.is_file() { - std::fs::read_to_string(&workspace_path).unwrap_or_else(|e| { - log::warn!( - "[agent::builder] failed to read prompt {}: {e} — using empty body", - workspace_path.display() - ); - String::new() - }) - } else { - log::warn!( - "[agent::builder] prompt file {} not found — using empty body", - path - ); - String::new() - } - } - }; - SystemPromptBuilder::for_subagent( - body, + Some(def) => match &def.system_prompt { + PromptSource::Dynamic(build) => SystemPromptBuilder::from_dynamic(*build), + PromptSource::Inline(text) => SystemPromptBuilder::for_subagent( + text.clone(), def.omit_identity, def.omit_safety_preamble, def.omit_skills_catalog, - ) - } - _ => SystemPromptBuilder::with_defaults(), + ), + PromptSource::File { path } => { + let workspace_path = config + .workspace_dir + .join("agent") + .join("prompts") + .join(path); + let body_text = if workspace_path.is_file() { + std::fs::read_to_string(&workspace_path).unwrap_or_else(|e| { + log::warn!( + "[agent::builder] failed to read prompt {}: {e} — using empty body", + workspace_path.display() + ); + String::new() + }) + } else { + log::warn!( + "[agent::builder] prompt file {} not found — using empty body", + path + ); + String::new() + }; + SystemPromptBuilder::for_subagent( + body_text, + def.omit_identity, + def.omit_safety_preamble, + def.omit_skills_catalog, + ) + } + }, + None => SystemPromptBuilder::with_defaults(), }; if config.learning.enabled { prompt_builder = prompt_builder @@ -829,10 +881,10 @@ impl Agent { // that even 16–25 of them blow past that ceiling, regardless of // how aggressively the fuzzy filter in `tool_filter.rs` narrows // the list. When that happens the provider rejects the request - // with a 400 before any generation starts, so skills_agent can + // with a 400 before any generation starts, so integrations_agent can // never actually invoke the toolkit. // - // Workaround: if we're building skills_agent and the selected + // Workaround: if we're building integrations_agent and the selected // dispatcher would ship `tools: [...]` in the API payload // (`should_send_tool_specs() == true`, i.e. native mode), swap // to XML mode. XmlToolDispatcher puts the tool catalogue inside @@ -842,9 +894,9 @@ impl Agent { // than native; the existing `parse_tool_calls` recovers from // stray formatting and the loop retries on malformed output. let tool_dispatcher: Box = - if agent_id == "skills_agent" && tool_dispatcher.should_send_tool_specs() { + if agent_id == "integrations_agent" && tool_dispatcher.should_send_tool_specs() { log::info!( - "[agent::builder] skills_agent: overriding native tool dispatcher with \ + "[agent::builder] integrations_agent: overriding native tool dispatcher with \ XmlToolDispatcher (native mode hits provider grammar-rule limits on \ large Composio toolkits)" ); diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 90c387a8a..8095ca2c3 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -34,7 +34,7 @@ impl Agent { } /// The agent definition id this session is running - /// (`"welcome"`, `"orchestrator"`, `"skills_agent"`, …). + /// (`"welcome"`, `"orchestrator"`, `"integrations_agent"`, …). /// /// Exposed so callers that build sessions via /// [`Agent::from_config_for_agent`] can stamp the resolved id onto @@ -116,6 +116,28 @@ impl Agent { &self.connected_integrations } + /// The Composio client cached on the session, if any. Populated by + /// [`Agent::fetch_connected_integrations`]; remains `None` when the + /// user is not signed in. + pub fn composio_client(&self) -> Option<&crate::openhuman::composio::ComposioClient> { + self.composio_client.as_ref() + } + + /// This session's transcript key — `"{unix_ts}_{agent_id}"`, + /// generated once at build time. Sub-agents chain this into their + /// own transcript filenames so the parent → child hierarchy is + /// visible on disk. + pub fn session_key(&self) -> &str { + &self.session_key + } + + /// The ancestor chain of session keys for a sub-agent, joined with + /// `__`. `None` for a root session. Root + prefix together produce + /// the full transcript stem. + pub fn session_parent_prefix(&self) -> Option<&str> { + self.session_parent_prefix.as_deref() + } + /// Replace the agent's connected integrations (e.g. from a cached /// fetch result when the agent was built outside the normal turn loop). pub fn set_connected_integrations( @@ -163,7 +185,6 @@ impl Agent { /// Clears the agent's conversation history. pub fn clear_history(&mut self) { self.history.clear(); - self.system_prompt_cache_boundary = None; } // ───────────────────────────────────────────────────────────────── @@ -575,7 +596,6 @@ mod tests { }); let mut agent = make_agent(provider); agent.history = vec![ConversationMessage::Chat(ChatMessage::system("sys"))]; - agent.system_prompt_cache_boundary = Some(7); agent.skills = vec![crate::openhuman::skills::Skill { name: "demo".into(), ..Default::default() @@ -602,7 +622,6 @@ mod tests { agent.clear_history(); assert!(agent.history().is_empty()); - assert!(agent.system_prompt_cache_boundary.is_none()); assert_eq!(Agent::count_iterations(agent.history()), 1); } diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index f6ccaa8e5..170c4cdde 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -63,7 +63,6 @@ struct RecordingProvider { struct CapturedCall { system_prompt: Option, model: String, - cache_boundary: Option, } #[async_trait] @@ -92,7 +91,6 @@ impl Provider for RecordingProvider { self.captures.lock().push(CapturedCall { system_prompt, model: model.to_string(), - cache_boundary: request.system_prompt_cache_boundary, }); let mut guard = self.responses.lock(); @@ -203,13 +201,18 @@ fn build_minimal_agent_with_definition_name(definition_name: Option<&str>) -> Ag /// `.agent_definition_name(id)` on the builder chain produces an /// `Agent` whose [`Agent::agent_definition_name`] accessor returns /// that id verbatim. `"welcome"` and `"orchestrator"` exercise the -/// two ids that reach `from_config_for_agent` today; `"skills_agent"` +/// two ids that reach `from_config_for_agent` today; `"integrations_agent"` /// and `"trigger_triage"` are defensive coverage so that if a /// future commit adds a new top-level caller for one of those ids /// the builder contract is already pinned. #[test] fn agent_builder_threads_agent_definition_name_when_set() { - for expected in ["welcome", "skills_agent", "orchestrator", "trigger_triage"] { + for expected in [ + "welcome", + "integrations_agent", + "orchestrator", + "trigger_triage", + ] { let agent = build_minimal_agent_with_definition_name(Some(expected)); assert_eq!( agent.agent_definition_name(), @@ -682,19 +685,9 @@ async fn system_prompt_and_model_are_byte_stable_across_turns() { "model name flipped on turn {} — KV cache namespace broken", idx ); - assert_eq!( - cap.cache_boundary, captures[0].cache_boundary, - "cache boundary drifted on turn {} — provider prompt caching became unstable", - idx - ); - assert!( - cap.cache_boundary.is_some(), - "turn {} should carry an explicit prompt cache boundary", - idx - ); assert!( !sys.contains(""), - "system prompt should not leak the internal cache marker" + "system prompt should not leak any cache-boundary marker" ); } } diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 46cf492bc..80cad8821 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -12,7 +12,7 @@ //! //! **Line 1 (meta):** //! ```json -//! {"_meta":{"agent":"code_executor","dispatcher":"native","cache_boundary":1847,"created":"...","updated":"...","turn_count":3,"input_tokens":5000,"output_tokens":1200,"cached_input_tokens":3500,"charged_amount_usd":0.0045}} +//! {"_meta":{"agent":"code_executor","dispatcher":"native","created":"...","updated":"...","turn_count":3,"input_tokens":5000,"output_tokens":1200,"cached_input_tokens":3500,"charged_amount_usd":0.0045}} //! ``` //! //! **Message lines:** @@ -67,7 +67,6 @@ pub struct TurnUsage { pub struct TranscriptMeta { pub agent_name: String, pub dispatcher: String, - pub cache_boundary: Option, pub created: String, pub updated: String, pub turn_count: usize, @@ -101,8 +100,6 @@ struct MetaLine { struct MetaPayload { agent: String, dispatcher: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_boundary: Option, created: String, updated: String, turn_count: usize, @@ -157,7 +154,6 @@ pub fn write_transcript( meta: MetaPayload { agent: meta.agent_name.clone(), dispatcher: meta.dispatcher.clone(), - cache_boundary: meta.cache_boundary, created: meta.created.clone(), updated: meta.updated.clone(), turn_count: meta.turn_count, @@ -320,7 +316,6 @@ fn read_transcript_jsonl(path: &Path) -> Result { meta = Some(TranscriptMeta { agent_name: mp.agent, dispatcher: mp.dispatcher, - cache_boundary: mp.cache_boundary, created: mp.created, updated: mp.updated, turn_count: mp.turn_count, @@ -374,6 +369,47 @@ fn read_transcript_jsonl(path: &Path) -> Result { /// Creates the date directory if needed. Index = max existing + 1. /// Scans both the new `session_raw/` dir (for `.jsonl`) **and** the legacy /// `sessions/` dir (for `.md`) so indices stay unique across migration. +/// Resolve a transcript path under `session_raw/DDMMYYYY/{stem}.jsonl` +/// where `stem` is deterministic (no auto-indexing). Used by the new +/// session-key flow: the session-key stem is `"{unix_ts}_{agent_id}"` +/// for a root session, or `"{parent_chain}__{session_key}"` for a +/// sub-agent — so nested delegations produce a single flat filename +/// that encodes the parent → child path. +/// +/// Creates the date directory if needed. Overwrites are intentional: +/// the Agent persists the same transcript file across every turn of a +/// session, and every sub-agent spawn gets a unique timestamp in its +/// own key so collisions are effectively impossible. +pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result { + let raw_dir = today_raw_session_dir(workspace_dir); + fs::create_dir_all(&raw_dir) + .with_context(|| format!("create session_raw dir {}", raw_dir.display()))?; + let sanitized = sanitize_stem(stem); + Ok(raw_dir.join(format!("{sanitized}.jsonl"))) +} + +/// Sanitize a user-supplied transcript stem so it never escapes the +/// `session_raw/DDMMYYYY/` directory. Allows ASCII alphanumerics plus +/// a small punctuation set (`_`, `-`, `.`); every other byte is +/// replaced with `_`. Empty inputs fall back to `"session"`. +fn sanitize_stem(stem: &str) -> String { + let cleaned: String = stem + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + if cleaned.is_empty() { + "session".to_string() + } else { + cleaned + } +} + pub fn resolve_new_transcript_path(workspace_dir: &Path, agent_name: &str) -> Result { let raw_dir = today_raw_session_dir(workspace_dir); fs::create_dir_all(&raw_dir) @@ -442,9 +478,6 @@ fn render_markdown( let _ = writeln!(buf, "# Session transcript — {}", meta.agent_name); buf.push('\n'); let _ = writeln!(buf, "- Dispatcher: {}", meta.dispatcher); - if let Some(boundary) = meta.cache_boundary { - let _ = writeln!(buf, "- Cache boundary: {}", boundary); - } let _ = writeln!(buf, "- Turns: {}", meta.turn_count); if meta.input_tokens > 0 || meta.output_tokens > 0 { let cache_pct = if meta.input_tokens > 0 { @@ -543,7 +576,6 @@ fn parse_legacy_meta(raw: &str) -> Result { Ok(TranscriptMeta { agent_name: get("agent").unwrap_or_else(|| "unknown".into()), dispatcher: get("dispatcher").unwrap_or_else(|| "native".into()), - cache_boundary: get("cache_boundary").and_then(|s| s.parse().ok()), created: get("created").unwrap_or_default(), updated: get("updated").unwrap_or_default(), turn_count: get("turn_count").and_then(|s| s.parse().ok()).unwrap_or(0), @@ -699,35 +731,66 @@ fn next_index(dir: &Path, agent_prefix: &str) -> Result { /// (legacy sessions). When both exist for the same index the `.jsonl` /// wins. fn latest_in_dir(dir: &Path, agent_prefix: &str) -> Option { - let prefix = format!("{}_", agent_prefix); - // Track best (index, path) for each extension. - let mut best_jsonl: Option<(usize, PathBuf)> = None; - let mut best_md: Option<(usize, PathBuf)> = None; + // Two transcript-naming schemes coexist on disk: + // * Legacy: `{agent}_{index}.jsonl|.md` — strictly increasing + // index, used by the now-removed `resolve_new_transcript_path`. + // * Keyed: `{unix_ts}_{agent}.jsonl` (root session) or + // `{parent_chain}__{unix_ts}_{agent}.jsonl` (sub-agent). The + // root stem starts with `{unix_ts}_{agent}` and has no `__` + // prefix segment. + // + // For resume we only care about root sessions (sub-agents rebuild + // from scratch), so we scan for filenames matching either scheme + // and pick the newest. "Newest" is the largest sort key — indices + // and unix timestamps both order naturally as integers. + let legacy_prefix = format!("{}_", agent_prefix); + let keyed_suffix = format!("_{}", agent_prefix); + let mut best_jsonl: Option<(u64, PathBuf)> = None; + let mut best_md: Option<(u64, PathBuf)> = None; let entries = fs::read_dir(dir).ok()?; for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); - if !name_str.starts_with(&prefix) { + // Extract the stem minus extension. + let (stem, is_jsonl) = if let Some(s) = name_str.strip_suffix(".jsonl") { + (s, true) + } else if let Some(s) = name_str.strip_suffix(".md") { + (s, false) + } else { + continue; + }; + // Skip sub-agent transcripts — they carry at least one `__` + // separator in their stem (e.g. + // `{orch_key}__{planner_key}`). Root resume never targets a + // sub-agent's transcript directly. + if stem.contains("__") { continue; } - if name_str.ends_with(".jsonl") { - let idx_str = &name_str[prefix.len()..name_str.len() - 6]; - if let Ok(idx) = idx_str.parse::() { - if best_jsonl - .as_ref() - .is_none_or(|(best_idx, _)| idx > *best_idx) - { - best_jsonl = Some((idx, entry.path())); - } + // Determine sort key. Keyed filenames end with + // `_{agent_prefix}`: everything before that is the unix + // timestamp. Legacy filenames start with `{agent_prefix}_`: + // everything after is the numeric index. + let sort_key: u64 = if let Some(ts_part) = stem.strip_suffix(&keyed_suffix) { + match ts_part.parse::() { + Ok(ts) => ts, + Err(_) => continue, } - } else if name_str.ends_with(".md") { - let idx_str = &name_str[prefix.len()..name_str.len() - 3]; - if let Ok(idx) = idx_str.parse::() { - if best_md.as_ref().is_none_or(|(best_idx, _)| idx > *best_idx) { - best_md = Some((idx, entry.path())); - } + } else if let Some(idx_part) = stem.strip_prefix(&legacy_prefix) { + match idx_part.parse::() { + Ok(idx) => idx, + Err(_) => continue, } + } else { + continue; + }; + let slot = if is_jsonl { + &mut best_jsonl + } else { + &mut best_md + }; + if slot.as_ref().is_none_or(|(best, _)| sort_key > *best) { + *slot = Some((sort_key, entry.path())); } } @@ -770,7 +833,6 @@ mod tests { TranscriptMeta { agent_name: "code_executor".into(), dispatcher: "native".into(), - cache_boundary: Some(1847), created: "2026-04-11T14:30:00Z".into(), updated: "2026-04-11T14:35:22Z".into(), turn_count: 3, @@ -849,7 +911,6 @@ mod tests { assert_eq!(loaded.meta.agent_name, "code_executor"); assert_eq!(loaded.meta.dispatcher, "native"); - assert_eq!(loaded.meta.cache_boundary, Some(1847)); assert_eq!(loaded.meta.created, "2026-04-11T14:30:00Z"); assert_eq!(loaded.meta.updated, "2026-04-11T14:35:22Z"); assert_eq!(loaded.meta.turn_count, 3); @@ -859,19 +920,6 @@ mod tests { assert!((loaded.meta.charged_amount_usd - 0.0045).abs() < 1e-8); } - #[test] - fn meta_without_cache_boundary() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("no_boundary.jsonl"); - let mut meta = sample_meta(); - meta.cache_boundary = None; - - write_transcript(&path, &[], &meta, None).unwrap(); - let loaded = read_transcript(&path).unwrap(); - - assert_eq!(loaded.meta.cache_boundary, None); - } - #[test] fn path_resolution_creates_dir_and_increments_index() { let dir = TempDir::new().unwrap(); @@ -1069,7 +1117,7 @@ mod tests { let dir = TempDir::new().unwrap(); // Write a legacy .md file directly (old format). let md_path = dir.path().join("legacy.md"); - let legacy_content = "\n\n\nhello\n\n"; + let legacy_content = "\n\n\nhello\n\n"; fs::write(&md_path, legacy_content).unwrap(); // read_transcript called with a .jsonl path that doesn't exist @@ -1077,7 +1125,6 @@ mod tests { let jsonl_path = dir.path().join("legacy.jsonl"); let loaded = read_transcript(&jsonl_path).unwrap(); assert_eq!(loaded.meta.agent_name, "test_agent"); - assert_eq!(loaded.meta.cache_boundary, Some(100)); assert_eq!(loaded.messages.len(), 1); assert_eq!(loaded.messages[0].role, "system"); assert_eq!(loaded.messages[0].content, "hello"); diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 054cace63..c300c74c9 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -26,15 +26,14 @@ use crate::openhuman::agent::dispatcher::{ParsedToolCall, ToolExecutionResult}; use crate::openhuman::agent::harness; use crate::openhuman::agent::hooks::{self, ToolCallRecord, TurnContext}; use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::context::prompt::{ - LearnedContextData, PromptContext, PromptTool, RenderedPrompt, -}; +use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, PromptTool}; use crate::openhuman::context::{ReductionOutcome, ARCHIVIST_EXTRACTION_PROMPT}; use crate::openhuman::memory::MemoryCategory; use crate::openhuman::providers::{ChatMessage, ChatRequest, ConversationMessage, ProviderDelta}; use crate::openhuman::tools::Tool; use crate::openhuman::util::truncate_with_ellipsis; use anyhow::Result; +use std::hash::{Hash, Hasher}; use std::sync::Arc; impl Agent { @@ -88,32 +87,29 @@ impl Agent { log::info!("[agent] system prompt built — initialising conversation history"); log::info!( "[agent_loop] system prompt built chars={}", - rendered_prompt.text.chars().count() + rendered_prompt.chars().count() ); // User-file injection (PROFILE.md, MEMORY.md) puts // potentially-sensitive content (LinkedIn scrape output, // archivist-curated memories) into the system prompt. Avoid // leaking that to debug logs — log a length + content hash - // instead so cache-boundary diagnostics still work. Narrow - // specialists (both flags off) keep the full-body log so - // prompt-engineering iteration on tools/safety sections - // stays easy. + // instead. Narrow specialists (both flags off) keep the + // full-body log so prompt-engineering iteration on + // tools/safety sections stays easy. if self.omit_profile && self.omit_memory_md { - log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt.text); + log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt); } else { - use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); - rendered_prompt.text.hash(&mut hasher); + rendered_prompt.hash(&mut hasher); log::debug!( "[agent_loop] system prompt body redacted (contains PROFILE/MEMORY): chars={} hash={:016x}", - rendered_prompt.text.chars().count(), + rendered_prompt.chars().count(), hasher.finish() ); } - self.system_prompt_cache_boundary = rendered_prompt.cache_boundary; self.history .push(ConversationMessage::Chat(ChatMessage::system( - rendered_prompt.text, + rendered_prompt, ))); } else { // Deliberately do NOT rebuild the system prompt on subsequent @@ -383,7 +379,6 @@ impl Agent { } else { None }, - system_prompt_cache_boundary: self.system_prompt_cache_boundary, stream: delta_tx_opt.as_ref(), }, &effective_model, @@ -491,6 +486,21 @@ impl Agent { msgs.push(ChatMessage::assistant(final_text.clone())); } + // Persist the transcript **now** — right after the + // provider response lands — so a crash during hooks + // / memory-extraction / the outer epilogue can't + // lose the assistant's reply. + if let Some(ref messages) = last_provider_messages { + self.persist_session_transcript( + messages, + cumulative_input_tokens, + cumulative_output_tokens, + cumulative_cached_input_tokens, + cumulative_charged_usd, + last_turn_usage.as_ref(), + ); + } + if self.auto_save { let summary = truncate_with_ellipsis(&final_text, 100); let _ = self @@ -569,6 +579,26 @@ impl Agent { tool_calls: persisted_tool_calls, }); + // Persist the transcript **right after** the provider + // response lands — before executing tools — so if the + // session crashes mid-tool-call we still have the + // assistant's response + tool-call intents on disk. + // Rebuild `last_provider_messages` from the current + // history so the snapshot includes whatever the + // assistant just emitted (plain text + tool calls). + last_provider_messages = + Some(self.tool_dispatcher.to_provider_messages(&self.history)); + if let Some(ref messages) = last_provider_messages { + self.persist_session_transcript( + messages, + cumulative_input_tokens, + cumulative_output_tokens, + cumulative_cached_input_tokens, + cumulative_charged_usd, + last_turn_usage.as_ref(), + ); + } + let (results, records) = self.execute_tools(&calls, iteration).await; all_tool_records.extend(records); log::info!( @@ -596,6 +626,21 @@ impl Agent { let formatted = self.tool_dispatcher.format_results(&results); self.history.push(formatted); self.trim_history(); + // Flush the transcript again now that tool results have + // been appended — the pre-tool persist above only + // captured the assistant's tool-call intents. A crash + // or early-exit between iterations would otherwise lose + // the tool output from the on-disk session record. + let post_tool_messages = self.tool_dispatcher.to_provider_messages(&self.history); + self.persist_session_transcript( + &post_tool_messages, + cumulative_input_tokens, + cumulative_output_tokens, + cumulative_cached_input_tokens, + cumulative_charged_usd, + last_turn_usage.as_ref(), + ); + last_provider_messages = Some(post_tool_messages); log::info!( "[agent_loop] iteration end i={} history_len={}", iteration + 1, @@ -623,21 +668,13 @@ impl Agent { // the PARENT_CONTEXT task-local. let result = harness::with_parent_context(parent_context, turn_body).await; - // ── Session transcript persistence ──────────────────────────── - // Persist the exact provider messages so a future session can - // resume with a byte-identical prefix for KV cache reuse. - if result.is_ok() { - if let Some(ref messages) = last_provider_messages { - self.persist_session_transcript( - messages, - cumulative_input_tokens, - cumulative_output_tokens, - cumulative_cached_input_tokens, - cumulative_charged_usd, - last_turn_usage.as_ref(), - ); - } - } + // Session transcript persistence lives INSIDE the turn body — + // one write per provider response, fired right after the + // response lands (see the tool-call and terminal branches in + // `turn_body`). A crash during tool execution no longer drops + // the assistant's reply because it was already flushed to + // disk before tool dispatch started. No outer-loop save is + // needed here. // ── Session-memory extraction (stage 5) ─────────────────────── // @@ -915,6 +952,8 @@ impl Agent { connected_integrations: self.connected_integrations.clone(), composio_client: self.composio_client.clone(), tool_call_format: self.tool_dispatcher.tool_call_format(), + session_key: self.session_key.clone(), + session_parent_prefix: self.session_parent_prefix.clone(), } } @@ -947,7 +986,6 @@ impl Agent { system_prompt: Arc::new(system_prompt), tool_specs: Arc::clone(&self.visible_tool_specs), message_prefix: Arc::new(messages), - cache_boundary: self.system_prompt_cache_boundary, fork_task_prompt, } } @@ -1068,12 +1106,12 @@ impl Agent { /// Fetches the user's active Composio connections and populates /// `self.connected_integrations` so the system prompt can surface them. /// Also caches a [`ComposioClient`] on the session so the sub-agent - /// runner can construct per-action tools for `skills_agent` spawns + /// runner can construct per-action tools for `integrations_agent` spawns /// without rebuilding the client on every call. /// /// Delegates to the shared [`crate::openhuman::composio::fetch_connected_integrations`] /// which is the single source of truth for integration discovery. - pub(super) async fn fetch_connected_integrations(&mut self) { + pub async fn fetch_connected_integrations(&mut self) { let config = match crate::openhuman::config::Config::load_or_init().await { Ok(c) => c, Err(e) => { @@ -1090,10 +1128,7 @@ impl Agent { /// Builds the system prompt for the current turn, including tool /// instructions and learned context. - pub(super) fn build_system_prompt( - &self, - learned: LearnedContextData, - ) -> Result { + pub fn build_system_prompt(&self, learned: LearnedContextData) -> Result { let tools_slice: &[Box] = self.tools.as_slice(); let instructions = self.tool_dispatcher.prompt_instructions(tools_slice); // Adapt the owned Box slice into the shared PromptTool @@ -1117,9 +1152,8 @@ impl Agent { }; // Route through the global context manager so every // prompt-building call-site — main agent, sub-agent runner, - // channel runtimes — shares one builder configuration while - // still preserving cache-boundary metadata for provider calls. - self.context.build_system_prompt_with_cache_metadata(&ctx) + // channel runtimes — shares one builder configuration. + self.context.build_system_prompt(&ctx) } // ───────────────────────────────────────────────────────────────── @@ -1144,14 +1178,9 @@ impl Agent { ); return; } - // Restore the cache boundary from the transcript - // metadata so the provider request carries the - // same offset as the original session. - self.system_prompt_cache_boundary = session.meta.cache_boundary; log::info!( - "[transcript] loaded {} messages for resume (cache_boundary={:?})", - session.messages.len(), - session.meta.cache_boundary + "[transcript] loaded {} messages for resume", + session.messages.len() ); self.cached_transcript_messages = Some(session.messages); } @@ -1191,12 +1220,17 @@ impl Agent { charged_amount_usd: f64, turn_usage: Option<&transcript::TurnUsage>, ) { - // Resolve the transcript path on first write. + // Resolve the transcript path on first write. The stem is + // `{parent_prefix}__{session_key}` for sub-agents (producing a + // flat hierarchical filename) or just `{session_key}` for a + // root session. Prefix chaining is already done by the + // sub-agent runner when it populates `session_parent_prefix`. if self.session_transcript_path.is_none() { - match transcript::resolve_new_transcript_path( - &self.workspace_dir, - &self.agent_definition_name, - ) { + let stem = match &self.session_parent_prefix { + Some(prefix) => format!("{}__{}", prefix, self.session_key), + None => self.session_key.clone(), + }; + match transcript::resolve_keyed_transcript_path(&self.workspace_dir, &stem) { Ok(path) => { log::info!( "[transcript] new session transcript path={}", @@ -1221,7 +1255,6 @@ impl Agent { } else { "xml".into() }, - cache_boundary: self.system_prompt_cache_boundary, created: now.clone(), updated: now, turn_count: self.context.stats().session_memory_current_turn as usize, @@ -1654,21 +1687,18 @@ mod tests { ChatMessage::user("hello"), ChatMessage::assistant("done"), ]; - agent.system_prompt_cache_boundary = Some(12); agent.persist_session_transcript(&messages, 10, 5, 3, 0.25, None); assert!(agent.session_transcript_path.is_some()); let loaded = transcript::read_transcript(agent.session_transcript_path.as_ref().unwrap()) .expect("transcript should be readable"); assert_eq!(loaded.messages.len(), 3); - assert_eq!(loaded.meta.cache_boundary, Some(12)); assert_eq!(loaded.meta.input_tokens, 10); let mut resumed = make_agent(None); resumed.workspace_dir = agent.workspace_dir.clone(); resumed.agent_definition_name = agent.agent_definition_name.clone(); resumed.try_load_session_transcript(); - assert_eq!(resumed.system_prompt_cache_boundary, Some(12)); assert_eq!( resumed.cached_transcript_messages.as_ref().map(|m| m.len()), Some(3) diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index a94771337..859df87a2 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -52,9 +52,6 @@ pub struct Agent { /// Last memory context loaded for the current turn. Stored so it can /// be forwarded to subagents via `ParentExecutionContext`. pub(super) last_memory_context: Option, - /// Explicit cache boundary for the current rendered system prompt. - /// `None` means the prompt is treated as entirely dynamic. - pub(super) system_prompt_cache_boundary: Option, pub(super) history: Vec, pub(super) post_turn_hooks: Vec>, pub(super) learning_enabled: bool, @@ -68,6 +65,20 @@ pub struct Agent { /// Set on first write, reused for subsequent overwrites within the /// same session. pub(super) session_transcript_path: Option, + /// Unique transcript key for this session, formatted as + /// `"{unix_ts}_{agent_id}"`. Generated once at agent-build time so + /// every transcript write in this session uses the same filename + /// stem. Sub-agents chain their parent's key into the transcript + /// directory to produce a hierarchical layout — + /// `session_raw/DDMMYYYY/{parent_key}/{child_key}.jsonl`. + pub(super) session_key: String, + /// Directory chain of parent session keys for a sub-agent, or + /// `None` for a root session. A planner spawned by the orchestrator + /// carries `Some("1713000000_orchestrator")`; a critic spawned by + /// that planner carries + /// `Some("1713000000_orchestrator/1713000123_planner")` so nested + /// delegations produce a tree on disk. + pub(super) session_parent_prefix: Option, /// Messages loaded from a previous session transcript on resume. /// Consumed once (via `.take()`) on the first turn to provide a /// byte-identical prefix for KV cache reuse. @@ -87,15 +98,15 @@ pub struct Agent { /// tool-call and iteration updates to the UI. pub(super) on_progress: Option>, /// Active Composio integrations the user has connected. Populated at - /// agent build time; surfaced in the system prompt via - /// [`ConnectedIntegrationsSection`] so the orchestrator knows which - /// external services are available. + /// agent build time and threaded into each agent's `prompt.rs` so + /// the delegator / skill-executor voices can render their own + /// integration blocks. pub(super) connected_integrations: Vec, /// Composio client, built alongside `connected_integrations` and /// shared into [`harness::ParentExecutionContext`] at turn start /// so the sub-agent runner can dynamically construct per-action /// [`crate::openhuman::composio::ComposioActionTool`] instances - /// when `skills_agent` is spawned with a `toolkit` argument. + /// when `integrations_agent` is spawned with a `toolkit` argument. /// `None` when the user isn't signed in or the backend is /// unreachable. pub(super) composio_client: Option, @@ -143,6 +154,11 @@ pub struct AgentBuilder { pub(super) event_session_id: Option, pub(super) event_channel: Option, pub(super) agent_definition_name: Option, + /// Directory chain of parent session keys for a sub-agent. `None` + /// (default) means this is a root session — its transcript lands + /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated + /// by the sub-agent runner so nested delegations produce a tree. + pub(super) session_parent_prefix: Option, /// Forwarded to [`Agent::omit_profile`] at `build()` time. Mirrors the /// target definition's `omit_profile` flag; `None` means "fall back /// to the safe default" (omit). diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs index dcd17d6ec..5f13d4b72 100644 --- a/src/openhuman/agent/harness/subagent_runner.rs +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -32,14 +32,15 @@ use super::definition::{AgentDefinition, PromptSource, ToolScope}; use super::fork_context::{current_fork, current_parent, ForkContext, ParentExecutionContext}; use super::session::transcript; use crate::openhuman::context::prompt::{ - extract_cache_boundary, render_subagent_system_prompt, SubagentRenderOptions, + render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions, }; use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall}; use crate::openhuman::tools::{Tool, ToolCategory, ToolResult, ToolSpec}; use async_trait::async_trait; +use futures::stream::StreamExt; use regex::Regex; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::fmt::Write as _; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, Instant}; use thiserror::Error; @@ -58,18 +59,12 @@ pub struct SubagentRunOptions { /// starts with `{skill}__`. Overrides `definition.skill_filter`. pub skill_filter_override: Option, - /// Optional category override. When set, replaces - /// `definition.category_filter` for this single spawn. Useful when - /// the parent wants to reuse a generic definition but scope it to - /// skill or system tools for this specific call. - pub category_filter_override: Option, - /// Optional Composio toolkit scope (e.g. `"gmail"`, `"notion"`). /// When set, skill-category tools are further restricted to those /// whose name starts with the uppercased `{toolkit}_` prefix, and /// the sub-agent's rendered `Connected Integrations` section is /// narrowed to only that toolkit's entry. Used by main/orchestrator - /// when spawning `skills_agent` for a specific platform so the + /// when spawning `integrations_agent` for a specific platform so the /// sub-agent only sees one integration's tool catalogue. pub toolkit_override: Option, @@ -214,18 +209,17 @@ async fn run_typed_mode( ) -> Result { let started = Instant::now(); - // ── Resolve archetype prompt body ────────────────────────────────── - let archetype_prompt_body = - load_prompt_source(&definition.system_prompt, &parent.workspace_dir)?; - // ── Resolve model + temperature ──────────────────────────────────── let model = definition.model.resolve(&parent.model_name); let temperature = definition.temperature; + // Archetype prompt loading is deferred until AFTER tool filtering so + // dynamic builders receive the final, filtered tool list (rather + // than the parent's full registry). The actual + // `load_prompt_source(...)` call lives just above + // `render_subagent_system_prompt` below. + // ── Filter tools per definition + per-spawn override ─────────────── - let category_filter = options - .category_filter_override - .or(definition.category_filter); let toolkit_filter = options.toolkit_override.as_deref(); let mut allowed_indices = filter_tool_indices( &parent.all_tools, @@ -235,15 +229,24 @@ async fn run_typed_mode( .skill_filter_override .as_deref() .or(definition.skill_filter.as_deref()), - category_filter, ); - // ── Force-include extra_tools that bypass category_filter ────────── + // `complete_onboarding` is a welcome-only tool — it flips the + // onboarding-complete flag in workspace config and is meaningless + // (and potentially destructive) from any other agent. Strip it + // from every non-welcome subagent regardless of their scope. + if definition.id != "welcome" { + allowed_indices.retain(|&i| !is_welcome_only_tool(parent.all_tools[i].name())); + } + + // ── Force-include extra_tools ────────────────────────────────────── // - // `extra_tools` lets an agent definition request specific system tools - // even when `category_filter` restricts to a different category. For - // example, `skills_agent` sets `category_filter = "skill"` but still - // needs `file_write` and `csv_export` for exporting oversized payloads. + // `extra_tools` is a simple "also include these" hook that bypasses + // [`ToolScope`] / [`AgentDefinition::skill_filter`] but still honours + // `disallowed_tools`. Historically this was the bypass list for the + // now-removed `category_filter`; it remains useful for custom + // definitions that want to add a couple of named tools on top of a + // narrow scope. if !definition.extra_tools.is_empty() { let disallow_set: std::collections::HashSet<&str> = definition .disallowed_tools @@ -261,9 +264,9 @@ async fn run_typed_mode( } } - // ── Dynamic per-action toolkit tools (skills_agent + toolkit) ────── + // ── Dynamic per-action toolkit tools (integrations_agent + toolkit) ────── // - // When `skills_agent` is spawned with a `toolkit` argument (e.g. + // When `integrations_agent` is spawned with a `toolkit` argument (e.g. // `toolkit="gmail"`), build one [`ComposioActionTool`] per action // in that toolkit and inject them into the sub-agent's tool list. // Each carries the action's real JSON schema, so the LLM's native @@ -275,28 +278,80 @@ async fn run_typed_mode( // are stripped from the parent-filtered indices in this path so // the model only sees one way to call each action. let mut dynamic_tools: Vec> = Vec::new(); - let is_skills_agent_with_toolkit = definition.id == "skills_agent" && toolkit_filter.is_some(); - if is_skills_agent_with_toolkit { - // Drop EVERY skill-category parent tool. In the new - // architecture all integration discovery / authorization / - // dispatching is the orchestrator's responsibility (via the - // Delegation Guide and `spawn_subagent` pre-flight). The - // sub-agent's only job is to execute per-action tools for - // its bound toolkit, so leftover meta-tools (composio_*, - // apify_*, other-toolkit dispatchers) are pure noise that - // confuses the model and wastes tokens. + let is_integrations_agent_with_toolkit = + definition.id == "integrations_agent" && toolkit_filter.is_some(); + + // `tools_agent` is the Composio-free counterpart to + // `integrations_agent`: it inherits the orchestrator's wildcard + // scope but must never see Skill-category tools. Stripping them + // here (before any dynamic additions) keeps the parent-fed + // `allowed_indices` clean of composio_* meta-tools and + // toolkit-specific action tools. Delegation to integrations_agent + // is the orchestrator's job, not this agent's. + if definition.id == "tools_agent" { allowed_indices.retain(|&i| parent.all_tools[i].category() != ToolCategory::Skill); + } + + if is_integrations_agent_with_toolkit { + // Tool visibility is fully governed by the TOML scope + // (`agent.tools.named = [...]` on the integrations_agent + // definition) plus the dynamic per-action ComposioActionTools + // injected below. Anything the agent author explicitly named + // in the TOML is kept as-is — no extra stripping here. + // Previously we dropped every Skill-category tool at this + // point, which also dropped `composio_list_tools` / + // `composio_execute` whenever they were declared in the TOML, + // making the TOML changes look like no-ops. if let (Some(tk), Some(client)) = (toolkit_filter, parent.composio_client.as_ref()) { // The spawn_subagent pre-flight already verified the // toolkit is in the allowlist AND has an active // connection, so the matching entry must be present and // marked connected. Defensive lookup anyway. - if let Some(integration) = parent + if let Some(cached_integration) = parent .connected_integrations .iter() .find(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(tk)) { + // Refresh the toolkit's action catalogue at spawn time + // by calling `composio_list_tools` for the bound toolkit. + // The cached list on `parent.connected_integrations` + // comes from the session-start bulk fetch, which can + // return zero actions for some toolkits even when the + // per-toolkit endpoint returns a full catalogue. Falling + // back to the cached list preserves the previous + // behaviour on network failure. + let fresh_actions = match crate::openhuman::composio::fetch_toolkit_actions( + client, tk, + ) + .await + { + Ok(actions) if !actions.is_empty() => actions, + Ok(_) => { + tracing::debug!( + agent_id = %definition.id, + toolkit = %tk, + "[subagent_runner:typed] fresh list_tools returned empty; falling back to cached catalogue" + ); + cached_integration.tools.clone() + } + Err(e) => { + tracing::warn!( + agent_id = %definition.id, + toolkit = %tk, + error = %e, + "[subagent_runner:typed] fresh list_tools failed; falling back to cached catalogue" + ); + cached_integration.tools.clone() + } + }; + let integration = crate::openhuman::context::prompt::ConnectedIntegration { + toolkit: cached_integration.toolkit.clone(), + description: cached_integration.description.clone(), + tools: fresh_actions, + connected: cached_integration.connected, + }; + let integration = &integration; // Fuzzy-filter the toolkit's actions against the task prompt // so large catalogues (e.g. github ~500 actions) are narrowed // to the handful actually relevant to this delegation. The @@ -379,7 +434,7 @@ async fn run_typed_mode( // ── Progressive-disclosure handoff cache ─────────────────────────── // - // Built only for skills_agent-with-toolkit because that's the only + // Built only for integrations_agent-with-toolkit because that's the only // typed sub-agent that regularly calls external tools capable of // returning megabyte-scale payloads (Composio actions). Every other // typed sub-agent gets `None` and its tool results stay inline. @@ -391,7 +446,7 @@ async fn run_typed_mode( // summarizer sub-agent against the cached payload with a targeted // query. Lazy / pay-per-question, so trivial asks answerable from // the preview don't pay any extra LLM cost. - let handoff_cache: Option> = if is_skills_agent_with_toolkit { + let handoff_cache: Option> = if is_integrations_agent_with_toolkit { let cache = Arc::new(ResultHandoffCache::new()); // Resolve the summarizer definition once and register the @@ -460,7 +515,7 @@ async fn run_typed_mode( // Per-definition omit_* flags are threaded through via // `SubagentRenderOptions` — previously the narrow renderer // hard-coded all three as "omit", which silently downgraded - // definitions like `code_executor` / `tool_maker` / `skills_agent` + // definitions like `code_executor` / `tool_maker` / `integrations_agent` // that set `omit_safety_preamble = false`. let render_options = SubagentRenderOptions::from_definition_flags( definition.omit_identity, @@ -490,19 +545,96 @@ async fn run_typed_mode( .cloned() .collect(), }; - let rendered_prompt = extract_cache_boundary(&render_subagent_system_prompt( - &parent.workspace_dir, - &model, - &allowed_indices, - &parent.all_tools, - &dynamic_tools, - &archetype_prompt_body, - render_options, - parent.tool_call_format, - &narrowed_integrations, - )); - let system_prompt = rendered_prompt.text; - let system_prompt_cache_boundary = rendered_prompt.cache_boundary; + // ── Resolve archetype prompt body (post-filter) ──────────────────── + // + // Build a live [`PromptContext`] — same shape the main agent uses + // on every turn — so `Dynamic` builders can compose the full + // system prompt via the section helpers in + // [`crate::openhuman::context::prompt`]. `Inline` / `File` sources + // continue to use the legacy `render_subagent_system_prompt` + // wrapper. + let prompt_tools: Vec> = allowed_indices + .iter() + .map(|&i| { + let t = parent.all_tools[i].as_ref(); + PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + } + }) + .chain(dynamic_tools.iter().map(|t| PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + })) + .collect(); + // Derive the visible-tool set from the prompt tool list so prompt + // sections that gate on `visible_tool_names` (e.g. tool-protocol + // notes) see exactly what the model sees, rather than an empty set. + let visible_tool_names: std::collections::HashSet = + prompt_tools.iter().map(|t| t.name.to_string()).collect(); + // Match the main-agent turn (`session/turn.rs::build_system_prompt`) + // by supplying the dispatcher's protocol instructions here. Dynamic + // prompt builders route tools through `render_tools(ctx)`, which + // appends `ctx.dispatcher_instructions` after the tool catalogue — + // passing an empty string drops the `## Tool Use Protocol` block and + // leaves PFormat/Json sub-agents with no call-format guidance. + let dispatcher_instructions = { + use crate::openhuman::agent::dispatcher::{ + NativeToolDispatcher, PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher, + }; + use crate::openhuman::agent::pformat::PFormatRegistry; + use crate::openhuman::context::prompt::ToolCallFormat; + let empty_tools: Vec> = Vec::new(); + match parent.tool_call_format { + ToolCallFormat::PFormat => { + PFormatToolDispatcher::new(PFormatRegistry::new()).prompt_instructions(&empty_tools) + } + ToolCallFormat::Native => NativeToolDispatcher.prompt_instructions(&empty_tools), + ToolCallFormat::Json => XmlToolDispatcher.prompt_instructions(&empty_tools), + } + }; + let prompt_ctx = PromptContext { + workspace_dir: &parent.workspace_dir, + model_name: &model, + agent_id: &definition.id, + tools: &prompt_tools, + skills: &parent.skills, + dispatcher_instructions: &dispatcher_instructions, + learned: crate::openhuman::context::prompt::LearnedContextData::default(), + visible_tool_names: &visible_tool_names, + tool_call_format: parent.tool_call_format, + connected_integrations: &narrowed_integrations, + include_profile: !definition.omit_profile, + include_memory_md: !definition.omit_memory_md, + }; + + let system_prompt = match &definition.system_prompt { + PromptSource::Dynamic(build) => { + // Function-driven builder returns the final prompt text. + build(&prompt_ctx).map_err(|e| SubagentRunError::PromptLoad { + path: format!("", definition.id), + source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()), + })? + } + PromptSource::Inline(_) | PromptSource::File { .. } => { + // Legacy path for TOML-authored agents: load the raw body, + // then wrap it with the canonical section layout. + let archetype_prompt_body = load_prompt_source(&definition.system_prompt, &prompt_ctx)?; + render_subagent_system_prompt( + &parent.workspace_dir, + &model, + &allowed_indices, + &parent.all_tools, + &dynamic_tools, + &archetype_prompt_body, + render_options, + parent.tool_call_format, + &narrowed_integrations, + ) + } + }; // ── Build the user message (with optional context prefix) ────────── // Merge explicit orchestrator context with the parent's auto-loaded @@ -529,7 +661,10 @@ async fn run_typed_mode( ]; // ── Run the inner tool-call loop ─────────────────────────────────── - let (output, iterations, agg_usage) = run_inner_loop( + // Transcript persistence lives INSIDE the loop (one write per + // provider response), mirroring the main-agent turn loop in + // `session/turn.rs`. No post-loop write needed here. + let (output, iterations, _agg_usage) = run_inner_loop( parent.provider.as_ref(), &mut history, &parent.all_tools, @@ -539,21 +674,13 @@ async fn run_typed_mode( &model, temperature, definition.max_iterations, - system_prompt_cache_boundary, task_id, &definition.id, handoff_cache.as_deref(), + &parent, ) .await?; - persist_subagent_transcript( - &parent.workspace_dir, - &definition.id, - &history, - system_prompt_cache_boundary, - &agg_usage, - ); - Ok(SubagentRunOutcome { task_id: task_id.to_string(), agent_id: definition.id.clone(), @@ -593,7 +720,6 @@ async fn run_fork_mode( tracing::debug!( agent_id = %definition.id, prefix_len = fork.message_prefix.len(), - cache_boundary = ?fork.cache_boundary, "[subagent_runner:fork] replaying parent prefix" ); @@ -630,7 +756,9 @@ async fn run_fork_mode( // Fork mode replays the parent's exact tool list — no dynamic // toolkit-scoped tools, so `extra_tools` is empty. let fork_extra_tools: Vec> = Vec::new(); - let (output, iterations, agg_usage) = run_inner_loop( + // Transcript persistence happens per-iteration inside + // `run_inner_loop`; no post-loop write needed. + let (output, iterations, _agg_usage) = run_inner_loop( parent.provider.as_ref(), &mut history, &parent.all_tools, @@ -640,21 +768,13 @@ async fn run_fork_mode( &model, temperature, max_iterations, - fork.cache_boundary, task_id, &definition.id, None, + &parent, ) .await?; - persist_subagent_transcript( - &parent.workspace_dir, - &definition.id, - &history, - fork.cache_boundary, - &agg_usage, - ); - Ok(SubagentRunOutcome { task_id: task_id.to_string(), agent_id: definition.id.clone(), @@ -665,61 +785,6 @@ async fn run_fork_mode( }) } -// ───────────────────────────────────────────────────────────────────────────── -// Session transcript persistence for sub-agents -// ───────────────────────────────────────────────────────────────────────────── - -/// Best-effort: persist the sub-agent's conversation as a session transcript -/// so it can be inspected for debugging and KV cache analysis. -fn persist_subagent_transcript( - workspace_dir: &Path, - agent_id: &str, - history: &[ChatMessage], - cache_boundary: Option, - usage: &AggregatedUsage, -) { - let path = match transcript::resolve_new_transcript_path(workspace_dir, agent_id) { - Ok(p) => p, - Err(err) => { - tracing::debug!( - agent_id = %agent_id, - error = %err, - "[subagent_runner] failed to resolve transcript path" - ); - return; - } - }; - - let now = chrono::Utc::now().to_rfc3339(); - let meta = transcript::TranscriptMeta { - agent_name: agent_id.to_string(), - dispatcher: "native".into(), - cache_boundary, - created: now.clone(), - updated: now, - turn_count: 1, - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - cached_input_tokens: usage.cached_input_tokens, - charged_amount_usd: usage.charged_amount_usd, - }; - - if let Err(err) = transcript::write_transcript(&path, history, &meta, None) { - tracing::debug!( - agent_id = %agent_id, - error = %err, - "[subagent_runner] failed to write transcript" - ); - } else { - tracing::debug!( - agent_id = %agent_id, - messages = history.len(), - path = %path.display(), - "[subagent_runner] transcript written" - ); - } -} - // ───────────────────────────────────────────────────────────────────────────── // Inner tool-call loop (slim version of agent::loop_::tool_loop) // ───────────────────────────────────────────────────────────────────────────── @@ -754,14 +819,58 @@ async fn run_inner_loop( model: &str, temperature: f64, max_iterations: usize, - system_prompt_cache_boundary: Option, task_id: &str, agent_id: &str, handoff_cache: Option<&ResultHandoffCache>, + parent: &ParentExecutionContext, ) -> Result<(String, usize, AggregatedUsage), SubagentRunError> { let max_iterations = max_iterations.max(1); - // ── Text-mode override for skills_agent ──────────────────────────── + // Sub-agent transcript stem — mirrors what + // `persist_subagent_transcript` used to compute on one-shot + // post-loop writes. We compute it once up front so **every + // iteration's** persist call resolves to the same file on disk: + // `{parent_chain}__{unix_ts}_{agent_id}.jsonl`. + let child_session_key = { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let unix_ts = now.as_secs(); + // Nanos component + task_id suffix disambiguate sibling sub-agents + // spawned within the same wall-clock second (tests and fan-out + // flows routinely do this, and a shared stem would overwrite the + // earlier sibling's transcript file). + let nanos = now.subsec_nanos(); + let sanitized: String = agent_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + let task_suffix: String = task_id + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .take(12) + .collect(); + if task_suffix.is_empty() { + format!("{unix_ts}_{nanos:09}_{sanitized}") + } else { + format!("{unix_ts}_{nanos:09}_{sanitized}_{task_suffix}") + } + }; + let transcript_stem = { + let parent_chain = match parent.session_parent_prefix.as_deref() { + Some(prefix) => format!("{}__{}", prefix, parent.session_key), + None => parent.session_key.clone(), + }; + format!("{parent_chain}__{child_session_key}") + }; + + // ── Text-mode override for integrations_agent ──────────────────────────── // // Large Composio toolkits (Notion, Salesforce, HubSpot, GitHub) ship // per-action JSON schemas that are extraordinarily dense — deeply @@ -781,12 +890,12 @@ async fn run_inner_loop( // prose (XmlToolDispatcher format) and parse `` tags out // of the model's free-form response text. // - // Scoped to `skills_agent` because that's the only path where we + // Scoped to `integrations_agent` because that's the only path where we // pass Composio toolkit schemas. Every other typed sub-agent // (welcome, researcher, summarizer, …) uses small built-in tool // sets that stay well under the grammar ceiling and benefit from // native mode's stricter formatting guarantees. - let force_text_mode = agent_id == "skills_agent" && !tool_specs.is_empty(); + let force_text_mode = agent_id == "integrations_agent" && !tool_specs.is_empty(); let supports_native = !force_text_mode && provider.supports_native_tools() && !tool_specs.is_empty(); @@ -817,6 +926,49 @@ async fn run_inner_loop( let mut usage = AggregatedUsage::default(); + // Per-iteration transcript persistence. Mirrors the main-agent + // turn loop: right after each provider response lands (and again + // after the final response is pushed) we flush the full history + // to disk. A crash during tool execution no longer erases the + // sub-agent's response — the bytes are on disk before any tool + // runs. Best-effort: write failures are logged at `debug` and the + // loop continues. + let persist_transcript = |history: &[ChatMessage], usage: &AggregatedUsage| { + let path = match transcript::resolve_keyed_transcript_path( + &parent.workspace_dir, + &transcript_stem, + ) { + Ok(p) => p, + Err(err) => { + tracing::debug!( + agent_id = %agent_id, + error = %err, + "[subagent_runner] failed to resolve transcript path" + ); + return; + } + }; + let now = chrono::Utc::now().to_rfc3339(); + let meta = transcript::TranscriptMeta { + agent_name: agent_id.to_string(), + dispatcher: "native".into(), + created: now.clone(), + updated: now, + turn_count: 1, + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cached_input_tokens: usage.cached_input_tokens, + charged_amount_usd: usage.charged_amount_usd, + }; + if let Err(err) = transcript::write_transcript(&path, history, &meta, None) { + tracing::debug!( + agent_id = %agent_id, + error = %err, + "[subagent_runner] failed to write transcript" + ); + } + }; + for iteration in 0..max_iterations { tracing::debug!( task_id = %task_id, @@ -831,7 +983,6 @@ async fn run_inner_loop( ChatRequest { messages: history.as_slice(), tools: request_tools, - system_prompt_cache_boundary, stream: None, }, model, @@ -888,6 +1039,9 @@ async fn run_inner_loop( "[subagent_runner] no tool calls — returning final response" ); history.push(ChatMessage::assistant(response_text.clone())); + // Persist the final response before returning so the + // transcript always captures the last provider reply. + persist_transcript(history, &usage); return Ok((response_text, iteration + 1, usage)); } @@ -905,6 +1059,11 @@ async fn run_inner_loop( history.push(ChatMessage::assistant(assistant_history_content)); } + // Persist the assistant response + tool-call intents **before** + // executing tools. If the session crashes mid-tool-call we + // still have what the model emitted on disk. + persist_transcript(history, &usage); + // Execute each call, collect outputs. Native mode pushes one // `role=tool` message per call with the structured `tool_call_id` // reference. Text mode has no such reference (the model just @@ -948,7 +1107,7 @@ async fn run_inner_loop( }; // Progressive-disclosure handoff: if this spawn has a cache - // (skills_agent-with-toolkit path) and the result is large + // (integrations_agent-with-toolkit path) and the result is large // and not itself an error / not from the extractor tool, // stash the raw payload and replace it in history with a // short placeholder. The sub-agent can drill in with @@ -1031,6 +1190,14 @@ async fn run_inner_loop( "[Tool results]\n{text_mode_result_block}" ))); } + + // Persist again after tool results have been appended so the + // on-disk transcript reflects each round's complete + // assistant-intent + tool-result pair. Without this, a crash + // between `persist_transcript` at line ~1044 and the next + // iteration's provider call would leave the transcript without + // the tool outputs the next turn will be reasoning from. + persist_transcript(history, &usage); } Err(SubagentRunError::MaxIterationsExceeded(max_iterations)) @@ -1045,7 +1212,7 @@ fn parse_tool_arguments(arguments: &str) -> serde_json::Value { // Oversized-tool-result handoff (progressive disclosure) // ───────────────────────────────────────────────────────────────────────────── // -// Typed sub-agents (skills_agent in particular) regularly call tools that +// Typed sub-agents (integrations_agent in particular) regularly call tools that // return megabyte-scale payloads — `GMAIL_LIST_MESSAGES`, `NOTION_GET_PAGE`, // `GOOGLEDRIVE_LIST_FILES`. The default behaviour pushes that raw blob into // the sub-agent's history as a tool-result message, and the NEXT iteration @@ -1325,7 +1492,6 @@ impl Tool for ExtractFromResultTool { } }); - use futures::stream::StreamExt; let mut map_results: Vec<(usize, _)> = futures::stream::iter(map_futures) .buffer_unordered(MAP_CONCURRENCY) .collect() @@ -1565,9 +1731,7 @@ fn top_k_for_toolkit(toolkit: &str) -> usize { /// correctly while staying within budget. If the model needs deeper /// schema detail it can surface the error and the orchestrator will /// clarify on the next turn. -fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { - use std::fmt::Write as _; - +pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { let mut out = String::new(); out.push_str("## Tool Use Protocol\n\n"); out.push_str( @@ -1602,7 +1766,7 @@ fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { /// Kept intentionally terse: Composio action schemas routinely contain /// per-parameter descriptions several hundred tokens long, so even a /// "short description" per param balloons to tens of thousands of -/// tokens across a 27-tool skills_agent toolkit and pushes the prompt +/// tokens across a 27-tool integrations_agent toolkit and pushes the prompt /// past the 196 607-token context window. The model can infer usage /// from the parameter names + the tool's overall description; any /// validation mismatch surfaces at call time and the orchestrator can @@ -1653,15 +1817,30 @@ fn first_line_truncated(s: &str, max_chars: usize) -> String { /// /// Filters are applied in this order (shorter-circuit first): /// 1. `disallowed` — explicit deny list. -/// 2. `category_filter` — restrict to `System` or `Skill` category. -/// 3. `skill_filter` — restrict to tools named `{skill}__*`. -/// 4. `scope` — `Wildcard` (everything remaining) or `Named` allowlist. -fn filter_tool_indices( +/// 2. `skill_filter` — restrict to tools named `{skill}__*`. +/// 3. `scope` — `Wildcard` (everything remaining) or `Named` allowlist. +/// +/// Exposed `pub(crate)` so the debug dump path in +/// [`crate::openhuman::context::debug_dump`] shares the exact same +/// filter logic as the live runner — previously debug_dump carried a +/// "standalone copy" which drifted over time. +/// Tools that must never be visible to any agent except `welcome`. +/// +/// `complete_onboarding` flips the onboarding-complete flag in +/// workspace config and is the terminal step of the welcome flow; +/// every other agent must route the user back to the welcome agent +/// rather than call it directly. Central list here so both the main +/// agent builder ([`crate::openhuman::agent::harness::session::builder`]) +/// and the subagent runner apply the same guard. +pub(crate) fn is_welcome_only_tool(name: &str) -> bool { + matches!(name, "complete_onboarding") +} + +pub(crate) fn filter_tool_indices( parent_tools: &[Box], scope: &ToolScope, disallowed: &[String], skill_filter: Option<&str>, - category_filter: Option, ) -> Vec { let disallow_set: HashSet<&str> = disallowed.iter().map(|s| s.as_str()).collect(); let skill_prefix = skill_filter.map(|s| format!("{s}__")); @@ -1674,11 +1853,6 @@ fn filter_tool_indices( if disallow_set.contains(name) { return false; } - if let Some(required) = category_filter { - if tool.category() != required { - return false; - } - } if let Some(prefix) = skill_prefix.as_deref() { if !name.starts_with(prefix) { return false; @@ -1698,14 +1872,24 @@ fn filter_tool_indices( // ───────────────────────────────────────────────────────────────────────────── /// Resolve a [`PromptSource`] to its raw markdown body. Inline sources -/// return immediately; file sources are read from disk relative to the +/// return immediately, `Dynamic` calls the builder with the supplied +/// [`PromptContext`], `File` sources are read from disk relative to the /// workspace `prompts/` directory or the agent crate's bundled prompts. -fn load_prompt_source( +/// +/// Exposed `pub(crate)` so the debug dump path in +/// [`crate::openhuman::context::debug_dump`] loads prompts through the +/// exact same code the runner uses — no parallel body-loading logic. +pub(crate) fn load_prompt_source( source: &PromptSource, - workspace_dir: &std::path::Path, + ctx: &PromptContext<'_>, ) -> Result { + let workspace_dir = ctx.workspace_dir; match source { PromptSource::Inline(body) => Ok(body.clone()), + PromptSource::Dynamic(build) => build(ctx).map_err(|e| SubagentRunError::PromptLoad { + path: format!("", ctx.agent_id), + source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()), + }), PromptSource::File { path } => { // Try the workspace's `agent/prompts/` first (so users can // override built-in prompts), then fall back to the crate's @@ -1778,7 +1962,6 @@ mod tests { tools: ToolScope::Named(names.iter().map(|s| s.to_string()).collect()), disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 5, timeout_secs: None, @@ -1826,7 +2009,7 @@ mod tests { fn filter_named_scope_keeps_only_named() { let parent: Vec> = vec![stub("alpha"), stub("beta"), stub("gamma")]; let def = make_def_named_tools(&["alpha", "gamma"]); - let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None, None); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["alpha", "gamma"]); } @@ -1837,7 +2020,7 @@ mod tests { let mut def = make_def_named_tools(&[]); def.tools = ToolScope::Wildcard; def.disallowed_tools = vec!["beta".into()]; - let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None, None); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["alpha", "gamma"]); } @@ -1852,13 +2035,7 @@ mod tests { ]; let mut def = make_def_named_tools(&[]); def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - Some("notion"), - None, - ); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, Some("notion")); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["notion__search", "notion__read"]); } @@ -1873,203 +2050,11 @@ mod tests { stub("gmail__send"), ]; let def = make_def_named_tools(&["notion__search", "gmail__send"]); - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - Some("notion"), - None, - ); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, Some("notion")); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["notion__search"]); } - /// A stub tool that claims to be a skill-category tool, so we can - /// exercise `filter_tool_indices` / `category_filter` without - /// needing the real skill-bridge runtime. - struct SkillStubTool { - name: &'static str, - } - - #[async_trait] - impl Tool for SkillStubTool { - fn name(&self) -> &str { - self.name - } - fn description(&self) -> &str { - "skill stub" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::success("ok")) - } - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - fn category(&self) -> ToolCategory { - ToolCategory::Skill - } - } - - fn skill_stub(name: &'static str) -> Box { - Box::new(SkillStubTool { name }) - } - - #[test] - fn filter_category_skill_keeps_only_skill_tools() { - let parent: Vec> = vec![ - stub("file_read"), - stub("shell"), - skill_stub("notion__search"), - skill_stub("gmail__send"), - ]; - let def = make_def_named_tools(&[]); // Named([]) - // Wildcard + Skill category → only skill-category tools. - let mut def = def; - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - None, - Some(ToolCategory::Skill), - ); - let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - assert_eq!(names, vec!["notion__search", "gmail__send"]); - } - - #[test] - fn filter_category_system_excludes_skill_tools() { - let parent: Vec> = vec![ - stub("file_read"), - skill_stub("notion__search"), - stub("shell"), - ]; - let mut def = make_def_named_tools(&[]); - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - None, - Some(ToolCategory::System), - ); - let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - assert_eq!(names, vec!["file_read", "shell"]); - } - - #[test] - fn filter_category_and_skill_filter_compose() { - // Category=Skill AND skill_filter=notion → only notion__* tools - // that are also Skill-category. - let parent: Vec> = vec![ - skill_stub("notion__search"), - skill_stub("notion__read"), - skill_stub("gmail__send"), - stub("file_read"), - // A pathological system-category tool with a "notion__" - // name prefix — the category filter should still exclude it. - stub("notion__fake"), - ]; - let mut def = make_def_named_tools(&[]); - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - Some("notion"), - Some(ToolCategory::Skill), - ); - let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - assert_eq!(names, vec!["notion__search", "notion__read"]); - } - - /// End-to-end verification that a sub-agent with - /// `category_filter = "skill"` (like the built-in `skills_agent`) sees - /// the real Composio tools alongside any other `Skill`-category tools - /// and does **not** see `System`-category tools. - /// - /// This is the regression test for "skills subagent has access to - /// composio tools": if any of the composio tool impls forgets to - /// override `category()` and falls back to the default `System`, it - /// gets filtered out here and this test fails. - #[test] - fn skills_subagent_filter_admits_composio_tools() { - use crate::openhuman::composio::client::ComposioClient; - use crate::openhuman::composio::tools::{ - ComposioAuthorizeTool, ComposioExecuteTool, ComposioListConnectionsTool, - ComposioListToolkitsTool, ComposioListToolsTool, - }; - use crate::openhuman::integrations::IntegrationClient; - use std::sync::Arc; - - // Build a throwaway composio client. The filter only touches - // `Tool::name()` and `Tool::category()`, so no HTTP calls happen. - let inner = - IntegrationClient::new("http://127.0.0.1:0".to_string(), "test-token".to_string()); - let client = ComposioClient::new(Arc::new(inner)); - - // Parent registry = the five real Composio tools + a couple of - // plain system-category stubs. We expect exactly the composio - // tools to survive the skills sub-agent's category filter. - let parent: Vec> = vec![ - Box::new(ComposioListToolkitsTool::new(client.clone())), - Box::new(ComposioListConnectionsTool::new(client.clone())), - Box::new(ComposioAuthorizeTool::new(client.clone())), - Box::new(ComposioListToolsTool::new(client.clone())), - Box::new(ComposioExecuteTool::new(client)), - stub("file_read"), - stub("shell"), - ]; - - // Mirror the skills_agent definition: wildcard tool scope, - // category_filter = Skill, no skill_filter. - let mut def = make_def_named_tools(&[]); - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - None, - Some(ToolCategory::Skill), - ); - - let surviving: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - - // All five composio tools must be present. - for expected in &[ - "composio_list_toolkits", - "composio_list_connections", - "composio_authorize", - "composio_list_tools", - "composio_execute", - ] { - assert!( - surviving.contains(expected), - "skills sub-agent filter dropped composio tool `{}` — \ - did someone remove the `category()` override? \ - surviving = {:?}", - expected, - surviving, - ); - } - - // System-category tools must be filtered out. - assert!(!surviving.contains(&"file_read")); - assert!(!surviving.contains(&"shell")); - - // And we should see exactly 5 survivors, no more, no less. - assert_eq!( - surviving.len(), - 5, - "expected exactly 5 composio tools to pass the skills filter, \ - got {:?}", - surviving, - ); - } - #[test] fn subagent_mode_as_str_roundtrip() { assert_eq!(SubagentMode::Typed.as_str(), "typed"); @@ -2090,7 +2075,6 @@ mod tests { #[derive(Clone)] struct CapturedRequest { messages: Vec, - cache_boundary: Option, tool_count: usize, } @@ -2128,7 +2112,6 @@ mod tests { ) -> anyhow::Result { self.captured.lock().push(CapturedRequest { messages: request.messages.to_vec(), - cache_boundary: request.system_prompt_cache_boundary, tool_count: request.tools.map_or(0, |tools| tools.len()), }); let mut q = self.responses.lock(); @@ -2191,6 +2174,8 @@ mod tests { connected_integrations: vec![], composio_client: None, tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat, + session_key: "0_test".into(), + session_parent_prefix: None, } } @@ -2256,7 +2241,6 @@ mod tests { "summarise X", SubagentRunOptions { skill_filter_override: None, - category_filter_override: None, toolkit_override: None, context: None, task_id: Some("t1".into()), @@ -2337,31 +2321,6 @@ mod tests { assert!(user_msg.content.contains("branch X failed")); } - #[tokio::test] - async fn typed_mode_threads_system_prompt_cache_boundary() { - let provider = ScriptedProvider::new(vec![text_response("ok")]); - let parent = make_parent(provider.clone(), vec![stub("file_read")]); - let def = make_def_named_tools(&[]); - - let _ = with_parent_context(parent, async { - run_subagent( - &def, - "the actual task prompt", - SubagentRunOptions::default(), - ) - .await - }) - .await - .unwrap(); - - let captured = provider.captured.lock(); - assert_eq!(captured.len(), 1); - assert!( - captured[0].cache_boundary.is_some(), - "typed sub-agent request should carry a prompt cache boundary" - ); - } - #[tokio::test] async fn typed_mode_filters_tools_by_skill_filter() { // Parent has tools spanning notion__*, gmail__*, and a generic @@ -2387,7 +2346,6 @@ mod tests { "lookup", SubagentRunOptions { skill_filter_override: Some("notion".into()), - category_filter_override: None, toolkit_override: None, context: None, task_id: None, @@ -2504,7 +2462,6 @@ mod tests { system_prompt: Arc::new("PARENT_SYSTEM_PROMPT_BYTES".into()), tool_specs: Arc::new(vec![parent.all_tool_specs[0].clone()]), message_prefix: Arc::new(prefix.clone()), - cache_boundary: Some(9), fork_task_prompt: "ANALYSE THIS BRANCH".into(), }; @@ -2540,7 +2497,6 @@ mod tests { let appended = first_call.messages.last().unwrap(); assert_eq!(appended.role, "user"); assert_eq!(appended.content, "ANALYSE THIS BRANCH"); - assert_eq!(first_call.cache_boundary, Some(9)); assert_eq!(first_call.tool_count, 1); } diff --git a/src/openhuman/agent/harness/tool_filter.rs b/src/openhuman/agent/harness/tool_filter.rs index 83f86b7a8..8218d7a44 100644 --- a/src/openhuman/agent/harness/tool_filter.rs +++ b/src/openhuman/agent/harness/tool_filter.rs @@ -1,6 +1,6 @@ //! Fuzzy tool-filter for sub-agent delegation. //! -//! When `skills_agent` is spawned with a bound Composio toolkit (e.g. +//! When `integrations_agent` is spawned with a bound Composio toolkit (e.g. //! `toolkit="github"`), the parent-refined task prompt is usually specific //! enough that only a handful of the toolkit's actions are relevant. Github's //! catalogue alone has 500 actions; loading every one into the sub-agent's diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs index 4dc455a53..a96c02e23 100644 --- a/src/openhuman/agent/harness/tool_loop.rs +++ b/src/openhuman/agent/harness/tool_loop.rs @@ -277,7 +277,6 @@ pub(crate) async fn run_tool_call_loop( ChatRequest { messages: &prepared_messages.messages, tools: request_tools, - system_prompt_cache_boundary: None, stream: delta_tx_opt.as_ref(), }, model, diff --git a/src/openhuman/agent/mod.rs b/src/openhuman/agent/mod.rs index e30c1a5a7..3a83a974e 100644 --- a/src/openhuman/agent/mod.rs +++ b/src/openhuman/agent/mod.rs @@ -29,6 +29,12 @@ pub mod memory_loader; pub mod multimodal; pub mod pformat; pub mod progress; +/// Prompt plumbing — types, section builders, and +/// [`SystemPromptBuilder`](prompts::SystemPromptBuilder). Moved from +/// `openhuman::context::prompt` so prompt rendering lives next to the +/// agents that consume it. `openhuman::context::prompt` is retained as +/// a thin re-export shim for now. +pub mod prompts; mod schemas; pub mod triage; pub mod welcome_proactive; diff --git a/src/openhuman/agent/prompts/mod.rs b/src/openhuman/agent/prompts/mod.rs new file mode 100644 index 000000000..95d392f1d --- /dev/null +++ b/src/openhuman/agent/prompts/mod.rs @@ -0,0 +1,2062 @@ +pub mod types; +pub use types::*; + +use crate::openhuman::skills::Skill; +use crate::openhuman::tools::Tool; +use anyhow::Result; +use chrono::Local; +use std::fmt::Write; +use std::hash::{Hash, Hasher}; +use std::path::Path; +use std::sync::OnceLock; + +#[derive(Default)] +pub struct SystemPromptBuilder { + sections: Vec>, +} + +impl SystemPromptBuilder { + pub fn with_defaults() -> Self { + Self { + sections: vec![ + Box::new(IdentitySection), + // User files (PROFILE.md, MEMORY.md) ride right after the + // identity bootstrap so they land in the cache-friendly + // prefix alongside SOUL/IDENTITY. Gated per-agent — see + // `UserFilesSection`. Intentionally separate from + // `IdentitySection` so agents that strip the identity + // preamble via `for_subagent(omit_identity=true)` still + // get their user files (welcome / orchestrator / the + // trigger pair). + Box::new(UserFilesSection), + // User memory sits right after the identity bootstrap so the + // model has rich, persistent context about the user before it + // sees the tool catalogue. Section is empty (and skipped) when + // the tree summarizer has nothing on disk yet. + Box::new(UserMemorySection), + Box::new(ToolsSection), + Box::new(SafetySection), + Box::new(WorkspaceSection), + Box::new(DateTimeSection), + Box::new(RuntimeSection), + ], + } + } + + /// Build a narrow prompt for a sub-agent. + /// + /// The sub-agent's archetype prompt is registered as a dedicated + /// section that always renders first. The remaining sections respect + /// the `omit_*` flags from the [`crate::openhuman::agent::harness::definition::AgentDefinition`]: + /// `omit_identity` skips the project-context dump, `omit_safety_preamble` + /// skips the safety rules, and so on. The `WorkspaceSection` is always + /// included so the sub-agent knows its working directory. + /// + /// `archetype_prompt_text` is the already-loaded body of the + /// `system_prompt` source on the definition (the runner resolves + /// inline vs file before calling this). + /// + /// # KV cache stability + /// + /// `DateTimeSection` is intentionally **not** included here. + /// Repeat spawns of the same sub-agent definition must produce + /// byte-identical system prompts so the inference backend's + /// automatic prefix cache can reuse the prefill from the previous + /// run. Injecting `Local::now()` into the prompt would defeat that + /// goal — if a sub-agent genuinely needs the current time it + /// should receive it via the user message, not the system prompt. + pub fn for_subagent( + archetype_prompt_text: String, + omit_identity: bool, + omit_safety_preamble: bool, + _omit_skills_catalog: bool, + ) -> Self { + let mut sections: Vec> = + vec![Box::new(ArchetypePromptSection::new(archetype_prompt_text))]; + + if !omit_identity { + sections.push(Box::new(IdentitySection)); + } + // User files (PROFILE.md / MEMORY.md) are gated independently of + // `omit_identity` so agents that drop the identity preamble (e.g. + // welcome's `omit_identity = true`) still surface the user's + // onboarding + archivist context when `omit_profile` / + // `omit_memory_md` are opted in. + sections.push(Box::new(UserFilesSection)); + // Tools section is always included — the sub-agent needs to see + // its own (filtered) tool catalogue. + sections.push(Box::new(ToolsSection)); + if !omit_safety_preamble { + sections.push(Box::new(SafetySection)); + } + // Skills catalogue and connected integrations are rendered by + // the individual agent's `prompt.rs` when that agent needs + // them (integrations_agent for the skill-executor voice, + // orchestrator/welcome for the delegator voice). The shared + // builder intentionally does not emit them — keeping + // agent-specific prose scoped to the agent that owns it. + sections.push(Box::new(WorkspaceSection)); + + Self { sections } + } + + /// Build from a fully-assembled prompt string — no section wrapping. + /// + /// Used when the caller has already composed the final prompt (e.g. + /// via a function-driven `PromptSource::Dynamic` builder that calls + /// the `render_*` section helpers itself). The returned builder has + /// a single [`ArchetypePromptSection`] containing the body verbatim. + pub fn from_final_body(body: String) -> Self { + Self { + sections: vec![Box::new(ArchetypePromptSection::new(body))], + } + } + + /// Build from a [`PromptSource::Dynamic`] function pointer. + /// + /// The function is called every time [`Self::build`] runs, with the + /// live [`PromptContext`] the call-site supplies — so late-arriving + /// state like `connected_integrations` (fetched asynchronously at + /// the start of a session) reaches the dynamic renderer instead of + /// being frozen into an empty slice at builder-construction time. + /// + /// KV-cache contract: callers must only invoke `build_system_prompt` + /// once per session (after `fetch_connected_integrations`). The + /// rendered bytes are then frozen for the rest of the session the + /// same way `from_final_body` freezes them — the difference is just + /// *when* the freeze happens. + pub fn from_dynamic( + builder: crate::openhuman::agent::harness::definition::PromptBuilder, + ) -> Self { + Self { + sections: vec![Box::new(DynamicPromptSection::new(builder))], + } + } + + pub fn add_section(mut self, section: Box) -> Self { + self.sections.push(section); + self + } + + /// Render every section in order into a single prompt string. + /// + /// The rendered bytes are intended to be **frozen for the whole + /// session** — callers build the system prompt once at session + /// start and reuse the exact bytes on every subsequent turn so the + /// inference backend's prefix cache hits uniformly. There is no + /// cache-boundary marker to emit because the entire prompt is + /// static from the provider's perspective. + pub fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut output = String::new(); + for section in &self.sections { + let part = section.build(ctx)?; + if part.trim().is_empty() { + continue; + } + output.push_str(part.trim_end()); + output.push_str("\n\n"); + } + Ok(output) + } +} + +/// Sub-agent role prompt — pre-loaded text from an +/// [`crate::openhuman::agent::harness::definition::AgentDefinition`]'s +/// `system_prompt` field. Always rendered first when present. +pub struct ArchetypePromptSection { + body: String, +} + +impl ArchetypePromptSection { + pub fn new(body: String) -> Self { + Self { body } + } +} + +impl PromptSection for ArchetypePromptSection { + fn name(&self) -> &str { + "archetype_prompt" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + if self.body.trim().is_empty() { + return Ok(String::new()); + } + Ok(self.body.clone()) + } +} + +/// Section that defers to a [`crate::openhuman::agent::harness::definition::PromptBuilder`] +/// every time it renders, so dynamic prompts (orchestrator, welcome, +/// integrations_agent, …) get to see the live runtime +/// [`PromptContext`] — including `connected_integrations`, which are +/// fetched asynchronously after the builder itself has been +/// constructed. +pub struct DynamicPromptSection { + builder: crate::openhuman::agent::harness::definition::PromptBuilder, +} + +impl DynamicPromptSection { + pub fn new(builder: crate::openhuman::agent::harness::definition::PromptBuilder) -> Self { + Self { builder } + } +} + +impl PromptSection for DynamicPromptSection { + fn name(&self) -> &str { + "dynamic_prompt" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + (self.builder)(ctx) + } +} + +pub struct IdentitySection; +pub struct ToolsSection; +pub struct SafetySection; +// `SkillsSection` and `ConnectedIntegrationsSection` previously lived +// here and branched on `ctx.agent_id` to pick between the skill- +// executor and delegator voice. They've been removed — each agent's +// `prompt.rs` now renders its own block inline (integrations_agent owns the +// `## Available Skills` + executor-voice `## Connected Integrations` +// blocks, orchestrator owns `## Delegation Guide — Integrations`, +// welcome owns its onboarding-flavoured connected list). +pub struct WorkspaceSection; +pub struct RuntimeSection; +pub struct DateTimeSection; +pub struct UserMemorySection; + +/// Injects the user-specific, session-frozen workspace files +/// (`PROFILE.md` + `MEMORY.md`), each capped at [`USER_FILE_MAX_CHARS`]. +/// +/// Separate from [`IdentitySection`] so agents that strip the project- +/// context preamble (`omit_identity = true` — welcome, orchestrator, +/// the trigger pair) still get their user-file injection at runtime via +/// [`SystemPromptBuilder::for_subagent`], which skips `IdentitySection` +/// entirely when `omit_identity` is on. +/// +/// Cache-stability: static per session — the whole point of the +/// 2000-char cap and the load-once rule documented on +/// [`AgentDefinition::omit_profile`] / `omit_memory_md`. +pub struct UserFilesSection; + +impl PromptSection for IdentitySection { + fn name(&self) -> &str { + "identity" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut prompt = String::from("## Project Context\n\n"); + prompt.push_str( + "The following workspace files define your identity, behavior, and context.\n\n", + ); + // When the visible-tool filter is active the main agent is a pure + // orchestrator: it routes via spawn_subagent, synthesises results, + // and talks to the user. It does NOT need the periodic-task config + // (HEARTBEAT.md) — subagents handle their own concerns. + let is_orchestrator = !ctx.visible_tool_names.is_empty(); + let all_files: &[&str] = &["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"]; + // Orchestrator skips these from the prompt but we still sync them + // to disk so they stay current. + let skip_in_prompt: &[&str] = if is_orchestrator { + &["HEARTBEAT.md"] + } else { + &[] + }; + for file in all_files { + // Always sync to disk so builtin updates ship. + sync_workspace_file(ctx.workspace_dir, file); + if !skip_in_prompt.contains(file) { + inject_workspace_file(&mut prompt, ctx.workspace_dir, file); + } + } + + // PROFILE.md / MEMORY.md injection lives in the dedicated + // `UserFilesSection` (below) so agents that strip the identity + // preamble (`omit_identity = true`) — welcome, orchestrator, the + // trigger pair — still get their user files at runtime via + // `SystemPromptBuilder::for_subagent`, which omits + // `IdentitySection` entirely when `omit_identity` is set. + + Ok(prompt) + } +} + +impl PromptSection for UserFilesSection { + fn name(&self) -> &str { + "user_files" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + // Gate on the per-agent flags derived from + // `AgentDefinition::omit_profile` / `omit_memory_md`. Both files + // are user-specific, potentially growing, and capped at + // [`USER_FILE_MAX_CHARS`] (~1000 tokens) so they can't bloat the + // cached prefix. + // + // KV-cache contract: once injected into a session's rendered + // prompt, the bytes are frozen for the remainder of that + // session — any mid-session archivist write or enrichment + // refresh lands on the NEXT session, never the in-flight one. + let mut out = String::new(); + if ctx.include_profile { + inject_workspace_file_capped( + &mut out, + ctx.workspace_dir, + "PROFILE.md", + USER_FILE_MAX_CHARS, + ); + } + if ctx.include_memory_md { + inject_workspace_file_capped( + &mut out, + ctx.workspace_dir, + "MEMORY.md", + USER_FILE_MAX_CHARS, + ); + } + Ok(out) + } +} + +impl PromptSection for ToolsSection { + fn name(&self) -> &str { + "tools" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut out = String::from("## Tools\n\n"); + let has_filter = !ctx.visible_tool_names.is_empty(); + for tool in ctx.tools { + // Skip tools not in the visible set when a filter is active. + if has_filter && !ctx.visible_tool_names.contains(tool.name) { + continue; + } + + // One rendering shape for every dispatcher: a compact + // P-Format signature (`name[a|b|c]`). The signature comes + // straight from the parameter schema (alphabetical by + // property name — see `pformat` module docs for why) so + // model and parser agree on argument ordering. For + // `Native` dispatchers the provider already has the full + // JSON schema in the API request, so repeating it in the + // prompt is pure token bloat; for `Json` / `PFormat` text + // dispatchers the dispatcher's own `prompt_instructions` + // block (appended below) carries whatever schema detail + // the wire format needs. + let signature = render_pformat_signature_for_prompt(tool); + let _ = writeln!( + out, + "- **{}**: {}\n Call as: `{}`", + tool.name, tool.description, signature + ); + } + if !ctx.dispatcher_instructions.is_empty() { + out.push('\n'); + out.push_str(ctx.dispatcher_instructions); + } + Ok(out) + } +} + +/// Build a P-Format signature line (`name[a|b|c]`) from a `&dyn Tool`. +/// Used by `render_subagent_system_prompt` which operates on `Box` +/// directly (no intermediate `PromptTool`). Mirrors the `PromptTool` variant +/// below — both BTreeMap-iterate the schema's `properties` in the same order. +fn render_pformat_signature_for_box_tool(tool: &dyn crate::openhuman::tools::Tool) -> String { + let schema = tool.parameters_schema(); + let names: Vec = schema + .get("properties") + .and_then(|p| p.as_object()) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + if names.is_empty() { + format!("{}[]", tool.name()) + } else { + format!("{}[{}]", tool.name(), names.join("|")) + } +} + +/// Build a P-Format signature line (`name[a|b|c]`) from a [`PromptTool`]. +/// Local to this module so [`ToolsSection`] doesn't have to depend on +/// the agent crate's `pformat` helper. The two implementations stay in +/// lockstep — both use BTreeMap iteration order on the schema's +/// `properties` field. +fn render_pformat_signature_for_prompt(tool: &PromptTool<'_>) -> String { + let names: Vec = tool + .parameters_schema + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| { + v.get("properties") + .and_then(|p| p.as_object()) + .map(|m| m.keys().cloned().collect()) + }) + .unwrap_or_default(); + if names.is_empty() { + format!("{}[]", tool.name) + } else { + format!("{}[{}]", tool.name, names.join("|")) + } +} + +impl PromptSection for SafetySection { + fn name(&self) -> &str { + "safety" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) + } +} + +impl PromptSection for WorkspaceSection { + fn name(&self) -> &str { + "workspace" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + Ok(format!( + "## Workspace\n\nWorking directory: `{}`", + ctx.workspace_dir.display() + )) + } +} + +impl PromptSection for RuntimeSection { + fn name(&self) -> &str { + "runtime" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let host = + hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + Ok(format!( + "## Runtime\n\nHost: {host} | OS: {} | Model: {}", + std::env::consts::OS, + ctx.model_name + )) + } +} + +impl PromptSection for UserMemorySection { + fn name(&self) -> &str { + "user_memory" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + if ctx.learned.tree_root_summaries.is_empty() { + return Ok(String::new()); + } + + let mut out = String::from("## User Memory\n\n"); + out.push_str( + "Long-term memory distilled by the tree summarizer. \ + Each section is the root summary for a memory namespace, \ + representing everything we've learned about that domain over time. \ + Treat this as durable context: the model has seen these facts before, \ + they should not need to be re-discovered.\n\n", + ); + + for (namespace, body) in &ctx.learned.tree_root_summaries { + let trimmed = body.trim(); + if trimmed.is_empty() { + continue; + } + let _ = writeln!(out, "### {namespace}\n"); + out.push_str(trimmed); + out.push_str("\n\n"); + } + + Ok(out) + } +} + +impl PromptSection for DateTimeSection { + fn name(&self) -> &str { + "datetime" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + let now = Local::now(); + Ok(format!( + "## Current Date & Time\n\n{} ({})", + now.format("%Y-%m-%d %H:%M:%S"), + now.format("%Z") + )) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section helpers for function-driven prompts +// ───────────────────────────────────────────────────────────────────────────── +// +// Each of the `Section` unit structs above is also available as a free +// `render_*` function that takes the same `PromptContext` and returns +// the section body (or an empty string when the section's gate is +// closed). +// +// These exist so `agents//prompt.rs` builders can assemble their own +// final system prompt, composing the exact sections they care about in +// the order they want — no `SystemPromptBuilder` machinery required. + +/// Render the `## Project Context` identity block +/// (`SOUL.md` / `IDENTITY.md` / optionally `HEARTBEAT.md`). +pub fn render_identity(ctx: &PromptContext<'_>) -> Result { + IdentitySection.build(ctx) +} + +/// Render the `PROFILE.md` + `MEMORY.md` user-file injection. +/// Empty when neither `ctx.include_profile` nor `ctx.include_memory_md` +/// is set. +pub fn render_user_files(ctx: &PromptContext<'_>) -> Result { + UserFilesSection.build(ctx) +} + +/// Render the tree-summariser user-memory block. +pub fn render_user_memory(ctx: &PromptContext<'_>) -> Result { + UserMemorySection.build(ctx) +} + +/// Render the `## Tools` catalogue in the dispatcher's tool-call format. +pub fn render_tools(ctx: &PromptContext<'_>) -> Result { + ToolsSection.build(ctx) +} + +/// Render the static `## Safety` block. +pub fn render_safety() -> String { + SafetySection + .build(&empty_prompt_context_for_static_sections()) + .expect("SafetySection::build is infallible") +} + +// `render_skills` and `render_connected_integrations` helpers are +// gone — `## Available Skills` lives in `integrations_agent/prompt.rs`, and +// the connected-integrations / delegation-guide blocks each live in +// their owning agent's `prompt.rs` so no branching-on-agent-id logic +// needs to exist here. + +/// Render the `## Workspace` block (working directory + file listing +/// bounds) — part of the dynamic, per-request suffix. +pub fn render_workspace(ctx: &PromptContext<'_>) -> Result { + WorkspaceSection.build(ctx) +} + +/// Render the `## Runtime` block (model name, dispatcher format) — +/// dynamic. +pub fn render_runtime(ctx: &PromptContext<'_>) -> Result { + RuntimeSection.build(ctx) +} + +/// Render the `## Current Date & Time` block. Intentionally **not** +/// included in byte-stable sub-agent prompts (`for_subagent`) because +/// injecting `Local::now()` defeats prefix caching. Exposed so full- +/// assembly main-agent builders can opt in. +pub fn render_datetime(ctx: &PromptContext<'_>) -> Result { + DateTimeSection.build(ctx) +} + +/// Build a throwaway `PromptContext` for sections whose `build` only +/// uses static/immutable inputs (currently just `SafetySection`). Keeps +/// the `render_safety()` free function from forcing callers to +/// manufacture a full context when they only need the static text. +fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { + static EMPTY_TOOLS: &[PromptTool<'static>] = &[]; + static EMPTY_SKILLS: &[Skill] = &[]; + static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[]; + // SAFETY: the &HashSet reference must outlive the returned context; + // a leaked OnceLock-style allocation gives us a permanent 'static + // anchor without adding runtime cost on the hot path. + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + let visible = EMPTY_VISIBLE.get_or_init(std::collections::HashSet::new); + PromptContext { + workspace_dir: std::path::Path::new(""), + model_name: "", + agent_id: "", + tools: EMPTY_TOOLS, + skills: EMPTY_SKILLS, + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: EMPTY_INTEGRATIONS, + include_profile: false, + include_memory_md: false, + } +} + +/// Render a narrow, KV-cache-stable system prompt for a typed sub-agent. +/// +/// This is a purpose-built alternative to +/// [`SystemPromptBuilder::for_subagent`] for call sites that only have +/// indices into the parent's `&[Box]` vec (so they can't +/// cheaply build a filtered owning slice for `ToolsSection`). The +/// output mirrors what `for_subagent` would emit with the matching +/// `omit_*` flags, plus a sub-agent-specific calling-convention +/// preamble and a model-only runtime banner. +/// +/// `archetype_body` is the already-loaded archetype markdown — for +/// `PromptSource::Inline` this is the inline string, for +/// `PromptSource::File` this is the file contents loaded by the caller. +/// Callers resolve the source exactly once and hand the body in, so +/// this renderer works uniformly for both definition shapes. +/// +/// `options` carries the per-definition rendering flags (safety, etc.) +/// inverted into positive-sense `include_*` form. +/// [`SubagentRenderOptions::narrow`] preserves the historical behaviour. +/// +/// # KV cache stability +/// +/// The rendered bytes MUST be a pure function of: +/// - the `archetype_body` (archetype role prompt) +/// - the filtered tool set (names, descriptions, schemas) +/// - the workspace directory +/// - the resolved model name +/// - the `options` (all static per definition) +/// +/// Anything that varies across invocations at the *same* call site +/// (e.g. `chrono::Local::now()`, hostnames, pids, turn counters) is +/// forbidden here. Repeat spawns of the same sub-agent within a session +/// must produce byte-identical system prompts so the inference +/// backend's automatic prefix caching can reuse the prefill from the +/// previous run. Time-of-day information, if a sub-agent needs it, +/// belongs in the user message — not the system prompt. +pub fn render_subagent_system_prompt( + workspace_dir: &Path, + model_name: &str, + allowed_indices: &[usize], + parent_tools: &[Box], + extra_tools: &[Box], + archetype_body: &str, + options: SubagentRenderOptions, + tool_call_format: ToolCallFormat, + connected_integrations: &[ConnectedIntegration], +) -> String { + render_subagent_system_prompt_with_format( + workspace_dir, + model_name, + allowed_indices, + parent_tools, + extra_tools, + archetype_body, + options, + tool_call_format, + connected_integrations, + ) +} + +/// Inner renderer that accepts an explicit [`ToolCallFormat`] so callers +/// that know the active dispatcher format can thread it through. The +/// public [`render_subagent_system_prompt`] defaults to PFormat for +/// backwards compatibility. +pub fn render_subagent_system_prompt_with_format( + workspace_dir: &Path, + model_name: &str, + allowed_indices: &[usize], + parent_tools: &[Box], + extra_tools: &[Box], + archetype_body: &str, + options: SubagentRenderOptions, + tool_call_format: ToolCallFormat, + _connected_integrations: &[ConnectedIntegration], +) -> String { + let mut out = String::new(); + + // 1. Archetype role prompt. Works for `PromptSource::Inline`, + // `PromptSource::File`, and `PromptSource::Dynamic` because the + // caller preloaded the body via `load_prompt_source`. + let trimmed = archetype_body.trim(); + if !trimmed.is_empty() { + out.push_str(trimmed); + out.push_str("\n\n"); + } + + // 1b. Optional identity block. Off by default; turned on when the + // definition sets `omit_identity = false`. Renders the same + // OpenClaw bootstrap files the main agent loads, keeping the + // byte layout stable across repeat spawns of the same + // definition within a session. + if options.include_identity { + out.push_str("## Project Context\n\n"); + out.push_str( + "The following workspace files define your identity, behavior, and context.\n\n", + ); + for file in &["SOUL.md", "IDENTITY.md"] { + inject_workspace_file(&mut out, workspace_dir, file); + } + } + + // 1c. PROFILE.md (onboarding enrichment output) and MEMORY.md + // (archivist-curated long-term memory). Each is gated on its own + // flag and capped at `USER_FILE_MAX_CHARS` (~1000 tokens) so a + // growing on-disk file can't push the system prompt out of the + // cache-friendly prefix range. + // + // KV-cache contract: once these files land in a session's + // rendered prompt the bytes are frozen for the remainder of that + // session. Do not re-read them mid-turn — a byte change breaks + // the backend's automatic prefix cache. Mid-session writes to + // either file are intentionally only visible on the NEXT session. + if options.include_profile { + inject_workspace_file_capped(&mut out, workspace_dir, "PROFILE.md", USER_FILE_MAX_CHARS); + } + if options.include_memory_md { + inject_workspace_file_capped(&mut out, workspace_dir, "MEMORY.md", USER_FILE_MAX_CHARS); + } + + // 2. Filtered tool catalogue. Indices are taken in ascending order + // from `allowed_indices`, which itself preserves `parent_tools` + // order, so the rendering is deterministic. We use `.get(i)` + // defensively even though the current caller (subagent_runner) + // only produces in-range indices — a future caller that derives + // indices from a different source must not be able to panic this + // renderer with a stale index. + // + // Rendering uses the caller-specified `tool_call_format` so + // sub-agents and the main dispatcher stay in lockstep. + // Tool catalogue rendering is dispatcher-format-aware: + // + // - **Native**: The provider receives full tool schemas through + // the request body's `tools` field (via `filtered_specs` in the + // sub-agent runner) and emits structured `tool_calls`. Listing + // the same tools again as prose in the system prompt is pure + // duplication — for a integrations_agent spawn with 62 dynamic gmail + // tools, that duplication added ~54k tokens and blew past the + // model's context window. We skip the prose `## Tools` section + // entirely in this mode. + // + // - **PFormat / Json**: Both are prompt-driven formats — the + // model discovers tools by reading the prose `## Tools` section + // and emits text-wrapped tool calls (`name[a|b]` + // for PFormat, `{"name":...}` for Json). + // Neither uses the native `tools` request field, so we MUST + // list each tool in prose — including dynamically-registered + // `extra_tools` — or the model has no way to know they exist. + if !matches!(tool_call_format, ToolCallFormat::Native) { + out.push_str("## Tools\n\n"); + let render_one = |out: &mut String, tool: &dyn Tool| match tool_call_format { + ToolCallFormat::PFormat => { + let sig = render_pformat_signature_for_box_tool(tool); + let _ = writeln!( + out, + "- **{}**: {}\n Call as: `{}`", + tool.name(), + tool.description(), + sig + ); + } + ToolCallFormat::Json => { + let _ = writeln!( + out, + "- **{}**: {}\n Parameters: `{}`", + tool.name(), + tool.description(), + tool.parameters_schema() + ); + } + ToolCallFormat::Native => { + // Unreachable — outer guard skips Native entirely. + } + }; + for &i in allowed_indices { + let Some(tool) = parent_tools.get(i) else { + tracing::warn!( + index = i, + tool_count = parent_tools.len(), + "[context::prompt] dropping out-of-range tool index in subagent render" + ); + continue; + }; + render_one(&mut out, tool.as_ref()); + } + for tool in extra_tools { + render_one(&mut out, tool.as_ref()); + } + } + + // 3. Sub-agent calling-convention preamble — format-aware. + // Sub-agents need the same call format the main dispatcher expects + // so their output parses correctly. + out.push('\n'); + match tool_call_format { + ToolCallFormat::PFormat => { + out.push_str( + "## Tool Use Protocol\n\n\ + Tool calls use **P-Format**: compact, positional, pipe-delimited syntax \ + wrapped in `` tags.\n\n\ + ```\n\ntool_name[arg1|arg2]\n\n```\n\n\ + Arguments are positional — match the order shown in each tool's `Call as:` \ + signature above (alphabetical by parameter name). \ + Escape `|` as `\\|`, `]` as `\\]` inside values. \ + You may emit multiple `` blocks per response.\n\n\ + Use the provided tools to accomplish the task. Reply with a concise, dense \ + final answer when you have one — the parent agent will weave it back into the \ + user-visible response.\n\n", + ); + } + ToolCallFormat::Json => { + out.push_str( + "## Tool Use Protocol\n\n\ + To use a tool, wrap a JSON object in `` tags:\n\n\ + ```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n\ + You may emit multiple `` blocks in a single response.\n\n\ + Use the provided tools to accomplish the task. Reply with a concise, dense \ + final answer when you have one — the parent agent will weave it back into the \ + user-visible response.\n\n", + ); + } + ToolCallFormat::Native => { + out.push_str( + "Use the provided tools via the model's native tool-calling output. \ + Reply with a concise, dense final answer when you have one — the parent \ + agent will weave it back into the user-visible response.\n\n", + ); + } + } + + // 3b. Optional safety preamble. Definitions that do work with real + // side-effects (code_executor, tool_maker, integrations_agent) set + // `omit_safety_preamble = false` so the narrow renderer used to + // silently drop that instruction — we now honour the flag. + // Byte-identical to `SafetySection::build`. + if options.include_safety_preamble { + out.push_str( + "## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.\n\n", + ); + } + + // 3c/3d. `## Available Skills` and `## Connected Integrations` + // are no longer emitted here. Each agent that needs them + // renders its own block in its `prompt.rs` (integrations_agent + // owns the executor voice, orchestrator/welcome own the + // delegator voice). Legacy Inline/File-sourced TOML agents + // that still route through this helper simply don't get + // either block — which matches the fact that none of them + // currently opt in. + + // 4. Workspace so the model knows where it is. Intentionally stable: + // no datetime, no hostname, no pid — see the KV-cache note above. + let _ = writeln!( + out, + "## Workspace\n\nWorking directory: `{}`\n", + workspace_dir.display() + ); + + // 6. Runtime banner — model name only. Stable for the lifetime of + // this sub-agent's definition. + let _ = writeln!(out, "## Runtime\n\nModel: {model_name}"); + + out +} + +/// Ensure the workspace file is up-to-date with the compiled-in default. +/// +/// On first install the file doesn't exist → write it. On subsequent runs +/// we store a hash of the compiled-in content in a sidecar file +/// (`.{filename}.builtin-hash`). If the hash changes (code was updated), +/// the disk file is overwritten so prompt improvements ship automatically. +/// User edits between code releases are preserved — we only overwrite when +/// the built-in default itself changes. +fn sync_workspace_file(workspace_dir: &Path, filename: &str) { + let default_content = default_workspace_file_content(filename); + if default_content.is_empty() { + return; + } + + let path = workspace_dir.join(filename); + let hash_path = workspace_dir.join(format!(".{filename}.builtin-hash")); + + // Compute a simple hash of the current compiled-in content. + let current_hash = { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + default_content.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + + // Read the last-written hash (if any). + let stored_hash = std::fs::read_to_string(&hash_path).unwrap_or_default(); + let stored_hash = stored_hash.trim(); + + if stored_hash == current_hash && path.exists() { + // Built-in hasn't changed and file exists — nothing to do. + return; + } + + // Decide whether to overwrite the existing file. Two safe cases: + // 1. File doesn't exist yet — first install, write the default. + // 2. File exists AND its current hash matches the stored builtin + // hash — the user hasn't edited it since we last wrote it, so + // it's safe to ship the new default. + // Otherwise the file has been hand-edited between releases; leave + // the user's version in place and just update the stored hash so we + // stop re-comparing against the old default on every boot. + let file_exists = path.exists(); + let user_unmodified = if file_exists { + match std::fs::read_to_string(&path) { + Ok(disk) => { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + disk.hash(&mut hasher); + let disk_hash = format!("{:016x}", hasher.finish()); + disk_hash == stored_hash + } + Err(_) => false, + } + } else { + false + }; + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + if !file_exists || user_unmodified { + if let Err(e) = std::fs::write(&path, default_content) { + log::warn!("[agent:prompt] failed to write workspace file {filename}: {e}"); + return; + } + log::info!("[agent:prompt] updated workspace file {filename} (builtin content changed)"); + } else { + log::info!( + "[agent:prompt] keeping user-edited workspace file {filename} (builtin changed but disk contents diverge)" + ); + } + let _ = std::fs::write(&hash_path, ¤t_hash); +} + +/// Inject `filename` from `workspace_dir` into `prompt`, truncated to +/// [`BOOTSTRAP_MAX_CHARS`]. Thin wrapper around +/// [`inject_workspace_file_capped`] for bootstrap-class files +/// (`SOUL.md`, `IDENTITY.md`, `HEARTBEAT.md`). +fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { + inject_workspace_file_capped(prompt, workspace_dir, filename, BOOTSTRAP_MAX_CHARS); +} + +/// Inject `filename` into `prompt` with an explicit character budget. +/// +/// Used directly by callers that want a tighter cap than +/// [`BOOTSTRAP_MAX_CHARS`] — notably `PROFILE.md` and `MEMORY.md` which +/// are user-specific, potentially growing, and do not warrant a full +/// 20K-char budget (see [`USER_FILE_MAX_CHARS`]). +/// +/// Missing / empty files are silently skipped so callers can inject +/// optional files unconditionally without emitting a noisy placeholder. +/// +/// **KV-cache contract:** the output is a pure function of `filename`, +/// file bytes at call time, and `max_chars`. Callers must invoke this +/// once per session — re-reading mid-session breaks the inference +/// backend's automatic prefix cache. See the byte-stability note on +/// [`render_subagent_system_prompt`]. +fn inject_workspace_file_capped( + prompt: &mut String, + workspace_dir: &Path, + filename: &str, + max_chars: usize, +) { + let path = workspace_dir.join(filename); + + match std::fs::read_to_string(&path) { + Ok(content) => { + let trimmed = content.trim(); + if trimmed.is_empty() { + return; + } + let _ = writeln!(prompt, "### {filename}\n"); + let truncated = if trimmed.chars().count() > max_chars { + trimmed + .char_indices() + .nth(max_chars) + .map(|(idx, _)| &trimmed[..idx]) + .unwrap_or(trimmed) + } else { + trimmed + }; + prompt.push_str(truncated); + if truncated.len() < trimmed.len() { + let _ = writeln!( + prompt, + "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" + ); + } else { + prompt.push_str("\n\n"); + } + } + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => { + // Keep prompt focused: missing optional identity/bootstrap files should not + // add noisy placeholders that dilute tool-calling instructions. + } + _ => { + log::debug!("[prompt] failed to read {}: {e}", path.display()); + } + }, + } +} + +fn default_workspace_file_content(filename: &str) -> &'static str { + // The bundled identity files live at `src/openhuman/agent/prompts/` + // (owned by the `agent/` tree because they describe agent identity). + // This module is under `src/openhuman/context/`, so the relative path + // walks up one level and back into `agent/prompts/`. + match filename { + "SOUL.md" => include_str!("SOUL.md"), + "IDENTITY.md" => include_str!("IDENTITY.md"), + "HEARTBEAT.md" => { + "# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n" + } + _ => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::Tool; + use async_trait::async_trait; + use std::collections::HashSet; + use std::sync::LazyLock; + + static NO_FILTER: LazyLock> = LazyLock::new(HashSet::new); + + struct TestTool; + + #[async_trait] + impl Tool for TestTool { + fn name(&self) -> &str { + "test_tool" + } + + fn description(&self) -> &str { + "tool desc" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::openhuman::tools::ToolResult::success("ok")) + } + } + + #[test] + fn prompt_builder_assembles_sections() { + let tools: Vec> = vec![Box::new(TestTool)]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "instr", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + assert!(rendered.contains("## Tools")); + assert!(rendered.contains("test_tool")); + assert!(rendered.contains("instr")); + } + + #[test] + fn identity_section_creates_missing_workspace_files() { + let workspace = + std::env::temp_dir().join(format!("openhuman_prompt_create_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + + let section = IdentitySection; + let _ = section.build(&ctx).unwrap(); + + for file in ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"] { + assert!( + workspace.join(file).exists(), + "expected workspace file to be created: {file}" + ); + } + let soul = std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(); + assert!( + soul.starts_with("# OpenHuman"), + "SOUL.md should be seeded from src/openhuman/agent/prompts/SOUL.md" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn datetime_section_includes_timestamp_and_timezone() { + let tools: Vec> = vec![]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "instr", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + + let rendered = DateTimeSection.build(&ctx).unwrap(); + assert!(rendered.starts_with("## Current Date & Time\n\n")); + + let payload = rendered.trim_start_matches("## Current Date & Time\n\n"); + assert!(payload.chars().any(|c| c.is_ascii_digit())); + assert!(payload.contains(" (")); + assert!(payload.ends_with(')')); + } + + #[test] + fn tools_section_pformat_renders_signature_not_schema() { + // ToolsSection must render `name[arg1|arg2]` signatures when + // `tool_call_format = PFormat`, NOT the verbose JSON schema — + // that's where most of the prompt token saving comes from. + struct ParamTool; + #[async_trait] + impl Tool for ParamTool { + fn name(&self) -> &str { + "make_tea" + } + fn description(&self) -> &str { + "brew a cup of tea" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "kind": { "type": "string" }, + "sugar": { "type": "boolean" } + } + }) + } + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::openhuman::tools::ToolResult::success("ok")) + } + } + + let tools: Vec> = vec![Box::new(ParamTool)]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + + let rendered = ToolsSection.build(&ctx).unwrap(); + // Alphabetical: kind, sugar. + assert!( + rendered.contains("Call as: `make_tea[kind|sugar]`"), + "expected p-format signature in tools section, got:\n{rendered}" + ); + // Should NOT contain the raw JSON schema dump. + assert!( + !rendered.contains("\"properties\""), + "tools section should drop the raw JSON schema in p-format mode, got:\n{rendered}" + ); + } + + #[test] + fn tools_section_uses_pformat_signature_for_every_dispatcher() { + // Tool rendering is uniform across dispatchers: always the + // compact `Call as: name[args]` signature, never a raw JSON + // schema dump. Native tool calls still carry the full schema + // in the provider request; the `Json` / `PFormat` text + // dispatchers supply any extra framing via + // `ctx.dispatcher_instructions`. + let tools: Vec> = vec![Box::new(TestTool)]; + let prompt_tools = PromptTool::from_tools(&tools); + for format in [ + ToolCallFormat::PFormat, + ToolCallFormat::Json, + ToolCallFormat::Native, + ] { + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: format, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = ToolsSection.build(&ctx).unwrap(); + assert!( + rendered.contains("Call as:"), + "{format:?} must use the signature format, got:\n{rendered}" + ); + assert!( + !rendered.contains("Parameters:"), + "{format:?} should never emit the JSON `Parameters:` line, got:\n{rendered}" + ); + } + } + + #[test] + fn user_memory_section_renders_namespaces_with_headings() { + let learned = LearnedContextData { + tree_root_summaries: vec![ + ("user".into(), "Steven prefers terse Rust answers.".into()), + ( + "conversations".into(), + "Recent thread: prompt rework.".into(), + ), + ], + ..Default::default() + }; + let prompt_tools: Vec> = Vec::new(); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned, + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = UserMemorySection.build(&ctx).unwrap(); + assert!(rendered.starts_with("## User Memory\n\n")); + assert!(rendered.contains("### user\n\nSteven prefers terse Rust answers.")); + assert!(rendered.contains("### conversations\n\nRecent thread: prompt rework.")); + } + + #[test] + fn user_memory_section_returns_empty_when_no_summaries() { + // Empty learned context → section returns empty string and is + // skipped by the prompt builder, so the cache boundary stays + // exactly where it was for workspaces with no tree summaries. + let learned = LearnedContextData::default(); + let prompt_tools: Vec> = Vec::new(); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned, + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = UserMemorySection.build(&ctx).unwrap(); + assert!(rendered.is_empty()); + } + + #[test] + fn render_subagent_system_prompt_renders_workspace_tail() { + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_subagent_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a focused sub-agent.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + + assert!(rendered.contains("## Workspace")); + assert!(rendered.contains("## Runtime")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn subagent_render_options_invert_definition_flags() { + // (omit_identity, omit_safety_preamble, omit_skills_catalog, + // omit_profile, omit_memory_md) + let options = SubagentRenderOptions::from_definition_flags(true, false, true, false, false); + assert!(!options.include_identity); + assert!(options.include_safety_preamble); + assert!(!options.include_skills_catalog); + assert!(options.include_profile); + assert!(options.include_memory_md); + let narrow = SubagentRenderOptions::narrow(); + let default = SubagentRenderOptions::default(); + assert_eq!(narrow.include_identity, default.include_identity); + assert_eq!( + narrow.include_safety_preamble, + default.include_safety_preamble + ); + assert_eq!( + narrow.include_skills_catalog, + default.include_skills_catalog + ); + assert_eq!(narrow.include_profile, default.include_profile); + assert_eq!(narrow.include_memory_md, default.include_memory_md); + // Narrow default = every flag off, including both user files. + assert!(!narrow.include_profile); + assert!(!narrow.include_memory_md); + } + + #[test] + fn render_subagent_system_prompt_honors_identity_safety_and_skills_flags() { + let workspace = + std::env::temp_dir().join(format!("openhuman_prompt_opts_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("SOUL.md"), "# Soul\nContext").unwrap(); + std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nContext").unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt_with_format( + &workspace, + "reasoning-v1", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions { + include_identity: true, + include_safety_preamble: true, + include_skills_catalog: true, + include_profile: false, + include_memory_md: false, + }, + ToolCallFormat::Json, + &[], + ); + + assert!(rendered.contains("## Project Context")); + assert!(rendered.contains("### SOUL.md")); + assert!(rendered.contains("## Safety")); + // Json is a prompt-driven format (the model wraps JSON tool + // calls in `` tags); it does NOT use the provider's + // native function-calling channel. So the prose `## Tools` + // section MUST still be rendered for Json, with each tool's + // parameter schema inline so the model knows what to emit. + // Only `ToolCallFormat::Native` gets the section omitted (see + // the `native` branch below and the `!matches!(…, Native)` + // guard in the renderer). + assert!(rendered.contains("## Tools")); + assert!(rendered.contains("Parameters:")); + assert!(rendered.contains("\"type\"")); + + let native = render_subagent_system_prompt_with_format( + &workspace, + "reasoning-v1", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions::narrow(), + ToolCallFormat::Native, + &[], + ); + assert!(native.contains("native tool-calling output")); + assert!(!native.contains("## Safety")); + // Native is the only format where the prose `## Tools` section + // is intentionally omitted — schemas travel through the + // provider's `tools` field instead. Regression guard against + // the ~54k-token schema duplication from the #447 PR. + assert!(!native.contains("\n## Tools\n")); + assert!(!native.contains("Parameters:")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_injects_profile_md_even_when_identity_omitted() { + // Regression: the welcome agent sets `omit_identity = true` to + // drop the SOUL/IDENTITY preamble (it has its own voice) but it + // still needs PROFILE.md to personalise the greeting. PROFILE.md + // is gated on its own `include_profile` flag so the welcome path + // can opt in without pulling SOUL/IDENTITY back in. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_nosoul_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("SOUL.md"), "# Soul\nShould be hidden").unwrap(); + std::fs::write( + workspace.join("IDENTITY.md"), + "# Identity\nShould be hidden", + ) + .unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nName: Jane Doe\nRole: Data scientist", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the welcome agent.", + SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: false, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + rendered.contains("### PROFILE.md"), + "PROFILE.md header must appear when include_profile=true, got:\n{rendered}" + ); + assert!( + rendered.contains("Jane Doe"), + "PROFILE.md body must be injected when include_profile=true, got:\n{rendered}" + ); + assert!( + !rendered.contains("## Project Context"), + "identity preamble must still be suppressed when include_identity=false" + ); + assert!( + !rendered.contains("### SOUL.md") && !rendered.contains("### IDENTITY.md"), + "SOUL/IDENTITY must still be suppressed when include_identity=false" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_skips_profile_md_when_include_profile_false() { + // Mirror of the opt-in regression above: narrow specialists + // (planner, code_executor, critic, …) set `omit_profile = true` + // and must NOT see PROFILE.md even when the file is on disk — + // otherwise every sub-agent pays the token cost of onboarding + // enrichment output that is irrelevant to their task. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_opt_out_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nName: Jane Doe\nRole: Data scientist", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a narrow specialist.", + SubagentRenderOptions::narrow(), // include_profile defaults to false + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### PROFILE.md"), + "PROFILE.md must NOT appear when include_profile=false, got:\n{rendered}" + ); + assert!( + !rendered.contains("Jane Doe"), + "PROFILE.md body must NOT be leaked when include_profile=false" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_injects_profile_md_when_identity_included() { + // When identity is on, PROFILE.md must still be injected alongside + // SOUL/IDENTITY — the split must not regress the non-welcome path. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_with_identity_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("SOUL.md"), "# Soul\nctx").unwrap(); + std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nctx").unwrap(); + std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nhello").unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions { + include_identity: true, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: false, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!(rendered.contains("## Project Context")); + assert!(rendered.contains("### SOUL.md")); + assert!(rendered.contains("### IDENTITY.md")); + assert!(rendered.contains("### PROFILE.md")); + assert!(rendered.contains("hello")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_silently_skips_missing_profile_md() { + // Pre-onboarding workspaces have no PROFILE.md. The renderer must + // not emit a noisy "[File not found: PROFILE.md]" placeholder or + // an orphan "### PROFILE.md" header — the subagent prompt stays + // focused on tools. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_missing_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the welcome agent.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### PROFILE.md"), + "empty/missing PROFILE.md should not emit a header, got:\n{rendered}" + ); + assert!( + !rendered.contains("[File not found: PROFILE.md]"), + "missing PROFILE.md should be silent, not a noisy placeholder" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn welcome_agent_definition_flags_still_load_profile_md() { + // End-to-end-ish check against the real welcome agent flags: the + // agent.toml sets omit_identity=true/omit_skills_catalog=true/ + // omit_safety_preamble=true/omit_profile=false. Mirror that exact + // combo and verify PROFILE.md still lands in the rendered prompt. + // If someone flips `omit_profile` back to its default (true), this + // test breaks. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_welcome_flags_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nTimezone: PST\nRole: Crypto trader", + ) + .unwrap(); + + // Match `src/openhuman/agent/agents/welcome/agent.toml` exactly. + let options = SubagentRenderOptions::from_definition_flags( + true, // omit_identity + true, // omit_safety_preamble + true, // omit_skills_catalog + false, // omit_profile — welcome opts IN to PROFILE.md + false, // omit_memory_md — welcome opts IN to MEMORY.md too + ); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "# Welcome Agent\n\nYou are the welcome agent.", + options, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + rendered.contains("### PROFILE.md"), + "welcome agent (omit_profile=false) must load PROFILE.md, got:\n{rendered}" + ); + assert!( + rendered.contains("Crypto trader"), + "PROFILE.md body must reach the welcome agent prompt" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn narrow_subagent_definition_flags_skip_profile_md() { + // Inverse of `welcome_agent_definition_flags_still_load_profile_md`: + // a narrow specialist (e.g. `code_executor`, `critic`) leaves + // `omit_profile` at its default `true`. PROFILE.md must NOT be + // injected even when present on disk — the narrow runner is + // task-focused and should not pay the token cost. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_narrow_flags_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nTimezone: PST\nRole: Crypto trader", + ) + .unwrap(); + + // Mirrors e.g. `critic/agent.toml` — all omit_* default-true. + let options = SubagentRenderOptions::from_definition_flags(true, true, true, true, true); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a narrow specialist.", + options, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### PROFILE.md"), + "narrow specialist (omit_profile=true) must NOT load PROFILE.md, got:\n{rendered}" + ); + assert!( + !rendered.contains("Crypto trader"), + "narrow specialist must not leak PROFILE.md body" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_injects_memory_md_when_enabled() { + // Opt-in agents with `omit_memory_md = false` must see MEMORY.md + // (archivist-curated long-term memory) in their rendered prompt. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_memory_on_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("MEMORY.md"), + "# Long-term memory\nUser prefers terse Rust answers.", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the welcome agent.", + SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: false, + include_memory_md: true, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + rendered.contains("### MEMORY.md"), + "MEMORY.md header must appear when include_memory_md=true, got:\n{rendered}" + ); + assert!( + rendered.contains("terse Rust answers"), + "MEMORY.md body must be injected when include_memory_md=true" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_skips_memory_md_when_disabled() { + // Narrow specialists with `omit_memory_md = true` (the default) + // must NOT see MEMORY.md even when it exists on disk. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_memory_off_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("MEMORY.md"), + "# Long-term memory\nUser prefers terse Rust answers.", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a narrow specialist.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### MEMORY.md"), + "MEMORY.md must NOT appear when include_memory_md=false, got:\n{rendered}" + ); + assert!( + !rendered.contains("terse Rust answers"), + "MEMORY.md body must not leak when include_memory_md=false" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn profile_md_and_memory_md_are_capped_at_user_file_max_chars() { + // Both PROFILE.md and MEMORY.md are user-specific files that can + // grow over time. Injection caps them at USER_FILE_MAX_CHARS + // (~1000 tokens each) so the system prompt footprint stays + // bounded. Test both files at once to pin the shared budget. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_user_cap_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + let big = "x".repeat(USER_FILE_MAX_CHARS + 500); + std::fs::write(workspace.join("PROFILE.md"), &big).unwrap(); + std::fs::write(workspace.join("MEMORY.md"), &big).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the orchestrator.", + SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: true, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!(rendered.contains("### PROFILE.md")); + assert!(rendered.contains("### MEMORY.md")); + // Each file gets its own truncation marker mentioning the cap. + let marker = format!("[... truncated at {USER_FILE_MAX_CHARS} chars"); + assert_eq!( + rendered.matches(marker.as_str()).count(), + 2, + "both PROFILE.md and MEMORY.md must emit the truncation marker at \ + USER_FILE_MAX_CHARS — found:\n{rendered}" + ); + // Sanity-check the cap is genuinely tighter than the bootstrap cap. + assert!(USER_FILE_MAX_CHARS < BOOTSTRAP_MAX_CHARS); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn rendered_subagent_system_prompt_is_byte_stable_across_repeat_calls() { + // KV-cache contract: two spawns of the same sub-agent definition + // against the same workspace must produce byte-identical system + // prompts. If PROFILE.md or MEMORY.md are re-read with a + // different-typed truncation path, or if either cap drifts, the + // bytes differ and the backend's automatic prefix cache busts. + // This test pins the invariant end-to-end. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_byte_stable_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nJane Doe").unwrap(); + std::fs::write(workspace.join("MEMORY.md"), "# Memory\nRecent: shipped v1").unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let opts = SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: true, + }; + + let first = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the orchestrator.", + opts, + ToolCallFormat::PFormat, + &[], + ); + let second = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the orchestrator.", + opts, + ToolCallFormat::PFormat, + &[], + ); + + assert_eq!( + first, second, + "repeat spawns must produce byte-identical prompts" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { + // Regression pin for the review finding: the runtime Tauri chat + // path spins welcome/trigger_* via `Agent::from_config_for_agent` + // → `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`, + // which deliberately drops `IdentitySection`. Before + // `UserFilesSection` existed, our PROFILE/MEMORY injection lived + // inside `IdentitySection::build` and got dropped along with it, + // so the first Tauri turn never saw the user's onboarding output + // even though the subagent_runner path and the debug dumper did. + // + // This test exercises the exact builder call-site the runtime + // uses for welcome (`omit_identity = true`, both user-file flags + // opted in via PromptContext) and pins that the rendered prompt + // contains both files. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_for_subagent_user_files_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nJane Doe — crypto trader in PST.", + ) + .unwrap(); + std::fs::write( + workspace.join("MEMORY.md"), + "# Long-term memory\nShipped v1 last sprint; prefers terse Rust.", + ) + .unwrap(); + + let tools: Vec> = vec![]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: true, + include_memory_md: true, + }; + + // Mirror the welcome agent runtime path: + // `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`. + let builder = SystemPromptBuilder::for_subagent( + "You are the welcome agent.".into(), + true, // omit_identity — drops SOUL/IDENTITY preamble + true, // omit_safety_preamble + true, // omit_skills_catalog + ); + let rendered = builder.build(&ctx).unwrap(); + + assert!( + !rendered.contains("## Project Context"), + "identity preamble must still be suppressed when omit_identity=true" + ); + assert!( + rendered.contains("### PROFILE.md") && rendered.contains("Jane Doe"), + "welcome runtime path must inject PROFILE.md despite omit_identity=true, got:\n{rendered}" + ); + assert!( + rendered.contains("### MEMORY.md") && rendered.contains("terse Rust"), + "welcome runtime path must inject MEMORY.md despite omit_identity=true, got:\n{rendered}" + ); + + // Mirror the narrow-specialist runtime path (code_executor, + // critic, …): both flags off → user files must stay out. + let ctx_narrow = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let narrow = builder.build(&ctx_narrow).unwrap(); + assert!( + !narrow.contains("### PROFILE.md") && !narrow.contains("### MEMORY.md"), + "narrow specialist runtime path must NOT leak user files, got:\n{narrow}" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn sync_workspace_file_updates_hash_and_inject_workspace_file_truncates() { + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_workspace_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + sync_workspace_file(&workspace, "SOUL.md"); + let hash_path = workspace.join(".SOUL.md.builtin-hash"); + assert!(workspace.join("SOUL.md").exists()); + assert!(hash_path.exists()); + let original_hash = std::fs::read_to_string(&hash_path).unwrap(); + + std::fs::write(workspace.join("SOUL.md"), "user override").unwrap(); + sync_workspace_file(&workspace, "SOUL.md"); + assert_eq!(std::fs::read_to_string(&hash_path).unwrap(), original_hash); + assert_eq!( + std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(), + "user override" + ); + + std::fs::write( + workspace.join("BIG.md"), + "x".repeat(BOOTSTRAP_MAX_CHARS + 50), + ) + .unwrap(); + let mut prompt = String::new(); + inject_workspace_file(&mut prompt, &workspace, "BIG.md"); + assert!(prompt.contains("### BIG.md")); + assert!(prompt.contains("[... truncated at")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { + let plain = PromptTool::new("shell", "run commands"); + assert_eq!(plain.name, "shell"); + assert!(plain.parameters_schema.is_none()); + + let with_schema = + PromptTool::with_schema("http_request", "fetch data", "{\"type\":\"object\"}".into()); + assert_eq!( + with_schema.parameters_schema.as_deref(), + Some("{\"type\":\"object\"}") + ); + + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "model", + agent_id: "", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData { + tree_root_summaries: vec![ + ("user".into(), "kept".into()), + ("empty".into(), " ".into()), + ], + ..Default::default() + }, + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = UserMemorySection.build(&ctx).unwrap(); + assert!(rendered.contains("### user")); + assert!(!rendered.contains("### empty")); + assert_eq!(default_workspace_file_content("missing"), ""); + } +} diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs new file mode 100644 index 000000000..f9c098ec8 --- /dev/null +++ b/src/openhuman/agent/prompts/types.rs @@ -0,0 +1,232 @@ +//! Data types shared across the prompt-plumbing pipeline. +//! +//! Everything in this file is pure data (structs, enums, traits, +//! constants). The rendering logic — section implementations, +//! `SystemPromptBuilder`, `render_subagent_system_prompt` — lives in +//! the sibling `mod.rs` so type edits don't pull in the whole 2 000-line +//! renderer. + +use crate::openhuman::skills::Skill; +use crate::openhuman::tools::Tool; +use anyhow::Result; +use std::path::Path; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +pub(crate) const BOOTSTRAP_MAX_CHARS: usize = 20_000; + +/// Tight per-file budget for user-specific, potentially growing files — +/// currently `PROFILE.md` (onboarding enrichment output) and `MEMORY.md` +/// (archivist-curated long-term memory). Caps the prompt footprint so +/// either file can reach at most ~1000 tokens (a few % of a typical +/// context window) regardless of how large the on-disk version has +/// grown. +pub(crate) const USER_FILE_MAX_CHARS: usize = 2_000; + +/// Per-namespace cap when injecting tree summarizer root summaries into +/// the prompt. ~8 000 chars ≈ 2 000 tokens — that's the floor the user +/// asked for ("at least 2000 tokens of user memory") for a single +/// namespace, and matches what the tree summarizer's `Day` level +/// already enforces upstream. +pub(crate) const USER_MEMORY_PER_NAMESPACE_MAX_CHARS: usize = 8_000; + +/// Hard ceiling across all namespaces, so a workspace with 30 namespaces +/// doesn't burn the entire context window. ~32 000 chars ≈ 8 000 tokens. +pub(crate) const USER_MEMORY_TOTAL_MAX_CHARS: usize = 32_000; + +// ───────────────────────────────────────────────────────────────────────────── +// Learned context (pre-fetched, not blocking) +// ───────────────────────────────────────────────────────────────────────────── + +/// Pre-fetched learned context data for prompt sections (avoids blocking the runtime). +#[derive(Debug, Clone, Default)] +pub struct LearnedContextData { + /// Recent observations from the learning subsystem. + pub observations: Vec, + /// Recognized patterns. + pub patterns: Vec, + /// Learned user profile entries. + pub user_profile: Vec, + /// Pre-fetched root-level summaries from the tree summarizer, one per + /// namespace that has a root node on disk. Each entry is + /// `(namespace, body)`. Empty when the tree summarizer hasn't run. + pub tree_root_summaries: Vec<(String, String)>, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connected integrations (Composio toolkits) +// ───────────────────────────────────────────────────────────────────────────── + +/// An external integration (e.g. a Composio OAuth-backed toolkit) +/// surfaced in the system prompt so the orchestrator knows which +/// services are available — both **already connected** and **available +/// to authorize**. +#[derive(Debug, Clone)] +pub struct ConnectedIntegration { + /// Toolkit slug, e.g. `"gmail"`, `"notion"`. + pub toolkit: String, + /// Human-readable one-line description of what this integration can do. + pub description: String, + /// Per-action catalogue (only populated when `connected == true`). + pub tools: Vec, + /// Whether the user has an active OAuth connection for this + /// toolkit. When `false`, the toolkit is in the backend allowlist + /// but no authorization has been completed yet — `tools` is empty + /// and the orchestrator must point the user at Settings instead of + /// attempting to delegate. + pub connected: bool, +} + +/// A single action available on a connected integration. +#[derive(Debug, Clone)] +pub struct ConnectedIntegrationTool { + /// Action slug, e.g. `"GMAIL_SEND_EMAIL"`. + pub name: String, + /// One-line description of the action. + pub description: String, + /// JSON schema for the action's parameters. `None` when the backend + /// didn't supply a schema. + pub parameters: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tool descriptor + call-format +// ───────────────────────────────────────────────────────────────────────────── + +/// A lightweight tool descriptor for prompt rendering. +/// +/// Shared shape so every call-site that builds a system prompt can feed +/// the same rendering pipeline — main agents (which own `Box`), +/// sub-agents, and channel runtimes (which only have `(name, +/// description)` tuples) all adapt to this. +#[derive(Debug, Clone)] +pub struct PromptTool<'a> { + pub name: &'a str, + pub description: &'a str, + pub parameters_schema: Option, +} + +impl<'a> PromptTool<'a> { + pub fn new(name: &'a str, description: &'a str) -> Self { + Self { + name, + description, + parameters_schema: None, + } + } + + pub fn with_schema(name: &'a str, description: &'a str, parameters_schema: String) -> Self { + Self { + name, + description, + parameters_schema: Some(parameters_schema), + } + } + + /// Adapt a `Box` slice into a `Vec>`. + pub fn from_tools(tools: &'a [Box]) -> Vec> { + tools + .iter() + .map(|t| PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + }) + .collect() + } +} + +/// How the tool catalogue should render each tool entry. Driven by the +/// dispatcher choice on the agent — JSON-schema rendering is the +/// historic format; P-Format is the new default text protocol. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ToolCallFormat { + /// `tool_name[arg1|arg2|...]` — compact, positional. Default. + #[default] + PFormat, + /// Legacy JSON-in-tag rendering with full schemas. + Json, + /// Provider supplies structured tool calls — catalogue is + /// informational. Renders in the same JSON-schema form as `Json`. + Native, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Prompt context (everything a section needs) +// ───────────────────────────────────────────────────────────────────────────── + +pub struct PromptContext<'a> { + pub workspace_dir: &'a Path, + pub model_name: &'a str, + /// Id of the agent this prompt is being built for. + pub agent_id: &'a str, + pub tools: &'a [PromptTool<'a>], + pub skills: &'a [Skill], + pub dispatcher_instructions: &'a str, + /// Pre-fetched learned context (empty when learning is disabled). + pub learned: LearnedContextData, + /// When non-empty, only tools in this set are rendered. Skills + /// section is also omitted when a filter is active. + pub visible_tool_names: &'a std::collections::HashSet, + pub tool_call_format: ToolCallFormat, + /// Active Composio integrations the user has connected. + pub connected_integrations: &'a [ConnectedIntegration], + /// When `true`, inject `PROFILE.md` (onboarding enrichment output). + pub include_profile: bool, + /// When `true`, inject `MEMORY.md` (archivist-curated long-term + /// memory). Capped at [`USER_FILE_MAX_CHARS`] and frozen per session. + pub include_memory_md: bool, +} + +// ───────────────────────────────────────────────────────────────────────────── +// PromptSection trait + rendered output +// ───────────────────────────────────────────────────────────────────────────── + +pub trait PromptSection: Send + Sync { + fn name(&self) -> &str; + fn build(&self, ctx: &PromptContext<'_>) -> Result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sub-agent render options (per-definition flags) +// ───────────────────────────────────────────────────────────────────────────── + +/// Per-definition rendering flags passed into the sub-agent prompt +/// renderer. Mirrors the `omit_*` fields on +/// [`crate::openhuman::agent::harness::definition::AgentDefinition`] +/// but inverted into positive-sense `include_*` form. +#[derive(Debug, Clone, Copy, Default)] +pub struct SubagentRenderOptions { + pub include_safety_preamble: bool, + pub include_identity: bool, + pub include_skills_catalog: bool, + pub include_profile: bool, + pub include_memory_md: bool, +} + +impl SubagentRenderOptions { + /// Build the narrow default (every section off). + pub fn narrow() -> Self { + Self::default() + } + + /// Construct from per-definition `omit_*` flags, inverting into the + /// positive-sense `include_*` shape. + pub fn from_definition_flags( + omit_identity: bool, + omit_safety_preamble: bool, + omit_skills_catalog: bool, + omit_profile: bool, + omit_memory_md: bool, + ) -> Self { + Self { + include_identity: !omit_identity, + include_safety_preamble: !omit_safety_preamble, + include_skills_catalog: !omit_skills_catalog, + include_profile: !omit_profile, + include_memory_md: !omit_memory_md, + } + } +} diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index 8f9f4feed..466f7eb81 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -157,7 +157,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result anyhow::Result Option { match &def.system_prompt { PromptSource::Inline(body) if !body.is_empty() => Some(body.clone()), + PromptSource::Dynamic(build) => { + use crate::openhuman::context::prompt::{ + ConnectedIntegration, LearnedContextData, PromptContext, PromptTool, ToolCallFormat, + }; + let empty_tools: Vec> = Vec::new(); + let empty_integrations: Vec = Vec::new(); + let empty_visible: std::collections::HashSet = std::collections::HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "", + agent_id: &def.id, + tools: &empty_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &empty_visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &empty_integrations, + include_profile: false, + include_memory_md: false, + }; + match build(&ctx) { + Ok(body) if !body.is_empty() => Some(body), + Ok(_) => None, + Err(e) => { + tracing::warn!( + agent_id = %def.id, + error = %e, + "[triage::evaluator] dynamic prompt builder failed" + ); + None + } + } + } _ => None, } } @@ -418,7 +458,7 @@ mod tests { .find(|b| b.id == TRIGGER_TRIAGE_AGENT_ID) .expect("trigger_triage built-in must be registered"); let mut def: AgentDefinition = toml::from_str(builtin.toml).expect("TOML must parse"); - def.system_prompt = PromptSource::Inline(builtin.prompt.to_string()); + def.system_prompt = PromptSource::Dynamic(builtin.prompt_fn); let body = extract_inline_prompt(&def).expect("body should be present"); assert!( body.to_lowercase().contains("trigger"), diff --git a/src/openhuman/channels/runtime/dispatch.rs b/src/openhuman/channels/runtime/dispatch.rs index 4ffd91b5a..26b7eba7a 100644 --- a/src/openhuman/channels/runtime/dispatch.rs +++ b/src/openhuman/channels/runtime/dispatch.rs @@ -531,7 +531,6 @@ mod scoping_tests { tools: scope, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 8, timeout_secs: None, @@ -546,7 +545,7 @@ mod scoping_tests { /// `ToolScope::Wildcard` must yield `None` — the prompt builder /// treats `None` as "no filter, every tool visible", which is the - /// correct behaviour for agents like `skills_agent` that want the + /// correct behaviour for agents like `integrations_agent` that want the /// full skill-category catalogue. Even when extras are present, a /// wildcard agent should not start filtering. #[test] diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index d5e3582ca..76e2227df 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -178,7 +178,7 @@ pub async fn start_channels(config: Config) -> Result<()> { )); } // Composio tool descriptions are intentionally excluded from the main - // agent prompt — those tools are only available to the skills_agent + // agent prompt — those tools are only available to the integrations_agent // subagent via category_filter = "skill". tool_descs.push(( "schedule", @@ -215,7 +215,7 @@ pub async fn start_channels(config: Config) -> Result<()> { None, ); // Filter out Skill-category tools (e.g. Composio, Apify) from the - // main agent prompt — those are only available to the skills_agent + // main agent prompt — those are only available to the integrations_agent // subagent via category_filter = "skill". let non_skill_tools: Vec<&Box> = tools_registry .iter() diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index 8e3f8046a..c93b23994 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -6,10 +6,10 @@ //! tool-calling path can validate arguments before they hit the wire. //! //! These are constructed **dynamically at spawn time** by the sub-agent -//! runner when `skills_agent` is spawned with a `toolkit` argument — +//! runner when `integrations_agent` is spawned with a `toolkit` argument — //! one tool per action in the chosen toolkit. The generic //! [`ComposioExecuteTool`](super::tools::ComposioExecuteTool) dispatcher -//! is deliberately excluded from `skills_agent`'s tool list in that +//! is deliberately excluded from `integrations_agent`'s tool list in that //! path so the model doesn't see two ways to call the same action. //! //! Lifetime: these tools live for the duration of a single sub-agent diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index dbcd59b38..ee3ccb960 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -49,7 +49,9 @@ pub mod types; pub use action_tool::ComposioActionTool; pub use bus::{register_composio_trigger_subscriber, ComposioTriggerSubscriber}; pub use client::{build_composio_client, ComposioClient}; -pub use ops::{fetch_connected_integrations, invalidate_connected_integrations_cache}; +pub use ops::{ + fetch_connected_integrations, fetch_toolkit_actions, invalidate_connected_integrations_cache, +}; pub use periodic::{record_sync_success, start_periodic_sync}; pub use providers::{ all_providers as all_composio_providers, get_provider as get_composio_provider, diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index caf9ab45d..b8d6756b8 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -512,7 +512,7 @@ async fn fetch_connected_integrations_uncached( // Pull the backend allowlist — every toolkit the orchestrator can // possibly suggest, regardless of whether the user has authorized // it yet. This is the universe of valid `toolkit` arguments to - // `spawn_subagent(skills_agent, …)`. + // `spawn_subagent(integrations_agent, …)`. // // On transient backend errors we return `None` instead of a // degraded `Some(Vec::new())` so `fetch_connected_integrations` @@ -571,7 +571,7 @@ async fn fetch_connected_integrations_uncached( // caching connected entries with empty `tools` vectors // would cause `subagent_runner::run_typed_mode` to // build zero dynamic Composio action tools for a - // toolkit-scoped `skills_agent` spawn, silently + // toolkit-scoped `integrations_agent` spawn, silently // leaving the sub-agent with nothing callable. return None; } @@ -639,6 +639,56 @@ async fn fetch_connected_integrations_uncached( Some(integrations) } +/// Just-in-time fetch of every available action for a single Composio +/// toolkit, returned in the [`ConnectedIntegrationTool`] shape the +/// `integrations_agent` spawn path expects. +/// +/// Unlike [`fetch_connected_integrations`] (which bulk-fetches every +/// connected toolkit's tools once per session and caches the result), +/// this helper is uncached and scoped to a single toolkit — meant to +/// be called at `integrations_agent` spawn time so the sub-agent's +/// prompt always reflects the toolkit's current action catalogue. +/// +/// The filter `starts_with("{TOOLKIT}_")` matches +/// `fetch_connected_integrations_uncached`'s own namespacing rule so +/// siblings like `github` / `git` don't leak into each other's buckets. +/// +/// Returns an empty vec when the backend has no actions for the +/// toolkit (valid steady state for a freshly-authorised integration +/// whose catalogue hasn't been published yet). Returns `Err` only for +/// transport / auth failures the caller should surface to the user. +pub async fn fetch_toolkit_actions( + client: &ComposioClient, + toolkit: &str, +) -> anyhow::Result> { + let toolkit_slug = toolkit.trim(); + if toolkit_slug.is_empty() { + anyhow::bail!("fetch_toolkit_actions: toolkit must not be empty"); + } + tracing::debug!(toolkit = %toolkit_slug, "[composio] fetch_toolkit_actions"); + let resp = client + .list_tools(Some(&[toolkit_slug.to_string()])) + .await + .map_err(|e| anyhow::anyhow!("list_tools failed for toolkit `{toolkit_slug}`: {e}"))?; + let action_prefix = format!("{}_", toolkit_slug.to_uppercase()); + let actions: Vec = resp + .tools + .into_iter() + .filter(|t| t.function.name.starts_with(&action_prefix)) + .map(|t| ConnectedIntegrationTool { + name: t.function.name, + description: t.function.description.unwrap_or_default(), + parameters: t.function.parameters, + }) + .collect(); + tracing::debug!( + toolkit = %toolkit_slug, + action_count = actions.len(), + "[composio] fetch_toolkit_actions: done" + ); + Ok(actions) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index 1feca1128..49d4350c9 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -93,8 +93,11 @@ impl Tool for ComposioListConnectionsTool { "composio_list_connections" } fn description(&self) -> &str { - "List the user's active Composio OAuth connections. Each entry has \ - {id, toolkit, status, createdAt}. Status is typically ACTIVE or CONNECTED." + "List the user's **currently-connected** Composio integrations. \ + Only entries with status ACTIVE / CONNECTED are returned; pending, \ + revoked, or failed connections are filtered out. Use this to detect \ + newly-authorised integrations mid-session. Each entry has \ + {id, toolkit, status, createdAt}." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": {}, "additionalProperties": false }) @@ -108,9 +111,23 @@ impl Tool for ComposioListConnectionsTool { async fn execute(&self, _args: Value) -> anyhow::Result { tracing::debug!("[composio] tool list_connections.execute"); match self.client.list_connections().await { - Ok(resp) => Ok(ToolResult::success( - serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), - )), + Ok(mut resp) => { + // Filter server-side-indistinguishable states here — + // callers should only ever see integrations the user + // can actually act on. Matches the same ACTIVE / + // CONNECTED allowlist used by + // `fetch_connected_integrations_uncached` so the tool + // output and the prompt's Delegation Guide agree on + // what counts as "connected". + resp.connections.retain(|c| { + let status = c.status.trim(); + status.eq_ignore_ascii_case("ACTIVE") + || status.eq_ignore_ascii_case("CONNECTED") + }); + Ok(ToolResult::success( + serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), + )) + } Err(e) => Ok(ToolResult::error(format!( "composio_list_connections failed: {e}" ))), diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 0ccc8830d..87505ae96 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -628,10 +628,17 @@ impl Config { } } - if let Ok(enabled) = std::env::var("OPENHUMAN_WEB_SEARCH_ENABLED") - .or_else(|_| std::env::var("WEB_SEARCH_ENABLED")) - { - self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true"); + // `OPENHUMAN_WEB_SEARCH_ENABLED` is intentionally ignored — + // web search is unconditionally registered in the tool set. + // Only the provider / API-key / budget knobs remain + // environment-configurable. Emit a one-shot deprecation warning + // if the caller still sets it so stale scripts surface clearly. + if std::env::var_os("OPENHUMAN_WEB_SEARCH_ENABLED").is_some() { + log::warn!( + "[config] OPENHUMAN_WEB_SEARCH_ENABLED is deprecated and ignored — \ + web search is always registered; use OPENHUMAN_WEB_SEARCH_PROVIDER / \ + API-key / budget env vars instead." + ); } if let Ok(provider) = std::env::var("OPENHUMAN_WEB_SEARCH_PROVIDER") @@ -1406,26 +1413,6 @@ mod tests { } } - #[test] - fn apply_env_overrides_web_search_enabled_parses_values() { - let _g = ENV_LOCK.lock().unwrap(); - clear_env(&["OPENHUMAN_WEB_SEARCH_ENABLED", "WEB_SEARCH_ENABLED"]); - let mut cfg = Config::default(); - unsafe { - std::env::set_var("OPENHUMAN_WEB_SEARCH_ENABLED", "true"); - } - cfg.apply_env_overrides(); - assert!(cfg.web_search.enabled); - unsafe { - std::env::set_var("OPENHUMAN_WEB_SEARCH_ENABLED", "0"); - } - cfg.apply_env_overrides(); - assert!(!cfg.web_search.enabled); - unsafe { - std::env::remove_var("OPENHUMAN_WEB_SEARCH_ENABLED"); - } - } - #[test] fn apply_env_overrides_web_search_provider_and_api_keys() { let _g = ENV_LOCK.lock().unwrap(); diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index ab3997c33..06bf58ecf 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -135,8 +135,6 @@ impl Default for BrowserConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] pub struct HttpRequestConfig { - #[serde(default)] - pub enabled: bool, #[serde(default)] pub allowed_domains: Vec, #[serde(default = "default_http_max_response_size")] @@ -155,8 +153,6 @@ fn default_http_timeout_secs() -> u64 { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct WebSearchConfig { - #[serde(default = "default_true")] - pub enabled: bool, /// Search provider. Valid values: `duckduckgo` (default, free), `brave` (requires `brave_api_key`), /// `parallel` (requires `parallel_api_key`). #[serde(default = "default_web_search_provider")] @@ -189,7 +185,6 @@ fn default_web_search_timeout_secs() -> u64 { impl Default for WebSearchConfig { fn default() -> Self { Self { - enabled: true, provider: default_web_search_provider(), brave_api_key: None, parallel_api_key: None, diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 603d33735..4a078ca6c 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -17,7 +17,7 @@ pub const MODEL_CODING_V1: &str = "coding-v1"; /// and synthesise the final answer from sub-agent outputs. Reasoning-tier /// models are tuned for that decision-heavy workload, so we pin the main /// agent to `reasoning-v1` by default. Sub-agents that actually execute tool -/// calls (e.g. `skills_agent`) explicitly ride on the `agentic` tier via +/// calls (e.g. `integrations_agent`) explicitly ride on the `agentic` tier via /// their `ModelSpec::Hint("agentic")` — see `builtin_definitions.rs`. pub const DEFAULT_MODEL: &str = MODEL_REASONING_V1; diff --git a/src/openhuman/context/debug_dump.rs b/src/openhuman/context/debug_dump.rs index 55b939217..934f13c5b 100644 --- a/src/openhuman/context/debug_dump.rs +++ b/src/openhuman/context/debug_dump.rs @@ -1,114 +1,70 @@ -//! Debug helper that renders the exact system prompt the context engine -//! would produce for a given agent. +//! Debug helper that renders the exact system prompt a live session +//! would see for a given agent. //! -//! This is the canonical entry point shared by: +//! Instead of re-implementing prompt assembly, this module routes +//! through [`Agent::from_config_for_agent`] — the same entry point the +//! Tauri web channel, CLI, and `welcome_proactive` all use — and then +//! calls [`Agent::build_system_prompt`] on the constructed session. The +//! output is byte-identical to what the LLM would receive on turn 1 of +//! that agent. //! -//! * the `openhuman agent dump-prompt` CLI (see [`crate::core::agent_cli`]) -//! * `scripts/debug-agent-prompts.sh` (loops over every built-in) -//! * any future JSON-RPC / tests that need to inspect the assembled prompt +//! Entry points: +//! * [`dump_agent_prompt`] — dump a single agent by id. +//! * [`dump_all_agent_prompts`] — dump every registered agent in one call. //! -//! The function assembles a **real** tool registry (via -//! [`crate::openhuman::tools::all_tools_with_runtime`]) and a **real** -//! [`AgentDefinitionRegistry`], then feeds them through the exact same -//! prompt builders used at runtime — so what you see here is byte-identical -//! to what the LLM would see at spawn time. -//! -//! # Targets -//! -//! * `"main"` (or any non-matching id when `--force-main` is set) → -//! the orchestrator / main-agent prompt assembled via -//! [`super::prompt::SystemPromptBuilder::with_defaults`]. This includes -//! the workspace identity files, tools visible to the main agent, and -//! the skills catalogue. -//! -//! * Any built-in or custom sub-agent id (e.g. `"skills_agent"`, -//! `"orchestrator"`, `"code_executor"`) → -//! [`super::prompt::render_subagent_system_prompt`] with the narrow -//! tool filter and per-definition `omit_*` flags the real runner applies. -//! -//! # Composio coverage -//! -//! When `Config::composio.enabled` is true, the Composio meta-tools -//! (`composio_list_toolkits`, `composio_list_connections`, -//! `composio_authorize`, `composio_list_tools`, `composio_execute`) are -//! registered into the tool list with `ToolCategory::Skill`. Agents whose -//! definition sets `category_filter = Skill` (notably `skills_agent`) will -//! render those tools in their system prompt, so this dump is the fastest -//! way to verify Composio is reaching the skills agent end-to-end. +//! `integrations_agent` is special: it is platform-parameterised and +//! has no meaningful prompt without a `toolkit` argument. Callers must +//! supply one (e.g. `"gmail"`, `"notion"`) via +//! [`DumpPromptOptions::toolkit`]; `dump_all_agent_prompts` expands +//! `integrations_agent` into one dump per currently-connected Composio +//! toolkit. use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::sync::Arc; use anyhow::{anyhow, Context, Result}; -use crate::openhuman::agent::dispatcher::{PFormatToolDispatcher, ToolDispatcher}; use crate::openhuman::agent::harness::definition::{ - AgentDefinition, AgentDefinitionRegistry, PromptSource, ToolScope, -}; -use crate::openhuman::agent::host_runtime::{self, RuntimeAdapter}; -use crate::openhuman::agent::pformat; -use crate::openhuman::composio::client::ComposioClient; -use crate::openhuman::composio::tools::{ - ComposioAuthorizeTool, ComposioExecuteTool, ComposioListConnectionsTool, - ComposioListToolkitsTool, ComposioListToolsTool, + AgentDefinition, AgentDefinitionRegistry, PromptSource, }; +use crate::openhuman::agent::harness::session::Agent; +use crate::openhuman::composio::ComposioActionTool; use crate::openhuman::config::Config; use crate::openhuman::context::prompt::{ - extract_cache_boundary, render_subagent_system_prompt, ConnectedIntegration, - LearnedContextData, PromptContext, PromptTool, SubagentRenderOptions, SystemPromptBuilder, - ToolCallFormat, USER_MEMORY_PER_NAMESPACE_MAX_CHARS, USER_MEMORY_TOTAL_MAX_CHARS, + LearnedContextData, PromptContext, PromptTool, ToolCallFormat, }; -use crate::openhuman::integrations::IntegrationClient; -use crate::openhuman::memory::{self, Memory}; -use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::{self, Tool, ToolCategory}; +use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec}; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- +/// Id reserved for the Composio-backed integrations specialist. +const INTEGRATIONS_AGENT_ID: &str = "integrations_agent"; + /// Inputs for [`dump_agent_prompt`]. #[derive(Debug, Clone)] pub struct DumpPromptOptions { - /// Target agent id — either `"main"` for the main/orchestrator agent, - /// or any id registered in [`AgentDefinitionRegistry`]. + /// Target agent id (any id registered in [`AgentDefinitionRegistry`]). pub agent_id: String, - /// Optional per-spawn skill filter override (e.g. `Some("notion".into())`). - /// Ignored for `"main"`. - pub skill_filter: Option, - /// Optional override for the workspace directory. When `None`, the - /// value from the loaded [`Config`] is used. + /// Composio toolkit to bind this dump to (e.g. `"gmail"`, + /// `"notion"`). **Required** when `agent_id == "integrations_agent"` + /// — the integrations specialist has no meaningful prompt without a + /// toolkit. Must match a currently-connected integration. + pub toolkit: Option, + /// Optional override for the workspace directory. pub workspace_dir_override: Option, - /// Optional override for the resolved model name. When `None`, the - /// value from the loaded [`Config`] is used. Only affects the - /// `## Runtime` line of the rendered prompt. + /// Optional override for the resolved model name. pub model_override: Option, - /// When `true`, always inject the five Composio meta-tool stubs - /// (`composio_list_toolkits`, `composio_list_connections`, - /// `composio_authorize`, `composio_list_tools`, `composio_execute`) - /// into the tool registry before rendering, even if the user is not - /// signed in or `config.composio` is disabled. - /// - /// This is strictly a **dump-time** debug aid: the stubs are real - /// `Tool` impls so their names, descriptions, and parameter schemas - /// render byte-identically to what a signed-in user would see — but - /// calling `execute()` on them would hit a dummy localhost endpoint, - /// so they're safe for prompt inspection only, not for running - /// agents against. Use this to answer "what would `skills_agent` - /// see if Composio were reachable right now?" on a fresh dev - /// machine without wiring up OAuth. - pub stub_composio: bool, } impl DumpPromptOptions { pub fn new(agent_id: impl Into) -> Self { Self { agent_id: agent_id.into(), - skill_filter: None, + toolkit: None, workspace_dir_override: None, model_override: None, - stub_composio: false, } } } @@ -118,1098 +74,438 @@ impl DumpPromptOptions { pub struct DumpedPrompt { /// Echoed from [`DumpPromptOptions::agent_id`]. pub agent_id: String, - /// `"main"` or `"subagent"` — which rendering path produced `text`. + /// Composio toolkit this dump was scoped to (set for + /// `integrations_agent`, `None` for everything else). Lets the CLI + /// / harness differentiate per-toolkit dumps on disk. + pub toolkit: Option, + /// Always `"session"` — dumps come from the live session path. pub mode: &'static str, - /// Resolved model name used in the `## Runtime` section. + /// Resolved model name. pub model: String, /// Workspace directory used for identity file injection. pub workspace_dir: PathBuf, - /// The final rendered system prompt (cache boundary marker already - /// stripped — `cache_boundary` below holds the byte offset instead). + /// The final rendered system prompt — frozen bytes that would be + /// sent verbatim on every turn of a live session. pub text: String, - /// Byte offset of the cache boundary, if the builder inserted one. - /// This is the same value that gets threaded into - /// `ChatRequest::system_prompt_cache_boundary` at runtime. - pub cache_boundary: Option, - /// Every tool that made it into the rendered prompt, in order. - /// Useful for quick assertions in scripts (e.g. "does the - /// skills_agent dump contain `composio_execute`?"). + /// Tool names that made it into the rendered prompt, in order. pub tool_names: Vec, - /// Number of `ToolCategory::Skill` tools included in the dump. + /// Number of `ToolCategory::Skill` tools in the dump. pub skill_tool_count: usize, } -/// Render and return the system prompt for the requested agent. -/// -/// Builds a real tool registry from the loaded [`Config`], loads the -/// full agent-definition registry (built-ins + `agents/*.toml` overrides), -/// resolves the target agent, and runs it through the matching prompt -/// renderer. See the module docs for the full behaviour contract. +/// Render and return the system prompt for a single agent via the +/// real [`Agent::from_config_for_agent`] construction path. pub async fn dump_agent_prompt(options: DumpPromptOptions) -> Result { - tracing::debug!( - agent_id = %options.agent_id, - skill_filter = ?options.skill_filter, - "[debug_dump] rendering prompt" - ); - - // ── Load config + workspace path ────────────────────────────────── - let mut config = Config::load_or_init() - .await - .context("loading Config for prompt dump")?; - config.apply_env_overrides(); - - if let Some(ref override_dir) = options.workspace_dir_override { - config.workspace_dir = override_dir.clone(); - } - let workspace_dir = config.workspace_dir.clone(); - std::fs::create_dir_all(&workspace_dir).ok(); - - let model_name = options.model_override.clone().unwrap_or_else(|| { - config - .default_model - .clone() - .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.to_string()) - }); - - tracing::debug!( - workspace_dir = %workspace_dir.display(), - model = %model_name, - composio_enabled = config.composio.enabled, - "[debug_dump] resolved environment" - ); - - // ── Build a real tool registry ───────────────────────────────────── - let security = Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &workspace_dir, - )); - let runtime: Arc = Arc::from( - host_runtime::create_runtime(&config.runtime) - .context("creating host runtime for prompt dump")?, - ); - let mem: Arc = Arc::from( - memory::create_memory(&config.memory, &workspace_dir, config.api_key.as_deref()) - .context("creating memory backend for prompt dump")?, - ); - - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() - } else { - None - }; - let composio_entity_id = if config.composio.enabled { - Some(config.composio.entity_id.as_str()) - } else { - None - }; - - let mut tools_vec: Vec> = tools::all_tools_with_runtime( - Arc::new(config.clone()), - &security, - runtime, - mem, - composio_key, - composio_entity_id, - &config.browser, - &config.http_request, - &workspace_dir, - &config.agents, - config.api_key.as_deref(), - &config, - ); - - // When requested, inject the Composio meta-tool stubs if (and only - // if) the real registry didn't already bring them in. This lets a - // dev machine without OAuth credentials dump the *intended* prompt - // for `skills_agent` — the names, descriptions and schemas are the - // same bytes a signed-in user would see. See - // [`DumpPromptOptions::stub_composio`] for the safety contract. - if options.stub_composio && !tools_vec.iter().any(|t| t.name().starts_with("composio_")) { - tracing::debug!("[debug_dump] injecting composio meta-tool stubs"); - tools_vec.extend(build_composio_stub_tools()); - } - - tracing::debug!( - tool_count = tools_vec.len(), - "[debug_dump] assembled tool registry" - ); - - // ── Fetch connected integrations ──────────────────────────────────── - let connected_integrations = fetch_connected_integrations_for_dump(&config).await; - - // Load the agent definition registry once. Both the main-agent and - // sub-agent paths consult it now: after #525/#526 the "main" dump - // routes through the same definition-driven filter as every other - // agent so what `dump-prompt main` shows matches what the runtime - // dispatch path actually feeds the LLM. - let registry = AgentDefinitionRegistry::load(&workspace_dir) - .context("loading agent definition registry for prompt dump")?; - - // ── Main agent path ──────────────────────────────────────────────── - if options.agent_id == "main" || options.agent_id == "orchestrator_main" { - let orchestrator = registry.get("orchestrator").cloned().ok_or_else(|| { - anyhow!( - "orchestrator definition not in registry — cannot render main dump. \ - Known agents: [{}]", - registry - .list() - .iter() - .map(|d| d.id.as_str()) - .collect::>() - .join(", ") - ) - })?; - return render_main_agent_dump( - &workspace_dir, - &model_name, - &tools_vec, - &connected_integrations, - ®istry, - &orchestrator, - ); - } - - // ── Sub-agent path ──────────────────────────────────────────────── - let definition = registry - .get(&options.agent_id) - .cloned() - .ok_or_else(|| { - let known: Vec<&str> = registry.list().iter().map(|d| d.id.as_str()).collect(); - anyhow!( - "unknown agent id `{}`. Known agents: [{}] — or pass `main` for the orchestrator prompt.", - options.agent_id, - known.join(", ") - ) - })?; - - render_subagent_dump( - &definition, - &workspace_dir, - &model_name, - &tools_vec, - options.skill_filter.as_deref(), - &connected_integrations, + let config = load_dump_config( + options.workspace_dir_override.clone(), + options.model_override.clone(), ) + .await?; + + // Ensure the registry is populated — `from_config_for_agent` + // errors for any non-orchestrator id when the global registry + // hasn't been initialised. + AgentDefinitionRegistry::init_global(&config.workspace_dir) + .context("initialising AgentDefinitionRegistry for prompt dump")?; + + if options.agent_id == INTEGRATIONS_AGENT_ID { + let toolkit = options.toolkit.as_deref().ok_or_else(|| { + anyhow!( + "integrations_agent requires a `toolkit` argument — e.g. \ + `gmail`, `notion`. See `composio list_connection` for \ + the currently-connected toolkits." + ) + })?; + render_integrations_agent(&config, toolkit).await + } else { + render_via_session(&config, &options.agent_id).await + } +} + +/// Dump every registered agent's system prompt in one shot. +/// +/// The synthetic `fork` archetype is skipped (byte-stable replay, no +/// standalone prompt). `integrations_agent` is expanded into one dump +/// per currently-connected Composio toolkit — if the user has gmail + +/// notion connected, `dump_all_agent_prompts` returns an entry for +/// `integrations_agent@gmail` and another for `integrations_agent@notion`. +/// When no toolkit is connected, `integrations_agent` is omitted +/// entirely (there's nothing meaningful to render). +/// +/// Order follows [`AgentDefinitionRegistry::list`], with +/// `integrations_agent` replaced in place by its per-toolkit expansion. +pub async fn dump_all_agent_prompts( + workspace_dir_override: Option, + model_override: Option, +) -> Result> { + let config = load_dump_config(workspace_dir_override, model_override).await?; + + AgentDefinitionRegistry::init_global(&config.workspace_dir) + .context("initialising AgentDefinitionRegistry for prompt dump")?; + + let registry = AgentDefinitionRegistry::global() + .ok_or_else(|| anyhow!("AgentDefinitionRegistry missing after init"))?; + + let ids: Vec = registry + .list() + .iter() + .filter(|d| d.id != "fork") + .map(|d| d.id.clone()) + .collect(); + + let mut results = Vec::with_capacity(ids.len()); + for id in ids { + if id == INTEGRATIONS_AGENT_ID { + let toolkits = connected_toolkits_for(&config).await?; + if toolkits.is_empty() { + log::info!("[debug_dump] skipping integrations_agent — no connected toolkits"); + continue; + } + for toolkit in toolkits { + let dumped = render_integrations_agent(&config, &toolkit) + .await + .with_context(|| { + format!("rendering integrations_agent prompt for toolkit `{toolkit}`") + })?; + results.push(dumped); + } + continue; + } + + let dumped = render_via_session(&config, &id) + .await + .with_context(|| format!("rendering prompt for agent `{id}`"))?; + results.push(dumped); + } + Ok(results) } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- -/// Build the five Composio meta-tools with a dummy client wired to -/// `http://127.0.0.1:0`. Rendering only calls `name()`, `description()`, -/// and `parameters_schema()` — all of which are static, pure accessors -/// — so the dummy backend URL is never contacted. **Do not** actually -/// execute these tools: calling `.execute()` on a stub would try to -/// POST against the dummy URL. -/// -/// This is only used by [`dump_agent_prompt`] when -/// [`DumpPromptOptions::stub_composio`] is `true`, to let prompt -/// engineers inspect the skills_agent prompt on an unauthed machine. -fn build_composio_stub_tools() -> Vec> { - let inner = Arc::new(IntegrationClient::new( - "http://127.0.0.1:0".to_string(), - "debug-dump-stub-token".to_string(), - )); - let client = ComposioClient::new(inner); - - vec![ - Box::new(ComposioListToolkitsTool::new(client.clone())), - Box::new(ComposioListConnectionsTool::new(client.clone())), - Box::new(ComposioAuthorizeTool::new(client.clone())), - Box::new(ComposioListToolsTool::new(client.clone())), - Box::new(ComposioExecuteTool::new(client)), - ] +async fn load_dump_config( + workspace_dir_override: Option, + model_override: Option, +) -> Result { + let mut config = Config::load_or_init() + .await + .context("loading Config for prompt dump")?; + config.apply_env_overrides(); + if let Some(override_dir) = workspace_dir_override { + config.workspace_dir = override_dir; + } + std::fs::create_dir_all(&config.workspace_dir).ok(); + if let Some(model) = model_override { + config.default_model = Some(model); + } + Ok(config) } -/// Fetch connected integrations for the prompt dump. -/// -/// Delegates to [`crate::openhuman::composio::fetch_connected_integrations`]. -/// The dump script often overrides `OPENHUMAN_WORKSPACE` to a throwaway -/// temp dir which causes config resolution to miss the real user's auth -/// token. We try the caller-supplied config first, then fall back to -/// [`Config::load_from_default_paths`] which bypasses the env var. -async fn fetch_connected_integrations_for_dump(config: &Config) -> Vec { - use crate::openhuman::composio::fetch_connected_integrations; +/// Build a real [`Agent`] via `from_config_for_agent`, populate live +/// connected integrations, and render the turn-1 system prompt. +async fn render_via_session(config: &Config, agent_id: &str) -> Result { + let mut agent = Agent::from_config_for_agent(config, agent_id) + .with_context(|| format!("building session agent for `{agent_id}`"))?; - let result = fetch_connected_integrations(config).await; - if !result.is_empty() { - return result; - } + // Match turn-1 behaviour: fetch the user's active Composio + // connections so the rendered prompt mirrors what the LLM actually + // sees. Best-effort — failures degrade to an empty integration + // list, same as the live runtime. + agent.fetch_connected_integrations().await; - // Fallback: load config from the default user paths (bypasses - // OPENHUMAN_WORKSPACE) so the real auth token is found. - match Config::load_from_default_paths().await { - Ok(c) => fetch_connected_integrations(&c).await, - Err(e) => { - tracing::debug!( - error = %e, - "[debug_dump] fallback config load failed, skipping integrations" - ); - Vec::new() - } - } -} + let text = agent + .build_system_prompt(LearnedContextData::default()) + .with_context(|| format!("rendering system prompt for `{agent_id}`"))?; -fn render_main_agent_dump( - workspace_dir: &Path, - model_name: &str, - tools_vec: &[Box], - connected_integrations: &[ConnectedIntegration], - registry: &AgentDefinitionRegistry, - orchestrator_def: &AgentDefinition, -) -> Result { - // Synthesise per-turn delegation tools the same way `dispatch:: - // resolve_target_agent` does at runtime, so the dump reflects the - // exact tool surface the orchestrator's LLM sees in production: - // the agent's `[tools] named` list ∪ the names of the synthesised - // delegate_* tools (research, plan, run_code, … plus - // delegate_ per connected Composio integration). - let extras = crate::openhuman::tools::orchestrator_tools::collect_orchestrator_tools( - orchestrator_def, - registry, - connected_integrations, - ); + let tools = agent.tools(); + let tool_names: Vec = tools.iter().map(|t| t.name().to_string()).collect(); + let skill_tool_count = tools + .iter() + .filter(|t| t.category() == ToolCategory::Skill) + .count(); - // Build the prompt tool list from the global registry plus the - // synthesised extras. The extras Vec must outlive `prompt_tools` - // because PromptTool borrows tool name + description fields, and - // both sources need to live until `build_with_cache_metadata` - // finishes consuming the PromptContext. - let mut prompt_tools = PromptTool::from_tools(tools_vec); - let extras_prompts = PromptTool::from_tools(&extras); - prompt_tools.extend(extras_prompts); + Ok(DumpedPrompt { + agent_id: agent_id.to_string(), + toolkit: None, + mode: "session", + model: agent.model_name().to_string(), + workspace_dir: agent.workspace_dir().to_path_buf(), + text, + tool_names, + skill_tool_count, + }) +} - // Build the visibility whitelist from the orchestrator definition's - // `[tools] named` list unioned with every synthesised extra. When - // the orchestrator uses `ToolScope::Wildcard` (legacy / custom - // workspace overrides), we fall back to an empty filter — which the - // prompt builder treats as "no filter, every tool visible" — so the - // dump preserves the legacy unscoped behaviour for those agents. - let visible_filter: HashSet = match &orchestrator_def.tools { - ToolScope::Named(names) => { - let mut set: HashSet = names.iter().cloned().collect(); - for tool in &extras { - set.insert(tool.name().to_string()); - } - set +/// Render the integrations_agent prompt bound to a single Composio +/// toolkit. Mirrors the subagent_runner's per-toolkit path: strips +/// Skill-category parent tools, injects one [`ComposioActionTool`] per +/// action in the toolkit, and narrows the `connected_integrations` +/// slice to only the requested toolkit before calling the agent's +/// dynamic prompt builder. +async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result { + let mut agent = Agent::from_config_for_agent(config, INTEGRATIONS_AGENT_ID) + .with_context(|| format!("building integrations_agent session for `{toolkit}`"))?; + agent.fetch_connected_integrations().await; + + let mut integration = agent + .connected_integrations() + .iter() + .find(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(toolkit)) + .cloned() + .ok_or_else(|| { + let connected: Vec = agent + .connected_integrations() + .iter() + .filter(|ci| ci.connected) + .map(|ci| ci.toolkit.clone()) + .collect(); + anyhow!( + "toolkit `{toolkit}` is not connected. Connected toolkits: [{}]", + connected.join(", ") + ) + })?; + + let composio_client = agent + .composio_client() + .cloned() + .ok_or_else(|| anyhow!("composio client unavailable — is the user signed in?"))?; + + // Refresh the action catalogue for this toolkit at prompt-generation + // time so the dump reflects the **current** backend state rather + // than the session-start bulk fetch's snapshot (which can return an + // empty list for some toolkits even when the per-toolkit endpoint + // returns actions). Mirrors subagent_runner's typed-mode fallback: + // an empty fresh list or a network error keeps the cached catalogue + // rather than blanking it. + match crate::openhuman::composio::fetch_toolkit_actions(&composio_client, &integration.toolkit) + .await + { + Ok(actions) if !actions.is_empty() => { + integration.tools = actions; + } + Ok(_) => { + log::debug!( + "[debug_dump] fresh list_tools for `{}` returned empty; keeping cached catalogue ({} actions)", + integration.toolkit, + integration.tools.len() + ); + } + Err(e) => { + log::warn!( + "[debug_dump] fresh list_tools for `{}` failed ({e}); keeping cached catalogue ({} actions)", + integration.toolkit, + integration.tools.len() + ); + } + } + + // Build the tool list that subagent_runner would produce for a + // real spawn. Tool visibility honours the TOML scope on the + // `integrations_agent` definition — `named = [...]` narrows, and + // `wildcard = {}` means "every parent tool". The dynamic + // ComposioActionTools for the bound toolkit are added after. + let definition_snapshot = AgentDefinitionRegistry::global() + .and_then(|reg| reg.get(INTEGRATIONS_AGENT_ID).cloned()) + .ok_or_else(|| anyhow!("integrations_agent definition missing from registry"))?; + let base_tools: Vec> = match &definition_snapshot.tools { + crate::openhuman::agent::harness::definition::ToolScope::Named(names) => { + let allow: HashSet<&str> = names.iter().map(|s| s.as_str()).collect(); + agent + .tools() + .iter() + .filter(|t| allow.contains(t.name())) + .map(|t| clone_tool_as_prompt_proxy(t.as_ref())) + .collect() } - ToolScope::Wildcard => HashSet::new(), + crate::openhuman::agent::harness::definition::ToolScope::Wildcard => agent + .tools() + .iter() + .map(|t| clone_tool_as_prompt_proxy(t.as_ref())) + .collect(), }; + let action_tools: Vec> = integration + .tools + .iter() + .map(|action| -> Box { + Box::new(ComposioActionTool::new( + composio_client.clone(), + action.name.clone(), + action.description.clone(), + action.parameters.clone(), + )) + }) + .collect(); + let mut rendered_tools: Vec> = base_tools; + rendered_tools.extend(action_tools); - // Construct a real PFormatToolDispatcher so the dump includes the - // exact "Tool Use Protocol" preamble the runtime would inject. - // Without this the catalogue still renders with p-format - // signatures (because `tool_call_format = PFormat`), but the - // model doesn't see the protocol description, which is the - // single most important piece of context for *teaching* the - // model how to emit calls correctly. - let pformat_registry = pformat::build_registry(tools_vec); - let pformat_dispatcher = PFormatToolDispatcher::new(pformat_registry); - let dispatcher_instructions = pformat_dispatcher.prompt_instructions(tools_vec); + let prompt_tools: Vec> = rendered_tools + .iter() + .map(|t| PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + }) + .collect(); - // Hydrate the same user-memory blob the runtime would inject on the - // first turn. The dump intentionally bypasses `Agent::fetch_learned_context` - // (which needs a live `Memory` backend and the `learning_enabled` - // flag), but the tree-summarizer side is pure filesystem reads, so - // we can mirror the runtime path byte-for-byte. This is what makes - // `openhuman agent dump-prompt --agent main` show the user memory - // section instead of an empty placeholder when summaries exist on - // disk. - let tree_root_summaries = - crate::openhuman::tree_summarizer::store::collect_root_summaries_with_caps( - workspace_dir, - USER_MEMORY_PER_NAMESPACE_MAX_CHARS, - USER_MEMORY_TOTAL_MAX_CHARS, - ); - tracing::debug!( - namespace_count = tree_root_summaries.len(), - "[debug_dump] hydrated user memory from tree summarizer" - ); - let learned = LearnedContextData { - tree_root_summaries, - ..Default::default() + // Narrow the connected_integrations slice to just the bound + // toolkit so the prompt's Connected Integrations / tool catalogue + // doesn't leak peer toolkits into this sub-agent's context. + let narrow_integrations = vec![integration.clone()]; + + let registry = AgentDefinitionRegistry::global() + .ok_or_else(|| anyhow!("AgentDefinitionRegistry missing after init"))?; + let definition: AgentDefinition = registry + .get(INTEGRATIONS_AGENT_ID) + .cloned() + .ok_or_else(|| anyhow!("integrations_agent definition not in registry"))?; + let build = match &definition.system_prompt { + PromptSource::Dynamic(f) => *f, + _ => { + return Err(anyhow!( + "integrations_agent must use PromptSource::Dynamic; got {:?}", + match &definition.system_prompt { + PromptSource::Inline(_) => "Inline", + PromptSource::File { .. } => "File", + PromptSource::Dynamic(_) => "Dynamic", + } + )); + } }; - // NOTE: the dump runs outside the agent lifecycle — there is no - // live Agent, ToolDispatcher, Memory backend, or SkillEngine. We - // reconstruct the best filesystem-based approximation: - // - // skills: &[] — the dump doesn't boot the QuickJS - // runtime, so installed skills are absent. The - // main agent at runtime would populate this. - // tool_call_format: PFormat — matches the runtime global default. - // If the user sets `agent.tool_dispatcher = "xml"` - // in config, the dump won't reflect that. - // learned: tree_root_summaries only — the learning - // subsystem's observations/patterns/user_profile - // entries require a live Memory backend. - // - // For a byte-exact match you'd need to boot a full Agent and call - // `build_system_prompt`. The dump is intentionally cheaper. + let empty_visible: HashSet = HashSet::new(); + let model_name = definition.model.resolve(agent.model_name()).to_string(); let ctx = PromptContext { - workspace_dir, - model_name, - agent_id: "orchestrator", + workspace_dir: agent.workspace_dir(), + model_name: &model_name, + agent_id: INTEGRATIONS_AGENT_ID, tools: &prompt_tools, - skills: &[], - dispatcher_instructions: &dispatcher_instructions, - learned, - visible_tool_names: &visible_filter, + skills: agent.skills(), + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &empty_visible, tool_call_format: ToolCallFormat::PFormat, - connected_integrations, - include_profile: !orchestrator_def.omit_profile, - include_memory_md: !orchestrator_def.omit_memory_md, + connected_integrations: &narrow_integrations, + include_profile: !definition.omit_profile, + include_memory_md: !definition.omit_memory_md, }; - let rendered = SystemPromptBuilder::with_defaults() - .build_with_cache_metadata(&ctx) - .context("building main-agent prompt")?; + let mut text = build(&ctx) + .with_context(|| format!("building integrations_agent prompt for toolkit `{toolkit}`"))?; - // Report the tool names that survived the visibility filter so - // tests and the CLI dump output reflect what the LLM actually - // sees, not the raw global registry. When the filter is empty - // (Wildcard scope), every tool from registry + extras is reported. - let visible_predicate = |name: &str| -> bool { - if visible_filter.is_empty() { - true - } else { - visible_filter.contains(name) - } - }; - let tool_names: Vec = tools_vec + // Mirror the runner's text-mode mutation: when integrations_agent + // has any tools the runner appends `build_text_mode_tool_instructions` + // to the system message (see `subagent_runner::run_fork_mode` / + // `run_typed_mode`, `force_text_mode` branch). Reproduce it here so + // the dump matches what the LLM actually receives on turn 1. + if !rendered_tools.is_empty() { + let tool_specs: Vec = rendered_tools + .iter() + .map(|t| ToolSpec { + name: t.name().to_string(), + description: t.description().to_string(), + parameters: t.parameters_schema().clone(), + }) + .collect(); + text.push_str("\n\n"); + text.push_str( + &crate::openhuman::agent::harness::subagent_runner::build_text_mode_tool_instructions( + &tool_specs, + ), + ); + } + + let tool_names: Vec = rendered_tools .iter() - .chain(extras.iter()) - .filter(|t| visible_predicate(t.name())) .map(|t| t.name().to_string()) .collect(); - let skill_tool_count = tools_vec + let skill_tool_count = rendered_tools .iter() - .chain(extras.iter()) - .filter(|t| visible_predicate(t.name()) && t.category() == ToolCategory::Skill) + .filter(|t| t.category() == ToolCategory::Skill) .count(); Ok(DumpedPrompt { - agent_id: "main".into(), - mode: "main", - model: model_name.to_string(), - workspace_dir: workspace_dir.to_path_buf(), - text: rendered.text, - cache_boundary: rendered.cache_boundary, + agent_id: INTEGRATIONS_AGENT_ID.to_string(), + toolkit: Some(integration.toolkit.clone()), + mode: "session", + model: model_name, + workspace_dir: agent.workspace_dir().to_path_buf(), + text, tool_names, skill_tool_count, }) } -fn render_subagent_dump( - definition: &AgentDefinition, - workspace_dir: &Path, - model_name: &str, - tools_vec: &[Box], - skill_filter_override: Option<&str>, - connected_integrations: &[ConnectedIntegration], -) -> Result { - // Resolve the archetype prompt body. Inline sources short-circuit - // immediately; file sources walk the workspace override directory - // first, mirroring `subagent_runner::load_prompt_source`. - let archetype_body = match &definition.system_prompt { - PromptSource::Inline(body) => body.clone(), - PromptSource::File { path } => { - let workspace_path = workspace_dir.join("agent").join("prompts").join(path); - if workspace_path.is_file() { - std::fs::read_to_string(&workspace_path).with_context(|| { - format!("reading archetype prompt at {}", workspace_path.display()) - })? - } else { - let workspace_root_path = workspace_dir.join(path); - if workspace_root_path.is_file() { - std::fs::read_to_string(&workspace_root_path).with_context(|| { - format!( - "reading archetype prompt at {}", - workspace_root_path.display() - ) - })? - } else { - // Fall back to the repository-bundled location so the dump - // works on throwaway workspaces (e.g. the script's mktemp - // directory) that haven't had identity files synced yet. - let bundled_path = Path::new("src/openhuman/agent/prompts").join(path); - if bundled_path.is_file() { - std::fs::read_to_string(&bundled_path).with_context(|| { - format!( - "reading bundled archetype prompt at {}", - bundled_path.display() - ) - })? - } else { - tracing::warn!( - path = %path, - "[debug_dump] archetype prompt file not found, using empty body" - ); - String::new() - } - } - } - } - }; - - let model = definition.model.resolve(model_name); - let effective_skill_filter = skill_filter_override.or(definition.skill_filter.as_deref()); - - // Apply exactly the same filter the real runner uses so the dump - // reflects what the sub-agent actually sees. - let allowed_indices = filter_tool_indices_for_dump( - tools_vec, - &definition.tools, - &definition.disallowed_tools, - effective_skill_filter, - definition.category_filter, - ); - - let options = SubagentRenderOptions::from_definition_flags( - definition.omit_identity, - definition.omit_safety_preamble, - definition.omit_skills_catalog, - definition.omit_profile, - definition.omit_memory_md, - ); - - // Debug dump runs outside the agent lifecycle, so there's no - // dynamic per-action toolkit to inject and no live dispatcher to - // read the format from. Use empty extra_tools and the legacy - // PFormat default to preserve existing dump output for - // contributors who diff against committed snapshots. - let no_extra_tools: Vec> = Vec::new(); - let raw = render_subagent_system_prompt( - workspace_dir, - &model, - &allowed_indices, - tools_vec, - &no_extra_tools, - &archetype_body, - options, - crate::openhuman::context::prompt::ToolCallFormat::PFormat, - connected_integrations, - ); - let rendered = extract_cache_boundary(&raw); - - let tool_names: Vec = allowed_indices - .iter() - .map(|&i| tools_vec[i].name().to_string()) - .collect(); - let skill_tool_count = allowed_indices - .iter() - .filter(|&&i| tools_vec[i].category() == ToolCategory::Skill) - .count(); - - tracing::debug!( - agent_id = %definition.id, - tool_count = tool_names.len(), - skill_tool_count, - cache_boundary = ?rendered.cache_boundary, - "[debug_dump] sub-agent render complete" - ); - - Ok(DumpedPrompt { - agent_id: definition.id.clone(), - mode: "subagent", - model, - workspace_dir: workspace_dir.to_path_buf(), - text: rendered.text, - cache_boundary: rendered.cache_boundary, - tool_names, - skill_tool_count, +/// Wrap a `&dyn Tool` as a `Box` proxy that forwards +/// `name()` / `description()` / `parameters_schema()` / `category()` +/// — enough surface for prompt rendering. `execute` is intentionally +/// left as a no-op error since dumps never call it. +fn clone_tool_as_prompt_proxy(source: &dyn Tool) -> Box { + Box::new(PromptProxyTool { + name: source.name().to_string(), + description: source.description().to_string(), + schema: source.parameters_schema(), + category: source.category(), }) } -/// Standalone copy of the filter logic in -/// [`crate::openhuman::agent::harness::subagent_runner`] so this debug -/// module does not depend on crate-private items. Kept in lockstep with -/// the real `filter_tool_indices` — if you change the order or semantics -/// there, update this function too (and the unit tests below). -fn filter_tool_indices_for_dump( - parent_tools: &[Box], - scope: &ToolScope, - disallowed: &[String], - skill_filter: Option<&str>, - category_filter: Option, -) -> Vec { - let disallow_set: HashSet<&str> = disallowed.iter().map(|s| s.as_str()).collect(); - let skill_prefix = skill_filter.map(|s| format!("{s}__")); - - parent_tools - .iter() - .enumerate() - .filter(|(_, tool)| { - let name = tool.name(); - if disallow_set.contains(name) { - return false; - } - if let Some(required) = category_filter { - if tool.category() != required { - return false; - } - } - if let Some(prefix) = skill_prefix.as_deref() { - if !name.starts_with(prefix) { - return false; - } - } - match scope { - ToolScope::Wildcard => true, - ToolScope::Named(allowed) => allowed.iter().any(|n| n == name), - } - }) - .map(|(i, _)| i) - .collect() +struct PromptProxyTool { + name: String, + description: String, + schema: serde_json::Value, + category: ToolCategory, } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::agent::harness::definition::{ - DefinitionSource, ModelSpec, PromptSource, SandboxMode, - }; - use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; - use async_trait::async_trait; - - /// Minimal tool stub with a configurable category — enough for the - /// filter/render unit tests below. - struct StubTool { - name: &'static str, - category: ToolCategory, +#[async_trait::async_trait] +impl Tool for PromptProxyTool { + fn name(&self) -> &str { + &self.name } - - #[async_trait] - impl Tool for StubTool { - fn name(&self) -> &str { + fn description(&self) -> &str { + &self.description + } + fn parameters_schema(&self) -> serde_json::Value { + self.schema.clone() + } + fn category(&self) -> ToolCategory { + self.category + } + fn permission_level(&self) -> crate::openhuman::tools::PermissionLevel { + crate::openhuman::tools::PermissionLevel::None + } + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Err(anyhow!( + "PromptProxyTool (`{}`) is a render-only stub — execute is not callable", self.name - } - fn description(&self) -> &str { - "stub tool used by debug_dump tests" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ "type": "object" }) - } - fn category(&self) -> ToolCategory { - self.category - } - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::None - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::success("ok")) - } - } - - fn skills_agent_def() -> AgentDefinition { - AgentDefinition { - id: "skills_agent".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::Inline( - "# Skills Agent\n\nYou execute skill-category tools.".into(), - ), - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: false, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.4, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: Some(ToolCategory::Skill), - extra_tools: vec![], - max_iterations: 8, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - } - } - - #[test] - fn filter_respects_category_filter() { - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "composio_execute", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - ]; - - let indices = filter_tool_indices_for_dump( - &tools, - &ToolScope::Wildcard, - &[], - None, - Some(ToolCategory::Skill), - ); - - let names: Vec<&str> = indices.iter().map(|&i| tools[i].name()).collect(); - assert_eq!(names, vec!["composio_execute", "notion__create_page"]); - } - - #[test] - fn render_skills_agent_dump_contains_composio_tool() { - // Simulates: `openhuman agent dump-prompt --agent skills_agent` - // with a stub registry that mirrors what the real Composio - // integration registers. This guards the end-to-end property - // the user cares about: skills_agent must see composio tools. - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "composio_list_toolkits", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "composio_execute", - category: ToolCategory::Skill, - }), - ]; - - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_dump_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let definition = skills_agent_def(); - let dumped = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .expect("skills_agent prompt should render"); - - assert_eq!(dumped.mode, "subagent"); - assert!( - dumped.tool_names.iter().any(|n| n == "composio_execute"), - "skills_agent dump missing composio_execute; got: {:?}", - dumped.tool_names - ); - assert!( - !dumped.tool_names.iter().any(|n| n == "shell"), - "skills_agent dump should not include system tools; got: {:?}", - dumped.tool_names - ); - assert!( - dumped.text.contains("composio_execute"), - "rendered prompt body missing composio_execute — composio toolkit is not reaching the skills agent" - ); - assert!( - dumped.text.contains("## Safety"), - "skills_agent dump should include the safety preamble (omit_safety_preamble = false)" - ); - assert_eq!(dumped.skill_tool_count, 2); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_with_skill_filter_narrows_to_one_integration() { - let tools: Vec> = vec![ - Box::new(StubTool { - name: "composio_execute", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "gmail__send_email", - category: ToolCategory::Skill, - }), - ]; - - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_dump_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let definition = skills_agent_def(); - let dumped = render_subagent_dump( - &definition, - &workspace, - "reasoning-v1", - &tools, - Some("notion"), - &[], - ) - .expect("filtered dump should render"); - - assert_eq!(dumped.tool_names, vec!["notion__create_page"]); - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn dump_prompt_options_new_sets_expected_defaults() { - let options = DumpPromptOptions::new("skills_agent"); - assert_eq!(options.agent_id, "skills_agent"); - assert_eq!(options.skill_filter, None); - assert_eq!(options.workspace_dir_override, None); - assert_eq!(options.model_override, None); - assert!(!options.stub_composio); - } - - #[test] - fn composio_stub_tools_have_expected_names() { - let names: Vec = build_composio_stub_tools() - .into_iter() - .map(|tool| tool.name().to_string()) - .collect(); - assert_eq!( - names, - vec![ - "composio_list_toolkits", - "composio_list_connections", - "composio_authorize", - "composio_list_tools", - "composio_execute", - ] - ); - } - - /// Helper: build a minimal AgentDefinition + registry pair the - /// `render_main_agent_dump` tests can use. The "orchestrator" entry - /// here is wildcard-scoped with no subagents so the dump runs in - /// its legacy unfiltered shape (every tool from `tools_vec` shows - /// up). Tests that exercise the per-agent filter use - /// `orchestrator_def_with_named_scope` below. - fn wildcard_orchestrator_def() -> AgentDefinition { - AgentDefinition { - id: "orchestrator".into(), - when_to_use: "test".into(), - display_name: None, - system_prompt: PromptSource::Inline(String::new()), - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.4, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 8, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - } - } - - fn registry_with_orchestrator(orch: AgentDefinition) -> AgentDefinitionRegistry { - let mut reg = AgentDefinitionRegistry::default(); - reg.insert(orch); - reg - } - - /// Wildcard scope path: the dump should report every tool from - /// `tools_vec` (no filter applied) and produce the standard - /// system-prompt skeleton with the Tool Use Protocol section. This - /// preserves the legacy main-dump assertions for orchestrator - /// definitions that opt out of the named filter. - #[test] - fn render_main_agent_dump_wildcard_scope_shows_full_tool_set() { - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_main_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nContext").unwrap(); - std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nContext").unwrap(); - std::fs::write(workspace.join("USER.md"), "# User\nContext").unwrap(); - std::fs::write(workspace.join("HEARTBEAT.md"), "# Heartbeat\nContext").unwrap(); - - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - ]; - - let orch = wildcard_orchestrator_def(); - let registry = registry_with_orchestrator(orch.clone()); - let dumped = - render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[], ®istry, &orch) - .unwrap(); - assert_eq!(dumped.mode, "main"); - assert_eq!(dumped.model, "reasoning-v1"); - assert_eq!(dumped.tool_names, vec!["shell", "notion__create_page"]); - assert_eq!(dumped.skill_tool_count, 1); - assert!(dumped.text.contains("## Tools")); - assert!(dumped.text.contains("Tool Use Protocol")); - assert!(dumped.cache_boundary.is_some()); - - let _ = std::fs::remove_dir_all(workspace); - } - - /// Named-scope path (the new behaviour after #525/#526): the - /// orchestrator definition restricts the LLM to a small whitelist, - /// and the dump must reflect that — `shell` is in `tools_vec` but - /// not in the orchestrator's `named` list, so it must NOT appear - /// in `tool_names`. This is the regression guard for the - /// "render_main_agent_dump uses empty_filter so the catalogue - /// leaks" bug at the heart of #526. - #[test] - fn render_main_agent_dump_named_scope_filters_to_whitelist() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_debug_main_named_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "query_memory", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "GMAIL_SEND_EMAIL", - category: ToolCategory::Skill, - }), - ]; - - let mut orch = wildcard_orchestrator_def(); - orch.tools = ToolScope::Named(vec!["query_memory".into(), "ask_user_clarification".into()]); - let registry = registry_with_orchestrator(orch.clone()); - - let dumped = - render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[], ®istry, &orch) - .unwrap(); - - // `shell` and `GMAIL_SEND_EMAIL` are in the global tools_vec - // but NOT in the orchestrator's named whitelist → must be - // excluded from the dump output. Only `query_memory` survives - // (the other named entry, `ask_user_clarification`, isn't in - // tools_vec at all so nothing to render for it). - assert_eq!(dumped.tool_names, vec!["query_memory"]); - assert_eq!(dumped.skill_tool_count, 0); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn filter_respects_named_scope_and_disallowed_tools() { - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "gmail__send_email", - category: ToolCategory::Skill, - }), - ]; - - let indices = filter_tool_indices_for_dump( - &tools, - &ToolScope::Named(vec!["shell".into(), "gmail__send_email".into()]), - &["shell".into()], - None, - None, - ); - - let names: Vec<&str> = indices.iter().map(|&i| tools[i].name()).collect(); - assert_eq!(names, vec!["gmail__send_email"]); - } - - #[test] - fn render_subagent_dump_supports_file_prompt_fallbacks() { - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_file_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - })]; - - let definition = AgentDefinition { - id: "file_agent".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::File { - path: "USER.md".into(), - }, - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.0, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 2, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - }; - - let dumped = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(dumped.text.contains("## Tools")); - assert!(dumped.text.contains("OpenHuman")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_dump_handles_missing_file_prompt_without_panicking() { - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_missing_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - })]; - - let definition = AgentDefinition { - id: "missing_prompt".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::File { - path: "does-not-exist.md".into(), - }, - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.0, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 2, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - }; - - let dumped = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(dumped.text.contains("## Tools")); - assert!(!dumped.text.contains("does-not-exist")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_dump_prefers_workspace_prompt_locations() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_debug_workspace_prompt_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(workspace.join("agent/prompts")).unwrap(); - std::fs::write( - workspace.join("agent/prompts/custom.md"), - "Workspace agent prompt", - ) - .unwrap(); - std::fs::write(workspace.join("root.md"), "Workspace root prompt").unwrap(); - - let tools: Vec> = vec![Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - })]; - - let mut definition = AgentDefinition { - id: "workspace_file".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::File { - path: "custom.md".into(), - }, - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.0, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 2, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - }; - - let agent_prompt = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(agent_prompt.text.contains("Workspace agent prompt")); - - definition.id = "workspace_root".into(); - definition.system_prompt = PromptSource::File { - path: "root.md".into(), - }; - let root_prompt = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(root_prompt.text.contains("Workspace root prompt")); - - let _ = std::fs::remove_dir_all(workspace); + )) } } + +/// Return the slugs of every currently-connected Composio toolkit. +/// Used by [`dump_all_agent_prompts`] to decide how many times to +/// render `integrations_agent`. Empty when the user is not signed in +/// or has no active connections. +async fn connected_toolkits_for(config: &Config) -> Result> { + // Spin up a throwaway integrations_agent session just so we can + // reuse its `fetch_connected_integrations` cache — the call is + // deduped backend-side via `INTEGRATIONS_CACHE`, so repeated + // invocations in `dump_all_agent_prompts` only hit the wire once. + let mut agent = Agent::from_config_for_agent(config, INTEGRATIONS_AGENT_ID) + .with_context(|| "building integrations_agent probe session for toolkit discovery")?; + agent.fetch_connected_integrations().await; + Ok(agent + .connected_integrations() + .iter() + .filter(|ci| ci.connected) + .map(|ci| ci.toolkit.clone()) + .collect()) +} + +// `config` usage is currently limited to plumbing; keep the path-level +// reference alive so future hooks (overrides, scoped workspaces) can +// extend from here without touching every call site. +#[allow(dead_code)] +fn _keep_path_imports_alive(_p: &Path) {} diff --git a/src/openhuman/context/manager.rs b/src/openhuman/context/manager.rs index f68d50350..eadcd62df 100644 --- a/src/openhuman/context/manager.rs +++ b/src/openhuman/context/manager.rs @@ -33,7 +33,7 @@ use std::sync::Arc; use super::pipeline::{ ContextPipeline, ContextPipelineConfig, PipelineOutcome, SessionMemoryHandle, }; -use super::prompt::{PromptContext, RenderedPrompt, SystemPromptBuilder}; +use super::prompt::{PromptContext, SystemPromptBuilder}; use super::session_memory::SessionMemoryConfig; use super::summarizer::{Summarizer, SummaryStats}; use crate::openhuman::config::ContextConfig; @@ -230,20 +230,15 @@ impl ContextManager { /// Assemble the opening system prompt for a session using the /// manager's default [`SystemPromptBuilder`]. + /// + /// The returned bytes are the full system prompt, intended to be + /// built once at session start and reused verbatim on every turn — + /// the inference backend's prefix cache picks up the stable prefix + /// automatically, so no boundary marker is emitted. pub fn build_system_prompt(&self, ctx: &PromptContext<'_>) -> Result { self.default_prompt_builder.build(ctx) } - /// Assemble the opening system prompt for a session using the - /// manager's default builder and preserve cache-boundary metadata - /// for provider request prefix caching. - pub fn build_system_prompt_with_cache_metadata( - &self, - ctx: &PromptContext<'_>, - ) -> Result { - self.default_prompt_builder.build_with_cache_metadata(ctx) - } - /// Assemble the system prompt via a caller-supplied builder. /// /// Sub-agents pass `SystemPromptBuilder::for_subagent(...)` and diff --git a/src/openhuman/context/mod.rs b/src/openhuman/context/mod.rs index e255d7a15..b4a0e0ee9 100644 --- a/src/openhuman/context/mod.rs +++ b/src/openhuman/context/mod.rs @@ -47,8 +47,8 @@ pub use microcompact::{ pub use pipeline::{ContextPipeline, ContextPipelineConfig, PipelineOutcome}; pub use prompt::{ ArchetypePromptSection, DateTimeSection, IdentitySection, LearnedContextData, PromptContext, - PromptSection, PromptTool, RuntimeSection, SafetySection, SkillsSection, SystemPromptBuilder, - ToolsSection, WorkspaceSection, + PromptSection, PromptTool, RuntimeSection, SafetySection, SystemPromptBuilder, ToolsSection, + WorkspaceSection, }; pub use session_memory::{ SessionMemoryConfig, SessionMemoryState, ARCHIVIST_EXTRACTION_PROMPT, DEFAULT_MIN_TOKEN_GROWTH, diff --git a/src/openhuman/context/prompt.rs b/src/openhuman/context/prompt.rs index ea98ff140..2220807f2 100644 --- a/src/openhuman/context/prompt.rs +++ b/src/openhuman/context/prompt.rs @@ -1,2388 +1,11 @@ -use crate::openhuman::skills::Skill; -use crate::openhuman::tools::Tool; -use anyhow::Result; -use chrono::Local; -use std::fmt::Write; -use std::path::Path; - -const BOOTSTRAP_MAX_CHARS: usize = 20_000; - -/// Tight per-file budget for user-specific, potentially growing files — -/// currently `PROFILE.md` (onboarding enrichment output) and `MEMORY.md` -/// (archivist-curated long-term memory). Caps the prompt footprint so -/// either file can reach at most ~1000 tokens (a few % of a typical -/// context window) regardless of how large the on-disk version has -/// grown. Matches the [`render_subagent_system_prompt`] byte-stability -/// contract because it is a compile-time constant. -const USER_FILE_MAX_CHARS: usize = 2_000; -const CACHE_BOUNDARY_MARKER: &str = ""; - -/// Per-namespace cap when injecting tree summarizer root summaries into -/// the prompt. ~8 000 chars ≈ 2 000 tokens — that's the floor the user -/// asked for ("at least 2000 tokens of user memory") for a single -/// namespace, and matches what the tree summarizer's `Day` level -/// already enforces upstream. -pub(crate) const USER_MEMORY_PER_NAMESPACE_MAX_CHARS: usize = 8_000; - -/// Hard ceiling across all namespaces, so a workspace with 30 namespaces -/// doesn't burn the entire context window. ~32 000 chars ≈ 8 000 tokens. -pub(crate) const USER_MEMORY_TOTAL_MAX_CHARS: usize = 32_000; - -/// Pre-fetched learned context data for prompt sections (avoids blocking the runtime). -#[derive(Debug, Clone, Default)] -pub struct LearnedContextData { - /// Recent observations from the learning subsystem. - pub observations: Vec, - /// Recognized patterns. - pub patterns: Vec, - /// Learned user profile entries. - pub user_profile: Vec, - /// Pre-fetched root-level summaries from the tree summarizer, one per - /// namespace that has a root node on disk. - /// - /// Each entry is `(namespace, body)`. The body is the markdown body of - /// `memory/namespaces/{ns}/tree/root.md` — already truncated to a - /// per-namespace cap by the fetcher so the section can render without - /// any further sizing logic. - /// - /// Empty when the tree summarizer has never run on this workspace - /// (the section then renders nothing and is dropped from the prompt). - pub tree_root_summaries: Vec<(String, String)>, -} - -/// An external integration (e.g. a Composio OAuth-backed toolkit) -/// surfaced in the system prompt so the orchestrator knows which -/// services are available — both **already connected** and **available -/// to authorize**. Delegation guidance differs by status: -/// -/// - **Connected**: the orchestrator may `spawn_subagent(skills_agent, -/// toolkit=…)` directly. `tools` carries the per-action catalogue -/// used to dynamically register native tool-calling schemas inside -/// the spawned sub-agent. -/// - **Not connected**: the orchestrator must NOT delegate. Instead it -/// tells the user to authorize the toolkit in Settings → -/// Integrations. `tools` is empty in this case. -#[derive(Debug, Clone)] -pub struct ConnectedIntegration { - /// Toolkit slug, e.g. `"gmail"`, `"notion"`. - pub toolkit: String, - /// Human-readable one-line description of what this integration can do. - pub description: String, - /// Per-action catalogue (only populated when `connected == true`). - pub tools: Vec, - /// Whether the user has an active OAuth connection for this - /// toolkit. When `false`, the toolkit is in the backend allowlist - /// but no authorization has been completed yet — `tools` is empty - /// and the orchestrator must point the user at Settings instead - /// of attempting to delegate. - pub connected: bool, -} - -/// A single action available on a connected integration. -#[derive(Debug, Clone)] -pub struct ConnectedIntegrationTool { - /// Action slug, e.g. `"GMAIL_SEND_EMAIL"`. - pub name: String, - /// One-line description of the action. - pub description: String, - /// JSON schema for the action's parameters, carried through from - /// the backend tool-list response. `None` when the backend didn't - /// supply a schema. Consumed by [`ComposioActionTool`] when the - /// subagent runner dynamically registers per-action tools for a - /// `skills_agent(toolkit=…)` spawn — the schema becomes the - /// native tool-calling signature the LLM sees. - pub parameters: Option, -} - -/// A lightweight tool descriptor for prompt rendering. -/// -/// Shared shape so every call-site that builds a system prompt — main -/// agents (which own `Box`), sub-agents, and channel runtimes -/// (which only have `(name, description)` tuples from their tool -/// registries) — can feed the same [`ToolsSection`] implementation -/// instead of each writing its own. Callers adapt their own tool -/// representation into a `Vec>` at the PromptContext -/// construction site via a one-line `.iter().map(...).collect()` or via -/// [`PromptTool::from_tools`]. -/// -/// `parameters_schema` is optional because channel runtimes don't have -/// full JSON schemas at prompt-build time; the tools section renders -/// the schema line only when it's present. -#[derive(Debug, Clone)] -pub struct PromptTool<'a> { - pub name: &'a str, - pub description: &'a str, - pub parameters_schema: Option, -} - -impl<'a> PromptTool<'a> { - pub fn new(name: &'a str, description: &'a str) -> Self { - Self { - name, - description, - parameters_schema: None, - } - } - - pub fn with_schema(name: &'a str, description: &'a str, parameters_schema: String) -> Self { - Self { - name, - description, - parameters_schema: Some(parameters_schema), - } - } - - /// Adapt a `Box` slice into a `Vec>`. The - /// returned vector borrows names and descriptions from the original - /// tools, so it must not outlive them. Main-agent call-sites use - /// this one-liner to build the slice passed into [`PromptContext::tools`]. - pub fn from_tools(tools: &'a [Box]) -> Vec> { - tools - .iter() - .map(|t| PromptTool { - name: t.name(), - description: t.description(), - parameters_schema: Some(t.parameters_schema().to_string()), - }) - .collect() - } -} - -/// How the [`ToolsSection`] should render each tool entry. Driven by -/// the dispatcher choice on the agent — JSON-schema rendering is the -/// historic format; P-Format is the new default text protocol. -/// -/// `Native` is for providers that ship structured tool calls directly, -/// in which case the catalogue body is informational only and the -/// renderer falls back to JSON-schema (it's the most descriptive form). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum ToolCallFormat { - /// `tool_name[arg1|arg2|...]` — compact, positional, ~80% fewer - /// tokens than JSON. The default. - #[default] - PFormat, - /// Legacy JSON-in-tag rendering. Each tool entry shows the full - /// JSON schema. Kept for backwards compatibility with prompts - /// tuned against the old format. - Json, - /// Provider supplies structured tool calls — the catalogue is - /// informational. Renders in the same JSON-schema form as `Json`. - Native, -} - -pub struct PromptContext<'a> { - pub workspace_dir: &'a Path, - pub model_name: &'a str, - /// Id of the agent this prompt is being built for (e.g. `orchestrator`, - /// `skills_agent`, `welcome`). Drives per-agent rendering decisions — - /// notably [`ConnectedIntegrationsSection`], which dumps the full - /// Composio tool catalog only for `skills_agent` and leaves delegating - /// agents with a toolkit-only summary. Empty string means "unknown", - /// which is treated as a delegating agent. - pub agent_id: &'a str, - pub tools: &'a [PromptTool<'a>], - pub skills: &'a [Skill], - pub dispatcher_instructions: &'a str, - /// Pre-fetched learned context (empty when learning is disabled). - pub learned: LearnedContextData, - /// When non-empty, only tools in this set are rendered in the prompt. - /// Skills section is omitted when a filter is active (the main agent - /// delegates skill work to sub-agents). - pub visible_tool_names: &'a std::collections::HashSet, - /// How [`ToolsSection`] should render each tool entry. Defaults to - /// [`ToolCallFormat::PFormat`] when not set. - pub tool_call_format: ToolCallFormat, - /// Active Composio integrations the user has connected. Rendered by - /// [`ConnectedIntegrationsSection`] so the orchestrator knows which - /// external services are available via the Skills Agent. - pub connected_integrations: &'a [ConnectedIntegration], - /// When `true`, inject `PROFILE.md` (onboarding enrichment output) - /// into [`IdentitySection`]. Mirrors the inverse of the agent - /// definition's `omit_profile` flag — opt-in per agent so narrow - /// specialists (planner, code_executor, …) stay lean, while - /// user-facing agents (welcome, orchestrator, triggers) personalise. - pub include_profile: bool, - /// When `true`, inject `MEMORY.md` (archivist-curated long-term - /// memory) into [`IdentitySection`]. Mirrors the inverse of the - /// agent definition's `omit_memory_md` flag. Capped at - /// [`USER_FILE_MAX_CHARS`] and frozen per session — see the - /// KV-cache note on the injection site. - pub include_memory_md: bool, -} - -pub trait PromptSection: Send + Sync { - fn name(&self) -> &str; - fn build(&self, ctx: &PromptContext<'_>) -> Result; -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RenderedPrompt { - pub text: String, - pub cache_boundary: Option, -} - -#[derive(Default)] -pub struct SystemPromptBuilder { - sections: Vec>, -} - -impl SystemPromptBuilder { - pub fn with_defaults() -> Self { - Self { - sections: vec![ - Box::new(IdentitySection), - // User files (PROFILE.md, MEMORY.md) ride right after the - // identity bootstrap so they land in the cache-friendly - // prefix alongside SOUL/IDENTITY. Gated per-agent — see - // `UserFilesSection`. Intentionally separate from - // `IdentitySection` so agents that strip the identity - // preamble via `for_subagent(omit_identity=true)` still - // get their user files (welcome / orchestrator / the - // trigger pair). - Box::new(UserFilesSection), - // User memory sits right after the identity bootstrap so the - // model has rich, persistent context about the user before it - // sees the tool catalogue. Section is empty (and skipped) when - // the tree summarizer has nothing on disk yet. - Box::new(UserMemorySection), - Box::new(ToolsSection), - // Connected integrations sit after tools so the orchestrator - // sees which external services are available via the Skills - // Agent. Empty when the user has no active Composio connections. - Box::new(ConnectedIntegrationsSection), - Box::new(SafetySection), - Box::new(SkillsSection), - Box::new(WorkspaceSection), - Box::new(DateTimeSection), - Box::new(RuntimeSection), - ], - } - } - - /// Build a narrow prompt for a sub-agent. - /// - /// The sub-agent's archetype prompt is registered as a dedicated - /// section that always renders first. The remaining sections respect - /// the `omit_*` flags from the [`crate::openhuman::agent::harness::definition::AgentDefinition`]: - /// `omit_identity` skips the project-context dump, `omit_safety_preamble` - /// skips the safety rules, and so on. The `WorkspaceSection` is always - /// included so the sub-agent knows its working directory. - /// - /// `archetype_prompt_text` is the already-loaded body of the - /// `system_prompt` source on the definition (the runner resolves - /// inline vs file before calling this). - /// - /// # KV cache stability - /// - /// `DateTimeSection` is intentionally **not** included here. - /// Repeat spawns of the same sub-agent definition must produce - /// byte-identical system prompts so the inference backend's - /// automatic prefix cache can reuse the prefill from the previous - /// run. Injecting `Local::now()` into the prompt would defeat that - /// goal — if a sub-agent genuinely needs the current time it - /// should receive it via the user message, not the system prompt. - pub fn for_subagent( - archetype_prompt_text: String, - omit_identity: bool, - omit_safety_preamble: bool, - omit_skills_catalog: bool, - ) -> Self { - let mut sections: Vec> = - vec![Box::new(ArchetypePromptSection::new(archetype_prompt_text))]; - - if !omit_identity { - sections.push(Box::new(IdentitySection)); - } - // User files (PROFILE.md / MEMORY.md) are gated independently of - // `omit_identity` so agents that drop the identity preamble (e.g. - // welcome's `omit_identity = true`) still surface the user's - // onboarding + archivist context when `omit_profile` / - // `omit_memory_md` are opted in. The section builds to an empty - // string when both flags are off, so the existing empty-section - // skip in `build_with_cache_metadata` keeps the prompt layout - // byte-identical for narrow specialists. - sections.push(Box::new(UserFilesSection)); - // Tools section is always included — the sub-agent needs to see - // its own (filtered) tool catalogue. - sections.push(Box::new(ToolsSection)); - if !omit_safety_preamble { - sections.push(Box::new(SafetySection)); - } - if !omit_skills_catalog { - sections.push(Box::new(SkillsSection)); - } - sections.push(Box::new(WorkspaceSection)); - - Self { sections } - } - - pub fn add_section(mut self, section: Box) -> Self { - self.sections.push(section); - self - } - - pub fn build(&self, ctx: &PromptContext<'_>) -> Result { - Ok(self.build_with_cache_metadata(ctx)?.text) - } - - pub fn build_with_cache_metadata(&self, ctx: &PromptContext<'_>) -> Result { - let mut output = String::new(); - let mut cache_boundary_inserted = false; - for section in &self.sections { - let part = section.build(ctx)?; - if part.trim().is_empty() { - continue; - } - // Insert cache boundary marker before the first dynamic section. - // Static sections (identity, tools, safety, skills) are cacheable; - // dynamic sections (workspace, datetime, runtime) change per request. - if !cache_boundary_inserted && is_dynamic_section(section.name()) { - output.push_str(CACHE_BOUNDARY_MARKER); - output.push_str("\n\n"); - cache_boundary_inserted = true; - } - output.push_str(part.trim_end()); - output.push_str("\n\n"); - } - Ok(extract_cache_boundary(&output)) - } -} - -pub fn extract_cache_boundary(rendered: &str) -> RenderedPrompt { - if let Some(marker_idx) = rendered.find(CACHE_BOUNDARY_MARKER) { - let mut text = rendered.to_string(); - let end = marker_idx + CACHE_BOUNDARY_MARKER.len(); - text.replace_range(marker_idx..end, ""); - if text[marker_idx..].starts_with("\n\n") { - text.replace_range(marker_idx..marker_idx + 2, ""); - } - return RenderedPrompt { - text, - cache_boundary: Some(marker_idx), - }; - } - - RenderedPrompt { - text: rendered.to_string(), - cache_boundary: None, - } -} - -/// Sub-agent role prompt — pre-loaded text from an -/// [`crate::openhuman::agent::harness::definition::AgentDefinition`]'s -/// `system_prompt` field. Always rendered first when present. -pub struct ArchetypePromptSection { - body: String, -} - -impl ArchetypePromptSection { - pub fn new(body: String) -> Self { - Self { body } - } -} - -impl PromptSection for ArchetypePromptSection { - fn name(&self) -> &str { - "archetype_prompt" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - if self.body.trim().is_empty() { - return Ok(String::new()); - } - Ok(self.body.clone()) - } -} - -pub struct IdentitySection; -pub struct ToolsSection; -pub struct SafetySection; -pub struct SkillsSection; -pub struct ConnectedIntegrationsSection; -pub struct WorkspaceSection; -pub struct RuntimeSection; -pub struct DateTimeSection; -pub struct UserMemorySection; - -/// Injects the user-specific, session-frozen workspace files -/// (`PROFILE.md` + `MEMORY.md`), each capped at [`USER_FILE_MAX_CHARS`]. -/// -/// Separate from [`IdentitySection`] so agents that strip the project- -/// context preamble (`omit_identity = true` — welcome, orchestrator, -/// the trigger pair) still get their user-file injection at runtime via -/// [`SystemPromptBuilder::for_subagent`], which skips `IdentitySection` -/// entirely when `omit_identity` is on. -/// -/// Cache-stability: static per session — the whole point of the -/// 2000-char cap and the load-once rule documented on -/// [`AgentDefinition::omit_profile`] / `omit_memory_md`. -pub struct UserFilesSection; - -impl PromptSection for IdentitySection { - fn name(&self) -> &str { - "identity" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - let mut prompt = String::from("## Project Context\n\n"); - prompt.push_str( - "The following workspace files define your identity, behavior, and context.\n\n", - ); - // When the visible-tool filter is active the main agent is a pure - // orchestrator: it routes via spawn_subagent, synthesises results, - // and talks to the user. It does NOT need the periodic-task config - // (HEARTBEAT.md) — subagents handle their own concerns. - let is_orchestrator = !ctx.visible_tool_names.is_empty(); - let all_files: &[&str] = &["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"]; - // Orchestrator skips these from the prompt but we still sync them - // to disk so they stay current. - let skip_in_prompt: &[&str] = if is_orchestrator { - &["HEARTBEAT.md"] - } else { - &[] - }; - for file in all_files { - // Always sync to disk so builtin updates ship. - sync_workspace_file(ctx.workspace_dir, file); - if !skip_in_prompt.contains(file) { - inject_workspace_file(&mut prompt, ctx.workspace_dir, file); - } - } - - // PROFILE.md / MEMORY.md injection lives in the dedicated - // `UserFilesSection` (below) so agents that strip the identity - // preamble (`omit_identity = true`) — welcome, orchestrator, the - // trigger pair — still get their user files at runtime via - // `SystemPromptBuilder::for_subagent`, which omits - // `IdentitySection` entirely when `omit_identity` is set. - - Ok(prompt) - } -} - -impl PromptSection for UserFilesSection { - fn name(&self) -> &str { - "user_files" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - // Gate on the per-agent flags derived from - // `AgentDefinition::omit_profile` / `omit_memory_md`. Both files - // are user-specific, potentially growing, and capped at - // [`USER_FILE_MAX_CHARS`] (~1000 tokens) so they can't bloat the - // cached prefix. - // - // KV-cache contract: once injected into a session's rendered - // prompt, the bytes are frozen for the remainder of that - // session — any mid-session archivist write or enrichment - // refresh lands on the NEXT session, never the in-flight one. - let mut out = String::new(); - if ctx.include_profile { - inject_workspace_file_capped( - &mut out, - ctx.workspace_dir, - "PROFILE.md", - USER_FILE_MAX_CHARS, - ); - } - if ctx.include_memory_md { - inject_workspace_file_capped( - &mut out, - ctx.workspace_dir, - "MEMORY.md", - USER_FILE_MAX_CHARS, - ); - } - Ok(out) - } -} - -impl PromptSection for ToolsSection { - fn name(&self) -> &str { - "tools" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - let mut out = String::from("## Tools\n\n"); - let has_filter = !ctx.visible_tool_names.is_empty(); - for tool in ctx.tools { - // Skip tools not in the visible set when a filter is active. - if has_filter && !ctx.visible_tool_names.contains(tool.name) { - continue; - } - - match ctx.tool_call_format { - ToolCallFormat::PFormat => { - // P-Format renders a positional signature line: - // `**name[a|b]**: description`. The signature comes - // straight from the parameter schema (alphabetical - // by property name — see `pformat` module docs for - // why), so the model and the parser agree on - // argument ordering. We deliberately do NOT print - // the full JSON schema here: that's exactly the - // ~25-token-per-tool overhead p-format exists to - // eliminate. - let signature = render_pformat_signature_for_prompt(tool); - let _ = writeln!( - out, - "- **{}**: {}\n Call as: `{}`", - tool.name, tool.description, signature - ); - } - ToolCallFormat::Json | ToolCallFormat::Native => { - if let Some(schema) = &tool.parameters_schema { - let _ = writeln!( - out, - "- **{}**: {}\n Parameters: `{}`", - tool.name, tool.description, schema - ); - } else { - let _ = writeln!(out, "- **{}**: {}", tool.name, tool.description); - } - } - } - } - if !ctx.dispatcher_instructions.is_empty() { - out.push('\n'); - out.push_str(ctx.dispatcher_instructions); - } - Ok(out) - } -} - -/// Build a P-Format signature line (`name[a|b|c]`) from a `&dyn Tool`. -/// Used by `render_subagent_system_prompt` which operates on `Box` -/// directly (no intermediate `PromptTool`). Mirrors the `PromptTool` variant -/// below — both BTreeMap-iterate the schema's `properties` in the same order. -fn render_pformat_signature_for_box_tool(tool: &dyn crate::openhuman::tools::Tool) -> String { - let schema = tool.parameters_schema(); - let names: Vec = schema - .get("properties") - .and_then(|p| p.as_object()) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - if names.is_empty() { - format!("{}[]", tool.name()) - } else { - format!("{}[{}]", tool.name(), names.join("|")) - } -} - -/// Build a P-Format signature line (`name[a|b|c]`) from a [`PromptTool`]. -/// Local to this module so [`ToolsSection`] doesn't have to depend on -/// the agent crate's `pformat` helper. The two implementations stay in -/// lockstep — both use BTreeMap iteration order on the schema's -/// `properties` field. -fn render_pformat_signature_for_prompt(tool: &PromptTool<'_>) -> String { - let names: Vec = tool - .parameters_schema - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .and_then(|v| { - v.get("properties") - .and_then(|p| p.as_object()) - .map(|m| m.keys().cloned().collect()) - }) - .unwrap_or_default(); - if names.is_empty() { - format!("{}[]", tool.name) - } else { - format!("{}[{}]", tool.name, names.join("|")) - } -} - -impl PromptSection for SafetySection { - fn name(&self) -> &str { - "safety" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) - } -} - -impl PromptSection for SkillsSection { - fn name(&self) -> &str { - "skills" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - // When a visible-tool filter is active the main agent delegates - // all skill work to sub-agents — skip the skills catalog. - if ctx.skills.is_empty() || !ctx.visible_tool_names.is_empty() { - return Ok(String::new()); - } - - let mut prompt = String::from("## Available Skills\n\n\n"); - for skill in ctx.skills { - let location = skill.location.clone().unwrap_or_else(|| { - ctx.workspace_dir - .join("skills") - .join(&skill.name) - .join("SKILL.md") - }); - let _ = writeln!( - prompt, - " \n {}\n {}\n {}\n ", - skill.name, - skill.description, - location.display() - ); - } - prompt.push_str(""); - Ok(prompt) - } -} - -impl PromptSection for ConnectedIntegrationsSection { - fn name(&self) -> &str { - "connected_integrations" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - if ctx.connected_integrations.is_empty() { - return Ok(String::new()); - } - - // Skill-executing agents (`skills_agent` and its specialisations) - // need the full per-action catalog so they can emit Composio calls - // directly. Every other agent (main/orchestrator/welcome/planner/…) - // is a delegator — it only needs a Delegation Guide: a toolkit - // list + the exact `spawn_subagent` invocation to use. Dumping - // the full tool catalog into a delegator's system prompt wastes - // thousands of tokens and teaches the model to call actions - // that aren't actually in its tool list. - let is_skill_executor = ctx.agent_id == "skills_agent"; - - if is_skill_executor { - // skills_agent only ever sees CONNECTED integrations — - // unconnected ones are filtered out by the sub-agent - // runner before we get here, but we double-filter for - // safety in case some other caller invokes the section - // directly. - let mut out = String::from( - "## Connected Integrations\n\n\ - You have direct access to the following external services. \ - The corresponding action tools are in your tool list with \ - their typed parameter schemas — call them by name.\n\n", - ); - for integration in ctx.connected_integrations.iter().filter(|ci| ci.connected) { - let _ = writeln!( - out, - "- **{}** — {}", - integration.toolkit, integration.description, - ); - } - out.push('\n'); - return Ok(out); - } - - // Delegating agent path — render a single unified Delegation - // Guide. Every integration in the backend allowlist is listed - // with the same spawn snippet, regardless of whether the user - // has authorized it yet. Auth state is the `spawn_subagent` - // pre-flight's responsibility: - // - // - Connected toolkit → spawn proceeds normally. - // - In allowlist but not connected → pre-flight returns a - // structured error telling the orchestrator to ask the - // user to authorize it in Settings → Integrations and NOT - // to retry. - // - Not in allowlist → pre-flight returns a different error - // listing the valid toolkits. - // - // Keeping the prompt small and uniform — one bullet per - // integration, no auth-state branching in prose — and - // trusting the runtime check as the single source of truth - // for which toolkits are actually callable. - let mut out = String::from( - "## Delegation Guide — Integrations\n\n\ - For any task that touches one of these external services, \ - delegate to `skills_agent` with the matching `toolkit` \ - argument. The sub-agent receives the full action catalogue \ - for that integration as native tool schemas — do not \ - attempt to call integration actions directly from this \ - agent.\n\n\ - If a spawn returns an authorization error, surface it to \ - the user and ask them to authorize the integration in \ - **Settings → Integrations** before retrying.\n\n", - ); - for integration in ctx.connected_integrations { - let _ = writeln!( - out, - "- **{}** — {}\n Delegate with: `spawn_subagent(agent_id=\"skills_agent\", toolkit=\"{}\", prompt=)`", - integration.toolkit, integration.description, integration.toolkit, - ); - } - out.push('\n'); - - Ok(out) - } -} - -impl PromptSection for WorkspaceSection { - fn name(&self) -> &str { - "workspace" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - Ok(format!( - "## Workspace\n\nWorking directory: `{}`", - ctx.workspace_dir.display() - )) - } -} - -impl PromptSection for RuntimeSection { - fn name(&self) -> &str { - "runtime" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - let host = - hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); - Ok(format!( - "## Runtime\n\nHost: {host} | OS: {} | Model: {}", - std::env::consts::OS, - ctx.model_name - )) - } -} - -impl PromptSection for UserMemorySection { - fn name(&self) -> &str { - "user_memory" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - if ctx.learned.tree_root_summaries.is_empty() { - return Ok(String::new()); - } - - let mut out = String::from("## User Memory\n\n"); - out.push_str( - "Long-term memory distilled by the tree summarizer. \ - Each section is the root summary for a memory namespace, \ - representing everything we've learned about that domain over time. \ - Treat this as durable context: the model has seen these facts before, \ - they should not need to be re-discovered.\n\n", - ); - - for (namespace, body) in &ctx.learned.tree_root_summaries { - let trimmed = body.trim(); - if trimmed.is_empty() { - continue; - } - let _ = writeln!(out, "### {namespace}\n"); - out.push_str(trimmed); - out.push_str("\n\n"); - } - - Ok(out) - } -} - -impl PromptSection for DateTimeSection { - fn name(&self) -> &str { - "datetime" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - let now = Local::now(); - Ok(format!( - "## Current Date & Time\n\n{} ({})", - now.format("%Y-%m-%d %H:%M:%S"), - now.format("%Z") - )) - } -} - -/// Returns true for sections whose content changes between requests. -/// Static sections (identity, tools, safety, skills) are placed before -/// the cache boundary; dynamic sections (workspace, datetime, runtime) after. -fn is_dynamic_section(name: &str) -> bool { - matches!(name, "workspace" | "datetime" | "runtime") -} - -/// Per-definition rendering flags passed into -/// [`render_subagent_system_prompt`]. Mirrors the `omit_*` fields on -/// [`crate::openhuman::agent::harness::definition::AgentDefinition`] so -/// the runner can thread each definition's preferences through without -/// growing the function signature. -/// -/// KV-cache-stable as long as the flags are read from a definition that -/// does not change mid-session. -#[derive(Debug, Clone, Copy, Default)] -pub struct SubagentRenderOptions { - /// When `false`, include the standard `## Safety` block. Mirrors - /// `AgentDefinition::omit_safety_preamble`. Defaults to `false` - /// (omit) because the narrow sub-agent renderer historically - /// skipped this section entirely. - pub include_safety_preamble: bool, - /// When `false`, skip the identity/project-context dump. Mirrors - /// `AgentDefinition::omit_identity`. Defaults to `false`; setting - /// this to `true` is uncommon because sub-agents usually run - /// narrow and the identity block pushes too many tokens. - pub include_identity: bool, - /// When `false`, skip the skills catalogue. Mirrors - /// `AgentDefinition::omit_skills_catalog`. Defaults to `false` - /// for the same reason as `include_identity`. - pub include_skills_catalog: bool, - /// When `true`, inject the user's `PROFILE.md` (onboarding - /// enrichment output) into the sub-agent prompt. Mirrors the - /// inverse of `AgentDefinition::omit_profile`. Defaults to `false` - /// — opt-in per agent so narrow specialists stay lean. - pub include_profile: bool, - /// When `true`, inject the archivist-curated `MEMORY.md` long-term - /// memory file. Mirrors the inverse of - /// `AgentDefinition::omit_memory_md`. Capped at - /// [`USER_FILE_MAX_CHARS`] and frozen per session (see KV-cache - /// contract on [`render_subagent_system_prompt`]). - pub include_memory_md: bool, -} - -impl SubagentRenderOptions { - /// Build the narrow default (every section off) — matches the - /// historical behaviour of the purpose-built renderer before the - /// flags were threaded through. - pub fn narrow() -> Self { - Self::default() - } - - /// Construct from the per-definition flags, inverting them into the - /// positive-sense `include_*` shape used by the renderer. - pub fn from_definition_flags( - omit_identity: bool, - omit_safety_preamble: bool, - omit_skills_catalog: bool, - omit_profile: bool, - omit_memory_md: bool, - ) -> Self { - Self { - include_identity: !omit_identity, - include_safety_preamble: !omit_safety_preamble, - include_skills_catalog: !omit_skills_catalog, - include_profile: !omit_profile, - include_memory_md: !omit_memory_md, - } - } -} - -/// Render a narrow, KV-cache-stable system prompt for a typed sub-agent. -/// -/// This is a purpose-built alternative to -/// [`SystemPromptBuilder::for_subagent`] for call sites that only have -/// indices into the parent's `&[Box]` vec (so they can't -/// cheaply build a filtered owning slice for `ToolsSection`). The -/// output mirrors what `for_subagent` would emit with the matching -/// `omit_*` flags, plus a sub-agent-specific calling-convention -/// preamble and a model-only runtime banner. -/// -/// `archetype_body` is the already-loaded archetype markdown — for -/// `PromptSource::Inline` this is the inline string, for -/// `PromptSource::File` this is the file contents loaded by the caller. -/// Callers resolve the source exactly once and hand the body in, so -/// this renderer works uniformly for both definition shapes. -/// -/// `options` carries the per-definition rendering flags (safety, etc.) -/// inverted into positive-sense `include_*` form. -/// [`SubagentRenderOptions::narrow`] preserves the historical behaviour. -/// -/// # KV cache stability -/// -/// The rendered bytes MUST be a pure function of: -/// - the `archetype_body` (archetype role prompt) -/// - the filtered tool set (names, descriptions, schemas) -/// - the workspace directory -/// - the resolved model name -/// - the `options` (all static per definition) -/// -/// Anything that varies across invocations at the *same* call site -/// (e.g. `chrono::Local::now()`, hostnames, pids, turn counters) is -/// forbidden here. Repeat spawns of the same sub-agent within a session -/// must produce byte-identical system prompts so the inference -/// backend's automatic prefix caching can reuse the prefill from the -/// previous run. Time-of-day information, if a sub-agent needs it, -/// belongs in the user message — not the system prompt. -pub fn render_subagent_system_prompt( - workspace_dir: &Path, - model_name: &str, - allowed_indices: &[usize], - parent_tools: &[Box], - extra_tools: &[Box], - archetype_body: &str, - options: SubagentRenderOptions, - tool_call_format: ToolCallFormat, - connected_integrations: &[ConnectedIntegration], -) -> String { - render_subagent_system_prompt_with_format( - workspace_dir, - model_name, - allowed_indices, - parent_tools, - extra_tools, - archetype_body, - options, - tool_call_format, - connected_integrations, - ) -} - -/// Inner renderer that accepts an explicit [`ToolCallFormat`] so callers -/// that know the active dispatcher format can thread it through. The -/// public [`render_subagent_system_prompt`] defaults to PFormat for -/// backwards compatibility. -pub fn render_subagent_system_prompt_with_format( - workspace_dir: &Path, - model_name: &str, - allowed_indices: &[usize], - parent_tools: &[Box], - extra_tools: &[Box], - archetype_body: &str, - options: SubagentRenderOptions, - tool_call_format: ToolCallFormat, - connected_integrations: &[ConnectedIntegration], -) -> String { - let mut out = String::new(); - - // 1. Archetype role prompt. Works for both `PromptSource::Inline` - // and `PromptSource::File` because the caller preloaded the - // body via `load_prompt_source`. - let trimmed = archetype_body.trim(); - if !trimmed.is_empty() { - out.push_str(trimmed); - out.push_str("\n\n"); - } - - // 1b. Optional identity block. Off by default; turned on when the - // definition sets `omit_identity = false`. Renders the same - // OpenClaw bootstrap files the main agent loads, keeping the - // byte layout stable across repeat spawns of the same - // definition within a session. - if options.include_identity { - out.push_str("## Project Context\n\n"); - out.push_str( - "The following workspace files define your identity, behavior, and context.\n\n", - ); - for file in &["SOUL.md", "IDENTITY.md"] { - inject_workspace_file(&mut out, workspace_dir, file); - } - } - - // 1c. PROFILE.md (onboarding enrichment output) and MEMORY.md - // (archivist-curated long-term memory). Each is gated on its own - // flag and capped at `USER_FILE_MAX_CHARS` (~1000 tokens) so a - // growing on-disk file can't push the system prompt out of the - // cache-friendly prefix range. - // - // KV-cache contract: once these files land in a session's - // rendered prompt the bytes are frozen for the remainder of that - // session. Do not re-read them mid-turn — a byte change breaks - // the backend's automatic prefix cache. Mid-session writes to - // either file are intentionally only visible on the NEXT session. - if options.include_profile { - inject_workspace_file_capped(&mut out, workspace_dir, "PROFILE.md", USER_FILE_MAX_CHARS); - } - if options.include_memory_md { - inject_workspace_file_capped(&mut out, workspace_dir, "MEMORY.md", USER_FILE_MAX_CHARS); - } - - // 2. Filtered tool catalogue. Indices are taken in ascending order - // from `allowed_indices`, which itself preserves `parent_tools` - // order, so the rendering is deterministic. We use `.get(i)` - // defensively even though the current caller (subagent_runner) - // only produces in-range indices — a future caller that derives - // indices from a different source must not be able to panic this - // renderer with a stale index. - // - // Rendering uses the caller-specified `tool_call_format` so - // sub-agents and the main dispatcher stay in lockstep. - // Tool catalogue rendering is dispatcher-format-aware: - // - // - **Native**: The provider receives full tool schemas through - // the request body's `tools` field (via `filtered_specs` in the - // sub-agent runner) and emits structured `tool_calls`. Listing - // the same tools again as prose in the system prompt is pure - // duplication — for a skills_agent spawn with 62 dynamic gmail - // tools, that duplication added ~54k tokens and blew past the - // model's context window. We skip the prose `## Tools` section - // entirely in this mode. - // - // - **PFormat / Json**: Both are prompt-driven formats — the - // model discovers tools by reading the prose `## Tools` section - // and emits text-wrapped tool calls (`name[a|b]` - // for PFormat, `{"name":...}` for Json). - // Neither uses the native `tools` request field, so we MUST - // list each tool in prose — including dynamically-registered - // `extra_tools` — or the model has no way to know they exist. - if !matches!(tool_call_format, ToolCallFormat::Native) { - out.push_str("## Tools\n\n"); - let render_one = |out: &mut String, tool: &dyn Tool| match tool_call_format { - ToolCallFormat::PFormat => { - let sig = render_pformat_signature_for_box_tool(tool); - let _ = writeln!( - out, - "- **{}**: {}\n Call as: `{}`", - tool.name(), - tool.description(), - sig - ); - } - ToolCallFormat::Json => { - let _ = writeln!( - out, - "- **{}**: {}\n Parameters: `{}`", - tool.name(), - tool.description(), - tool.parameters_schema() - ); - } - ToolCallFormat::Native => { - // Unreachable — outer guard skips Native entirely. - } - }; - for &i in allowed_indices { - let Some(tool) = parent_tools.get(i) else { - tracing::warn!( - index = i, - tool_count = parent_tools.len(), - "[context::prompt] dropping out-of-range tool index in subagent render" - ); - continue; - }; - render_one(&mut out, tool.as_ref()); - } - for tool in extra_tools { - render_one(&mut out, tool.as_ref()); - } - } - - // 3. Sub-agent calling-convention preamble — format-aware. - // Sub-agents need the same call format the main dispatcher expects - // so their output parses correctly. - out.push('\n'); - match tool_call_format { - ToolCallFormat::PFormat => { - out.push_str( - "## Tool Use Protocol\n\n\ - Tool calls use **P-Format**: compact, positional, pipe-delimited syntax \ - wrapped in `` tags.\n\n\ - ```\n\ntool_name[arg1|arg2]\n\n```\n\n\ - Arguments are positional — match the order shown in each tool's `Call as:` \ - signature above (alphabetical by parameter name). \ - Escape `|` as `\\|`, `]` as `\\]` inside values. \ - You may emit multiple `` blocks per response.\n\n\ - Use the provided tools to accomplish the task. Reply with a concise, dense \ - final answer when you have one — the parent agent will weave it back into the \ - user-visible response.\n\n", - ); - } - ToolCallFormat::Json => { - out.push_str( - "## Tool Use Protocol\n\n\ - To use a tool, wrap a JSON object in `` tags:\n\n\ - ```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n\ - You may emit multiple `` blocks in a single response.\n\n\ - Use the provided tools to accomplish the task. Reply with a concise, dense \ - final answer when you have one — the parent agent will weave it back into the \ - user-visible response.\n\n", - ); - } - ToolCallFormat::Native => { - out.push_str( - "Use the provided tools via the model's native tool-calling output. \ - Reply with a concise, dense final answer when you have one — the parent \ - agent will weave it back into the user-visible response.\n\n", - ); - } - } - - // 3b. Optional safety preamble. Definitions that do work with real - // side-effects (code_executor, tool_maker, skills_agent) set - // `omit_safety_preamble = false` so the narrow renderer used to - // silently drop that instruction — we now honour the flag. - // Byte-identical to `SafetySection::build`. - if options.include_safety_preamble { - out.push_str( - "## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.\n\n", - ); - } - - // 3c. Optional skills catalogue. Off by default because sub-agents - // usually skip skills entirely. Kept here so a custom - // definition can opt in without falling back to the general - // builder. The renderer intentionally takes no `skills` slice - // — the caller would have to extend this helper before - // enabling this flag for real, which keeps the common (narrow) - // path free of extra arguments. - if options.include_skills_catalog { - out.push_str("## Available Skills\n\n"); - out.push_str( - "Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n", - ); - } - - // 3d. Connected integrations — short toolkit header only. The - // per-action catalogue is intentionally NOT rendered here: - // when the sub-agent runner spawns `skills_agent` with a - // `toolkit` argument it dynamically registers per-action - // tools whose JSON schemas travel through the provider's - // native tool-calling channel, making a prose enumeration - // redundant (and a token sink). For sub-agents that can - // delegate (`spawn_subagent` in their tool list), the - // wording instead points them at the Skills Agent. - if !connected_integrations.is_empty() { - let has_spawn = allowed_indices.iter().any(|&i| { - parent_tools - .get(i) - .map_or(false, |t| t.name() == "spawn_subagent") - }); - - out.push_str("## Connected Integrations\n\n"); - if has_spawn { - out.push_str( - "The user has the following external services connected. \ - To interact with any of these, delegate to the **Skills Agent** \ - (`skills_agent`) via `spawn_subagent`, passing the matching \ - `toolkit` argument.\n\n", - ); - } else { - out.push_str( - "You have direct access to the following external service. \ - Action tools are available in your tool list with their \ - typed parameter schemas — call them by name.\n\n", - ); - } - - for integration in connected_integrations { - let _ = writeln!( - out, - "- **{}** — {}", - integration.toolkit, integration.description, - ); - } - out.push('\n'); - } - - // 4. Insert the cache boundary before the dynamic tail. Typed - // sub-agents keep the narrow/static instructions above this - // marker and thread the resulting byte offset through the - // provider request so repeat spawns can reuse prompt prefill. - out.push_str(CACHE_BOUNDARY_MARKER); - out.push_str("\n\n"); - - // 5. Workspace so the model knows where it is. Intentionally stable: - // no datetime, no hostname, no pid — see the KV-cache note above. - let _ = writeln!( - out, - "## Workspace\n\nWorking directory: `{}`\n", - workspace_dir.display() - ); - - // 6. Runtime banner — model name only. Stable for the lifetime of - // this sub-agent's definition. - let _ = writeln!(out, "## Runtime\n\nModel: {model_name}"); - - out -} - -/// Ensure the workspace file is up-to-date with the compiled-in default. -/// -/// On first install the file doesn't exist → write it. On subsequent runs -/// we store a hash of the compiled-in content in a sidecar file -/// (`.{filename}.builtin-hash`). If the hash changes (code was updated), -/// the disk file is overwritten so prompt improvements ship automatically. -/// User edits between code releases are preserved — we only overwrite when -/// the built-in default itself changes. -fn sync_workspace_file(workspace_dir: &Path, filename: &str) { - let default_content = default_workspace_file_content(filename); - if default_content.is_empty() { - return; - } - - let path = workspace_dir.join(filename); - let hash_path = workspace_dir.join(format!(".{filename}.builtin-hash")); - - // Compute a simple hash of the current compiled-in content. - let current_hash = { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - default_content.hash(&mut hasher); - format!("{:016x}", hasher.finish()) - }; - - // Read the last-written hash (if any). - let stored_hash = std::fs::read_to_string(&hash_path).unwrap_or_default(); - - if stored_hash.trim() == current_hash && path.exists() { - // Built-in hasn't changed and file exists — nothing to do. - return; - } - - // Either first install, or the compiled-in default changed → write it. - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&path, default_content) { - log::warn!("[agent:prompt] failed to write workspace file {filename}: {e}"); - return; - } - let _ = std::fs::write(&hash_path, ¤t_hash); - log::info!("[agent:prompt] updated workspace file {filename} (builtin content changed)"); -} - -/// Inject `filename` from `workspace_dir` into `prompt`, truncated to -/// [`BOOTSTRAP_MAX_CHARS`]. Thin wrapper around -/// [`inject_workspace_file_capped`] for bootstrap-class files -/// (`SOUL.md`, `IDENTITY.md`, `HEARTBEAT.md`). -fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { - inject_workspace_file_capped(prompt, workspace_dir, filename, BOOTSTRAP_MAX_CHARS); -} - -/// Inject `filename` into `prompt` with an explicit character budget. -/// -/// Used directly by callers that want a tighter cap than -/// [`BOOTSTRAP_MAX_CHARS`] — notably `PROFILE.md` and `MEMORY.md` which -/// are user-specific, potentially growing, and do not warrant a full -/// 20K-char budget (see [`USER_FILE_MAX_CHARS`]). -/// -/// Missing / empty files are silently skipped so callers can inject -/// optional files unconditionally without emitting a noisy placeholder. -/// -/// **KV-cache contract:** the output is a pure function of `filename`, -/// file bytes at call time, and `max_chars`. Callers must invoke this -/// once per session — re-reading mid-session breaks the inference -/// backend's automatic prefix cache. See the byte-stability note on -/// [`render_subagent_system_prompt`]. -fn inject_workspace_file_capped( - prompt: &mut String, - workspace_dir: &Path, - filename: &str, - max_chars: usize, -) { - let path = workspace_dir.join(filename); - - match std::fs::read_to_string(&path) { - Ok(content) => { - let trimmed = content.trim(); - if trimmed.is_empty() { - return; - } - let _ = writeln!(prompt, "### {filename}\n"); - let truncated = if trimmed.chars().count() > max_chars { - trimmed - .char_indices() - .nth(max_chars) - .map(|(idx, _)| &trimmed[..idx]) - .unwrap_or(trimmed) - } else { - trimmed - }; - prompt.push_str(truncated); - if truncated.len() < trimmed.len() { - let _ = writeln!( - prompt, - "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" - ); - } else { - prompt.push_str("\n\n"); - } - } - Err(_) => { - // Keep prompt focused: missing optional identity/bootstrap files should not - // add noisy placeholders that dilute tool-calling instructions. - } - } -} - -fn default_workspace_file_content(filename: &str) -> &'static str { - // The bundled identity files live at `src/openhuman/agent/prompts/` - // (owned by the `agent/` tree because they describe agent identity). - // This module is under `src/openhuman/context/`, so the relative path - // walks up one level and back into `agent/prompts/`. - match filename { - "SOUL.md" => include_str!("../agent/prompts/SOUL.md"), - "IDENTITY.md" => include_str!("../agent/prompts/IDENTITY.md"), - "HEARTBEAT.md" => { - "# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n" - } - _ => "", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::tools::traits::Tool; - use async_trait::async_trait; - use std::collections::HashSet; - use std::sync::LazyLock; - - static NO_FILTER: LazyLock> = LazyLock::new(HashSet::new); - - struct TestTool; - - #[async_trait] - impl Tool for TestTool { - fn name(&self) -> &str { - "test_tool" - } - - fn description(&self) -> &str { - "tool desc" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - - async fn execute( - &self, - _args: serde_json::Value, - ) -> anyhow::Result { - Ok(crate::openhuman::tools::ToolResult::success("ok")) - } - } - - #[test] - fn prompt_builder_assembles_sections() { - let tools: Vec> = vec![Box::new(TestTool)]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "instr", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = SystemPromptBuilder::with_defaults() - .build_with_cache_metadata(&ctx) - .unwrap(); - assert!(rendered.text.contains("## Tools")); - assert!(rendered.text.contains("test_tool")); - assert!(rendered.text.contains("instr")); - assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER)); - assert!(rendered.cache_boundary.is_some()); - } - - #[test] - fn identity_section_creates_missing_workspace_files() { - let workspace = - std::env::temp_dir().join(format!("openhuman_prompt_create_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: &workspace, - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let section = IdentitySection; - let _ = section.build(&ctx).unwrap(); - - for file in ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"] { - assert!( - workspace.join(file).exists(), - "expected workspace file to be created: {file}" - ); - } - let soul = std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(); - assert!( - soul.starts_with("# OpenHuman"), - "SOUL.md should be seeded from src/openhuman/agent/prompts/SOUL.md" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn datetime_section_includes_timestamp_and_timezone() { - let tools: Vec> = vec![]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "instr", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let rendered = DateTimeSection.build(&ctx).unwrap(); - assert!(rendered.starts_with("## Current Date & Time\n\n")); - - let payload = rendered.trim_start_matches("## Current Date & Time\n\n"); - assert!(payload.chars().any(|c| c.is_ascii_digit())); - assert!(payload.contains(" (")); - assert!(payload.ends_with(')')); - } - - #[test] - fn extract_cache_boundary_removes_marker_and_returns_offset() { - let rendered = extract_cache_boundary("static\n\n\n\ndynamic\n"); - assert_eq!(rendered.text, "static\n\ndynamic\n"); - assert_eq!(rendered.cache_boundary, Some("static\n\n".len())); - } - - #[test] - fn tools_section_pformat_renders_signature_not_schema() { - // ToolsSection must render `name[arg1|arg2]` signatures when - // `tool_call_format = PFormat`, NOT the verbose JSON schema — - // that's where most of the prompt token saving comes from. - struct ParamTool; - #[async_trait] - impl Tool for ParamTool { - fn name(&self) -> &str { - "make_tea" - } - fn description(&self) -> &str { - "brew a cup of tea" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "kind": { "type": "string" }, - "sugar": { "type": "boolean" } - } - }) - } - async fn execute( - &self, - _args: serde_json::Value, - ) -> anyhow::Result { - Ok(crate::openhuman::tools::ToolResult::success("ok")) - } - } - - let tools: Vec> = vec![Box::new(ParamTool)]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let rendered = ToolsSection.build(&ctx).unwrap(); - // Alphabetical: kind, sugar. - assert!( - rendered.contains("Call as: `make_tea[kind|sugar]`"), - "expected p-format signature in tools section, got:\n{rendered}" - ); - // Should NOT contain the raw JSON schema dump. - assert!( - !rendered.contains("\"properties\""), - "tools section should drop the raw JSON schema in p-format mode, got:\n{rendered}" - ); - } - - #[test] - fn tools_section_json_renders_full_schema() { - // The legacy `Json` mode must keep emitting full schemas so - // existing prompts that depend on the verbose form are not - // silently changed. - let tools: Vec> = vec![Box::new(TestTool)]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::Json, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let rendered = ToolsSection.build(&ctx).unwrap(); - assert!( - rendered.contains("Parameters:"), - "JSON mode should still print Parameters lines, got:\n{rendered}" - ); - assert!( - rendered.contains("\"type\""), - "JSON mode should print the schema body, got:\n{rendered}" - ); - } - - #[test] - fn user_memory_section_renders_namespaces_with_headings() { - let learned = LearnedContextData { - tree_root_summaries: vec![ - ("user".into(), "Steven prefers terse Rust answers.".into()), - ( - "conversations".into(), - "Recent thread: prompt rework.".into(), - ), - ], - ..Default::default() - }; - let prompt_tools: Vec> = Vec::new(); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned, - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = UserMemorySection.build(&ctx).unwrap(); - assert!(rendered.starts_with("## User Memory\n\n")); - assert!(rendered.contains("### user\n\nSteven prefers terse Rust answers.")); - assert!(rendered.contains("### conversations\n\nRecent thread: prompt rework.")); - } - - #[test] - fn user_memory_section_returns_empty_when_no_summaries() { - // Empty learned context → section returns empty string and is - // skipped by the prompt builder, so the cache boundary stays - // exactly where it was for workspaces with no tree summaries. - let learned = LearnedContextData::default(); - let prompt_tools: Vec> = Vec::new(); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned, - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = UserMemorySection.build(&ctx).unwrap(); - assert!(rendered.is_empty()); - } - - #[test] - fn render_subagent_system_prompt_includes_cache_boundary_before_workspace() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_subagent_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = extract_cache_boundary(&render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a focused sub-agent.", - SubagentRenderOptions::narrow(), - ToolCallFormat::PFormat, - &[], - )); - - assert!( - rendered.cache_boundary.is_some(), - "typed sub-agent prompts should expose an explicit cache boundary" - ); - assert!(rendered.text.contains("## Workspace")); - assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER)); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn extract_cache_boundary_without_marker_returns_original_text() { - let rendered = extract_cache_boundary("hello"); - assert_eq!(rendered.text, "hello"); - assert_eq!(rendered.cache_boundary, None); - } - - #[test] - fn subagent_render_options_invert_definition_flags() { - // (omit_identity, omit_safety_preamble, omit_skills_catalog, - // omit_profile, omit_memory_md) - let options = SubagentRenderOptions::from_definition_flags(true, false, true, false, false); - assert!(!options.include_identity); - assert!(options.include_safety_preamble); - assert!(!options.include_skills_catalog); - assert!(options.include_profile); - assert!(options.include_memory_md); - let narrow = SubagentRenderOptions::narrow(); - let default = SubagentRenderOptions::default(); - assert_eq!(narrow.include_identity, default.include_identity); - assert_eq!( - narrow.include_safety_preamble, - default.include_safety_preamble - ); - assert_eq!( - narrow.include_skills_catalog, - default.include_skills_catalog - ); - assert_eq!(narrow.include_profile, default.include_profile); - assert_eq!(narrow.include_memory_md, default.include_memory_md); - // Narrow default = every flag off, including both user files. - assert!(!narrow.include_profile); - assert!(!narrow.include_memory_md); - } - - #[test] - fn render_subagent_system_prompt_honors_identity_safety_and_skills_flags() { - let workspace = - std::env::temp_dir().join(format!("openhuman_prompt_opts_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nContext").unwrap(); - std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nContext").unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt_with_format( - &workspace, - "reasoning-v1", - &[0], - &tools, - &[], - "You are a specialist.", - SubagentRenderOptions { - include_identity: true, - include_safety_preamble: true, - include_skills_catalog: true, - include_profile: false, - include_memory_md: false, - }, - ToolCallFormat::Json, - &[], - ); - - assert!(rendered.contains("## Project Context")); - assert!(rendered.contains("### SOUL.md")); - assert!(rendered.contains("## Safety")); - assert!(rendered.contains("## Available Skills")); - // Json is a prompt-driven format (the model wraps JSON tool - // calls in `` tags); it does NOT use the provider's - // native function-calling channel. So the prose `## Tools` - // section MUST still be rendered for Json, with each tool's - // parameter schema inline so the model knows what to emit. - // Only `ToolCallFormat::Native` gets the section omitted (see - // the `native` branch below and the `!matches!(…, Native)` - // guard in the renderer). - assert!(rendered.contains("## Tools")); - assert!(rendered.contains("Parameters:")); - assert!(rendered.contains("\"type\"")); - - let native = render_subagent_system_prompt_with_format( - &workspace, - "reasoning-v1", - &[0], - &tools, - &[], - "You are a specialist.", - SubagentRenderOptions::narrow(), - ToolCallFormat::Native, - &[], - ); - assert!(native.contains("native tool-calling output")); - assert!(!native.contains("## Safety")); - // Native is the only format where the prose `## Tools` section - // is intentionally omitted — schemas travel through the - // provider's `tools` field instead. Regression guard against - // the ~54k-token schema duplication from the #447 PR. - assert!(!native.contains("\n## Tools\n")); - assert!(!native.contains("Parameters:")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_injects_profile_md_even_when_identity_omitted() { - // Regression: the welcome agent sets `omit_identity = true` to - // drop the SOUL/IDENTITY preamble (it has its own voice) but it - // still needs PROFILE.md to personalise the greeting. PROFILE.md - // is gated on its own `include_profile` flag so the welcome path - // can opt in without pulling SOUL/IDENTITY back in. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_nosoul_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nShould be hidden").unwrap(); - std::fs::write( - workspace.join("IDENTITY.md"), - "# Identity\nShould be hidden", - ) - .unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nName: Jane Doe\nRole: Data scientist", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the welcome agent.", - SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: false, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - rendered.contains("### PROFILE.md"), - "PROFILE.md header must appear when include_profile=true, got:\n{rendered}" - ); - assert!( - rendered.contains("Jane Doe"), - "PROFILE.md body must be injected when include_profile=true, got:\n{rendered}" - ); - assert!( - !rendered.contains("## Project Context"), - "identity preamble must still be suppressed when include_identity=false" - ); - assert!( - !rendered.contains("### SOUL.md") && !rendered.contains("### IDENTITY.md"), - "SOUL/IDENTITY must still be suppressed when include_identity=false" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_skips_profile_md_when_include_profile_false() { - // Mirror of the opt-in regression above: narrow specialists - // (planner, code_executor, critic, …) set `omit_profile = true` - // and must NOT see PROFILE.md even when the file is on disk — - // otherwise every sub-agent pays the token cost of onboarding - // enrichment output that is irrelevant to their task. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_opt_out_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nName: Jane Doe\nRole: Data scientist", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a narrow specialist.", - SubagentRenderOptions::narrow(), // include_profile defaults to false - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### PROFILE.md"), - "PROFILE.md must NOT appear when include_profile=false, got:\n{rendered}" - ); - assert!( - !rendered.contains("Jane Doe"), - "PROFILE.md body must NOT be leaked when include_profile=false" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_injects_profile_md_when_identity_included() { - // When identity is on, PROFILE.md must still be injected alongside - // SOUL/IDENTITY — the split must not regress the non-welcome path. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_with_identity_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nctx").unwrap(); - std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nctx").unwrap(); - std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nhello").unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a specialist.", - SubagentRenderOptions { - include_identity: true, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: false, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!(rendered.contains("## Project Context")); - assert!(rendered.contains("### SOUL.md")); - assert!(rendered.contains("### IDENTITY.md")); - assert!(rendered.contains("### PROFILE.md")); - assert!(rendered.contains("hello")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_silently_skips_missing_profile_md() { - // Pre-onboarding workspaces have no PROFILE.md. The renderer must - // not emit a noisy "[File not found: PROFILE.md]" placeholder or - // an orphan "### PROFILE.md" header — the subagent prompt stays - // focused on tools. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_missing_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the welcome agent.", - SubagentRenderOptions::narrow(), - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### PROFILE.md"), - "empty/missing PROFILE.md should not emit a header, got:\n{rendered}" - ); - assert!( - !rendered.contains("[File not found: PROFILE.md]"), - "missing PROFILE.md should be silent, not a noisy placeholder" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn welcome_agent_definition_flags_still_load_profile_md() { - // End-to-end-ish check against the real welcome agent flags: the - // agent.toml sets omit_identity=true/omit_skills_catalog=true/ - // omit_safety_preamble=true/omit_profile=false. Mirror that exact - // combo and verify PROFILE.md still lands in the rendered prompt. - // If someone flips `omit_profile` back to its default (true), this - // test breaks. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_welcome_flags_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nTimezone: PST\nRole: Crypto trader", - ) - .unwrap(); - - // Match `src/openhuman/agent/agents/welcome/agent.toml` exactly. - let options = SubagentRenderOptions::from_definition_flags( - true, // omit_identity - true, // omit_safety_preamble - true, // omit_skills_catalog - false, // omit_profile — welcome opts IN to PROFILE.md - false, // omit_memory_md — welcome opts IN to MEMORY.md too - ); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "# Welcome Agent\n\nYou are the welcome agent.", - options, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - rendered.contains("### PROFILE.md"), - "welcome agent (omit_profile=false) must load PROFILE.md, got:\n{rendered}" - ); - assert!( - rendered.contains("Crypto trader"), - "PROFILE.md body must reach the welcome agent prompt" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn narrow_subagent_definition_flags_skip_profile_md() { - // Inverse of `welcome_agent_definition_flags_still_load_profile_md`: - // a narrow specialist (e.g. `code_executor`, `critic`) leaves - // `omit_profile` at its default `true`. PROFILE.md must NOT be - // injected even when present on disk — the narrow runner is - // task-focused and should not pay the token cost. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_narrow_flags_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nTimezone: PST\nRole: Crypto trader", - ) - .unwrap(); - - // Mirrors e.g. `critic/agent.toml` — all omit_* default-true. - let options = SubagentRenderOptions::from_definition_flags(true, true, true, true, true); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a narrow specialist.", - options, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### PROFILE.md"), - "narrow specialist (omit_profile=true) must NOT load PROFILE.md, got:\n{rendered}" - ); - assert!( - !rendered.contains("Crypto trader"), - "narrow specialist must not leak PROFILE.md body" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_injects_memory_md_when_enabled() { - // Opt-in agents with `omit_memory_md = false` must see MEMORY.md - // (archivist-curated long-term memory) in their rendered prompt. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_memory_on_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("MEMORY.md"), - "# Long-term memory\nUser prefers terse Rust answers.", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the welcome agent.", - SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: false, - include_memory_md: true, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - rendered.contains("### MEMORY.md"), - "MEMORY.md header must appear when include_memory_md=true, got:\n{rendered}" - ); - assert!( - rendered.contains("terse Rust answers"), - "MEMORY.md body must be injected when include_memory_md=true" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_skips_memory_md_when_disabled() { - // Narrow specialists with `omit_memory_md = true` (the default) - // must NOT see MEMORY.md even when it exists on disk. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_memory_off_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("MEMORY.md"), - "# Long-term memory\nUser prefers terse Rust answers.", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a narrow specialist.", - SubagentRenderOptions::narrow(), - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### MEMORY.md"), - "MEMORY.md must NOT appear when include_memory_md=false, got:\n{rendered}" - ); - assert!( - !rendered.contains("terse Rust answers"), - "MEMORY.md body must not leak when include_memory_md=false" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn profile_md_and_memory_md_are_capped_at_user_file_max_chars() { - // Both PROFILE.md and MEMORY.md are user-specific files that can - // grow over time. Injection caps them at USER_FILE_MAX_CHARS - // (~1000 tokens each) so the system prompt footprint stays - // bounded. Test both files at once to pin the shared budget. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_user_cap_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - let big = "x".repeat(USER_FILE_MAX_CHARS + 500); - std::fs::write(workspace.join("PROFILE.md"), &big).unwrap(); - std::fs::write(workspace.join("MEMORY.md"), &big).unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the orchestrator.", - SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: true, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!(rendered.contains("### PROFILE.md")); - assert!(rendered.contains("### MEMORY.md")); - // Each file gets its own truncation marker mentioning the cap. - let marker = format!("[... truncated at {USER_FILE_MAX_CHARS} chars"); - assert_eq!( - rendered.matches(marker.as_str()).count(), - 2, - "both PROFILE.md and MEMORY.md must emit the truncation marker at \ - USER_FILE_MAX_CHARS — found:\n{rendered}" - ); - // Sanity-check the cap is genuinely tighter than the bootstrap cap. - assert!(USER_FILE_MAX_CHARS < BOOTSTRAP_MAX_CHARS); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn rendered_subagent_system_prompt_is_byte_stable_across_repeat_calls() { - // KV-cache contract: two spawns of the same sub-agent definition - // against the same workspace must produce byte-identical system - // prompts. If PROFILE.md or MEMORY.md are re-read with a - // different-typed truncation path, or if either cap drifts, the - // bytes differ and the backend's automatic prefix cache busts. - // This test pins the invariant end-to-end. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_byte_stable_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nJane Doe").unwrap(); - std::fs::write(workspace.join("MEMORY.md"), "# Memory\nRecent: shipped v1").unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let opts = SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: true, - }; - - let first = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the orchestrator.", - opts, - ToolCallFormat::PFormat, - &[], - ); - let second = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the orchestrator.", - opts, - ToolCallFormat::PFormat, - &[], - ); - - assert_eq!( - first, second, - "repeat spawns must produce byte-identical prompts" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { - // Regression pin for the review finding: the runtime Tauri chat - // path spins welcome/trigger_* via `Agent::from_config_for_agent` - // → `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`, - // which deliberately drops `IdentitySection`. Before - // `UserFilesSection` existed, our PROFILE/MEMORY injection lived - // inside `IdentitySection::build` and got dropped along with it, - // so the first Tauri turn never saw the user's onboarding output - // even though the subagent_runner path and the debug dumper did. - // - // This test exercises the exact builder call-site the runtime - // uses for welcome (`omit_identity = true`, both user-file flags - // opted in via PromptContext) and pins that the rendered prompt - // contains both files. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_for_subagent_user_files_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nJane Doe — crypto trader in PST.", - ) - .unwrap(); - std::fs::write( - workspace.join("MEMORY.md"), - "# Long-term memory\nShipped v1 last sprint; prefers terse Rust.", - ) - .unwrap(); - - let tools: Vec> = vec![]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: &workspace, - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: true, - include_memory_md: true, - }; - - // Mirror the welcome agent runtime path: - // `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`. - let builder = SystemPromptBuilder::for_subagent( - "You are the welcome agent.".into(), - true, // omit_identity — drops SOUL/IDENTITY preamble - true, // omit_safety_preamble - true, // omit_skills_catalog - ); - let rendered = builder.build(&ctx).unwrap(); - - assert!( - !rendered.contains("## Project Context"), - "identity preamble must still be suppressed when omit_identity=true" - ); - assert!( - rendered.contains("### PROFILE.md") && rendered.contains("Jane Doe"), - "welcome runtime path must inject PROFILE.md despite omit_identity=true, got:\n{rendered}" - ); - assert!( - rendered.contains("### MEMORY.md") && rendered.contains("terse Rust"), - "welcome runtime path must inject MEMORY.md despite omit_identity=true, got:\n{rendered}" - ); - - // Mirror the narrow-specialist runtime path (code_executor, - // critic, …): both flags off → user files must stay out. - let ctx_narrow = PromptContext { - workspace_dir: &workspace, - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let narrow = builder.build(&ctx_narrow).unwrap(); - assert!( - !narrow.contains("### PROFILE.md") && !narrow.contains("### MEMORY.md"), - "narrow specialist runtime path must NOT leak user files, got:\n{narrow}" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn sync_workspace_file_updates_hash_and_inject_workspace_file_truncates() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_workspace_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - sync_workspace_file(&workspace, "SOUL.md"); - let hash_path = workspace.join(".SOUL.md.builtin-hash"); - assert!(workspace.join("SOUL.md").exists()); - assert!(hash_path.exists()); - let original_hash = std::fs::read_to_string(&hash_path).unwrap(); - - std::fs::write(workspace.join("SOUL.md"), "user override").unwrap(); - sync_workspace_file(&workspace, "SOUL.md"); - assert_eq!(std::fs::read_to_string(&hash_path).unwrap(), original_hash); - assert_eq!( - std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(), - "user override" - ); - - std::fs::write( - workspace.join("BIG.md"), - "x".repeat(BOOTSTRAP_MAX_CHARS + 50), - ) - .unwrap(); - let mut prompt = String::new(); - inject_workspace_file(&mut prompt, &workspace, "BIG.md"); - assert!(prompt.contains("### BIG.md")); - assert!(prompt.contains("[... truncated at")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn dynamic_section_classification_matches_cache_boundary_rules() { - assert!(is_dynamic_section("workspace")); - assert!(is_dynamic_section("datetime")); - assert!(is_dynamic_section("runtime")); - assert!(!is_dynamic_section("tools")); - assert!(!is_dynamic_section("identity")); - } - - #[test] - fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { - let plain = PromptTool::new("shell", "run commands"); - assert_eq!(plain.name, "shell"); - assert!(plain.parameters_schema.is_none()); - - let with_schema = - PromptTool::with_schema("http_request", "fetch data", "{\"type\":\"object\"}".into()); - assert_eq!( - with_schema.parameters_schema.as_deref(), - Some("{\"type\":\"object\"}") - ); - - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "model", - agent_id: "", - tools: &[], - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData { - tree_root_summaries: vec![ - ("user".into(), "kept".into()), - ("empty".into(), " ".into()), - ], - ..Default::default() - }, - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = UserMemorySection.build(&ctx).unwrap(); - assert!(rendered.contains("### user")); - assert!(!rendered.contains("### empty")); - assert_eq!(default_workspace_file_content("missing"), ""); - } -} +//! Compat shim — prompt plumbing has moved to [`crate::openhuman::agent::prompts`]. +//! +//! This file used to hold the full prompt rendering pipeline (type +//! definitions, section builders, `SystemPromptBuilder`, +//! `render_subagent_system_prompt`). All of that now lives under +//! `agent::prompts` so prompt logic sits next to the agents that +//! consume it. This module stays around as a stable import path for +//! the rest of the tree — `use crate::openhuman::context::prompt::...` +//! keeps working unchanged. + +pub use crate::openhuman::agent::prompts::*; diff --git a/src/openhuman/providers/reliable.rs b/src/openhuman/providers/reliable.rs index 6578ab197..05a20c818 100644 --- a/src/openhuman/providers/reliable.rs +++ b/src/openhuman/providers/reliable.rs @@ -576,7 +576,6 @@ impl Provider for ReliableProvider { let req = ChatRequest { messages: request.messages, tools: request.tools, - system_prompt_cache_boundary: request.system_prompt_cache_boundary, stream: stream_this_attempt, }; match provider.chat(req, current_model, temperature).await { diff --git a/src/openhuman/providers/traits.rs b/src/openhuman/providers/traits.rs index 5907ec94e..b0d735b92 100644 --- a/src/openhuman/providers/traits.rs +++ b/src/openhuman/providers/traits.rs @@ -114,15 +114,15 @@ pub enum ProviderDelta { } /// Request payload for provider chat calls. +/// +/// The system prompt is built once at session start and frozen for the +/// rest of the session — the inference backend's automatic prefix +/// cache covers the whole thing, so there is no explicit cache-boundary +/// to thread through the request. #[derive(Debug, Clone, Copy)] pub struct ChatRequest<'a> { pub messages: &'a [ChatMessage], pub tools: Option<&'a [ToolSpec]>, - /// Byte offset in the system prompt where static (cacheable) content ends - /// and dynamic content begins. Providers that support prompt caching can - /// apply `cache_control` to the prefix before this boundary. - /// `None` means no cache boundary is known. - pub system_prompt_cache_boundary: Option, /// Optional sink for `ProviderDelta` events. When `Some`, providers /// that support streaming will ask the upstream API for SSE and /// forward fine-grained events here. Providers without a streaming @@ -825,7 +825,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::user("Hello")], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; @@ -844,7 +843,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::user("Hello")], tools: None, - system_prompt_cache_boundary: None, stream: None, }; @@ -946,7 +944,6 @@ mod tests { ChatMessage::system("BASE_SYSTEM_PROMPT"), ], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; @@ -970,7 +967,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::system("BASE"), ChatMessage::user("Hello")], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; @@ -994,7 +990,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::user("Hello")], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; diff --git a/src/openhuman/tools/impl/agent/complete_onboarding.rs b/src/openhuman/tools/impl/agent/complete_onboarding.rs index ffc100bbb..e11f8c6ef 100644 --- a/src/openhuman/tools/impl/agent/complete_onboarding.rs +++ b/src/openhuman/tools/impl/agent/complete_onboarding.rs @@ -143,7 +143,7 @@ impl Tool for CompleteOnboardingTool { \"composio\": true,\n\ \"browser\": false,\n\ \"web_search\": true,\n\ - \"http_request\": false,\n\ + \"http_request\": true,\n\ \"local_ai\": true\n\ },\n\ \"memory\": { \"backend\": \"sqlite\", \"auto_save\": true },\n\ @@ -418,8 +418,8 @@ pub(crate) fn build_status_snapshot( "integrations": { "composio": composio_enabled, "browser": config.browser.enabled, - "web_search": config.web_search.enabled, - "http_request": config.http_request.enabled, + "web_search": true, + "http_request": true, "local_ai": config.local_ai.enabled, }, "memory": { diff --git a/src/openhuman/tools/impl/agent/dispatch.rs b/src/openhuman/tools/impl/agent/dispatch.rs index 0bc7d2618..900b42503 100644 --- a/src/openhuman/tools/impl/agent/dispatch.rs +++ b/src/openhuman/tools/impl/agent/dispatch.rs @@ -54,8 +54,8 @@ pub(crate) async fn dispatch_subagent( ); // Propagate the per-call toolkit scope into the subagent runner so - // that `SkillDelegationTool`s can narrow `skills_agent` to a single - // Composio toolkit (e.g. `delegate_gmail` → skills_agent + + // that `SkillDelegationTool`s can narrow `integrations_agent` to a single + // Composio toolkit (e.g. `delegate_gmail` → integrations_agent + // toolkit="gmail"). Earlier code plumbed this through // `skill_filter_override` (which matches `{skill}__` QuickJS-style // names), but Composio actions are named `GMAIL_*` / `NOTION_*` — @@ -64,7 +64,6 @@ pub(crate) async fn dispatch_subagent( // check, restricted to skill-category tools. let options = SubagentRunOptions { skill_filter_override: None, - category_filter_override: None, toolkit_override: skill_filter.map(str::to_string), context: None, task_id: Some(task_id.clone()), diff --git a/src/openhuman/tools/impl/agent/skill_delegation.rs b/src/openhuman/tools/impl/agent/skill_delegation.rs index 2b3f09ca4..f9c761364 100644 --- a/src/openhuman/tools/impl/agent/skill_delegation.rs +++ b/src/openhuman/tools/impl/agent/skill_delegation.rs @@ -56,7 +56,7 @@ impl Tool for SkillDelegationTool { } super::dispatch_subagent( - "skills_agent", + "integrations_agent", &self.tool_name, &prompt, Some(&self.skill_id), diff --git a/src/openhuman/tools/impl/agent/spawn_subagent.rs b/src/openhuman/tools/impl/agent/spawn_subagent.rs index f7fadc3e7..a1ad0857f 100644 --- a/src/openhuman/tools/impl/agent/spawn_subagent.rs +++ b/src/openhuman/tools/impl/agent/spawn_subagent.rs @@ -22,7 +22,7 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::current_parent; use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; @@ -75,7 +75,7 @@ impl Tool for SpawnSubagentTool { fn description(&self) -> &str { "Delegate a task to a specialised sub-agent. See the Delegation \ Guide in the system prompt for available agent_ids and when to \ - use each. When delegating to `skills_agent`, you MUST also pass \ + use each. When delegating to `integrations_agent`, you MUST also pass \ `toolkit=\"\"` naming the Composio integration the \ sub-task targets (e.g. `gmail`, `notion`); the sub-agent will \ only see that toolkit's actions." @@ -120,14 +120,9 @@ impl Tool for SpawnSubagentTool { "type": "string", "description": "Optional context blob from prior task results. Rendered as a `[Context]` block before the prompt." }, - "category_filter": { - "type": "string", - "enum": ["system", "skill"], - "description": "Optional tool-category restriction. `skill` scopes the sub-agent to integration tools (for example Composio-backed SaaS actions); `system` scopes it to built-in Rust tools. Overrides the definition's `category_filter` for this single spawn." - }, "toolkit": { "type": "string", - "description": "Composio toolkit slug to scope this spawn to — e.g. `gmail`, `notion`, `slack`. REQUIRED when `agent_id = \"skills_agent\"`. Narrows the sub-agent's visible Composio actions AND its Connected Integrations prompt section to only that toolkit's catalogue, so the sub-agent's context window only carries the platform it was asked to operate on. Must match a currently-connected integration (see the Delegation Guide)." + "description": "Composio toolkit slug to scope this spawn to — e.g. `gmail`, `notion`, `slack`. REQUIRED when `agent_id = \"integrations_agent\"`. Narrows the sub-agent's visible Composio actions AND its Connected Integrations prompt section to only that toolkit's catalogue, so the sub-agent's context window only carries the platform it was asked to operate on. Must match a currently-connected integration (see the Delegation Guide)." }, "mode": { "type": "string", @@ -164,17 +159,6 @@ impl Tool for SpawnSubagentTool { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let category_filter_override = match args.get("category_filter").and_then(|v| v.as_str()) { - Some("system") => Some(ToolCategory::System), - Some("skill") => Some(ToolCategory::Skill), - Some(other) => { - return Ok(ToolResult::error(format!( - "spawn_subagent: unknown category_filter '{other}' (expected 'system' or 'skill')" - ))); - } - None => None, - }; - let toolkit_override = args .get("toolkit") .and_then(|v| v.as_str()) @@ -222,14 +206,14 @@ impl Tool for SpawnSubagentTool { } }; - // ── skills_agent toolkit gate ────────────────────────────────── - // skills_agent is a platform-parameterised specialist. Every + // ── integrations_agent toolkit gate ────────────────────────────────── + // integrations_agent is a platform-parameterised specialist. Every // spawn MUST name a CONNECTED toolkit so the sub-agent only // sees one integration's tool catalogue instead of all of // them. We split validation into three cases so the model // gets a precise, actionable error on every failure mode — // nothing reaches the LLM loop unless the spawn is valid. - if definition.id == "skills_agent" { + if definition.id == "integrations_agent" { let parent_ctx = current_parent(); let allowlist: Vec<&crate::openhuman::context::prompt::ConnectedIntegration> = parent_ctx @@ -242,10 +226,19 @@ impl Tool for SpawnSubagentTool { .map(|ci| ci.toolkit.clone()) .collect(); + tracing::debug!( + target: "spawn_subagent", + toolkit = ?toolkit_override, + allowlist_count = allowlist.len(), + connected_count = connected_slugs.len(), + connected = ?connected_slugs, + "[spawn_subagent] integrations_agent gate: validating toolkit" + ); + match toolkit_override.as_deref() { None => { return Ok(ToolResult::error(format!( - "spawn_subagent(skills_agent): the `toolkit` argument is required. \ + "spawn_subagent(integrations_agent): the `toolkit` argument is required. \ Pass one of the currently-connected toolkits: [{}]. \ See the Delegation Guide in your system prompt for which toolkit \ matches each task.", @@ -260,7 +253,7 @@ impl Tool for SpawnSubagentTool { None => { // Toolkit isn't even in the backend allowlist. return Ok(ToolResult::error(format!( - "spawn_subagent(skills_agent): toolkit '{tk}' is not in \ + "spawn_subagent(integrations_agent): toolkit '{tk}' is not in \ the backend allowlist. Valid toolkits: [{}]. Check the \ Delegation Guide in your system prompt for the exact slug.", allowlist @@ -292,7 +285,11 @@ impl Tool for SpawnSubagentTool { ))); } Some(_) => { - // Connected — fall through to spawn. + tracing::debug!( + target: "spawn_subagent", + toolkit = %tk, + "[spawn_subagent] integrations_agent gate: toolkit connected, proceeding with spawn" + ); } } } @@ -316,7 +313,6 @@ impl Tool for SpawnSubagentTool { // ── Run the sub-agent ────────────────────────────────────────── let options = SubagentRunOptions { skill_filter_override: None, - category_filter_override, toolkit_override, context, task_id: Some(task_id.clone()), diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index fd4d37425..b371d735f 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -132,25 +132,28 @@ pub fn all_tools_with_runtime( ))); } - if http_config.enabled { - tools.push(Box::new(HttpRequestTool::new( - security.clone(), - http_config.allowed_domains.clone(), - http_config.max_response_size, - http_config.timeout_secs, - ))); - } + // HTTP request — always registered. `http_request.allowed_domains` + // + `security` still gate which hosts are reachable; there is no + // enable flag because every session needs basic HTTP as a baseline + // capability. + tools.push(Box::new(HttpRequestTool::new( + security.clone(), + http_config.allowed_domains.clone(), + http_config.max_response_size, + http_config.timeout_secs, + ))); - // Web search tool (enabled by default for GLM and other models) - if root_config.web_search.enabled { - tools.push(Box::new(WebSearchTool::new( - root_config.web_search.provider.clone(), - root_config.web_search.brave_api_key.clone(), - root_config.web_search.parallel_api_key.clone(), - root_config.web_search.max_results, - root_config.web_search.timeout_secs, - ))); - } + // Web search — always registered. Provider / API-key / budget + // knobs still come from `config.web_search`, but there is no + // enable flag: every session needs research as a baseline + // capability. + tools.push(Box::new(WebSearchTool::new( + root_config.web_search.provider.clone(), + root_config.web_search.brave_api_key.clone(), + root_config.web_search.parallel_api_key.clone(), + root_config.web_search.max_results, + root_config.web_search.timeout_secs, + ))); // Vision tools are always available tools.push(Box::new(ScreenshotTool::new(security.clone()))); diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index dabc0bf93..9242df71b 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -44,7 +44,7 @@ use super::{ArchetypeDelegationTool, SkillDelegationTool, Tool}; /// Each [`SubagentEntry::Skills`] wildcard expands to one /// [`SkillDelegationTool`] per connected Composio integration in /// `connected_integrations`. The synthesised tool routes to the generic -/// `skills_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set. +/// `integrations_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set. /// /// Entries that reference unknown agent ids (not in the registry) are /// logged at `warn` and skipped — the orchestrator still builds, just @@ -128,17 +128,17 @@ pub fn collect_orchestrator_tools( // on brand-new or poorly-populated toolkits. let description = if integration.description.trim().is_empty() { format!( - "Delegate to the skills agent with the `{}` integration pre-selected.", + "Delegate to the integrations_agent with the `{}` integration pre-selected.", integration.toolkit ) } else { format!( - "Delegate to the skills agent using `{}`. {}", + "Delegate to the integrations_agent using `{}`. {}", integration.toolkit, integration.description ) }; log::debug!( - "[orchestrator_tools] registering skill delegation tool: {} -> skills_agent (skill_filter={})", + "[orchestrator_tools] registering skill delegation tool: {} -> integrations_agent (skill_filter={})", tool_name, slug ); @@ -202,7 +202,6 @@ mod tests { tools: ToolScope::Wildcard, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 8, timeout_secs: None, diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index f08828682..c8319232c 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -129,6 +129,8 @@ fn stub_parent_context() -> ParentExecutionContext { connected_integrations: vec![], composio_client: None, tool_call_format: openhuman_core::openhuman::context::prompt::ToolCallFormat::PFormat, + session_key: "test-session".into(), + session_parent_prefix: None, } } @@ -192,7 +194,6 @@ async fn fork_and_parent_contexts_are_visible_only_within_scope() { system_prompt: Arc::new("hello".into()), tool_specs: Arc::new(vec![]), message_prefix: Arc::new(vec![ChatMessage::system("hello")]), - cache_boundary: Some(5), fork_task_prompt: "do thing".into(), }; @@ -200,7 +201,6 @@ async fn fork_and_parent_contexts_are_visible_only_within_scope() { let inner = current_fork().expect("fork context should be visible"); assert_eq!(*inner.system_prompt, "hello"); assert_eq!(inner.fork_task_prompt, "do thing"); - assert_eq!(inner.cache_boundary, Some(5)); assert_eq!(inner.message_prefix.len(), 1); }) .await;