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