From fff24a6c6fbcaf02d19ffc1f858864c18907b5ef Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:47:31 -0700 Subject: [PATCH] fix(prompts): v2 prompt pipeline, integrations_agent, and subagent session plumbing (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(transcript): update session transcript paths and enhance directory structure - Changed the source of truth path for session transcripts from `sessions/{DDMMYYYY}/{agent}_{index}.jsonl` to `session_raw/{DDMMYYYY}/{agent}_{index}.jsonl` to better reflect the organization of files. - Updated the logic for creating and resolving transcript paths to accommodate the new directory structure, ensuring compatibility with legacy `.md` files. - Improved documentation to clarify the changes in file organization and their implications for transcript management. This refactor enhances the clarity and maintainability of session transcript handling by establishing a more logical file structure. * refactor(prompts): update tool handling in prompt builders - Replaced `Vec` with `Vec>` for available tools in multiple agent prompt builders, enhancing type safety and clarity. - Introduced `render_tool_catalog` and `render_connected_integrations` functions to dynamically generate sections in prompts based on available tools and connected integrations. - Updated the `build` function in various agent prompts to utilize the new rendering functions, ensuring that prompts accurately reflect the current context and available resources. These changes improve the maintainability and functionality of the prompt generation process across different agents. * refactor(prompts): streamline prompt builders for agent templates - Updated the prompt builders for various agents to utilize the sibling `prompt.md` template directly, enhancing clarity and maintainability. - Replaced `Vec>` with `Vec` and `Vec` for improved type safety in test cases. - Adjusted the `build` function to ensure consistent formatting and handling of tool catalogs across different agents. These changes simplify the prompt generation process and prepare the codebase for future enhancements. * refactor(harness): unify tool filtering and prompt loading for debug consistency - Exposed `filter_tool_indices` and `load_prompt_source` as `pub(crate)` to ensure that both the live runner and debug dump share the same filtering and loading logic, eliminating discrepancies. - Enhanced documentation for both functions to clarify their purpose and usage, improving maintainability and understanding of the codebase. These changes streamline the tool management process and enhance the reliability of debug outputs, ensuring consistency across different contexts. * refactor(prompts): enhance prompt builders for agent templates - Updated the prompt builders for various agents to return fully-assembled system prompts, incorporating section helpers from `crate::openhuman::context::prompt`. - Replaced `Vec>` with `Vec` and `Vec` for improved type safety in test cases. - Adjusted the `build` function to ensure consistent formatting and handling of user files, tools, and workspace sections across different agents. These changes streamline the prompt generation process, improve maintainability, and prepare the codebase for future enhancements. * refactor(prompts): enhance prompt context handling for dynamic sources - Updated the prompt builders to support fully-assembled prompts from dynamic sources, allowing for more flexible prompt generation. - Introduced `PromptTool` and `PromptContext` structures to replace `ToolSummary`, improving type safety and clarity in prompt construction. - Refactored the handling of prompt sources in both the subagent runner and session builder to streamline the integration of dynamic prompts and legacy sources. These changes improve the maintainability and functionality of the prompt generation process, ensuring accurate representation of available tools and context in agent interactions. * refactor(prompts): improve prompt context and tool handling - Enhanced the `PromptContext` structure to include additional fields for better context management, such as `skills`, `dispatcher_instructions`, and `tool_call_format`. - Replaced `ToolSummary` with `PromptTool` for improved type safety and clarity in prompt generation. - Updated the handling of dynamic prompt sources in both the subagent runner and debug dump, ensuring consistent integration and rendering of prompts. - Introduced a mechanism to handle empty visible tool names, enhancing the robustness of prompt generation. These changes streamline the prompt construction process and improve the overall maintainability of the codebase. * refactor(prompts): reorganize prompt handling and introduce SystemPromptBuilder - Moved prompt-related types and builders from `openhuman::context::prompt` to `openhuman::agent::prompts` for better modularity. - Introduced `SystemPromptBuilder` to streamline the construction of system prompts, allowing for flexible section management. - Updated module exports to maintain compatibility while enhancing the organization of prompt-related code. These changes improve the clarity and maintainability of the prompt generation process, aligning it more closely with the agents that utilize these prompts. * refactor(prompts): unify agent prompt handling and update CLI references - Removed the "main" alias for the orchestrator in the prompt dumping process, treating it as just another registered agent. - Updated the `debug-agent-prompts.sh` script to reflect this change, ensuring all agents are included uniformly. - Revised documentation and error messages in `agent_cli.rs` to replace references to "main" with "orchestrator" for clarity. - Enhanced the debug dump functionality to maintain consistency across agent prompts, improving overall maintainability and usability. These changes streamline the prompt handling process and clarify the usage of agent identifiers in the CLI, aligning with the new architecture. * refactor(prompts): remove CACHE_BOUNDARY references from agent prompts - Eliminated the CACHE_BOUNDARY marker from various agent prompt files, streamlining the prompt generation process. - Updated the build functions in multiple agents to ensure consistent handling of workspace rendering without the cache boundary. - Refactored related prompt handling logic to enhance clarity and maintainability, aligning with the new architecture. These changes simplify the prompt structure and improve the overall efficiency of prompt generation across agents. * refactor(prompts): remove cache boundary references from tests and prompts - Eliminated all instances of cache boundary references from the subagent runner and related tests, simplifying the prompt handling logic. - Updated test assertions to reflect the removal of cache boundary checks, ensuring consistency across the testing framework. - Refactored the session manager to streamline the system prompt assembly process without relying on cache boundaries. These changes enhance the clarity and maintainability of the prompt generation process, aligning with the recent architectural updates. * refactor(harness): clean up unused imports and streamline code - Removed unnecessary imports from multiple files, including `RandomState`, `Hasher`, and `SerializeMap`, to enhance code clarity and maintainability. - Simplified the structure of several modules by eliminating redundant use statements, ensuring a cleaner and more efficient codebase. These changes contribute to a more organized and readable code structure, aligning with ongoing refactoring efforts. * refactor(prompts): enhance agent prompt structures and integration handling - Updated the `orchestrator`, `skills_agent`, and `welcome` prompts to streamline the rendering of connected integrations and delegation guides. - Introduced dedicated functions for rendering integration information, ensuring clarity in the agent's voice and responsibilities. - Removed redundant sections from the shared prompt builder, allowing each agent to manage its own prompt content more effectively. - Improved test coverage for prompt generation, ensuring accurate representation of connected integrations and skills. These changes enhance the maintainability and clarity of the prompt generation process, aligning with the recent architectural updates. * refactor(session): update integration handling and clean up prompt parameters - Revised documentation for `connected_integrations` in the `Agent` struct to clarify its role in the agent's prompt rendering. - Updated the parameter name in `render_subagent_system_prompt_with_format` to `_connected_integrations` to indicate it is unused, enhancing code clarity. - Cleaned up import statements in the context module for better organization and maintainability. These changes improve the clarity of integration handling and streamline the code structure, aligning with ongoing refactoring efforts. * refactor(agent_cli): simplify command options and improve documentation - Removed the `--skill` option from the `dump-prompt` command, streamlining the command usage and focusing on essential parameters. - Updated documentation to clarify the usage of the `dump-prompt` command and its parameters, enhancing user understanding. - Cleaned up the `DumpFlags` structure by removing unused fields, contributing to a more maintainable codebase. These changes improve the clarity and usability of the agent CLI, aligning with ongoing refactoring efforts. * refactor(cli): streamline dotenv loading and clean up prompt rendering - Introduced `load_dotenv_for_cli` to load environment variables for all CLI entrypoints, ensuring consistent configuration across commands. - Updated documentation to clarify the purpose of the dotenv loading mechanism. - Removed unnecessary blank lines in prompt rendering functions across multiple agents, enhancing code readability. These changes improve the maintainability and clarity of the CLI and prompt handling, aligning with ongoing refactoring efforts. * refactor(debug-agent-prompts): enhance environment loading and streamline workspace resolution - Updated the script to load environment variables from a `.env` file, ensuring consistent configuration for prompt generation. - Simplified workspace resolution by delegating to the binary's internal logic, improving reliability and reducing code duplication. - Revised documentation to clarify the usage of command options and the impact of environment variables on prompt rendering. These changes improve the maintainability and clarity of the debug agent prompts script, aligning with ongoing refactoring efforts. * refactor(agent): remove category filter and simplify agent definitions - Eliminated the `category_filter` from various agent definitions and related tests, streamlining the agent configuration. - Updated the `run_list` function in `agent_cli.rs` to reflect the removal of category filtering, enhancing output clarity. - Revised documentation and comments to remove references to the now-removed category filter, improving overall code maintainability. These changes contribute to a cleaner and more efficient agent architecture, aligning with ongoing refactoring efforts. * feat(agents): introduce integrations_agent and tools_agent for enhanced service handling - Added the `integrations_agent` to manage service integrations via Composio, including a new TOML configuration and prompt structure. - Introduced the `tools_agent` for general ad-hoc tasks using built-in OpenHuman tools, with its own configuration and prompt. - Updated the loader to include both agents in the built-in agent list, increasing the total number of agents from 13 to 14. - Revised orchestrator and welcome prompts to delegate integration tasks to the new `integrations_agent`, ensuring clarity in agent responsibilities. - Enhanced tests to verify the registration and functionality of the new agents, improving overall test coverage. These changes expand the capabilities of the agent architecture, allowing for more specialized handling of integrations and tool usage. * refactor(agents): update references from skills_agent to integrations_agent - Changed all instances of `skills_agent` to `integrations_agent` across various files, including prompts, CLI commands, and tool registrations. - Updated documentation and comments to reflect the new agent name, ensuring clarity in agent responsibilities and usage. - Revised debug scripts to align with the new prompt structure for the integrations agent. These changes enhance consistency in the codebase and improve the clarity of agent interactions. * refactor(agents): rename skills_agent to integrations_agent throughout the codebase - Updated all instances of `skills_agent` to `integrations_agent` in various files, including tests, documentation, and comments. - Ensured consistency in agent references to improve clarity in agent responsibilities and interactions. - Revised related code structures to align with the new naming convention, enhancing overall maintainability. These changes support the transition to the new agent architecture and improve code readability. * refactor(prompts): implement dynamic prompt rendering for enhanced context handling - Introduced `DynamicPromptSection` to allow prompts to be built dynamically using a function pointer, enabling real-time access to the `PromptContext`. - Updated `SystemPromptBuilder` to support dynamic prompts, ensuring that late-arriving state like `connected_integrations` is accurately reflected in the rendered output. - Revised the prompt handling logic in the agent builder to streamline the integration of dynamic prompts, improving overall flexibility and responsiveness. These changes enhance the prompt generation process, aligning with the ongoing improvements in agent architecture and context management. * refactor(prompts): refine delegation guide to display only connected integrations - Updated the `render_delegation_guide` function to list only the toolkits that are actively connected, omitting unauthorized toolkits to prevent hallucinations during delegation. - Revised related tests to ensure the prompt correctly reflects the current state of integrations, including scenarios where no integrations are connected. - Introduced a new utility function to filter out welcome-only tools from non-welcome agents, enhancing the clarity and safety of tool visibility. These changes improve the accuracy and focus of the delegation guide, aligning with the ongoing enhancements in agent prompt handling. * refactor(planner): update tool usage and prompt guidelines for read-only operations - Modified the `agent.toml` configuration to clarify that the planner operates in a read-only mode, specifying that it does not mutate the workspace or memory. - Revised the prompt guidelines to reflect the read-only nature of the planner, emphasizing the need for explicit nodes for any required writes to be handled by downstream agents. These changes enhance the clarity of the planner's operational constraints and improve the overall structure of the planning process. * refactor(config): remove web search enable flag and update related configurations - Eliminated the `OPENHUMAN_WEB_SEARCH_ENABLED` environment variable and associated logic, as web search is now always enabled by default. - Updated the configuration schema to reflect the removal of the enable flag from `WebSearchConfig`. - Adjusted tool registration to ensure web search is always available, simplifying the configuration process. These changes streamline the web search functionality, ensuring it is consistently available across all sessions. * refactor(config): update http_request flag to always enabled - Changed the `http_request` configuration to always be enabled, removing the dependency on the `config.http_request.enabled` flag. - This adjustment simplifies the configuration process and ensures consistent behavior across the application. These changes contribute to a more streamlined configuration and enhance the overall reliability of the onboarding process. * refactor(debug-agent-prompts): transition to dump-all command for agent prompts - Replaced the previous method of listing agent IDs and dumping prompts with a new `dump-all` command that consolidates the functionality into a single call. - Updated the script to handle output directory and workspace options more efficiently, leveraging Rust's `dump_all_agent_prompts` for processing. - Enhanced the handling of the `integrations_agent` to generate separate dumps for each connected toolkit, improving the clarity and organization of output files. - Revised related logging and summary generation to reflect the new structure, ensuring a more streamlined user experience. These changes modernize the prompt dumping process, aligning it with the latest architectural improvements and enhancing usability. * feat(composio): implement dynamic fetching of toolkit actions for integrations - Added a new `fetch_toolkit_actions` function to retrieve the current action catalogue for a specified Composio toolkit, enhancing the responsiveness of the integrations agent. - Updated the `subagent_runner` to utilize the fresh action list at spawn time, ensuring that the toolkit's actions reflect the latest backend state. - Modified the `render_integrations_agent` function to refresh the action catalogue during prompt generation, improving the accuracy of the displayed tools. These changes enhance the integration experience by providing up-to-date action information, aligning with the ongoing improvements in agent functionality. * refactor(integrations-agent): update tool visibility and configuration handling - Modified the `agent.toml` to replace `wildcard` with `named` tools, enhancing control over tool visibility for the integrations agent. - Updated the `subagent_runner` to ensure that tool visibility aligns with the new TOML configuration, preventing unnecessary stripping of tools. - Revised the `render_integrations_agent` function to respect the updated tool scope, improving the accuracy of the tool list generated for subagents. These changes streamline the tool management process, ensuring that only explicitly defined tools are available during agent execution. * feat(composio): add composio_list_connections tool for dynamic integration detection - Introduced the `composio_list_connections` tool in the orchestrator's configuration, allowing the agent to detect newly-authorized Composio integrations mid-session. - Enhanced the `ComposioListConnectionsTool` to filter and return only currently-connected integrations with ACTIVE or CONNECTED status, improving the accuracy of integration management. - Updated the tool's description to clarify its functionality and usage context. These changes enhance the agent's ability to manage integrations dynamically, aligning with ongoing improvements in the integration experience. * fix(prompt): update delegation guide to reference Skills page - Modified the `render_delegation_guide` function to change the reference from **Settings → Integrations** to the **Skills** page for connecting integrations. This update clarifies the user instructions for integration management. * feat(session): introduce session key management for sub-agents - Added `session_key` and `session_parent_prefix` fields to `ParentExecutionContext` to facilitate hierarchical transcript naming for sub-agents. - Updated `persist_subagent_transcript` to generate transcript filenames based on the parent's session key, ensuring a flat file structure that reflects the parent-child relationship. - Enhanced `AgentBuilder` and `Agent` to support the new session key management, allowing for better organization of session transcripts. - Adjusted related functions to ensure proper handling of session keys during agent execution and transcript persistence. These changes improve the clarity and organization of session transcripts, aligning with the ongoing enhancements in agent functionality. * refactor(tests): update integrations agent tests for tool scope and transcript handling - Renamed the `integrations_agent_is_wildcard` test to `integrations_agent_tool_scope_honours_toml` to better reflect its purpose of validating tool scope based on TOML configuration. - Enhanced the test to assert that the `integrations_agent` correctly recognizes named tools instead of a wildcard. - Removed the `integrations_agent_has_extra_tools_for_export` test as it is no longer relevant to the current tool management strategy. - Improved the `latest_in_dir` function to clarify the handling of transcript naming schemes, ensuring proper differentiation between legacy and keyed formats. These changes streamline the testing process and improve the accuracy of tool scope validation, aligning with recent updates in agent functionality. * refactor(subagent_runner): improve transcript persistence and streamline inner loop - Removed the `persist_subagent_transcript` function, transitioning transcript persistence to occur per-iteration within the `run_inner_loop`, enhancing reliability by ensuring transcripts are written immediately after each provider response. - Updated the handling of session keys to maintain consistent naming for transcripts, reflecting the parent-child relationship in the file structure. - Simplified the code by eliminating redundant post-loop transcript writes, aligning with recent changes in agent functionality and improving overall clarity in transcript management. * refactor(agent_cli, subagent_runner, session): improve code readability and formatting - Enhanced formatting in `agent_cli.rs` for better readability by adjusting the structure of string formatting. - Streamlined conditional checks in `subagent_runner.rs` to improve clarity and maintainability. - Simplified the handling of agent IDs in `builder.rs` to reduce line length and improve code flow. - Updated test cases in `tests.rs` for better alignment and readability of expected values. - Improved formatting in `debug_dump.rs` to enhance the clarity of toolkit action fetching and logging. These changes collectively enhance the overall readability and maintainability of the codebase, aligning with ongoing refactoring efforts. * fix(tests): add missing session_key fields to ParentExecutionContext stub * fix: address PR review feedback * fix(prompts-v2): round 2 PR review — dispatcher instructions, workspace-file preservation, cached-tool fallback, transcript persistence - subagent_runner: populate PromptContext.dispatcher_instructions for Dynamic prompts (was empty string, dropping the ## Tool Use Protocol block in render_tools for PFormat/Json/Native sub-agents) - subagent_runner: add post-tool persist_transcript after tool results are appended so a mid-round crash doesn't lose tool outputs - prompts::sync_workspace_file: preserve user-edited workspace files — only overwrite when the file doesn't exist OR its current hash matches the stored builtin hash - context::debug_dump: mirror runner's cached-tool fallback — keep cached action catalogue on empty/error from fetch_toolkit_actions instead of blanking it - core::agent_cli: add entry/exit debug logs around dump_all / dump_prompt calls and a trace log around each prompt file write; update module banner to note --toolkit is required when --agent is integrations_agent --- .env.example | 4 +- app/src/lib/ai/skillsAgentContext.ts | 2 +- scripts/debug-agent-prompts.sh | 202 +- src/core/agent_cli.rs | 308 ++- src/core/cli.rs | 74 +- src/openhuman/agent/agents/archivist/mod.rs | 1 + .../agent/agents/archivist/prompt.rs | 67 + .../agent/agents/code_executor/mod.rs | 1 + .../agent/agents/code_executor/prompt.rs | 71 + src/openhuman/agent/agents/critic/mod.rs | 1 + src/openhuman/agent/agents/critic/prompt.rs | 66 + .../agents/integrations_agent/agent.toml | 16 + .../agent/agents/integrations_agent/mod.rs | 1 + .../agent/agents/integrations_agent/prompt.md | 45 + .../agent/agents/integrations_agent/prompt.rs | 204 ++ src/openhuman/agent/agents/loader.rs | 120 +- src/openhuman/agent/agents/mod.rs | 19 + .../agent/agents/morning_briefing/agent.toml | 4 - .../agent/agents/morning_briefing/mod.rs | 1 + .../agent/agents/morning_briefing/prompt.rs | 67 + .../agent/agents/orchestrator/agent.toml | 12 +- .../agent/agents/orchestrator/mod.rs | 1 + .../agent/agents/orchestrator/prompt.rs | 169 ++ src/openhuman/agent/agents/planner/agent.toml | 6 +- src/openhuman/agent/agents/planner/mod.rs | 1 + src/openhuman/agent/agents/planner/prompt.md | 4 +- src/openhuman/agent/agents/planner/prompt.rs | 67 + src/openhuman/agent/agents/researcher/mod.rs | 1 + .../agent/agents/researcher/prompt.rs | 67 + .../agent/agents/skills_agent/agent.toml | 24 - .../agent/agents/skills_agent/prompt.md | 56 - src/openhuman/agent/agents/summarizer/mod.rs | 1 + .../agent/agents/summarizer/prompt.rs | 67 + src/openhuman/agent/agents/tool_maker/mod.rs | 1 + .../agent/agents/tool_maker/prompt.rs | 71 + .../agent/agents/tools_agent/agent.toml | 21 + src/openhuman/agent/agents/tools_agent/mod.rs | 1 + .../agent/agents/tools_agent/prompt.md | 16 + .../agent/agents/tools_agent/prompt.rs | 69 + .../agent/agents/trigger_reactor/mod.rs | 1 + .../agent/agents/trigger_reactor/prompt.rs | 67 + .../agent/agents/trigger_triage/mod.rs | 1 + .../agent/agents/trigger_triage/prompt.rs | 67 + src/openhuman/agent/agents/welcome/mod.rs | 1 + src/openhuman/agent/agents/welcome/prompt.rs | 150 ++ src/openhuman/agent/harness/archivist.rs | 4 +- .../agent/harness/builtin_definitions.rs | 20 +- src/openhuman/agent/harness/definition.rs | 91 +- src/openhuman/agent/harness/fork_context.rs | 23 +- .../agent/harness/payload_summarizer.rs | 3 +- .../agent/harness/session/builder.rs | 146 +- .../agent/harness/session/runtime.rs | 27 +- src/openhuman/agent/harness/session/tests.rs | 23 +- .../agent/harness/session/transcript.rs | 141 +- src/openhuman/agent/harness/session/turn.rs | 142 +- src/openhuman/agent/harness/session/types.rs | 30 +- .../agent/harness/subagent_runner.rs | 728 +++-- src/openhuman/agent/harness/tool_filter.rs | 2 +- src/openhuman/agent/harness/tool_loop.rs | 1 - src/openhuman/agent/mod.rs | 6 + src/openhuman/agent/prompts/mod.rs | 2062 ++++++++++++++ src/openhuman/agent/prompts/types.rs | 232 ++ src/openhuman/agent/triage/escalation.rs | 7 +- src/openhuman/agent/triage/evaluator.rs | 52 +- src/openhuman/channels/runtime/dispatch.rs | 3 +- src/openhuman/channels/runtime/startup.rs | 4 +- src/openhuman/composio/action_tool.rs | 4 +- src/openhuman/composio/mod.rs | 4 +- src/openhuman/composio/ops.rs | 54 +- src/openhuman/composio/tools.rs | 27 +- src/openhuman/config/schema/load.rs | 35 +- src/openhuman/config/schema/tools.rs | 5 - src/openhuman/config/schema/types.rs | 2 +- src/openhuman/context/debug_dump.rs | 1528 +++-------- src/openhuman/context/manager.rs | 17 +- src/openhuman/context/mod.rs | 4 +- src/openhuman/context/prompt.rs | 2399 +---------------- src/openhuman/providers/reliable.rs | 1 - src/openhuman/providers/traits.rs | 15 +- .../tools/impl/agent/complete_onboarding.rs | 6 +- src/openhuman/tools/impl/agent/dispatch.rs | 5 +- .../tools/impl/agent/skill_delegation.rs | 2 +- .../tools/impl/agent/spawn_subagent.rs | 48 +- src/openhuman/tools/ops.rs | 39 +- src/openhuman/tools/orchestrator_tools.rs | 9 +- tests/agent_harness_public.rs | 4 +- 86 files changed, 5587 insertions(+), 4584 deletions(-) create mode 100644 src/openhuman/agent/agents/archivist/mod.rs create mode 100644 src/openhuman/agent/agents/archivist/prompt.rs create mode 100644 src/openhuman/agent/agents/code_executor/mod.rs create mode 100644 src/openhuman/agent/agents/code_executor/prompt.rs create mode 100644 src/openhuman/agent/agents/critic/mod.rs create mode 100644 src/openhuman/agent/agents/critic/prompt.rs create mode 100644 src/openhuman/agent/agents/integrations_agent/agent.toml create mode 100644 src/openhuman/agent/agents/integrations_agent/mod.rs create mode 100644 src/openhuman/agent/agents/integrations_agent/prompt.md create mode 100644 src/openhuman/agent/agents/integrations_agent/prompt.rs create mode 100644 src/openhuman/agent/agents/morning_briefing/mod.rs create mode 100644 src/openhuman/agent/agents/morning_briefing/prompt.rs create mode 100644 src/openhuman/agent/agents/orchestrator/mod.rs create mode 100644 src/openhuman/agent/agents/orchestrator/prompt.rs create mode 100644 src/openhuman/agent/agents/planner/mod.rs create mode 100644 src/openhuman/agent/agents/planner/prompt.rs create mode 100644 src/openhuman/agent/agents/researcher/mod.rs create mode 100644 src/openhuman/agent/agents/researcher/prompt.rs delete mode 100644 src/openhuman/agent/agents/skills_agent/agent.toml delete mode 100644 src/openhuman/agent/agents/skills_agent/prompt.md create mode 100644 src/openhuman/agent/agents/summarizer/mod.rs create mode 100644 src/openhuman/agent/agents/summarizer/prompt.rs create mode 100644 src/openhuman/agent/agents/tool_maker/mod.rs create mode 100644 src/openhuman/agent/agents/tool_maker/prompt.rs create mode 100644 src/openhuman/agent/agents/tools_agent/agent.toml create mode 100644 src/openhuman/agent/agents/tools_agent/mod.rs create mode 100644 src/openhuman/agent/agents/tools_agent/prompt.md create mode 100644 src/openhuman/agent/agents/tools_agent/prompt.rs create mode 100644 src/openhuman/agent/agents/trigger_reactor/mod.rs create mode 100644 src/openhuman/agent/agents/trigger_reactor/prompt.rs create mode 100644 src/openhuman/agent/agents/trigger_triage/mod.rs create mode 100644 src/openhuman/agent/agents/trigger_triage/prompt.rs create mode 100644 src/openhuman/agent/agents/welcome/mod.rs create mode 100644 src/openhuman/agent/agents/welcome/prompt.rs create mode 100644 src/openhuman/agent/prompts/mod.rs create mode 100644 src/openhuman/agent/prompts/types.rs diff --git a/.env.example b/.env.example index 4af824a5c..6b432ccdb 100644 --- a/.env.example +++ b/.env.example @@ -76,8 +76,8 @@ OPENHUMAN_REASONING_ENABLED= # --------------------------------------------------------------------------- # Web search # --------------------------------------------------------------------------- -# [optional] Default: false -OPENHUMAN_WEB_SEARCH_ENABLED=false +# Web search is always enabled — no opt-in flag. Configure the +# provider / API keys / budgets below. # [optional] Search provider name OPENHUMAN_WEB_SEARCH_PROVIDER= # [optional] Required if web search provider is Brave diff --git a/app/src/lib/ai/skillsAgentContext.ts b/app/src/lib/ai/skillsAgentContext.ts index 43c530339..01c0d0d15 100644 --- a/app/src/lib/ai/skillsAgentContext.ts +++ b/app/src/lib/ai/skillsAgentContext.ts @@ -1,4 +1,4 @@ -// Source of truth: src/openhuman/agent/agents/skills_agent/prompt.md +// Source of truth: src/openhuman/agent/agents/integrations_agent/prompt.md // Keep in sync when the Rust-side prompt changes. export const SKILLS_AGENT_PROMPT = `# Skills Agent — Service Integration Specialist diff --git a/scripts/debug-agent-prompts.sh b/scripts/debug-agent-prompts.sh index 75d70530a..550f203a3 100755 --- a/scripts/debug-agent-prompts.sh +++ b/scripts/debug-agent-prompts.sh @@ -10,13 +10,14 @@ # to stderr. Useful workflow: # # bash scripts/debug-agent-prompts.sh -# diff -u prompts.before/skills_agent.md prompts.after/skills_agent.md +# diff -u prompts.before/integrations_agent.md prompts.after/integrations_agent.md # -# When run with `--stub-composio` (the default) the dumper injects the -# five Composio meta-tools into the registry even on machines that are -# not signed in, so `skills_agent` always renders the full Composio -# surface. Pass `--no-stub-composio` to see the raw on-disk state instead -# (useful for sanity-checking the unauthed onboarding path). +# The dumper runs against the real session construction path +# (`Agent::from_config_for_agent` → `Agent::build_system_prompt`), so the +# Composio surface reflects the signed-in user's actual integrations. +# If you need the toolkit list populated, sign in via the desktop app or +# point `OPENHUMAN_WORKSPACE` at a workspace that already holds the +# connection state. # # The dumper runs against the currently-logged-in user's workspace # (`$OPENHUMAN_WORKSPACE`, falling back to `~/.openhuman/workspace`) so @@ -25,16 +26,16 @@ # different workspace. # # Usage: -# bash scripts/debug-agent-prompts.sh [--out ] [--no-stub-composio] [--with-tools] [-v] +# bash scripts/debug-agent-prompts.sh [--out ] [--with-tools] [-v] # # The output directory is wiped and recreated at the start of each run # so the snapshot only reflects the current agent set — stale files from # an earlier run cannot hide a regression. # # Defaults: -# --out ./prompt-dumps (deleted + recreated each run) -# --stub-composio ON (override with --no-stub-composio) -# --with-tools OFF (pass to also list each agent's tool names) +# --out ./prompt-dumps (deleted + recreated each run) +# --with-tools DEPRECATED / no-op — tool names are always recorded in +# the per-agent `.meta.txt` files emitted by dump-all. # set -euo pipefail @@ -44,6 +45,25 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" BIN="${REPO_ROOT}/target/debug/openhuman-core" +# Load the repo .env so staging/prod backend URLs, API keys, and the +# Composio toggle reach the dumped prompts. `Config::load_or_init` +# calls `apply_env_overrides` after reading from disk, so any variable +# exported here wins over whatever is baked into the workspace config. +# Mirrors `yarn tauri dev`, which sources the same file via +# `scripts/load-dotenv.sh` before launching the sidecar. +if [[ -f "${REPO_ROOT}/.env" ]]; then + echo "[debug-agent-prompts] loading env from ${REPO_ROOT}/.env" >&2 + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/load-dotenv.sh" "${REPO_ROOT}/.env" +fi + +# The project's CLI logger writes to stdout (not stderr), so any +# `RUST_LOG` value inherited from `.env` (typically `info`) would +# interleave log lines into the JSON/prompt payloads this script +# expects on stdout. Force quiet unless the caller passed `-v` — in +# which case the later `--verbose` flag restores debug logging. +export RUST_LOG=error + # Always run `cargo build` — it no-ops when the binary is already # up-to-date, and re-links quickly when it isn't. The old `-x` existence # check let a stale debug binary survive across agent-registry changes @@ -54,7 +74,6 @@ echo "[debug-agent-prompts] building openhuman-core (no-op if up-to-date) …" > # ── Parse flags ─────────────────────────────────────────────────────────── OUT_DIR="" -STUB_COMPOSIO=1 WITH_TOOLS=0 VERBOSE_FLAG=() @@ -68,14 +87,6 @@ while [[ $# -gt 0 ]]; do OUT_DIR="$2" shift 2 ;; - --stub-composio) - STUB_COMPOSIO=1 - shift - ;; - --no-stub-composio) - STUB_COMPOSIO=0 - shift - ;; --with-tools) WITH_TOOLS=1 shift @@ -85,7 +96,7 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -158,127 +169,58 @@ OUT_DIR="${resolved_out}" rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" -# Point the binary at the real, currently-logged-in user's workspace so -# onboarding-generated files like `PROFILE.md` appear in the dump. A -# throwaway mktemp workspace would silently hide them. +# Workspace resolution is owned by `Config::load_or_init` inside the +# binary: it reads `~/.openhuman/active_user.toml`, falls back to the +# persisted workspace marker, then to the pre-login user directory. We +# only pass `--workspace` when the caller has explicitly exported one +# (an empty `OPENHUMAN_WORKSPACE=` in `.env` counts as unset — the +# binary's resolver is what we want in that case). # -# Resolution order (matches what the desktop app does): -# 1. $OPENHUMAN_WORKSPACE if explicitly exported. -# 2. ~/.openhuman/users//workspace, where active_user_id -# is read from ~/.openhuman/active_user.toml. This is the path the -# Tauri app writes PROFILE.md into after onboarding. -# 3. ~/.openhuman/workspace as a last-resort fallback for pre-multi-user -# installs. -# -# The binary auto-seeds SOUL.md/IDENTITY.md/HEARTBEAT.md when they are -# missing, so pointing at the real workspace is read-mostly — the only -# writes are the same self-healing writes that happen on every normal -# agent run. +# Previously this script duplicated the resolution in shell and guessed +# wrong when the user's active install used a multi-user layout under +# `~/.openhuman/users//workspace` without a top-level +# `active_user.toml`, causing the dumper to bail with "workspace not +# found". Delegating to the binary removes that divergence and makes +# `.env` (including `OPENHUMAN_APP_ENV=staging`) take effect +# automatically. +WORKSPACE_OVERRIDE="" if [[ -n "${OPENHUMAN_WORKSPACE:-}" ]]; then - WORKSPACE="${OPENHUMAN_WORKSPACE}" -else - ACTIVE_USER_TOML="${HOME}/.openhuman/active_user.toml" - ACTIVE_USER_ID="" - if [[ -f "${ACTIVE_USER_TOML}" ]]; then - # Extract `user_id = "…"` without pulling in a TOML parser — the file - # is one or two lines and the shape is stable (config/ops.rs writes - # it with a plain quoted string). - ACTIVE_USER_ID="$(sed -n 's/^[[:space:]]*user_id[[:space:]]*=[[:space:]]*"\([^"]*\)"[[:space:]]*$/\1/p' "${ACTIVE_USER_TOML}" | head -n 1)" - fi - if [[ -n "${ACTIVE_USER_ID}" && -d "${HOME}/.openhuman/users/${ACTIVE_USER_ID}/workspace" ]]; then - WORKSPACE="${HOME}/.openhuman/users/${ACTIVE_USER_ID}/workspace" - else - WORKSPACE="${HOME}/.openhuman/workspace" - fi -fi - -if [[ ! -d "${WORKSPACE}" ]]; then - echo "[debug-agent-prompts] workspace not found: ${WORKSPACE}" >&2 - echo "[debug-agent-prompts] complete onboarding in the app first, or export OPENHUMAN_WORKSPACE=." >&2 - exit 66 -fi - -if [[ -f "${WORKSPACE}/PROFILE.md" ]]; then - PROFILE_STATE="present" -else - PROFILE_STATE="MISSING (onboarding enrichment has not run)" + WORKSPACE_OVERRIDE="${OPENHUMAN_WORKSPACE}" fi echo "[debug-agent-prompts] output dir : ${OUT_DIR}" >&2 -echo "[debug-agent-prompts] workspace : ${WORKSPACE}" >&2 -echo "[debug-agent-prompts] PROFILE.md : ${PROFILE_STATE}" >&2 -echo "[debug-agent-prompts] stub composio: $([[ ${STUB_COMPOSIO} -eq 1 ]] && echo on || echo off)" >&2 +if [[ -n "${WORKSPACE_OVERRIDE}" ]]; then + echo "[debug-agent-prompts] workspace : ${WORKSPACE_OVERRIDE} (OPENHUMAN_WORKSPACE override)" >&2 +else + echo "[debug-agent-prompts] workspace : " >&2 +fi +if [[ -n "${OPENHUMAN_APP_ENV:-}" ]]; then + echo "[debug-agent-prompts] app env : ${OPENHUMAN_APP_ENV}" >&2 +fi +if [[ -n "${OPENHUMAN_BASE_URL:-}" ]]; then + echo "[debug-agent-prompts] base url : ${OPENHUMAN_BASE_URL}" >&2 +fi echo >&2 -# ── Discover agent ids from `agent list --json` ─────────────────────────── -# `mapfile` is bash 4+, but macOS ships bash 3 — use a portable -# read-while-IFS loop instead so the script works out of the box on a -# vanilla `/bin/bash`. -AGENT_LIST_JSON="$("${BIN}" agent list --workspace "${WORKSPACE}" --json 2>/dev/null)" -AGENT_IDS=() -while IFS= read -r line; do - [[ -n "${line}" ]] && AGENT_IDS+=("${line}") -done < <(printf '%s' "${AGENT_LIST_JSON}" | python3 -c ' -import json, sys -for entry in json.load(sys.stdin): - aid = entry.get("id", "") - # The synthetic `fork` definition replays the parent verbatim and - # has no standalone prompt — skip it. - if aid and aid != "fork": - print(aid) -') - -# Always include the main / orchestrator prompt as the first dump. -TARGETS=("main" "${AGENT_IDS[@]}") - -# ── Build common dump-prompt flag list ──────────────────────────────────── -DUMP_FLAGS=(--workspace "${WORKSPACE}") -if [[ ${STUB_COMPOSIO} -eq 1 ]]; then - DUMP_FLAGS+=(--stub-composio) -fi -if [[ ${WITH_TOOLS} -eq 1 ]]; then - DUMP_FLAGS+=(--with-tools) +# ── Delegate to `openhuman-core agent dump-all` ────────────────────────── +# All the per-agent iteration + `integrations_agent`-per-toolkit +# expansion now lives in Rust (`debug_dump::dump_all_agent_prompts`). +# The shell script just supplies the output directory and passes +# through workspace / verbose toggles. +DUMP_ARGS=(agent dump-all --out "${OUT_DIR}") +if [[ -n "${WORKSPACE_OVERRIDE}" ]]; then + DUMP_ARGS+=(--workspace "${WORKSPACE_OVERRIDE}") fi if [[ ${#VERBOSE_FLAG[@]} -gt 0 ]]; then - DUMP_FLAGS+=("${VERBOSE_FLAG[@]}") + DUMP_ARGS+=("${VERBOSE_FLAG[@]}") fi -# ── Dump every target ───────────────────────────────────────────────────── -INDEX=0 -SUMMARY="" -for AGENT in "${TARGETS[@]}"; do - INDEX=$((INDEX + 1)) - SAFE_NAME="$(printf '%s' "${AGENT}" | tr -c 'A-Za-z0-9._-' '_')" - PROMPT_PATH="${OUT_DIR}/${INDEX}_${SAFE_NAME}.md" - META_PATH="${OUT_DIR}/${INDEX}_${SAFE_NAME}.meta.txt" +"${BIN}" "${DUMP_ARGS[@]}" - printf '[debug-agent-prompts] %-20s → %s\n' "${AGENT}" "${PROMPT_PATH}" >&2 - if "${BIN}" agent dump-prompt --agent "${AGENT}" "${DUMP_FLAGS[@]}" \ - > "${PROMPT_PATH}" 2> "${META_PATH}"; then - LINES="$(wc -l < "${PROMPT_PATH}" | tr -d ' ')" - TOOL_COUNT="$(grep -E '^tool_count:' "${META_PATH}" | awk '{print $2}')" - SKILL_COUNT="$(grep -E '^skill_tools:' "${META_PATH}" | awk '{print $2}')" - SUMMARY+="$(printf '%-20s lines=%-5s tools=%-4s skill=%-4s\n' \ - "${AGENT}" "${LINES}" "${TOOL_COUNT:-?}" "${SKILL_COUNT:-?}") -" - else - echo "[debug-agent-prompts] ✘ failed to dump ${AGENT} (see ${META_PATH})" >&2 - SUMMARY+="$(printf '%-20s FAILED — see %s\n' "${AGENT}" "${META_PATH}") -" - fi -done - -# ── Write a summary index file alongside the dumps ──────────────────────── -SUMMARY_PATH="${OUT_DIR}/SUMMARY.txt" -{ - echo "OpenHuman agent prompt dump summary" - echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "Workspace: ${WORKSPACE}" - echo "Stub composio: $([[ ${STUB_COMPOSIO} -eq 1 ]] && echo on || echo off)" - echo - echo "${SUMMARY}" -} > "${SUMMARY_PATH}" +if [[ ${WITH_TOOLS} -eq 1 ]]; then + echo "[debug-agent-prompts] NOTE: --with-tools is no longer honoured by dump-all" >&2 + echo "[debug-agent-prompts] (tool names are always recorded in the .meta.txt files)" >&2 +fi echo >&2 -echo "[debug-agent-prompts] done — ${INDEX} prompts dumped" >&2 -echo "[debug-agent-prompts] summary : ${SUMMARY_PATH}" >&2 +echo "[debug-agent-prompts] done — see ${OUT_DIR}/SUMMARY.txt" >&2 diff --git a/src/core/agent_cli.rs b/src/core/agent_cli.rs index f706c3188..6f3885e8a 100644 --- a/src/core/agent_cli.rs +++ b/src/core/agent_cli.rs @@ -6,24 +6,27 @@ //! agent definitions / tool registry and printing something. //! //! Usage: -//! openhuman agent dump-prompt --agent [--skill ] [--workspace ] [--json] [--with-tools] [-v] +//! openhuman agent dump-prompt --agent [--toolkit ] [--workspace ] [--json] [--with-tools] [-v] +//! (--toolkit is REQUIRED when --agent is `integrations_agent`.) +//! openhuman agent dump-all --out [--workspace ] [--model ] [-v] //! openhuman agent list [--json] [-v] //! //! `dump-prompt` is the main tool: it renders the exact system prompt the -//! context engine would hand to the LLM when that agent is spawned. Pass -//! `--agent main` for the orchestrator / main-agent prompt; otherwise pass -//! any built-in or workspace-custom agent id (e.g. `skills_agent`, -//! `orchestrator`, `code_executor`). -//! -//! `--skill` mirrors the `spawn_subagent { skill_filter: "…" }` runtime -//! argument — pair it with `skills_agent` to scope the dump to a single -//! integration (e.g. `--skill notion`). +//! context engine would hand to the LLM when that agent is spawned. The +//! dump routes through [`Agent::from_config_for_agent`] and calls +//! [`Agent::build_system_prompt`] on the live session, so the output is +//! byte-identical to what the LLM sees on turn 1. Pass +//! `--agent orchestrator` for the orchestrator prompt; otherwise pass +//! any built-in or workspace-custom agent id (e.g. `integrations_agent`, +//! `welcome`, `code_executor`). -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use std::path::PathBuf; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; -use crate::openhuman::context::debug_dump::{dump_agent_prompt, DumpPromptOptions, DumpedPrompt}; +use crate::openhuman::context::debug_dump::{ + dump_agent_prompt, dump_all_agent_prompts, DumpPromptOptions, DumpedPrompt, +}; /// Entry point for `openhuman agent `. pub fn run_agent_command(args: &[String]) -> Result<()> { @@ -34,6 +37,7 @@ pub fn run_agent_command(args: &[String]) -> Result<()> { match args[0].as_str() { "dump-prompt" => run_dump_prompt(&args[1..]), + "dump-all" => run_dump_all(&args[1..]), "list" => run_list(&args[1..]), other => Err(anyhow!( "unknown agent subcommand '{other}'. Run `openhuman agent --help`." @@ -41,30 +45,193 @@ pub fn run_agent_command(args: &[String]) -> Result<()> { } } +// --------------------------------------------------------------------------- +// dump-all +// --------------------------------------------------------------------------- + +struct DumpAllFlags { + out: PathBuf, + workspace: Option, + model: Option, + verbose: bool, +} + +fn parse_dump_all_flags(args: &[String]) -> Result { + let mut out: Option = None; + let mut workspace: Option = None; + let mut model: Option = None; + let mut verbose = false; + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--out" | "-o" => { + out = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --out"))?, + )); + i += 2; + } + "--workspace" | "-w" => { + workspace = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --workspace"))?, + )); + i += 2; + } + "--model" | "-m" => { + model = Some( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --model"))? + .clone(), + ); + i += 2; + } + "-v" | "--verbose" => { + verbose = true; + i += 1; + } + "-h" | "--help" => { + println!("Usage: openhuman agent dump-all --out [--workspace ] [--model ] [-v]"); + println!(); + println!("Render every registered agent's turn-1 system prompt into ."); + println!("`integrations_agent` is expanded into one file per currently-connected"); + println!("Composio toolkit; if no toolkit is connected, it is skipped."); + std::process::exit(0); + } + other => return Err(anyhow!("unknown dump-all arg: {other}")), + } + } + Ok(DumpAllFlags { + out: out.ok_or_else(|| anyhow!("--out is required"))?, + workspace, + model, + verbose, + }) +} + +fn run_dump_all(args: &[String]) -> Result<()> { + let flags = parse_dump_all_flags(args)?; + init_quiet_logging(flags.verbose); + + log::debug!( + "[agent-cli] run_dump_all entry: out={} workspace={:?} model={:?}", + flags.out.display(), + flags.workspace, + flags.model + ); + + std::fs::create_dir_all(&flags.out) + .with_context(|| format!("creating output dir {}", flags.out.display()))?; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + log::debug!("[agent-cli] run_dump_all: calling dump_all_agent_prompts"); + let dumps = rt.block_on(async { + dump_all_agent_prompts(flags.workspace.clone(), flags.model.clone()).await + })?; + log::debug!( + "[agent-cli] run_dump_all: dump_all_agent_prompts returned {} prompt(s)", + dumps.len() + ); + + let mut summary = String::new(); + for (idx, dumped) in dumps.iter().enumerate() { + let safe_agent = sanitise_filename_component(&dumped.agent_id); + let stem = match &dumped.toolkit { + Some(tk) => format!( + "{}_{}_{}", + idx + 1, + safe_agent, + sanitise_filename_component(tk) + ), + None => format!("{}_{}", idx + 1, safe_agent), + }; + let prompt_path = flags.out.join(format!("{stem}.md")); + let meta_path = flags.out.join(format!("{stem}.meta.txt")); + + log::trace!( + "[agent-cli] run_dump_all: writing prompt file {} ({} bytes)", + prompt_path.display(), + dumped.text.len() + ); + std::fs::write(&prompt_path, &dumped.text) + .with_context(|| format!("writing {}", prompt_path.display()))?; + + let mut meta = String::new(); + use std::fmt::Write as _; + let _ = writeln!(meta, "agent: {}", dumped.agent_id); + if let Some(tk) = &dumped.toolkit { + let _ = writeln!(meta, "toolkit: {tk}"); + } + let _ = writeln!(meta, "mode: {}", dumped.mode); + let _ = writeln!(meta, "model: {}", dumped.model); + let _ = writeln!(meta, "workspace: {}", dumped.workspace_dir.display()); + let _ = writeln!(meta, "tool_count: {}", dumped.tool_names.len()); + let _ = writeln!(meta, "skill_tools: {}", dumped.skill_tool_count); + std::fs::write(&meta_path, meta) + .with_context(|| format!("writing {}", meta_path.display()))?; + + let label = match &dumped.toolkit { + Some(tk) => format!("{}@{}", dumped.agent_id, tk), + None => dumped.agent_id.clone(), + }; + let _ = writeln!( + summary, + "{:<32} tools={:<4} skill={:<4}", + label, + dumped.tool_names.len(), + dumped.skill_tool_count + ); + eprintln!("[dump-all] {label:<32} → {}", prompt_path.display()); + } + + let summary_path = flags.out.join("SUMMARY.txt"); + std::fs::write(&summary_path, &summary) + .with_context(|| format!("writing {}", summary_path.display()))?; + eprintln!("[dump-all] wrote summary → {}", summary_path.display()); + log::debug!( + "[agent-cli] run_dump_all exit: wrote {} prompt(s) + SUMMARY.txt", + dumps.len() + ); + Ok(()) +} + +fn sanitise_filename_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect() +} + // --------------------------------------------------------------------------- // dump-prompt // --------------------------------------------------------------------------- struct DumpFlags { agent: Option, - skill: Option, + toolkit: Option, workspace: Option, model: Option, json: bool, with_tools: bool, - stub_composio: bool, verbose: bool, } fn parse_dump_flags(args: &[String]) -> Result { let mut out = DumpFlags { agent: None, - skill: None, + toolkit: None, workspace: None, model: None, json: false, with_tools: false, - stub_composio: false, verbose: false, }; let mut i = 0usize; @@ -78,10 +245,10 @@ fn parse_dump_flags(args: &[String]) -> Result { ); i += 2; } - "--skill" | "-s" => { - out.skill = Some( + "--toolkit" | "-t" => { + out.toolkit = Some( args.get(i + 1) - .ok_or_else(|| anyhow!("missing value for --skill"))? + .ok_or_else(|| anyhow!("missing value for --toolkit"))? .clone(), ); i += 2; @@ -109,10 +276,6 @@ fn parse_dump_flags(args: &[String]) -> Result { out.with_tools = true; i += 1; } - "--stub-composio" => { - out.stub_composio = true; - i += 1; - } "-v" | "--verbose" => { out.verbose = true; i += 1; @@ -130,25 +293,45 @@ fn parse_dump_flags(args: &[String]) -> Result { fn run_dump_prompt(args: &[String]) -> Result<()> { let flags = parse_dump_flags(args)?; - let agent = flags - .agent - .clone() - .ok_or_else(|| anyhow!("--agent is required (use `main` for the orchestrator)"))?; + let agent = flags.agent.clone().ok_or_else(|| { + anyhow!("--agent is required (e.g. `orchestrator`, `integrations_agent`, `welcome`)") + })?; + + if agent == "integrations_agent" && flags.toolkit.is_none() { + return Err(anyhow!( + "--toolkit is required when --agent is `integrations_agent` \ + (e.g. `--toolkit gmail`). Run `composio list_connection` to see active slugs." + )); + } init_quiet_logging(flags.verbose); + log::debug!( + "[agent-cli] run_dump_prompt entry: agent={} toolkit={:?} workspace={:?} model={:?}", + agent, + flags.toolkit, + flags.workspace, + flags.model + ); + let options = DumpPromptOptions { agent_id: agent, - skill_filter: flags.skill.clone(), + toolkit: flags.toolkit.clone(), workspace_dir_override: flags.workspace.clone(), model_override: flags.model.clone(), - stub_composio: flags.stub_composio, }; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; + log::debug!("[agent-cli] run_dump_prompt: calling dump_agent_prompt"); let dumped = rt.block_on(async { dump_agent_prompt(options).await })?; + log::debug!( + "[agent-cli] run_dump_prompt: dump returned (tools={}, skill_tools={}, prompt_bytes={})", + dumped.tool_names.len(), + dumped.skill_tool_count, + dumped.text.len() + ); if flags.json { print_json(&dumped, flags.with_tools)?; @@ -165,15 +348,14 @@ fn print_human(dumped: &DumpedPrompt, with_tools: bool) { // in `core/cli.rs` (banner to stderr, JSON result to stdout). eprintln!("# Agent prompt dump"); eprintln!("agent: {}", dumped.agent_id); + if let Some(tk) = &dumped.toolkit { + eprintln!("toolkit: {tk}"); + } eprintln!("mode: {}", dumped.mode); eprintln!("model: {}", dumped.model); eprintln!("workspace: {}", dumped.workspace_dir.display()); eprintln!("tool_count: {}", dumped.tool_names.len()); eprintln!("skill_tools: {}", dumped.skill_tool_count); - match dumped.cache_boundary { - Some(offset) => eprintln!("cache_boundary: byte offset {offset}"), - None => eprintln!("cache_boundary: none"), - } if with_tools { eprintln!("tools:"); for name in &dumped.tool_names { @@ -195,6 +377,13 @@ fn print_json(dumped: &DumpedPrompt, with_tools: bool) -> Result<()> { "agent_id".into(), serde_json::Value::String(dumped.agent_id.clone()), ); + obj.insert( + "toolkit".into(), + match &dumped.toolkit { + Some(tk) => serde_json::Value::String(tk.clone()), + None => serde_json::Value::Null, + }, + ); obj.insert( "mode".into(), serde_json::Value::String(dumped.mode.to_string()), @@ -215,13 +404,6 @@ fn print_json(dumped: &DumpedPrompt, with_tools: bool) -> Result<()> { "skill_tool_count".into(), serde_json::Value::Number(dumped.skill_tool_count.into()), ); - obj.insert( - "cache_boundary".into(), - match dumped.cache_boundary { - Some(offset) => serde_json::Value::Number(offset.into()), - None => serde_json::Value::Null, - }, - ); obj.insert( "system_prompt".into(), serde_json::Value::String(dumped.text.clone()), @@ -327,13 +509,6 @@ fn run_list(args: &[String]) -> Result<()> { "omit_skills_catalog".into(), serde_json::Value::Bool(def.omit_skills_catalog), ); - obj.insert( - "category_filter".into(), - match def.category_filter { - Some(cat) => serde_json::Value::String(format!("{cat:?}")), - None => serde_json::Value::Null, - }, - ); arr.push(serde_json::Value::Object(obj)); } println!( @@ -341,15 +516,11 @@ fn run_list(args: &[String]) -> Result<()> { serde_json::to_string_pretty(&serde_json::Value::Array(arr))? ); } else { - println!("{:<20} {:<22} WHEN TO USE", "ID", "CATEGORY FILTER"); + println!("{:<20} WHEN TO USE", "ID"); println!("{}", "-".repeat(90)); for def in registry.list() { - let cat = def - .category_filter - .map(|c| format!("{c:?}")) - .unwrap_or_else(|| "-".into()); - let when = def.when_to_use.chars().take(46).collect::(); - println!("{:<20} {:<22} {}", def.id, cat, when); + let when = def.when_to_use.chars().take(68).collect::(); + println!("{:<20} {}", def.id, when); } println!(); println!("{} agent(s) registered.", registry.len()); @@ -366,7 +537,8 @@ fn print_agent_help() { println!(); println!("Usage:"); println!(" openhuman agent list [--workspace ] [--json]"); - println!(" openhuman agent dump-prompt --agent [--skill ] [--workspace ] [--model ] [--with-tools] [--json] [-v]"); + println!(" openhuman agent dump-prompt --agent [--workspace ] [--model ] [--with-tools] [--json] [-v]"); + println!(" openhuman agent dump-all --out [--workspace ] [--model ] [-v]"); println!(); println!("Run `openhuman agent --help` for details."); } @@ -378,34 +550,30 @@ fn print_dump_prompt_help() { println!(" openhuman agent dump-prompt --agent [options]"); println!(); println!("Required:"); - println!(" --agent, -a Target agent id — any built-in or workspace-custom id."); - println!(" Use `main` for the orchestrator / main-agent prompt."); + println!(" --agent, -a Target agent id — any built-in or workspace-custom id"); + println!(" (e.g. `orchestrator`, `integrations_agent`, `welcome`)."); println!(); println!("Options:"); - println!(" --skill, -s Skill filter override — scopes the tool list to"); - println!(" tools named `__*`. Mirrors `spawn_subagent`'s"); - println!(" per-call `skill_filter` argument."); + println!(" --toolkit, -t REQUIRED when `--agent integrations_agent`. Names the"); + println!(" Composio toolkit to bind this dump to (e.g. `gmail`,"); + println!(" `notion`). Must match a currently-connected integration —"); + println!(" run `composio list_connection` to see the active slugs."); println!(" --workspace, -w

Override the workspace directory (defaults to"); println!(" Config::workspace_dir / ~/.openhuman/workspace)."); println!(" --model, -m Override the resolved model name (affects only the"); println!(" `## Runtime` section)."); println!(" --with-tools Also print the full list of tool names the agent sees."); - println!(" --stub-composio Inject the five Composio meta-tool stubs into the dump"); - println!(" even if the user is not signed in. Debug-only — do not"); - println!(" run agents against the resulting registry (stubs have no"); - println!(" real backend)."); println!(" --json Emit a machine-readable JSON object on stdout."); println!(" -v, --verbose Enable debug logging on stderr."); println!(); println!("Examples:"); - println!(" # Full skills_agent dump (includes Composio meta-tools when enabled)."); - println!(" openhuman agent dump-prompt --agent skills_agent --with-tools"); + println!(" # Orchestrator prompt, JSON for scripting."); + println!(" openhuman agent dump-prompt --agent orchestrator --json"); println!(); - println!(" # Narrow the skills_agent prompt to just the Notion integration."); - println!(" openhuman agent dump-prompt --agent skills_agent --skill notion"); - println!(); - println!(" # Main orchestrator prompt, JSON for scripting."); - println!(" openhuman agent dump-prompt --agent main --json"); + println!(" # integrations_agent bound to the user's gmail connection."); + println!( + " openhuman agent dump-prompt --agent integrations_agent --toolkit gmail --with-tools" + ); } fn is_help(value: &str) -> bool { diff --git a/src/core/cli.rs b/src/core/cli.rs index 7135ed791..5d4e97d09 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -47,6 +47,8 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { // Print the welcome banner to stderr to keep stdout clean for JSON output. eprint!("{CLI_BANNER}"); + load_dotenv_for_cli()?; + let grouped = grouped_schemas(); if args.is_empty() || is_help(&args[0]) { print_general_help(&grouped); @@ -81,13 +83,14 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { /// Loads key/value pairs from a `.env` file into the process environment. /// -/// This is used during the `run` command to load sensitive configurations. +/// This is used for all CLI entrypoints so direct namespace commands pick up +/// the same repo-local configuration as `run` / `serve`. /// /// Precedence: /// 1. Variables already set in the process environment are **not** overwritten. /// 2. If `OPENHUMAN_DOTENV_PATH` is set, that file is loaded. /// 3. Otherwise, it searches for `.env` in the current working directory. -fn load_dotenv_for_server() -> Result<()> { +fn load_dotenv_for_cli() -> Result<()> { match std::env::var("OPENHUMAN_DOTENV_PATH") { Ok(path) if !path.trim().is_empty() => { dotenvy::from_path(&path).map_err(|e| { @@ -110,8 +113,6 @@ fn load_dotenv_for_server() -> Result<()> { /// /// * `args` - Command-line arguments for the `run` command (e.g., `--port`). fn run_server_command(args: &[String]) -> Result<()> { - load_dotenv_for_server()?; - let mut port: Option = None; let mut host: Option = None; let mut socketio_enabled = true; @@ -564,8 +565,19 @@ fn is_help(value: &str) -> bool { #[cfg(test)] mod tests { - use super::{grouped_schemas, parse_function_params, parse_input_value}; + use super::{grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + use std::sync::{Mutex, OnceLock}; + use tempfile::tempdir; + + static CLI_ENV_LOCK: OnceLock> = OnceLock::new(); + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + CLI_ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } #[test] fn grouped_schemas_contains_migrated_namespaces() { @@ -612,4 +624,56 @@ mod tests { .expect_err("invalid bool should fail"); assert!(err.contains("expected bool")); } + + #[test] + fn load_dotenv_for_cli_reads_cwd_dotenv_without_overwriting_existing_env() { + let _guard = env_lock(); + let tmp = tempdir().expect("tempdir"); + let env_path = tmp.path().join(".env"); + std::fs::write( + &env_path, + "BACKEND_URL=https://staging-api.example.test\nOPENHUMAN_APP_ENV=staging\n", + ) + .expect("write .env"); + + let original_dir = std::env::current_dir().expect("current dir"); + let prior_backend = std::env::var("BACKEND_URL").ok(); + let prior_app_env = std::env::var("OPENHUMAN_APP_ENV").ok(); + let prior_dotenv_path = std::env::var("OPENHUMAN_DOTENV_PATH").ok(); + + unsafe { + std::env::remove_var("BACKEND_URL"); + std::env::set_var("OPENHUMAN_APP_ENV", "production"); + std::env::remove_var("OPENHUMAN_DOTENV_PATH"); + } + std::env::set_current_dir(tmp.path()).expect("set current dir"); + + let result = load_dotenv_for_cli(); + + let loaded_backend = std::env::var("BACKEND_URL").ok(); + let loaded_app_env = std::env::var("OPENHUMAN_APP_ENV").ok(); + + std::env::set_current_dir(&original_dir).expect("restore current dir"); + unsafe { + match prior_backend { + Some(value) => std::env::set_var("BACKEND_URL", value), + None => std::env::remove_var("BACKEND_URL"), + } + match prior_app_env { + Some(value) => std::env::set_var("OPENHUMAN_APP_ENV", value), + None => std::env::remove_var("OPENHUMAN_APP_ENV"), + } + match prior_dotenv_path { + Some(value) => std::env::set_var("OPENHUMAN_DOTENV_PATH", value), + None => std::env::remove_var("OPENHUMAN_DOTENV_PATH"), + } + } + + result.expect("dotenv load should succeed"); + assert_eq!( + loaded_backend.as_deref(), + Some("https://staging-api.example.test") + ); + assert_eq!(loaded_app_env.as_deref(), Some("production")); + } } diff --git a/src/openhuman/agent/agents/archivist/mod.rs b/src/openhuman/agent/agents/archivist/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/archivist/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/archivist/prompt.rs b/src/openhuman/agent/agents/archivist/prompt.rs new file mode 100644 index 000000000..1dbcfe35c --- /dev/null +++ b/src/openhuman/agent/agents/archivist/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `archivist` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "archivist", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/code_executor/mod.rs b/src/openhuman/agent/agents/code_executor/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/code_executor/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/code_executor/prompt.rs b/src/openhuman/agent/agents/code_executor/prompt.rs new file mode 100644 index 000000000..6639ba72e --- /dev/null +++ b/src/openhuman/agent/agents/code_executor/prompt.rs @@ -0,0 +1,71 @@ +//! System prompt builder for the `code_executor` built-in agent. +//! +//! Returns the fully-assembled system prompt, including the standard +//! `## Safety` block (this agent has `omit_safety_preamble = false` +//! in its TOML — it executes code or external actions and needs the +//! guard rails inlined). + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "code_executor", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/critic/mod.rs b/src/openhuman/agent/agents/critic/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/critic/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/critic/prompt.rs b/src/openhuman/agent/agents/critic/prompt.rs new file mode 100644 index 000000000..a5af3e081 --- /dev/null +++ b/src/openhuman/agent/agents/critic/prompt.rs @@ -0,0 +1,66 @@ +//! System prompt builder for the `critic` built-in agent. +//! +//! Returns the final, fully-assembled system prompt — archetype body +//! (from the sibling `prompt.md`) plus the same section helpers the +//! runtime uses for every other agent. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "critic", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/integrations_agent/agent.toml b/src/openhuman/agent/agents/integrations_agent/agent.toml new file mode 100644 index 000000000..5d0e2eba6 --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/agent.toml @@ -0,0 +1,16 @@ +id = "integrations_agent" +display_name = "Integrations Agent" +when_to_use = "Service integration specialist — drives a SINGLE Composio toolkit per spawn (gmail, notion, github, slack, …). The `toolkit` argument is mandatory. Use when a task should be completed via a managed OAuth integration rather than raw HTTP / file I/O." +temperature = 0.4 +max_iterations = 10 +sandbox_mode = "none" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = false +omit_skills_catalog = true + +[model] +hint = "agentic" + +[tools] +named = ["composio_list_tools", "file_read"] diff --git a/src/openhuman/agent/agents/integrations_agent/mod.rs b/src/openhuman/agent/agents/integrations_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.md b/src/openhuman/agent/agents/integrations_agent/prompt.md new file mode 100644 index 000000000..3d04374dc --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/prompt.md @@ -0,0 +1,45 @@ +# Integrations Agent — Service Integration Specialist + +You are the **Integrations Agent**. You interact with one connected external service at a time via **Composio** (a managed OAuth gateway). Each spawn is scoped to a single toolkit — the one your caller passed in the `toolkit` argument (e.g. `gmail`, `notion`, `github`, `slack`). + +## Your tool surface + +- **`composio_list_tools`** — inspect the action catalogue for your bound toolkit. Returns the `function.name` slug + JSON schema for each action. +- **`composio_execute`** — run a Composio action: `{ tool: "", arguments: {...} }`. +- **Per-action tools** — the toolkit's individual action tools are already registered in your tool list with typed schemas (e.g. `GMAIL_SEND_EMAIL`, `NOTION_CREATE_PAGE`). Prefer calling these directly over the generic `composio_execute`. + +You do **not** have `composio_list_toolkits`, `composio_list_connections`, `composio_authorize`, shell, file I/O, or any other capability. Stay inside this surface. + +## Typical flow + +1. You already have the toolkit's action tools in your tool list — start there. If you need a schema reminder or a slug you don't see, call `composio_list_tools`. +2. Call the per-action tool (or `composio_execute` with the slug) using the caller's task as your guide. +3. If the call fails with an authentication / authorization / connection error, stop and return: **"Connection error, try to authenticate"** — the orchestrator will take over and route the user to settings. + +## Rules + +- **Never fabricate action slugs.** Pull them from `composio_list_tools` or use the per-action tools already in your list. +- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. +- **Auth errors bubble up.** On any auth / connection failure reply exactly: `Connection error, try to authenticate`. Do not retry, do not attempt to re-authorise yourself — you have no tools for that. +- **Be precise** — every action expects a specific argument shape. Validate against the schema before calling. +- **Report results** — state what action was taken and the outcome, including any cost reported by Composio. + +## Handling oversized tool results + +When an action returns a very large payload (~100 KB or more), decide based on what the caller asked for. + +### Path A — caller wants an answer, not the raw data + +Examples: "how many unread emails do I have?", "which issues are labeled P0?", "what's the most recent message?" + +Scan the result for the specific facts that answer the question, then synthesise a concise answer referencing identifiers (issue numbers, email subjects, message timestamps). Do **not** dump raw output. + +### Path B — caller wants the dataset itself + +Examples: "show me all open issues", "export my contacts", "give me the full thread". + +You cannot write files from this agent. Return a concise summary inline (count, key highlights, representative identifiers) and tell the caller you are returning the structured data so the orchestrator can persist it — the orchestrator, not you, owns file I/O. + +### Hard cap + +Never paste more than ~2000 characters of raw tool output directly in your response. diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.rs b/src/openhuman/agent/agents/integrations_agent/prompt.rs new file mode 100644 index 000000000..8dc805e58 --- /dev/null +++ b/src/openhuman/agent/agents/integrations_agent/prompt.rs @@ -0,0 +1,204 @@ +//! System prompt builder for the `integrations_agent` built-in agent. +//! +//! `integrations_agent` is the one sub-agent that executes Composio actions +//! directly — every other agent delegates to it via `spawn_subagent`. +//! That means the prompt owns two blocks nobody else renders: +//! +//! * `## Available Skills` — the QuickJS skill catalogue it can invoke +//! through the runtime. +//! * `## Connected Integrations` — the list of Composio toolkits the +//! user has connected, framed as "you have direct access to the +//! action tools in your tool list" rather than "delegate to integrations_agent". +//! +//! Both blocks live here (not in the shared prompts module) so the +//! delegator agents stay lean and the integrations_agent-specific wording +//! isn't a branch on `agent_id` somewhere else. + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, ConnectedIntegration, + PromptContext, +}; +use crate::openhuman::skills::Skill; +use anyhow::Result; +use std::fmt::Write; +use std::path::Path; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let skills = render_available_skills(ctx.skills, ctx.workspace_dir); + if !skills.trim().is_empty() { + out.push_str(skills.trim_end()); + out.push_str("\n\n"); + } + + let integrations = render_connected_integrations(ctx.connected_integrations); + if !integrations.trim().is_empty() { + out.push_str(integrations.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +/// Render the `## Available Skills` XML catalogue of QuickJS skills +/// this agent can invoke through the host runtime. Empty when no skills +/// are registered. +fn render_available_skills(skills: &[Skill], workspace_dir: &Path) -> String { + if skills.is_empty() { + return String::new(); + } + let mut out = String::from("## Available Skills\n\n\n"); + for skill in skills { + let location = skill.location.clone().unwrap_or_else(|| { + workspace_dir + .join("skills") + .join(&skill.name) + .join("SKILL.md") + }); + let _ = writeln!( + out, + " \n {}\n {}\n {}\n ", + xml_escape(&skill.name), + xml_escape(&skill.description), + xml_escape(&location.display().to_string()), + ); + } + out.push_str(""); + out +} + +/// Escape XML-sensitive characters so skill metadata can't break the +/// surrounding `` block if a name or description +/// contains `<`, `>`, or `&`. +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// Render the skill-executor-flavoured `## Connected Integrations` +/// block. Tells the model that the action tools for each toolkit are +/// already in its tool list and to call them directly — no delegation +/// wording, because `integrations_agent` IS the delegation target. +fn render_connected_integrations(integrations: &[ConnectedIntegration]) -> String { + let connected: Vec<&ConnectedIntegration> = + integrations.iter().filter(|ci| ci.connected).collect(); + if connected.is_empty() { + return String::new(); + } + let mut out = String::from( + "## Connected Integrations\n\n\ + You have direct access to the following external services. \ + The corresponding action tools are in your tool list with \ + their typed parameter schemas — call them by name.\n\n", + ); + for ci in connected { + let _ = writeln!(out, "- **{}** — {}", ci.toolkit, ci.description); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn ctx_with<'a>( + integrations: &'a [ConnectedIntegration], + skills: &'a [Skill], + ) -> PromptContext<'a> { + // Leak a HashSet so the returned context borrows a 'static-ish + // reference — the test owns the value for its lifetime. + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "integrations_agent", + tools: &[], + skills, + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: integrations, + include_profile: false, + include_memory_md: false, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with(&[], &[])).unwrap(); + assert!(!body.is_empty()); + assert!(!body.contains("## Connected Integrations")); + assert!(!body.contains("## Available Skills")); + } + + #[test] + fn build_includes_connected_integrations_in_executor_voice() { + let integrations = vec![ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + connected: true, + }]; + let body = build(&ctx_with(&integrations, &[])).unwrap(); + assert!(body.contains("## Connected Integrations")); + assert!(body.contains("You have direct access")); + assert!(body.contains("- **gmail** — Email access.")); + // `integrations_agent` must NOT render the delegator spawn snippet — + // that belongs on the orchestrator/welcome side. + assert!(!body.contains("Delegation Guide")); + assert!(!body.contains("spawn_subagent")); + } + + #[test] + fn build_skips_unconnected_integrations() { + let integrations = vec![ConnectedIntegration { + toolkit: "notion".into(), + description: "Pages.".into(), + tools: Vec::new(), + connected: false, + }]; + let body = build(&ctx_with(&integrations, &[])).unwrap(); + assert!(!body.contains("## Connected Integrations")); + } +} diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index 261f28c11..38722962f 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -6,12 +6,15 @@ //! * `agent.toml` — id, when_to_use, model, tool allowlist, sandbox, //! iteration cap, and the `omit_*` flags. Parsed //! directly into [`AgentDefinition`] via serde. -//! * `prompt.md` — the sub-agent's system prompt body. +//! * `prompt.rs` — a Rust module exporting `pub fn build(ctx: &PromptContext) +//! -> anyhow::Result` that returns the sub-agent's system +//! prompt body. Dynamic: may branch on available tools, user profile, +//! connected integrations, model hint, etc. //! //! Adding a new built-in agent = creating a new subfolder with those two -//! files and appending one entry to [`BUILTINS`] below. There are no -//! match arms to update, no enum variants to add, and no `include_str!` -//! paths scattered across the harness. +//! files, declaring the module, and appending one entry to [`BUILTINS`] +//! below. There are no match arms to update, no enum variants to add, +//! and no `include_str!` paths scattered across the harness. //! //! ## Flow //! @@ -32,18 +35,22 @@ //! collision. use crate::openhuman::agent::harness::definition::{ - AgentDefinition, DefinitionSource, PromptSource, + AgentDefinition, DefinitionSource, PromptBuilder, PromptSource, }; use anyhow::{Context, Result}; -/// A single built-in agent: its id plus the two files that define it. +/// A single built-in agent: its id plus the metadata TOML and a +/// function-driven prompt builder. /// /// Kept as a static slice (rather than e.g. `include_dir!`) so the /// compile-time file-existence check is explicit and grep-friendly. pub struct BuiltinAgent { pub id: &'static str, pub toml: &'static str, - pub prompt: &'static str, + /// Prompt builder. Invoked at spawn time by the sub-agent runner + /// with a populated [`crate::openhuman::agent::harness::definition::PromptContext`] + /// so the returned body can branch on runtime state. + pub prompt_fn: PromptBuilder, } /// Every built-in agent, in stable display order. @@ -53,67 +60,72 @@ pub const BUILTINS: &[BuiltinAgent] = &[ BuiltinAgent { id: "orchestrator", toml: include_str!("orchestrator/agent.toml"), - prompt: include_str!("orchestrator/prompt.md"), + prompt_fn: super::orchestrator::prompt::build, }, BuiltinAgent { id: "planner", toml: include_str!("planner/agent.toml"), - prompt: include_str!("planner/prompt.md"), + prompt_fn: super::planner::prompt::build, }, BuiltinAgent { id: "code_executor", toml: include_str!("code_executor/agent.toml"), - prompt: include_str!("code_executor/prompt.md"), + prompt_fn: super::code_executor::prompt::build, }, BuiltinAgent { - id: "skills_agent", - toml: include_str!("skills_agent/agent.toml"), - prompt: include_str!("skills_agent/prompt.md"), + id: "integrations_agent", + toml: include_str!("integrations_agent/agent.toml"), + prompt_fn: super::integrations_agent::prompt::build, + }, + BuiltinAgent { + id: "tools_agent", + toml: include_str!("tools_agent/agent.toml"), + prompt_fn: super::tools_agent::prompt::build, }, BuiltinAgent { id: "tool_maker", toml: include_str!("tool_maker/agent.toml"), - prompt: include_str!("tool_maker/prompt.md"), + prompt_fn: super::tool_maker::prompt::build, }, BuiltinAgent { id: "researcher", toml: include_str!("researcher/agent.toml"), - prompt: include_str!("researcher/prompt.md"), + prompt_fn: super::researcher::prompt::build, }, BuiltinAgent { id: "critic", toml: include_str!("critic/agent.toml"), - prompt: include_str!("critic/prompt.md"), + prompt_fn: super::critic::prompt::build, }, BuiltinAgent { id: "archivist", toml: include_str!("archivist/agent.toml"), - prompt: include_str!("archivist/prompt.md"), + prompt_fn: super::archivist::prompt::build, }, BuiltinAgent { id: "trigger_triage", toml: include_str!("trigger_triage/agent.toml"), - prompt: include_str!("trigger_triage/prompt.md"), + prompt_fn: super::trigger_triage::prompt::build, }, BuiltinAgent { id: "trigger_reactor", toml: include_str!("trigger_reactor/agent.toml"), - prompt: include_str!("trigger_reactor/prompt.md"), + prompt_fn: super::trigger_reactor::prompt::build, }, BuiltinAgent { id: "morning_briefing", toml: include_str!("morning_briefing/agent.toml"), - prompt: include_str!("morning_briefing/prompt.md"), + prompt_fn: super::morning_briefing::prompt::build, }, BuiltinAgent { id: "welcome", toml: include_str!("welcome/agent.toml"), - prompt: include_str!("welcome/prompt.md"), + prompt_fn: super::welcome::prompt::build, }, BuiltinAgent { id: "summarizer", toml: include_str!("summarizer/agent.toml"), - prompt: include_str!("summarizer/prompt.md"), + prompt_fn: super::summarizer::prompt::build, }, ]; @@ -134,8 +146,8 @@ fn parse_builtin(b: &BuiltinAgent) -> Result { let mut def: AgentDefinition = toml::from_str(b.toml) .with_context(|| format!("parsing built-in agent `{}` TOML", b.id))?; - // Inject the prompt body and stamp the source. - def.system_prompt = PromptSource::Inline(b.prompt.to_string()); + // Install the function-driven prompt builder and stamp the source. + def.system_prompt = PromptSource::Dynamic(b.prompt_fn); def.source = DefinitionSource::Builtin; // Sanity check: file layout id must match declared TOML id. This @@ -160,7 +172,7 @@ mod tests { fn all_builtins_parse() { let defs = load_builtins().expect("built-in TOML must parse"); assert_eq!(defs.len(), BUILTINS.len()); - assert_eq!(defs.len(), 13, "expected 13 built-in agents"); + assert_eq!(defs.len(), 14, "expected 14 built-in agents"); } #[test] @@ -226,13 +238,35 @@ mod tests { #[test] fn every_builtin_has_a_prompt_body() { + use crate::openhuman::context::prompt::{ + ConnectedIntegration, LearnedContextData, PromptContext, PromptTool, ToolCallFormat, + }; + let empty_tools: Vec> = Vec::new(); + let empty_integrations: Vec = Vec::new(); + let empty_visible: std::collections::HashSet = std::collections::HashSet::new(); for def in load_builtins().unwrap() { match &def.system_prompt { - PromptSource::Inline(body) => { + PromptSource::Dynamic(build) => { + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: &def.id, + tools: &empty_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &empty_visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &empty_integrations, + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx) + .unwrap_or_else(|e| panic!("{} prompt build failed: {e}", def.id)); assert!(!body.is_empty(), "{} has empty prompt", def.id); } - PromptSource::File { .. } => { - panic!("{} should use inline prompt, not File", def.id); + PromptSource::Inline(_) | PromptSource::File { .. } => { + panic!("{} should use dynamic prompt builder", def.id); } } } @@ -292,16 +326,26 @@ mod tests { } #[test] - fn skills_agent_is_wildcard_with_skill_category_filter() { - let def = find("skills_agent"); - assert!(matches!(def.tools, ToolScope::Wildcard)); - assert_eq!( - def.category_filter, - Some(crate::openhuman::tools::ToolCategory::Skill) - ); + fn integrations_agent_tool_scope_honours_toml() { + let def = find("integrations_agent"); + // Current TOML: `named = ["composio_list_tools", "file_read"]`. + // Sub-agent runner additionally injects per-toolkit + // ComposioActionTools at spawn time. + match &def.tools { + ToolScope::Named(names) => { + assert!(names.iter().any(|n| n == "composio_list_tools")); + } + other => panic!("expected Named scope, got {other:?}"), + } assert!(!def.omit_safety_preamble); } + #[test] + fn tools_agent_is_registered() { + let def = find("tools_agent"); + assert!(matches!(def.tools, ToolScope::Wildcard)); + } + #[test] fn archivist_runs_in_background() { let def = find("archivist"); @@ -310,14 +354,10 @@ mod tests { } #[test] - fn morning_briefing_is_read_only_with_skill_filter() { + fn morning_briefing_is_read_only() { let def = find("morning_briefing"); assert_eq!(def.sandbox_mode, SandboxMode::ReadOnly); assert!(matches!(def.tools, ToolScope::Wildcard)); - assert_eq!( - def.category_filter, - Some(crate::openhuman::tools::ToolCategory::Skill) - ); assert!(!def.omit_memory_context); assert!(def.omit_identity); assert!(def.omit_safety_preamble); diff --git a/src/openhuman/agent/agents/mod.rs b/src/openhuman/agent/agents/mod.rs index ca9184dcb..9d519e4b5 100644 --- a/src/openhuman/agent/agents/mod.rs +++ b/src/openhuman/agent/agents/mod.rs @@ -1,3 +1,22 @@ mod loader; +// Built-in agents. Each module owns an `agent.toml` (metadata), the +// legacy `prompt.md` (kept alongside for reference / workspace +// overrides), and a `prompt.rs` exposing a `pub fn build(&PromptContext) +// -> Result` that the loader wires into `PromptSource::Dynamic`. +pub mod archivist; +pub mod code_executor; +pub mod critic; +pub mod integrations_agent; +pub mod morning_briefing; +pub mod orchestrator; +pub mod planner; +pub mod researcher; +pub mod summarizer; +pub mod tool_maker; +pub mod tools_agent; +pub mod trigger_reactor; +pub mod trigger_triage; +pub mod welcome; + pub use loader::{load_builtins, BuiltinAgent, BUILTINS}; diff --git a/src/openhuman/agent/agents/morning_briefing/agent.toml b/src/openhuman/agent/agents/morning_briefing/agent.toml index 198f1dfc4..4001b191c 100644 --- a/src/openhuman/agent/agents/morning_briefing/agent.toml +++ b/src/openhuman/agent/agents/morning_briefing/agent.toml @@ -12,10 +12,6 @@ omit_memory_context = false omit_safety_preamble = true omit_skills_catalog = true -# Skill-category tools so it can pull calendar, email, task data from -# connected integrations (Composio: Gmail, Google Calendar, Notion, etc.). -category_filter = "skill" - [model] hint = "agentic" diff --git a/src/openhuman/agent/agents/morning_briefing/mod.rs b/src/openhuman/agent/agents/morning_briefing/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/morning_briefing/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/morning_briefing/prompt.rs b/src/openhuman/agent/agents/morning_briefing/prompt.rs new file mode 100644 index 000000000..e6c9c42a8 --- /dev/null +++ b/src/openhuman/agent/agents/morning_briefing/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `morning_briefing` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "morning_briefing", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/orchestrator/agent.toml b/src/openhuman/agent/agents/orchestrator/agent.toml index 704bf274d..1c70affb0 100644 --- a/src/openhuman/agent/agents/orchestrator/agent.toml +++ b/src/openhuman/agent/agents/orchestrator/agent.toml @@ -26,7 +26,7 @@ omit_memory_md = false # LLM-visible tool description. # # * `{ skills = "*" }` → one `SkillDelegationTool` per connected -# Composio toolkit, all routing to the generic `skills_agent` with +# Composio toolkit, all routing to the generic `integrations_agent` with # the toolkit slug pre-filled as `skill_filter`. # # The orchestrator LLM sees these as first-class entries in its @@ -41,6 +41,7 @@ subagents = [ "researcher", "planner", "code_executor", + "tools_agent", "critic", "archivist", # Runtime-dispatched only — the runtime calls the summarizer sub-agent @@ -63,9 +64,18 @@ hint = "reasoning" # delegating. `spawn_subagent` is retained as an advanced fallback so # power users can still spawn arbitrary agent ids that are not listed # in `subagents` above (e.g. workspace-override custom agents). +# +# `composio_list_connections` is the orchestrator's only composio_* +# tool: it exists so the agent can detect newly-authorised integrations +# mid-session (the session-start fetch froze the Delegation Guide's +# connected list). Authorisation, toolkit discovery, action listing, +# and action execution all live downstream in `integrations_agent` — +# the orchestrator never calls composio_authorize / composio_list_tools +# / composio_execute directly. named = [ "query_memory", "read_workspace_state", "ask_user_clarification", "spawn_subagent", + "composio_list_connections", ] diff --git a/src/openhuman/agent/agents/orchestrator/mod.rs b/src/openhuman/agent/agents/orchestrator/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/orchestrator/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/orchestrator/prompt.rs b/src/openhuman/agent/agents/orchestrator/prompt.rs new file mode 100644 index 000000000..d3a493fcc --- /dev/null +++ b/src/openhuman/agent/agents/orchestrator/prompt.rs @@ -0,0 +1,169 @@ +//! System prompt builder for the `orchestrator` built-in agent. +//! +//! The orchestrator is a pure delegator — it never executes Composio +//! actions itself. Its integration block is a `## Delegation Guide` +//! that tells the model to `spawn_subagent(integrations_agent, toolkit=…)` +//! for anything touching an external service. That prose lives here +//! (not in the shared prompts module) so the skill-executor voice +//! stays in `integrations_agent/prompt.rs` and nobody has to branch on +//! `agent_id` in a shared section impl. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, ConnectedIntegration, PromptContext, +}; +use anyhow::Result; +use std::fmt::Write; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let integrations = render_delegation_guide(ctx.connected_integrations); + if !integrations.trim().is_empty() { + out.push_str(integrations.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +/// Render the delegator-voice `## Delegation Guide — Integrations` +/// block. Only toolkits the user has actively connected are listed — +/// unauthorised toolkits are hidden so the orchestrator can't hallucinate +/// a spawn against an integration the `spawn_subagent` pre-flight will +/// immediately reject. When every toolkit is unconnected, the whole +/// section is omitted. +fn render_delegation_guide(integrations: &[ConnectedIntegration]) -> String { + let connected: Vec<&ConnectedIntegration> = + integrations.iter().filter(|ci| ci.connected).collect(); + if connected.is_empty() { + return String::new(); + } + let mut out = String::from( + "## Delegation Guide — Integrations\n\n\ + For any task that touches one of these external services, \ + delegate to `integrations_agent` with the matching `toolkit` \ + argument. The sub-agent receives the full action catalogue \ + for that integration as native tool schemas — do not attempt \ + to call integration actions directly from this agent.\n\n\ + Only the integrations listed below are currently authorised. \ + If the user asks about another service, tell them to connect \ + it in **Skills** page before retrying.\n\n", + ); + for ci in connected { + let _ = writeln!( + out, + "- **{}** — {}\n Delegate with: `spawn_subagent(agent_id=\"integrations_agent\", toolkit=\"{}\", prompt=)`", + ci.toolkit, ci.description, ci.toolkit, + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn ctx_with<'a>(integrations: &'a [ConnectedIntegration]) -> PromptContext<'a> { + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "orchestrator", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: integrations, + include_profile: false, + include_memory_md: false, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with(&[])).unwrap(); + assert!(!body.is_empty()); + assert!(!body.contains("## Delegation Guide")); + } + + #[test] + fn build_emits_delegation_guide_with_spawn_snippet() { + let integrations = vec![ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + connected: true, + }]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(body.contains("## Delegation Guide — Integrations")); + assert!(body.contains( + "spawn_subagent(agent_id=\"integrations_agent\", toolkit=\"gmail\", prompt=)" + )); + // Delegator voice must NOT use the skill-executor wording. + assert!(!body.contains("You have direct access")); + } + + #[test] + fn build_hides_unconnected_integrations() { + // Only connected toolkits make it into the Delegation Guide + // — unconnected entries would just trigger a spawn_subagent + // pre-flight rejection, so keeping them out keeps the prompt + // focused on what the orchestrator can actually delegate. + let integrations = vec![ + ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email.".into(), + tools: Vec::new(), + connected: true, + }, + ConnectedIntegration { + toolkit: "linear".into(), + description: "Tracker.".into(), + tools: Vec::new(), + connected: false, + }, + ]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(body.contains("- **gmail**")); + assert!(!body.contains("- **linear**")); + } + + #[test] + fn build_omits_guide_when_no_integrations_connected() { + let integrations = vec![ConnectedIntegration { + toolkit: "linear".into(), + description: "Tracker.".into(), + tools: Vec::new(), + connected: false, + }]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(!body.contains("## Delegation Guide")); + } +} diff --git a/src/openhuman/agent/agents/planner/agent.toml b/src/openhuman/agent/agents/planner/agent.toml index 24e6e9374..b8ce6d39d 100644 --- a/src/openhuman/agent/agents/planner/agent.toml +++ b/src/openhuman/agent/agents/planner/agent.toml @@ -14,4 +14,8 @@ omit_skills_catalog = true hint = "reasoning" [tools] -named = ["file_read", "memory_recall", "memory_store", "memory_forget", "web_search_tool"] +# Read + research only. The planner produces plans — it never mutates +# the workspace, memory, or state. Any writes the plan requires get +# executed by downstream agents (code_executor, archivist, …) at the +# orchestrator's direction. +named = ["file_read", "memory_recall", "web_search_tool"] diff --git a/src/openhuman/agent/agents/planner/mod.rs b/src/openhuman/agent/agents/planner/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/planner/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/planner/prompt.md b/src/openhuman/agent/agents/planner/prompt.md index 02e081e47..26027729f 100644 --- a/src/openhuman/agent/agents/planner/prompt.md +++ b/src/openhuman/agent/agents/planner/prompt.md @@ -33,7 +33,7 @@ Return **only** valid JSON matching this schema: ## Available Agent IDs - `code_executor` — Writes and runs code. Use for implementation tasks. -- `skills_agent` — Executes skill tools (Notion, Gmail, etc.). Use for service interactions. +- `integrations_agent` — Executes skill tools (Notion, Gmail, etc.). Use for service interactions. - `tool_maker` — Writes polyfill scripts. Rarely needed in planning. - `researcher` — Reads docs, web searches. Use for information gathering. - `critic` — Reviews code quality and security. Use after code changes. @@ -48,4 +48,4 @@ Return **only** valid JSON matching this schema: 6. **Simple goals = single node** — If the goal is straightforward, return exactly 1 node. 7. **No cycles** — The graph must be a DAG (directed acyclic graph). 8. **Max 8 nodes** — Keep plans manageable. Split larger projects into multiple plans. -9. **Store insights** — If you discover something during research that future plans would benefit from, use `memory_store` to save it. +9. **Read-only** — You have no write tools. If a plan depends on saving an insight, facts, or artefacts, capture that as an explicit node (e.g. "archivist: store X") in the DAG so a downstream agent performs the write. diff --git a/src/openhuman/agent/agents/planner/prompt.rs b/src/openhuman/agent/agents/planner/prompt.rs new file mode 100644 index 000000000..8e3ce9b57 --- /dev/null +++ b/src/openhuman/agent/agents/planner/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `planner` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "planner", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/researcher/mod.rs b/src/openhuman/agent/agents/researcher/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/researcher/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/researcher/prompt.rs b/src/openhuman/agent/agents/researcher/prompt.rs new file mode 100644 index 000000000..4861389d9 --- /dev/null +++ b/src/openhuman/agent/agents/researcher/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `researcher` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "researcher", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/skills_agent/agent.toml b/src/openhuman/agent/agents/skills_agent/agent.toml deleted file mode 100644 index 03b9883f7..000000000 --- a/src/openhuman/agent/agents/skills_agent/agent.toml +++ /dev/null @@ -1,24 +0,0 @@ -id = "skills_agent" -display_name = "Skills Agent" -when_to_use = "Service integration specialist — executes skill-category tools that reach external SaaS. This primarily covers Composio (1000+ OAuth integrations: Gmail, Notion, GitHub, Slack, …). Use when the task should be completed via a managed integration rather than raw HTTP/file I/O." -temperature = 0.4 -max_iterations = 10 -sandbox_mode = "none" -omit_identity = true -omit_memory_context = true -omit_safety_preamble = false -omit_skills_catalog = true -category_filter = "skill" - -# `file_write` bypasses category_filter so the agent can save -# oversized tool payloads to workspace files (Path B in prompt.md). -# `csv_export` was previously included but removed — it was triggering -# superfluous export calls even after the answer had already been -# synthesised. Re-add here only if a typed CSV path is needed again. -extra_tools = ["file_write"] - -[model] -hint = "agentic" - -[tools] -wildcard = {} diff --git a/src/openhuman/agent/agents/skills_agent/prompt.md b/src/openhuman/agent/agents/skills_agent/prompt.md deleted file mode 100644 index 56de78152..000000000 --- a/src/openhuman/agent/agents/skills_agent/prompt.md +++ /dev/null @@ -1,56 +0,0 @@ -# Skills Agent — Service Integration Specialist - -You are the **Skills Agent**. You interact with connected external services primarily through **Composio** (a managed OAuth gateway for 1000+ apps like Gmail, Notion, GitHub, Slack). - -## Available tool surfaces - -1. **Composio tools** — a small meta-surface that discovers and executes Composio actions on the user's behalf: - - `composio_list_toolkits` — what integrations the backend allows (e.g. `gmail`, `notion`). - - `composio_list_connections` — which of those the user has already authorised. - - `composio_authorize` — start an OAuth handoff for a toolkit; returns a `connectUrl`. - - `composio_list_tools` — list available action schemas (optionally filtered by toolkit). Use the returned `function.name` slug as the `tool` argument to `composio_execute`. - - `composio_execute` — run a Composio action with `{ tool, arguments }` (e.g. `tool = "GMAIL_SEND_EMAIL"`). -## Typical Composio flow - -1. Call `composio_list_connections` to see what the user already has connected. -2. If the required toolkit is missing, call `composio_authorize` and return the `connectUrl` so the user can complete OAuth. -3. Once connected, call `composio_list_tools` (optionally scoped to one or two toolkits) to discover the action slug and its JSON schema. -4. Call `composio_execute` with the slug and argument object. - -## Rules - -- **Never fabricate action slugs.** Always pull them from `composio_list_tools` before calling `composio_execute`. -- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. -- **Handle OAuth expiry** — if an action fails with an auth error, surface the need to re-authorise rather than looping. -- **Use memory context** — consult the injected memory context for details about the user's integrations and preferences. -- **Be precise** — every tool expects a specific argument shape. Validate against the schema from `composio_list_tools` before calling. -- **Report results** — state what action was taken and the outcome, including any cost reported by Composio. - -## Handling Oversized Tool Results - -When a tool returns a very large result (roughly 100 KB or more — you'll recognize it by the sheer volume of data in the response), decide which path to take based on what the user actually asked for: - -### Path A — User wants an answer, not the raw data - -Examples: "how many unread emails do I have?", "which GitHub issues are labeled P0?", "what's the most recent Slack message in #general?" - -The data is a means to an answer. Do NOT dump the raw output. Instead: -1. Scan the tool result for the specific facts that answer the user's question. -2. Synthesize a concise answer referencing specific identifiers (issue numbers, email subjects, message timestamps). -3. If you can't find the answer in one pass, use your remaining iterations to refine. - -### Path B — User wants the actual data - -Examples: "show me all open issues", "export my contacts", "give me the full email thread", "list all files in the drive folder" - -The user wants the dataset itself, not a derivative. Do NOT try to paste it all inline — it won't fit. Instead: -1. Call `file_write` to save the content as `.md` (e.g. full email bodies, document content, long threads, or a markdown-formatted list of items). Example: - ``` - file_write(path="exports/slack-thread-general-2026-04-16.md", content=) - ``` -2. Return to the user: a brief summary of what's in the file (count of items, key highlights) plus the file path so they can access it. - -### Important - -- Never paste more than ~2000 characters of raw tool output directly in your response. If the output is larger, always use Path A or Path B. -- File paths are relative to the workspace root. The `exports/` directory will be created automatically. diff --git a/src/openhuman/agent/agents/summarizer/mod.rs b/src/openhuman/agent/agents/summarizer/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/summarizer/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/summarizer/prompt.rs b/src/openhuman/agent/agents/summarizer/prompt.rs new file mode 100644 index 000000000..62f7a80c9 --- /dev/null +++ b/src/openhuman/agent/agents/summarizer/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `summarizer` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "summarizer", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/tool_maker/mod.rs b/src/openhuman/agent/agents/tool_maker/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/tool_maker/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/tool_maker/prompt.rs b/src/openhuman/agent/agents/tool_maker/prompt.rs new file mode 100644 index 000000000..c96a5fd35 --- /dev/null +++ b/src/openhuman/agent/agents/tool_maker/prompt.rs @@ -0,0 +1,71 @@ +//! System prompt builder for the `tool_maker` built-in agent. +//! +//! Returns the fully-assembled system prompt, including the standard +//! `## Safety` block (this agent has `omit_safety_preamble = false` +//! in its TOML — it executes code or external actions and needs the +//! guard rails inlined). + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "tool_maker", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/tools_agent/agent.toml b/src/openhuman/agent/agents/tools_agent/agent.toml new file mode 100644 index 000000000..4c0087f1f --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/agent.toml @@ -0,0 +1,21 @@ +id = "tools_agent" +display_name = "Tools Agent" +when_to_use = "Generalist specialist for ad-hoc work that uses only built-in OpenHuman tools (shell, file I/O, HTTP, web search, memory). Use when a task does NOT require a managed Composio OAuth integration — for external SaaS, spawn `integrations_agent` with a `toolkit` argument instead." +temperature = 0.4 +max_iterations = 10 +sandbox_mode = "none" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = false +omit_skills_catalog = true + +[model] +hint = "agentic" + +[tools] +# Wildcard — the agent inherits the orchestrator's full built-in tool +# surface. Composio meta-tools and dynamic `_*` action tools +# are stripped at runtime (see `filter_non_composio_indices` in the +# subagent runner), so the LLM never sees integration-specific tools +# here; those belong to `integrations_agent`. +wildcard = {} diff --git a/src/openhuman/agent/agents/tools_agent/mod.rs b/src/openhuman/agent/agents/tools_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/tools_agent/prompt.md b/src/openhuman/agent/agents/tools_agent/prompt.md new file mode 100644 index 000000000..e28356261 --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/prompt.md @@ -0,0 +1,16 @@ +# Tools Agent — Built-in Tool Specialist + +You are the **Tools Agent**. You complete ad-hoc tasks using only OpenHuman's built-in tool surface: shell commands, file I/O, HTTP requests, web search, memory lookups, and the rest of the system-category tools in your tool list. + +## Scope + +- You do **NOT** have access to Composio / managed OAuth integrations. If a task requires acting on an external SaaS account (Gmail, Notion, GitHub, Slack, …), stop and report back — the orchestrator will spawn `integrations_agent` with the correct toolkit. +- You **DO** handle: running commands, reading and writing files in the workspace, scraping the web, searching the user's memory, querying structured data, chaining simple transformations. + +## Operating rules + +1. Plan briefly, then act. Prefer one well-chosen tool call over exploratory flailing. +2. Read before you write. Inspect the workspace or remote state first when the task touches existing data. +3. Keep tool output tight. Don't paste huge file bodies back to the caller — summarise, or save to a workspace file and return the path. +4. Surface blockers early. If a required tool isn't in your list, say so in the final response rather than faking progress. +5. When the task is done, reply with a concise summary of what you did and any relevant paths / identifiers. Don't repeat tool output verbatim. diff --git a/src/openhuman/agent/agents/tools_agent/prompt.rs b/src/openhuman/agent/agents/tools_agent/prompt.rs new file mode 100644 index 000000000..824767f59 --- /dev/null +++ b/src/openhuman/agent/agents/tools_agent/prompt.rs @@ -0,0 +1,69 @@ +//! System prompt builder for the `tools_agent` built-in agent. +//! +//! `tools_agent` is the counterpart to [`super::integrations_agent`]: +//! Composio-free specialist that only ever sees OpenHuman's built-in +//! (system-category) tools — shell, file I/O, HTTP, web search, memory. +//! Composio action tools are filtered out at tool-list construction +//! time in the subagent runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "tools_agent", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + assert!(body.contains("Tools Agent")); + } +} diff --git a/src/openhuman/agent/agents/trigger_reactor/mod.rs b/src/openhuman/agent/agents/trigger_reactor/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/trigger_reactor/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/trigger_reactor/prompt.rs b/src/openhuman/agent/agents/trigger_reactor/prompt.rs new file mode 100644 index 000000000..2fe490135 --- /dev/null +++ b/src/openhuman/agent/agents/trigger_reactor/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `trigger_reactor` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "trigger_reactor", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/trigger_triage/mod.rs b/src/openhuman/agent/agents/trigger_triage/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/trigger_triage/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/trigger_triage/prompt.rs b/src/openhuman/agent/agents/trigger_triage/prompt.rs new file mode 100644 index 000000000..5b7c28985 --- /dev/null +++ b/src/openhuman/agent/agents/trigger_triage/prompt.rs @@ -0,0 +1,67 @@ +//! System prompt builder for the `trigger_triage` built-in agent. +//! +//! Returns the fully-assembled system prompt. Each agent's `build()` +//! composes section helpers from [`crate::openhuman::context::prompt`] +//! in the order it wants — so the output IS what the LLM sees, no +//! post-processing in the runner. + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(4096); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + #[test] + fn build_returns_nonempty_body() { + let visible: HashSet = HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "trigger_triage", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let body = build(&ctx).unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/src/openhuman/agent/agents/welcome/mod.rs b/src/openhuman/agent/agents/welcome/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/welcome/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/welcome/prompt.rs b/src/openhuman/agent/agents/welcome/prompt.rs new file mode 100644 index 000000000..83644a202 --- /dev/null +++ b/src/openhuman/agent/agents/welcome/prompt.rs @@ -0,0 +1,150 @@ +//! System prompt builder for the `welcome` built-in agent. +//! +//! Welcome runs onboarding — it surfaces which integrations the user +//! has already connected and pitches the ones that are still pending. +//! Like the orchestrator, it delegates any integration work rather +//! than executing Composio actions directly, so it renders the same +//! delegator-voice block (inlined here rather than shared, so the +//! skill-executor wording stays scoped to `integrations_agent/prompt.rs`). + +use crate::openhuman::context::prompt::{ + render_tools, render_user_files, render_workspace, ConnectedIntegration, PromptContext, +}; +use anyhow::Result; +use std::fmt::Write; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let integrations = render_connected_integrations(ctx.connected_integrations); + if !integrations.trim().is_empty() { + out.push_str(integrations.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +/// Render welcome's connected-integrations block — a compact list of +/// the toolkits the user has already authorised. Unconnected entries +/// are skipped (welcome's job during onboarding is to pitch them, not +/// to treat them as usable yet). +fn render_connected_integrations(integrations: &[ConnectedIntegration]) -> String { + let connected: Vec<&ConnectedIntegration> = + integrations.iter().filter(|ci| ci.connected).collect(); + if connected.is_empty() { + return String::new(); + } + let mut out = String::from("## Connected Integrations\n\n"); + for ci in connected { + let _ = writeln!( + out, + "- **{}** — {}", + sanitize_bullet(&ci.toolkit), + sanitize_bullet(&ci.description), + ); + } + out +} + +/// Normalise a string for safe inclusion in a single markdown bullet: +/// replace newlines/carriage returns with spaces, collapse runs of +/// whitespace, and trim leading/trailing whitespace so a description +/// with embedded linebreaks can't split the bullet. +fn sanitize_bullet(s: &str) -> String { + let replaced: String = s + .chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .collect(); + let mut out = String::with_capacity(replaced.len()); + let mut prev_space = false; + for ch in replaced.chars() { + if ch.is_whitespace() { + if !prev_space { + out.push(' '); + } + prev_space = true; + } else { + out.push(ch); + prev_space = false; + } + } + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn ctx_with<'a>(integrations: &'a [ConnectedIntegration]) -> PromptContext<'a> { + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "welcome", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: integrations, + include_profile: false, + include_memory_md: false, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with(&[])).unwrap(); + assert!(!body.is_empty()); + assert!(!body.contains("## Connected Integrations")); + } + + #[test] + fn build_lists_only_connected_integrations() { + let integrations = vec![ + ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + connected: true, + }, + ConnectedIntegration { + toolkit: "notion".into(), + description: "Pitch during onboarding.".into(), + tools: Vec::new(), + connected: false, + }, + ]; + let body = build(&ctx_with(&integrations)).unwrap(); + assert!(body.contains("## Connected Integrations")); + assert!(body.contains("- **gmail**")); + assert!(!body.contains("- **notion**")); + } +} diff --git a/src/openhuman/agent/harness/archivist.rs b/src/openhuman/agent/harness/archivist.rs index 48d3f50ae..a81e35961 100644 --- a/src/openhuman/agent/harness/archivist.rs +++ b/src/openhuman/agent/harness/archivist.rs @@ -17,6 +17,8 @@ use crate::openhuman::memory::store::segments::{ use async_trait::async_trait; use parking_lot::Mutex; use rusqlite::Connection; +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -418,8 +420,6 @@ fn uuid_v4() -> String { /// Simple random u32 from system entropy. fn rand_u32() -> u32 { - use std::collections::hash_map::RandomState; - use std::hash::{BuildHasher, Hasher}; let state = RandomState::new(); let mut hasher = state.build_hasher(); hasher.write_u64( diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index fdd87458e..33d0c262e 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -63,7 +63,6 @@ pub fn fork_definition() -> AgentDefinition { tools: ToolScope::Wildcard, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], // Fork inherits the parent's max iterations from the runtime. max_iterations: 15, @@ -119,23 +118,6 @@ mod tests { assert!(!def.omit_memory_md); } - #[test] - fn skills_agent_has_extra_tools_for_export() { - let defs = all(); - let skills = defs.iter().find(|d| d.id == "skills_agent").unwrap(); - assert!( - skills.extra_tools.contains(&"file_write".to_string()), - "skills_agent must include file_write in extra_tools" - ); - // csv_export was removed from extra_tools — it triggered - // superfluous export calls after extraction had already - // answered. file_write alone covers the Path B export flow. - assert!( - !skills.extra_tools.contains(&"csv_export".to_string()), - "csv_export should not be present in extra_tools" - ); - } - #[test] fn expected_builtin_ids_are_present() { let ids: Vec = all().into_iter().map(|d| d.id).collect(); @@ -143,7 +125,7 @@ mod tests { "orchestrator", "planner", "code_executor", - "skills_agent", + "integrations_agent", "tool_maker", "researcher", "critic", diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index 89f115ddd..186ab2923 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -21,7 +21,7 @@ //! runtime — it is pure data so the model can be unit-tested in isolation //! and serialised straight from disk. -use crate::openhuman::tools::ToolCategory; +use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -113,18 +113,12 @@ pub struct AgentDefinition { #[serde(default)] pub skill_filter: Option, - /// Filter to only tools belonging to a specific high-level category. - #[serde(default)] - pub category_filter: Option, - - /// Additional system tool names to include even when `category_filter` - /// restricts to a different category. This allows an agent that is - /// primarily scoped to `Skill` tools (e.g. `skills_agent`) to also - /// access a handful of named system tools (e.g. `file_write`, - /// `csv_export`) without removing the category filter entirely. + /// Named tools that should always be visible to this agent in + /// addition to its [`ToolScope`]. Historically this was a bypass + /// list for the now-removed `category_filter`; kept as a generic + /// "also include these" hook for custom definitions. /// - /// Tools listed here bypass the `category_filter` check but are still - /// subject to `disallowed_tools` and `ToolScope` restrictions. + /// Entries are still subject to [`AgentDefinition::disallowed_tools`]. #[serde(default)] pub extra_tools: Vec, @@ -161,7 +155,7 @@ pub struct AgentDefinition { /// /// * [`SubagentEntry::Skills`] — one [`SkillDelegationTool`] per /// connected Composio toolkit, each named `delegate_{toolkit}`, - /// all routing to the generic `skills_agent` with an appropriate + /// all routing to the generic `integrations_agent` with an appropriate /// `skill_filter` pre-populated. /// /// `subagents` is intentionally separate from [`AgentDefinition::tools`] @@ -211,7 +205,7 @@ pub enum SubagentEntry { AgentId(String), /// Expand at build time to one `delegate_{toolkit}` tool per /// connected Composio toolkit, each routing to the generic - /// `skills_agent` with `skill_filter` pre-set. + /// `integrations_agent` with `skill_filter` pre-set. Skills(SkillsWildcard), } @@ -248,9 +242,18 @@ impl AgentDefinition { // Prompt source // ───────────────────────────────────────────────────────────────────────────── +/// Builder function signature for [`PromptSource::Dynamic`]. Takes the +/// full runtime [`crate::openhuman::context::prompt::PromptContext`] +/// (tools, skills, memory, connected integrations, dispatcher, model, +/// …) and returns the final system prompt body — typically assembled +/// by calling the `render_*` section helpers in +/// [`crate::openhuman::context::prompt`] in the order the builder +/// wants. +pub type PromptBuilder = + fn(&crate::openhuman::context::prompt::PromptContext<'_>) -> anyhow::Result; + /// Where the sub-agent's core system prompt comes from. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[derive(Clone)] pub enum PromptSource { /// Inline prompt string (custom TOML-defined agents). Inline(String), @@ -258,6 +261,61 @@ pub enum PromptSource { /// `src/openhuman/agent/prompts/` for built-ins. Resolved by the runner /// at spawn time. File { path: String }, + /// Function-driven prompt: the builder is invoked at spawn time with + /// a [`PromptContext`] so the returned body can depend on runtime + /// state (available tools, user profile, connected skills, etc.). + /// + /// Only constructed in-process (by built-in agent loaders). Not + /// deserializable from TOML — TOML-authored agents must use `inline` + /// or `file`. + Dynamic(PromptBuilder), +} + +impl std::fmt::Debug for PromptSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromptSource::Inline(s) => f.debug_tuple("Inline").field(&s).finish(), + PromptSource::File { path } => f.debug_struct("File").field("path", path).finish(), + PromptSource::Dynamic(_) => f.debug_tuple("Dynamic").field(&"").finish(), + } + } +} + +impl Serialize for PromptSource { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(1))?; + match self { + PromptSource::Inline(s) => map.serialize_entry("inline", s)?, + PromptSource::File { path } => { + #[derive(Serialize)] + struct FileBody<'a> { + path: &'a str, + } + map.serialize_entry("file", &FileBody { path })?; + } + // Opaque marker — runtime-only. Round-trips back through + // Deserialize would produce an error (Dynamic is unsupported + // there) which is intentional: RPC consumers treat Dynamic + // sources as "built-in, runtime-generated". + PromptSource::Dynamic(_) => map.serialize_entry("dynamic", &serde_json::Value::Null)?, + } + map.end() + } +} + +impl<'de> Deserialize<'de> for PromptSource { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + enum Shape { + Inline(String), + File { path: String }, + } + Shape::deserialize(deserializer).map(|s| match s { + Shape::Inline(body) => PromptSource::Inline(body), + Shape::File { path } => PromptSource::File { path }, + }) + } } // ───────────────────────────────────────────────────────────────────────────── @@ -519,7 +577,6 @@ mod tests { tools: ToolScope::Wildcard, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 8, timeout_secs: None, diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index 8923f0cb8..5d8cf0a73 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -90,7 +90,7 @@ pub struct ParentExecutionContext { /// when the parent agent fetches its integration list. Used by the /// sub-agent runner to dynamically construct per-action /// [`ComposioActionTool`](crate::openhuman::composio::ComposioActionTool) - /// entries at spawn time when `skills_agent` is scoped to a + /// entries at spawn time when `integrations_agent` is scoped to a /// specific toolkit. `None` when the user isn't signed in to /// Composio or the backend was unreachable. pub composio_client: Option, @@ -103,6 +103,21 @@ pub struct ParentExecutionContext { /// runtime uses native function-calling, and the model emits /// uncallable P-Format tool_call blocks. pub tool_call_format: crate::openhuman::context::prompt::ToolCallFormat, + + /// Parent's own session-transcript key, formatted as + /// `"{unix_ts}_{agent_id}"`. Sub-agents chain this (plus any + /// ancestor prefixes on the parent) into their own transcript + /// filename so the hierarchy `orchestrator → planner → critic` + /// lands on disk as a single flat file name — + /// `{orch_key}__{planner_key}__{critic_key}.jsonl`. + pub session_key: String, + + /// Parent's ancestor-chain of session keys (already joined with + /// `__`), or `None` when the parent is itself a root session. + /// A sub-agent spawned from a root parent observes + /// `Some(parent.session_key)`. A grand-child observes + /// `Some("{grandparent_key}__{parent_key}")`. + pub session_parent_prefix: Option, } tokio::task_local! { @@ -161,12 +176,6 @@ pub struct ForkContext { /// verbatim. Includes the system message at index 0. pub message_prefix: Arc>, - /// Optional system-prompt cache boundary the parent passed in its - /// own [`crate::openhuman::providers::ChatRequest`]. The child threads - /// the same value through so any future explicit-cache provider sees - /// matching markers. - pub cache_boundary: Option, - /// The actual instruction the model issued for *this* fork — appears /// as the new user message appended after `message_prefix`. pub fork_task_prompt: String, diff --git a/src/openhuman/agent/harness/payload_summarizer.rs b/src/openhuman/agent/harness/payload_summarizer.rs index 8e2f7de77..9923b7738 100644 --- a/src/openhuman/agent/harness/payload_summarizer.rs +++ b/src/openhuman/agent/harness/payload_summarizer.rs @@ -47,7 +47,7 @@ //! //! Only the orchestrator session gets a `PayloadSummarizer` wired in //! ([`crate::openhuman::agent::harness::session::builder::AgentBuilder`] -//! checks `agent_id == "orchestrator"`). Welcome, skills_agent, +//! checks `agent_id == "orchestrator"`). Welcome, integrations_agent, //! researcher, planner, archivist, and every other typed sub-agent get //! `None` and their tool results are untouched. The summarizer itself //! is also `None` so it can never recursively summarize its own input. @@ -357,7 +357,6 @@ mod tests { tools: ToolScope::Named(vec![]), disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 1, timeout_secs: None, diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index c360561cc..3ab7bc590 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -10,6 +10,9 @@ use super::types::{Agent, AgentBuilder}; use crate::openhuman::agent::dispatcher::{ NativeToolDispatcher, PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher, }; +use crate::openhuman::agent::harness::definition::{ + AgentDefinitionRegistry, PromptSource, ToolScope, +}; use crate::openhuman::agent::host_runtime; use crate::openhuman::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader}; use crate::openhuman::config::{Config, ContextConfig}; @@ -45,6 +48,7 @@ impl AgentBuilder { event_session_id: None, event_channel: None, agent_definition_name: None, + session_parent_prefix: None, omit_profile: None, omit_memory_md: None, payload_summarizer: None, @@ -188,7 +192,7 @@ impl AgentBuilder { } /// Sets the agent definition id this session is running - /// (`welcome`, `orchestrator`, `skills_agent`, …). + /// (`welcome`, `orchestrator`, `integrations_agent`, …). /// /// This value is stamped onto the built [`Agent`] and surfaces in /// the following places: @@ -209,13 +213,13 @@ impl AgentBuilder { /// * **[`PromptContext::agent_id`]** at prompt-build time (see /// `turn.rs`). Today only one prompt section reads this field — /// the `Connected Integrations` branch in `context/prompt.rs` - /// that special-cases `skills_agent` vs every other agent — so + /// that special-cases `integrations_agent` vs every other agent — so /// the current user-visible impact of a wrong id is limited to /// the two bullets above. The stamped `prompt_builder` injected /// by [`Agent::from_config_for_agent`] is what actually drives /// prompt flavour per archetype, independent of this field. That /// said, any future prompt section that branches on a - /// non-`skills_agent` id (e.g. welcome-specific banner, planner- + /// non-`integrations_agent` id (e.g. welcome-specific banner, planner- /// specific rubric) would silently never fire if the field were /// left at `"main"`, so keeping it correctly stamped closes a /// latent foot-gun for code that hasn't been written yet. @@ -229,6 +233,18 @@ impl AgentBuilder { self } + /// Set the parent session-key chain for a sub-agent. Passing + /// `Some("1713000000_orchestrator")` produces a sub-agent whose + /// transcript filename is prefixed with the parent's session key, + /// yielding a flat hierarchy on disk + /// (`session_raw/DDMMYYYY/{parent}__{child}.jsonl`). Nested + /// delegations chain further prefixes with `__`. Leave `None` + /// (default) for root sessions. + pub fn session_parent_prefix(mut self, prefix: Option) -> Self { + self.session_parent_prefix = prefix; + self + } + /// Forward the target agent definition's `omit_profile` flag so /// [`Agent::build_system_prompt`] can decide whether to inject /// `PROFILE.md`. Only opt-in agents (welcome, orchestrator, the @@ -351,7 +367,6 @@ impl AgentBuilder { skills: self.skills.unwrap_or_default(), auto_save: self.auto_save.unwrap_or(false), last_memory_context: None, - system_prompt_cache_boundary: None, history: Vec::new(), post_turn_hooks: self.post_turn_hooks, learning_enabled: self.learning_enabled, @@ -361,8 +376,28 @@ impl AgentBuilder { event_channel: self.event_channel.unwrap_or_else(|| "internal".to_string()), agent_definition_name: self .agent_definition_name + .clone() .unwrap_or_else(|| "main".to_string()), session_transcript_path: None, + session_key: { + let unix_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let agent_id = self.agent_definition_name.as_deref().unwrap_or("main"); + let sanitized: String = agent_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + format!("{unix_ts}_{sanitized}") + }, + session_parent_prefix: self.session_parent_prefix, cached_transcript_messages: None, context, on_progress: None, @@ -427,8 +462,6 @@ impl Agent { /// The welcome agent uses this entry point when routed from the /// Tauri web channel (see `channels::providers::web::build_session_agent`). pub fn from_config_for_agent(config: &Config, agent_id: &str) -> Result { - use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, ToolScope}; - // Look up the target definition up front so we can fail fast // with a clear error instead of building half an agent and then // discovering the id is unknown. The registry is a singleton @@ -511,7 +544,6 @@ impl Agent { agent_id: &str, target_def: Option<&crate::openhuman::agent::harness::definition::AgentDefinition>, ) -> Result { - use crate::openhuman::agent::harness::definition::{PromptSource, ToolScope}; let runtime: Arc = Arc::from(host_runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( @@ -553,6 +585,17 @@ impl Agent { config, ); + // `complete_onboarding` is the terminal step of the welcome + // flow and must never be callable from any other session. + // Stripping it here (before prompt + delegation assembly) keeps + // it out of both the LLM's function-calling schema and the + // rendered `## Tools` section. + if agent_id != "welcome" { + tools.retain(|t| { + !crate::openhuman::agent::harness::subagent_runner::is_welcome_only_tool(t.name()) + }); + } + let model_name = config .default_model .as_deref() @@ -601,46 +644,55 @@ impl Agent { // prompt stays byte-identical to the legacy CLI/REPL behaviour // except for the tool-scope tightening we already landed in // earlier commits. + // Every agent with a resolved definition (built-in or workspace + // override) goes through the per-agent pipeline — the legacy + // `with_defaults()` branch only fires when the registry is + // unavailable (pre-startup, tests). `PromptSource::Dynamic` + // agents install a [`DynamicPromptSection`] that re-runs the + // builder against the live [`PromptContext`] at + // `build_system_prompt` time, so `connected_integrations` + // fetched asynchronously on session start land in the prompt. + // `Inline`/`File` sources still resolve to just the archetype + // body and get wrapped by [`SystemPromptBuilder::for_subagent`]. let mut prompt_builder = match target_def { - Some(def) if agent_id != "orchestrator" => { - // Resolve the prompt body. For built-in agents, - // `system_prompt` is `PromptSource::Inline(...)` populated - // at crate-build time from the sibling `prompt.md` via - // `include_str!` in `agents/mod.rs`. File-based prompts - // (custom workspace overrides) read from disk. - let body = match &def.system_prompt { - PromptSource::Inline(text) => text.clone(), - PromptSource::File { path } => { - let workspace_path = config - .workspace_dir - .join("agent") - .join("prompts") - .join(path); - if workspace_path.is_file() { - std::fs::read_to_string(&workspace_path).unwrap_or_else(|e| { - log::warn!( - "[agent::builder] failed to read prompt {}: {e} — using empty body", - workspace_path.display() - ); - String::new() - }) - } else { - log::warn!( - "[agent::builder] prompt file {} not found — using empty body", - path - ); - String::new() - } - } - }; - SystemPromptBuilder::for_subagent( - body, + Some(def) => match &def.system_prompt { + PromptSource::Dynamic(build) => SystemPromptBuilder::from_dynamic(*build), + PromptSource::Inline(text) => SystemPromptBuilder::for_subagent( + text.clone(), def.omit_identity, def.omit_safety_preamble, def.omit_skills_catalog, - ) - } - _ => SystemPromptBuilder::with_defaults(), + ), + PromptSource::File { path } => { + let workspace_path = config + .workspace_dir + .join("agent") + .join("prompts") + .join(path); + let body_text = if workspace_path.is_file() { + std::fs::read_to_string(&workspace_path).unwrap_or_else(|e| { + log::warn!( + "[agent::builder] failed to read prompt {}: {e} — using empty body", + workspace_path.display() + ); + String::new() + }) + } else { + log::warn!( + "[agent::builder] prompt file {} not found — using empty body", + path + ); + String::new() + }; + SystemPromptBuilder::for_subagent( + body_text, + def.omit_identity, + def.omit_safety_preamble, + def.omit_skills_catalog, + ) + } + }, + None => SystemPromptBuilder::with_defaults(), }; if config.learning.enabled { prompt_builder = prompt_builder @@ -829,10 +881,10 @@ impl Agent { // that even 16–25 of them blow past that ceiling, regardless of // how aggressively the fuzzy filter in `tool_filter.rs` narrows // the list. When that happens the provider rejects the request - // with a 400 before any generation starts, so skills_agent can + // with a 400 before any generation starts, so integrations_agent can // never actually invoke the toolkit. // - // Workaround: if we're building skills_agent and the selected + // Workaround: if we're building integrations_agent and the selected // dispatcher would ship `tools: [...]` in the API payload // (`should_send_tool_specs() == true`, i.e. native mode), swap // to XML mode. XmlToolDispatcher puts the tool catalogue inside @@ -842,9 +894,9 @@ impl Agent { // than native; the existing `parse_tool_calls` recovers from // stray formatting and the loop retries on malformed output. let tool_dispatcher: Box = - if agent_id == "skills_agent" && tool_dispatcher.should_send_tool_specs() { + if agent_id == "integrations_agent" && tool_dispatcher.should_send_tool_specs() { log::info!( - "[agent::builder] skills_agent: overriding native tool dispatcher with \ + "[agent::builder] integrations_agent: overriding native tool dispatcher with \ XmlToolDispatcher (native mode hits provider grammar-rule limits on \ large Composio toolkits)" ); diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 90c387a8a..8095ca2c3 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -34,7 +34,7 @@ impl Agent { } /// The agent definition id this session is running - /// (`"welcome"`, `"orchestrator"`, `"skills_agent"`, …). + /// (`"welcome"`, `"orchestrator"`, `"integrations_agent"`, …). /// /// Exposed so callers that build sessions via /// [`Agent::from_config_for_agent`] can stamp the resolved id onto @@ -116,6 +116,28 @@ impl Agent { &self.connected_integrations } + /// The Composio client cached on the session, if any. Populated by + /// [`Agent::fetch_connected_integrations`]; remains `None` when the + /// user is not signed in. + pub fn composio_client(&self) -> Option<&crate::openhuman::composio::ComposioClient> { + self.composio_client.as_ref() + } + + /// This session's transcript key — `"{unix_ts}_{agent_id}"`, + /// generated once at build time. Sub-agents chain this into their + /// own transcript filenames so the parent → child hierarchy is + /// visible on disk. + pub fn session_key(&self) -> &str { + &self.session_key + } + + /// The ancestor chain of session keys for a sub-agent, joined with + /// `__`. `None` for a root session. Root + prefix together produce + /// the full transcript stem. + pub fn session_parent_prefix(&self) -> Option<&str> { + self.session_parent_prefix.as_deref() + } + /// Replace the agent's connected integrations (e.g. from a cached /// fetch result when the agent was built outside the normal turn loop). pub fn set_connected_integrations( @@ -163,7 +185,6 @@ impl Agent { /// Clears the agent's conversation history. pub fn clear_history(&mut self) { self.history.clear(); - self.system_prompt_cache_boundary = None; } // ───────────────────────────────────────────────────────────────── @@ -575,7 +596,6 @@ mod tests { }); let mut agent = make_agent(provider); agent.history = vec![ConversationMessage::Chat(ChatMessage::system("sys"))]; - agent.system_prompt_cache_boundary = Some(7); agent.skills = vec![crate::openhuman::skills::Skill { name: "demo".into(), ..Default::default() @@ -602,7 +622,6 @@ mod tests { agent.clear_history(); assert!(agent.history().is_empty()); - assert!(agent.system_prompt_cache_boundary.is_none()); assert_eq!(Agent::count_iterations(agent.history()), 1); } diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index f6ccaa8e5..170c4cdde 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -63,7 +63,6 @@ struct RecordingProvider { struct CapturedCall { system_prompt: Option, model: String, - cache_boundary: Option, } #[async_trait] @@ -92,7 +91,6 @@ impl Provider for RecordingProvider { self.captures.lock().push(CapturedCall { system_prompt, model: model.to_string(), - cache_boundary: request.system_prompt_cache_boundary, }); let mut guard = self.responses.lock(); @@ -203,13 +201,18 @@ fn build_minimal_agent_with_definition_name(definition_name: Option<&str>) -> Ag /// `.agent_definition_name(id)` on the builder chain produces an /// `Agent` whose [`Agent::agent_definition_name`] accessor returns /// that id verbatim. `"welcome"` and `"orchestrator"` exercise the -/// two ids that reach `from_config_for_agent` today; `"skills_agent"` +/// two ids that reach `from_config_for_agent` today; `"integrations_agent"` /// and `"trigger_triage"` are defensive coverage so that if a /// future commit adds a new top-level caller for one of those ids /// the builder contract is already pinned. #[test] fn agent_builder_threads_agent_definition_name_when_set() { - for expected in ["welcome", "skills_agent", "orchestrator", "trigger_triage"] { + for expected in [ + "welcome", + "integrations_agent", + "orchestrator", + "trigger_triage", + ] { let agent = build_minimal_agent_with_definition_name(Some(expected)); assert_eq!( agent.agent_definition_name(), @@ -682,19 +685,9 @@ async fn system_prompt_and_model_are_byte_stable_across_turns() { "model name flipped on turn {} — KV cache namespace broken", idx ); - assert_eq!( - cap.cache_boundary, captures[0].cache_boundary, - "cache boundary drifted on turn {} — provider prompt caching became unstable", - idx - ); - assert!( - cap.cache_boundary.is_some(), - "turn {} should carry an explicit prompt cache boundary", - idx - ); assert!( !sys.contains(""), - "system prompt should not leak the internal cache marker" + "system prompt should not leak any cache-boundary marker" ); } } diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 46cf492bc..80cad8821 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -12,7 +12,7 @@ //! //! **Line 1 (meta):** //! ```json -//! {"_meta":{"agent":"code_executor","dispatcher":"native","cache_boundary":1847,"created":"...","updated":"...","turn_count":3,"input_tokens":5000,"output_tokens":1200,"cached_input_tokens":3500,"charged_amount_usd":0.0045}} +//! {"_meta":{"agent":"code_executor","dispatcher":"native","created":"...","updated":"...","turn_count":3,"input_tokens":5000,"output_tokens":1200,"cached_input_tokens":3500,"charged_amount_usd":0.0045}} //! ``` //! //! **Message lines:** @@ -67,7 +67,6 @@ pub struct TurnUsage { pub struct TranscriptMeta { pub agent_name: String, pub dispatcher: String, - pub cache_boundary: Option, pub created: String, pub updated: String, pub turn_count: usize, @@ -101,8 +100,6 @@ struct MetaLine { struct MetaPayload { agent: String, dispatcher: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_boundary: Option, created: String, updated: String, turn_count: usize, @@ -157,7 +154,6 @@ pub fn write_transcript( meta: MetaPayload { agent: meta.agent_name.clone(), dispatcher: meta.dispatcher.clone(), - cache_boundary: meta.cache_boundary, created: meta.created.clone(), updated: meta.updated.clone(), turn_count: meta.turn_count, @@ -320,7 +316,6 @@ fn read_transcript_jsonl(path: &Path) -> Result { meta = Some(TranscriptMeta { agent_name: mp.agent, dispatcher: mp.dispatcher, - cache_boundary: mp.cache_boundary, created: mp.created, updated: mp.updated, turn_count: mp.turn_count, @@ -374,6 +369,47 @@ fn read_transcript_jsonl(path: &Path) -> Result { /// Creates the date directory if needed. Index = max existing + 1. /// Scans both the new `session_raw/` dir (for `.jsonl`) **and** the legacy /// `sessions/` dir (for `.md`) so indices stay unique across migration. +/// Resolve a transcript path under `session_raw/DDMMYYYY/{stem}.jsonl` +/// where `stem` is deterministic (no auto-indexing). Used by the new +/// session-key flow: the session-key stem is `"{unix_ts}_{agent_id}"` +/// for a root session, or `"{parent_chain}__{session_key}"` for a +/// sub-agent — so nested delegations produce a single flat filename +/// that encodes the parent → child path. +/// +/// Creates the date directory if needed. Overwrites are intentional: +/// the Agent persists the same transcript file across every turn of a +/// session, and every sub-agent spawn gets a unique timestamp in its +/// own key so collisions are effectively impossible. +pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result { + let raw_dir = today_raw_session_dir(workspace_dir); + fs::create_dir_all(&raw_dir) + .with_context(|| format!("create session_raw dir {}", raw_dir.display()))?; + let sanitized = sanitize_stem(stem); + Ok(raw_dir.join(format!("{sanitized}.jsonl"))) +} + +/// Sanitize a user-supplied transcript stem so it never escapes the +/// `session_raw/DDMMYYYY/` directory. Allows ASCII alphanumerics plus +/// a small punctuation set (`_`, `-`, `.`); every other byte is +/// replaced with `_`. Empty inputs fall back to `"session"`. +fn sanitize_stem(stem: &str) -> String { + let cleaned: String = stem + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + if cleaned.is_empty() { + "session".to_string() + } else { + cleaned + } +} + pub fn resolve_new_transcript_path(workspace_dir: &Path, agent_name: &str) -> Result { let raw_dir = today_raw_session_dir(workspace_dir); fs::create_dir_all(&raw_dir) @@ -442,9 +478,6 @@ fn render_markdown( let _ = writeln!(buf, "# Session transcript — {}", meta.agent_name); buf.push('\n'); let _ = writeln!(buf, "- Dispatcher: {}", meta.dispatcher); - if let Some(boundary) = meta.cache_boundary { - let _ = writeln!(buf, "- Cache boundary: {}", boundary); - } let _ = writeln!(buf, "- Turns: {}", meta.turn_count); if meta.input_tokens > 0 || meta.output_tokens > 0 { let cache_pct = if meta.input_tokens > 0 { @@ -543,7 +576,6 @@ fn parse_legacy_meta(raw: &str) -> Result { Ok(TranscriptMeta { agent_name: get("agent").unwrap_or_else(|| "unknown".into()), dispatcher: get("dispatcher").unwrap_or_else(|| "native".into()), - cache_boundary: get("cache_boundary").and_then(|s| s.parse().ok()), created: get("created").unwrap_or_default(), updated: get("updated").unwrap_or_default(), turn_count: get("turn_count").and_then(|s| s.parse().ok()).unwrap_or(0), @@ -699,35 +731,66 @@ fn next_index(dir: &Path, agent_prefix: &str) -> Result { /// (legacy sessions). When both exist for the same index the `.jsonl` /// wins. fn latest_in_dir(dir: &Path, agent_prefix: &str) -> Option { - let prefix = format!("{}_", agent_prefix); - // Track best (index, path) for each extension. - let mut best_jsonl: Option<(usize, PathBuf)> = None; - let mut best_md: Option<(usize, PathBuf)> = None; + // Two transcript-naming schemes coexist on disk: + // * Legacy: `{agent}_{index}.jsonl|.md` — strictly increasing + // index, used by the now-removed `resolve_new_transcript_path`. + // * Keyed: `{unix_ts}_{agent}.jsonl` (root session) or + // `{parent_chain}__{unix_ts}_{agent}.jsonl` (sub-agent). The + // root stem starts with `{unix_ts}_{agent}` and has no `__` + // prefix segment. + // + // For resume we only care about root sessions (sub-agents rebuild + // from scratch), so we scan for filenames matching either scheme + // and pick the newest. "Newest" is the largest sort key — indices + // and unix timestamps both order naturally as integers. + let legacy_prefix = format!("{}_", agent_prefix); + let keyed_suffix = format!("_{}", agent_prefix); + let mut best_jsonl: Option<(u64, PathBuf)> = None; + let mut best_md: Option<(u64, PathBuf)> = None; let entries = fs::read_dir(dir).ok()?; for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); - if !name_str.starts_with(&prefix) { + // Extract the stem minus extension. + let (stem, is_jsonl) = if let Some(s) = name_str.strip_suffix(".jsonl") { + (s, true) + } else if let Some(s) = name_str.strip_suffix(".md") { + (s, false) + } else { + continue; + }; + // Skip sub-agent transcripts — they carry at least one `__` + // separator in their stem (e.g. + // `{orch_key}__{planner_key}`). Root resume never targets a + // sub-agent's transcript directly. + if stem.contains("__") { continue; } - if name_str.ends_with(".jsonl") { - let idx_str = &name_str[prefix.len()..name_str.len() - 6]; - if let Ok(idx) = idx_str.parse::() { - if best_jsonl - .as_ref() - .is_none_or(|(best_idx, _)| idx > *best_idx) - { - best_jsonl = Some((idx, entry.path())); - } + // Determine sort key. Keyed filenames end with + // `_{agent_prefix}`: everything before that is the unix + // timestamp. Legacy filenames start with `{agent_prefix}_`: + // everything after is the numeric index. + let sort_key: u64 = if let Some(ts_part) = stem.strip_suffix(&keyed_suffix) { + match ts_part.parse::() { + Ok(ts) => ts, + Err(_) => continue, } - } else if name_str.ends_with(".md") { - let idx_str = &name_str[prefix.len()..name_str.len() - 3]; - if let Ok(idx) = idx_str.parse::() { - if best_md.as_ref().is_none_or(|(best_idx, _)| idx > *best_idx) { - best_md = Some((idx, entry.path())); - } + } else if let Some(idx_part) = stem.strip_prefix(&legacy_prefix) { + match idx_part.parse::() { + Ok(idx) => idx, + Err(_) => continue, } + } else { + continue; + }; + let slot = if is_jsonl { + &mut best_jsonl + } else { + &mut best_md + }; + if slot.as_ref().is_none_or(|(best, _)| sort_key > *best) { + *slot = Some((sort_key, entry.path())); } } @@ -770,7 +833,6 @@ mod tests { TranscriptMeta { agent_name: "code_executor".into(), dispatcher: "native".into(), - cache_boundary: Some(1847), created: "2026-04-11T14:30:00Z".into(), updated: "2026-04-11T14:35:22Z".into(), turn_count: 3, @@ -849,7 +911,6 @@ mod tests { assert_eq!(loaded.meta.agent_name, "code_executor"); assert_eq!(loaded.meta.dispatcher, "native"); - assert_eq!(loaded.meta.cache_boundary, Some(1847)); assert_eq!(loaded.meta.created, "2026-04-11T14:30:00Z"); assert_eq!(loaded.meta.updated, "2026-04-11T14:35:22Z"); assert_eq!(loaded.meta.turn_count, 3); @@ -859,19 +920,6 @@ mod tests { assert!((loaded.meta.charged_amount_usd - 0.0045).abs() < 1e-8); } - #[test] - fn meta_without_cache_boundary() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("no_boundary.jsonl"); - let mut meta = sample_meta(); - meta.cache_boundary = None; - - write_transcript(&path, &[], &meta, None).unwrap(); - let loaded = read_transcript(&path).unwrap(); - - assert_eq!(loaded.meta.cache_boundary, None); - } - #[test] fn path_resolution_creates_dir_and_increments_index() { let dir = TempDir::new().unwrap(); @@ -1069,7 +1117,7 @@ mod tests { let dir = TempDir::new().unwrap(); // Write a legacy .md file directly (old format). let md_path = dir.path().join("legacy.md"); - let legacy_content = "\n\n\nhello\n\n"; + let legacy_content = "\n\n\nhello\n\n"; fs::write(&md_path, legacy_content).unwrap(); // read_transcript called with a .jsonl path that doesn't exist @@ -1077,7 +1125,6 @@ mod tests { let jsonl_path = dir.path().join("legacy.jsonl"); let loaded = read_transcript(&jsonl_path).unwrap(); assert_eq!(loaded.meta.agent_name, "test_agent"); - assert_eq!(loaded.meta.cache_boundary, Some(100)); assert_eq!(loaded.messages.len(), 1); assert_eq!(loaded.messages[0].role, "system"); assert_eq!(loaded.messages[0].content, "hello"); diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 054cace63..c300c74c9 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -26,15 +26,14 @@ use crate::openhuman::agent::dispatcher::{ParsedToolCall, ToolExecutionResult}; use crate::openhuman::agent::harness; use crate::openhuman::agent::hooks::{self, ToolCallRecord, TurnContext}; use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::context::prompt::{ - LearnedContextData, PromptContext, PromptTool, RenderedPrompt, -}; +use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, PromptTool}; use crate::openhuman::context::{ReductionOutcome, ARCHIVIST_EXTRACTION_PROMPT}; use crate::openhuman::memory::MemoryCategory; use crate::openhuman::providers::{ChatMessage, ChatRequest, ConversationMessage, ProviderDelta}; use crate::openhuman::tools::Tool; use crate::openhuman::util::truncate_with_ellipsis; use anyhow::Result; +use std::hash::{Hash, Hasher}; use std::sync::Arc; impl Agent { @@ -88,32 +87,29 @@ impl Agent { log::info!("[agent] system prompt built — initialising conversation history"); log::info!( "[agent_loop] system prompt built chars={}", - rendered_prompt.text.chars().count() + rendered_prompt.chars().count() ); // User-file injection (PROFILE.md, MEMORY.md) puts // potentially-sensitive content (LinkedIn scrape output, // archivist-curated memories) into the system prompt. Avoid // leaking that to debug logs — log a length + content hash - // instead so cache-boundary diagnostics still work. Narrow - // specialists (both flags off) keep the full-body log so - // prompt-engineering iteration on tools/safety sections - // stays easy. + // instead. Narrow specialists (both flags off) keep the + // full-body log so prompt-engineering iteration on + // tools/safety sections stays easy. if self.omit_profile && self.omit_memory_md { - log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt.text); + log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt); } else { - use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); - rendered_prompt.text.hash(&mut hasher); + rendered_prompt.hash(&mut hasher); log::debug!( "[agent_loop] system prompt body redacted (contains PROFILE/MEMORY): chars={} hash={:016x}", - rendered_prompt.text.chars().count(), + rendered_prompt.chars().count(), hasher.finish() ); } - self.system_prompt_cache_boundary = rendered_prompt.cache_boundary; self.history .push(ConversationMessage::Chat(ChatMessage::system( - rendered_prompt.text, + rendered_prompt, ))); } else { // Deliberately do NOT rebuild the system prompt on subsequent @@ -383,7 +379,6 @@ impl Agent { } else { None }, - system_prompt_cache_boundary: self.system_prompt_cache_boundary, stream: delta_tx_opt.as_ref(), }, &effective_model, @@ -491,6 +486,21 @@ impl Agent { msgs.push(ChatMessage::assistant(final_text.clone())); } + // Persist the transcript **now** — right after the + // provider response lands — so a crash during hooks + // / memory-extraction / the outer epilogue can't + // lose the assistant's reply. + if let Some(ref messages) = last_provider_messages { + self.persist_session_transcript( + messages, + cumulative_input_tokens, + cumulative_output_tokens, + cumulative_cached_input_tokens, + cumulative_charged_usd, + last_turn_usage.as_ref(), + ); + } + if self.auto_save { let summary = truncate_with_ellipsis(&final_text, 100); let _ = self @@ -569,6 +579,26 @@ impl Agent { tool_calls: persisted_tool_calls, }); + // Persist the transcript **right after** the provider + // response lands — before executing tools — so if the + // session crashes mid-tool-call we still have the + // assistant's response + tool-call intents on disk. + // Rebuild `last_provider_messages` from the current + // history so the snapshot includes whatever the + // assistant just emitted (plain text + tool calls). + last_provider_messages = + Some(self.tool_dispatcher.to_provider_messages(&self.history)); + if let Some(ref messages) = last_provider_messages { + self.persist_session_transcript( + messages, + cumulative_input_tokens, + cumulative_output_tokens, + cumulative_cached_input_tokens, + cumulative_charged_usd, + last_turn_usage.as_ref(), + ); + } + let (results, records) = self.execute_tools(&calls, iteration).await; all_tool_records.extend(records); log::info!( @@ -596,6 +626,21 @@ impl Agent { let formatted = self.tool_dispatcher.format_results(&results); self.history.push(formatted); self.trim_history(); + // Flush the transcript again now that tool results have + // been appended — the pre-tool persist above only + // captured the assistant's tool-call intents. A crash + // or early-exit between iterations would otherwise lose + // the tool output from the on-disk session record. + let post_tool_messages = self.tool_dispatcher.to_provider_messages(&self.history); + self.persist_session_transcript( + &post_tool_messages, + cumulative_input_tokens, + cumulative_output_tokens, + cumulative_cached_input_tokens, + cumulative_charged_usd, + last_turn_usage.as_ref(), + ); + last_provider_messages = Some(post_tool_messages); log::info!( "[agent_loop] iteration end i={} history_len={}", iteration + 1, @@ -623,21 +668,13 @@ impl Agent { // the PARENT_CONTEXT task-local. let result = harness::with_parent_context(parent_context, turn_body).await; - // ── Session transcript persistence ──────────────────────────── - // Persist the exact provider messages so a future session can - // resume with a byte-identical prefix for KV cache reuse. - if result.is_ok() { - if let Some(ref messages) = last_provider_messages { - self.persist_session_transcript( - messages, - cumulative_input_tokens, - cumulative_output_tokens, - cumulative_cached_input_tokens, - cumulative_charged_usd, - last_turn_usage.as_ref(), - ); - } - } + // Session transcript persistence lives INSIDE the turn body — + // one write per provider response, fired right after the + // response lands (see the tool-call and terminal branches in + // `turn_body`). A crash during tool execution no longer drops + // the assistant's reply because it was already flushed to + // disk before tool dispatch started. No outer-loop save is + // needed here. // ── Session-memory extraction (stage 5) ─────────────────────── // @@ -915,6 +952,8 @@ impl Agent { connected_integrations: self.connected_integrations.clone(), composio_client: self.composio_client.clone(), tool_call_format: self.tool_dispatcher.tool_call_format(), + session_key: self.session_key.clone(), + session_parent_prefix: self.session_parent_prefix.clone(), } } @@ -947,7 +986,6 @@ impl Agent { system_prompt: Arc::new(system_prompt), tool_specs: Arc::clone(&self.visible_tool_specs), message_prefix: Arc::new(messages), - cache_boundary: self.system_prompt_cache_boundary, fork_task_prompt, } } @@ -1068,12 +1106,12 @@ impl Agent { /// Fetches the user's active Composio connections and populates /// `self.connected_integrations` so the system prompt can surface them. /// Also caches a [`ComposioClient`] on the session so the sub-agent - /// runner can construct per-action tools for `skills_agent` spawns + /// runner can construct per-action tools for `integrations_agent` spawns /// without rebuilding the client on every call. /// /// Delegates to the shared [`crate::openhuman::composio::fetch_connected_integrations`] /// which is the single source of truth for integration discovery. - pub(super) async fn fetch_connected_integrations(&mut self) { + pub async fn fetch_connected_integrations(&mut self) { let config = match crate::openhuman::config::Config::load_or_init().await { Ok(c) => c, Err(e) => { @@ -1090,10 +1128,7 @@ impl Agent { /// Builds the system prompt for the current turn, including tool /// instructions and learned context. - pub(super) fn build_system_prompt( - &self, - learned: LearnedContextData, - ) -> Result { + pub fn build_system_prompt(&self, learned: LearnedContextData) -> Result { let tools_slice: &[Box] = self.tools.as_slice(); let instructions = self.tool_dispatcher.prompt_instructions(tools_slice); // Adapt the owned Box slice into the shared PromptTool @@ -1117,9 +1152,8 @@ impl Agent { }; // Route through the global context manager so every // prompt-building call-site — main agent, sub-agent runner, - // channel runtimes — shares one builder configuration while - // still preserving cache-boundary metadata for provider calls. - self.context.build_system_prompt_with_cache_metadata(&ctx) + // channel runtimes — shares one builder configuration. + self.context.build_system_prompt(&ctx) } // ───────────────────────────────────────────────────────────────── @@ -1144,14 +1178,9 @@ impl Agent { ); return; } - // Restore the cache boundary from the transcript - // metadata so the provider request carries the - // same offset as the original session. - self.system_prompt_cache_boundary = session.meta.cache_boundary; log::info!( - "[transcript] loaded {} messages for resume (cache_boundary={:?})", - session.messages.len(), - session.meta.cache_boundary + "[transcript] loaded {} messages for resume", + session.messages.len() ); self.cached_transcript_messages = Some(session.messages); } @@ -1191,12 +1220,17 @@ impl Agent { charged_amount_usd: f64, turn_usage: Option<&transcript::TurnUsage>, ) { - // Resolve the transcript path on first write. + // Resolve the transcript path on first write. The stem is + // `{parent_prefix}__{session_key}` for sub-agents (producing a + // flat hierarchical filename) or just `{session_key}` for a + // root session. Prefix chaining is already done by the + // sub-agent runner when it populates `session_parent_prefix`. if self.session_transcript_path.is_none() { - match transcript::resolve_new_transcript_path( - &self.workspace_dir, - &self.agent_definition_name, - ) { + let stem = match &self.session_parent_prefix { + Some(prefix) => format!("{}__{}", prefix, self.session_key), + None => self.session_key.clone(), + }; + match transcript::resolve_keyed_transcript_path(&self.workspace_dir, &stem) { Ok(path) => { log::info!( "[transcript] new session transcript path={}", @@ -1221,7 +1255,6 @@ impl Agent { } else { "xml".into() }, - cache_boundary: self.system_prompt_cache_boundary, created: now.clone(), updated: now, turn_count: self.context.stats().session_memory_current_turn as usize, @@ -1654,21 +1687,18 @@ mod tests { ChatMessage::user("hello"), ChatMessage::assistant("done"), ]; - agent.system_prompt_cache_boundary = Some(12); agent.persist_session_transcript(&messages, 10, 5, 3, 0.25, None); assert!(agent.session_transcript_path.is_some()); let loaded = transcript::read_transcript(agent.session_transcript_path.as_ref().unwrap()) .expect("transcript should be readable"); assert_eq!(loaded.messages.len(), 3); - assert_eq!(loaded.meta.cache_boundary, Some(12)); assert_eq!(loaded.meta.input_tokens, 10); let mut resumed = make_agent(None); resumed.workspace_dir = agent.workspace_dir.clone(); resumed.agent_definition_name = agent.agent_definition_name.clone(); resumed.try_load_session_transcript(); - assert_eq!(resumed.system_prompt_cache_boundary, Some(12)); assert_eq!( resumed.cached_transcript_messages.as_ref().map(|m| m.len()), Some(3) diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index a94771337..859df87a2 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -52,9 +52,6 @@ pub struct Agent { /// Last memory context loaded for the current turn. Stored so it can /// be forwarded to subagents via `ParentExecutionContext`. pub(super) last_memory_context: Option, - /// Explicit cache boundary for the current rendered system prompt. - /// `None` means the prompt is treated as entirely dynamic. - pub(super) system_prompt_cache_boundary: Option, pub(super) history: Vec, pub(super) post_turn_hooks: Vec>, pub(super) learning_enabled: bool, @@ -68,6 +65,20 @@ pub struct Agent { /// Set on first write, reused for subsequent overwrites within the /// same session. pub(super) session_transcript_path: Option, + /// Unique transcript key for this session, formatted as + /// `"{unix_ts}_{agent_id}"`. Generated once at agent-build time so + /// every transcript write in this session uses the same filename + /// stem. Sub-agents chain their parent's key into the transcript + /// directory to produce a hierarchical layout — + /// `session_raw/DDMMYYYY/{parent_key}/{child_key}.jsonl`. + pub(super) session_key: String, + /// Directory chain of parent session keys for a sub-agent, or + /// `None` for a root session. A planner spawned by the orchestrator + /// carries `Some("1713000000_orchestrator")`; a critic spawned by + /// that planner carries + /// `Some("1713000000_orchestrator/1713000123_planner")` so nested + /// delegations produce a tree on disk. + pub(super) session_parent_prefix: Option, /// Messages loaded from a previous session transcript on resume. /// Consumed once (via `.take()`) on the first turn to provide a /// byte-identical prefix for KV cache reuse. @@ -87,15 +98,15 @@ pub struct Agent { /// tool-call and iteration updates to the UI. pub(super) on_progress: Option>, /// Active Composio integrations the user has connected. Populated at - /// agent build time; surfaced in the system prompt via - /// [`ConnectedIntegrationsSection`] so the orchestrator knows which - /// external services are available. + /// agent build time and threaded into each agent's `prompt.rs` so + /// the delegator / skill-executor voices can render their own + /// integration blocks. pub(super) connected_integrations: Vec, /// Composio client, built alongside `connected_integrations` and /// shared into [`harness::ParentExecutionContext`] at turn start /// so the sub-agent runner can dynamically construct per-action /// [`crate::openhuman::composio::ComposioActionTool`] instances - /// when `skills_agent` is spawned with a `toolkit` argument. + /// when `integrations_agent` is spawned with a `toolkit` argument. /// `None` when the user isn't signed in or the backend is /// unreachable. pub(super) composio_client: Option, @@ -143,6 +154,11 @@ pub struct AgentBuilder { pub(super) event_session_id: Option, pub(super) event_channel: Option, pub(super) agent_definition_name: Option, + /// Directory chain of parent session keys for a sub-agent. `None` + /// (default) means this is a root session — its transcript lands + /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated + /// by the sub-agent runner so nested delegations produce a tree. + pub(super) session_parent_prefix: Option, /// Forwarded to [`Agent::omit_profile`] at `build()` time. Mirrors the /// target definition's `omit_profile` flag; `None` means "fall back /// to the safe default" (omit). diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs index dcd17d6ec..5f13d4b72 100644 --- a/src/openhuman/agent/harness/subagent_runner.rs +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -32,14 +32,15 @@ use super::definition::{AgentDefinition, PromptSource, ToolScope}; use super::fork_context::{current_fork, current_parent, ForkContext, ParentExecutionContext}; use super::session::transcript; use crate::openhuman::context::prompt::{ - extract_cache_boundary, render_subagent_system_prompt, SubagentRenderOptions, + render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions, }; use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall}; use crate::openhuman::tools::{Tool, ToolCategory, ToolResult, ToolSpec}; use async_trait::async_trait; +use futures::stream::StreamExt; use regex::Regex; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::fmt::Write as _; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, Instant}; use thiserror::Error; @@ -58,18 +59,12 @@ pub struct SubagentRunOptions { /// starts with `{skill}__`. Overrides `definition.skill_filter`. pub skill_filter_override: Option, - /// Optional category override. When set, replaces - /// `definition.category_filter` for this single spawn. Useful when - /// the parent wants to reuse a generic definition but scope it to - /// skill or system tools for this specific call. - pub category_filter_override: Option, - /// Optional Composio toolkit scope (e.g. `"gmail"`, `"notion"`). /// When set, skill-category tools are further restricted to those /// whose name starts with the uppercased `{toolkit}_` prefix, and /// the sub-agent's rendered `Connected Integrations` section is /// narrowed to only that toolkit's entry. Used by main/orchestrator - /// when spawning `skills_agent` for a specific platform so the + /// when spawning `integrations_agent` for a specific platform so the /// sub-agent only sees one integration's tool catalogue. pub toolkit_override: Option, @@ -214,18 +209,17 @@ async fn run_typed_mode( ) -> Result { let started = Instant::now(); - // ── Resolve archetype prompt body ────────────────────────────────── - let archetype_prompt_body = - load_prompt_source(&definition.system_prompt, &parent.workspace_dir)?; - // ── Resolve model + temperature ──────────────────────────────────── let model = definition.model.resolve(&parent.model_name); let temperature = definition.temperature; + // Archetype prompt loading is deferred until AFTER tool filtering so + // dynamic builders receive the final, filtered tool list (rather + // than the parent's full registry). The actual + // `load_prompt_source(...)` call lives just above + // `render_subagent_system_prompt` below. + // ── Filter tools per definition + per-spawn override ─────────────── - let category_filter = options - .category_filter_override - .or(definition.category_filter); let toolkit_filter = options.toolkit_override.as_deref(); let mut allowed_indices = filter_tool_indices( &parent.all_tools, @@ -235,15 +229,24 @@ async fn run_typed_mode( .skill_filter_override .as_deref() .or(definition.skill_filter.as_deref()), - category_filter, ); - // ── Force-include extra_tools that bypass category_filter ────────── + // `complete_onboarding` is a welcome-only tool — it flips the + // onboarding-complete flag in workspace config and is meaningless + // (and potentially destructive) from any other agent. Strip it + // from every non-welcome subagent regardless of their scope. + if definition.id != "welcome" { + allowed_indices.retain(|&i| !is_welcome_only_tool(parent.all_tools[i].name())); + } + + // ── Force-include extra_tools ────────────────────────────────────── // - // `extra_tools` lets an agent definition request specific system tools - // even when `category_filter` restricts to a different category. For - // example, `skills_agent` sets `category_filter = "skill"` but still - // needs `file_write` and `csv_export` for exporting oversized payloads. + // `extra_tools` is a simple "also include these" hook that bypasses + // [`ToolScope`] / [`AgentDefinition::skill_filter`] but still honours + // `disallowed_tools`. Historically this was the bypass list for the + // now-removed `category_filter`; it remains useful for custom + // definitions that want to add a couple of named tools on top of a + // narrow scope. if !definition.extra_tools.is_empty() { let disallow_set: std::collections::HashSet<&str> = definition .disallowed_tools @@ -261,9 +264,9 @@ async fn run_typed_mode( } } - // ── Dynamic per-action toolkit tools (skills_agent + toolkit) ────── + // ── Dynamic per-action toolkit tools (integrations_agent + toolkit) ────── // - // When `skills_agent` is spawned with a `toolkit` argument (e.g. + // When `integrations_agent` is spawned with a `toolkit` argument (e.g. // `toolkit="gmail"`), build one [`ComposioActionTool`] per action // in that toolkit and inject them into the sub-agent's tool list. // Each carries the action's real JSON schema, so the LLM's native @@ -275,28 +278,80 @@ async fn run_typed_mode( // are stripped from the parent-filtered indices in this path so // the model only sees one way to call each action. let mut dynamic_tools: Vec> = Vec::new(); - let is_skills_agent_with_toolkit = definition.id == "skills_agent" && toolkit_filter.is_some(); - if is_skills_agent_with_toolkit { - // Drop EVERY skill-category parent tool. In the new - // architecture all integration discovery / authorization / - // dispatching is the orchestrator's responsibility (via the - // Delegation Guide and `spawn_subagent` pre-flight). The - // sub-agent's only job is to execute per-action tools for - // its bound toolkit, so leftover meta-tools (composio_*, - // apify_*, other-toolkit dispatchers) are pure noise that - // confuses the model and wastes tokens. + let is_integrations_agent_with_toolkit = + definition.id == "integrations_agent" && toolkit_filter.is_some(); + + // `tools_agent` is the Composio-free counterpart to + // `integrations_agent`: it inherits the orchestrator's wildcard + // scope but must never see Skill-category tools. Stripping them + // here (before any dynamic additions) keeps the parent-fed + // `allowed_indices` clean of composio_* meta-tools and + // toolkit-specific action tools. Delegation to integrations_agent + // is the orchestrator's job, not this agent's. + if definition.id == "tools_agent" { allowed_indices.retain(|&i| parent.all_tools[i].category() != ToolCategory::Skill); + } + + if is_integrations_agent_with_toolkit { + // Tool visibility is fully governed by the TOML scope + // (`agent.tools.named = [...]` on the integrations_agent + // definition) plus the dynamic per-action ComposioActionTools + // injected below. Anything the agent author explicitly named + // in the TOML is kept as-is — no extra stripping here. + // Previously we dropped every Skill-category tool at this + // point, which also dropped `composio_list_tools` / + // `composio_execute` whenever they were declared in the TOML, + // making the TOML changes look like no-ops. if let (Some(tk), Some(client)) = (toolkit_filter, parent.composio_client.as_ref()) { // The spawn_subagent pre-flight already verified the // toolkit is in the allowlist AND has an active // connection, so the matching entry must be present and // marked connected. Defensive lookup anyway. - if let Some(integration) = parent + if let Some(cached_integration) = parent .connected_integrations .iter() .find(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(tk)) { + // Refresh the toolkit's action catalogue at spawn time + // by calling `composio_list_tools` for the bound toolkit. + // The cached list on `parent.connected_integrations` + // comes from the session-start bulk fetch, which can + // return zero actions for some toolkits even when the + // per-toolkit endpoint returns a full catalogue. Falling + // back to the cached list preserves the previous + // behaviour on network failure. + let fresh_actions = match crate::openhuman::composio::fetch_toolkit_actions( + client, tk, + ) + .await + { + Ok(actions) if !actions.is_empty() => actions, + Ok(_) => { + tracing::debug!( + agent_id = %definition.id, + toolkit = %tk, + "[subagent_runner:typed] fresh list_tools returned empty; falling back to cached catalogue" + ); + cached_integration.tools.clone() + } + Err(e) => { + tracing::warn!( + agent_id = %definition.id, + toolkit = %tk, + error = %e, + "[subagent_runner:typed] fresh list_tools failed; falling back to cached catalogue" + ); + cached_integration.tools.clone() + } + }; + let integration = crate::openhuman::context::prompt::ConnectedIntegration { + toolkit: cached_integration.toolkit.clone(), + description: cached_integration.description.clone(), + tools: fresh_actions, + connected: cached_integration.connected, + }; + let integration = &integration; // Fuzzy-filter the toolkit's actions against the task prompt // so large catalogues (e.g. github ~500 actions) are narrowed // to the handful actually relevant to this delegation. The @@ -379,7 +434,7 @@ async fn run_typed_mode( // ── Progressive-disclosure handoff cache ─────────────────────────── // - // Built only for skills_agent-with-toolkit because that's the only + // Built only for integrations_agent-with-toolkit because that's the only // typed sub-agent that regularly calls external tools capable of // returning megabyte-scale payloads (Composio actions). Every other // typed sub-agent gets `None` and its tool results stay inline. @@ -391,7 +446,7 @@ async fn run_typed_mode( // summarizer sub-agent against the cached payload with a targeted // query. Lazy / pay-per-question, so trivial asks answerable from // the preview don't pay any extra LLM cost. - let handoff_cache: Option> = if is_skills_agent_with_toolkit { + let handoff_cache: Option> = if is_integrations_agent_with_toolkit { let cache = Arc::new(ResultHandoffCache::new()); // Resolve the summarizer definition once and register the @@ -460,7 +515,7 @@ async fn run_typed_mode( // Per-definition omit_* flags are threaded through via // `SubagentRenderOptions` — previously the narrow renderer // hard-coded all three as "omit", which silently downgraded - // definitions like `code_executor` / `tool_maker` / `skills_agent` + // definitions like `code_executor` / `tool_maker` / `integrations_agent` // that set `omit_safety_preamble = false`. let render_options = SubagentRenderOptions::from_definition_flags( definition.omit_identity, @@ -490,19 +545,96 @@ async fn run_typed_mode( .cloned() .collect(), }; - let rendered_prompt = extract_cache_boundary(&render_subagent_system_prompt( - &parent.workspace_dir, - &model, - &allowed_indices, - &parent.all_tools, - &dynamic_tools, - &archetype_prompt_body, - render_options, - parent.tool_call_format, - &narrowed_integrations, - )); - let system_prompt = rendered_prompt.text; - let system_prompt_cache_boundary = rendered_prompt.cache_boundary; + // ── Resolve archetype prompt body (post-filter) ──────────────────── + // + // Build a live [`PromptContext`] — same shape the main agent uses + // on every turn — so `Dynamic` builders can compose the full + // system prompt via the section helpers in + // [`crate::openhuman::context::prompt`]. `Inline` / `File` sources + // continue to use the legacy `render_subagent_system_prompt` + // wrapper. + let prompt_tools: Vec> = allowed_indices + .iter() + .map(|&i| { + let t = parent.all_tools[i].as_ref(); + PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + } + }) + .chain(dynamic_tools.iter().map(|t| PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + })) + .collect(); + // Derive the visible-tool set from the prompt tool list so prompt + // sections that gate on `visible_tool_names` (e.g. tool-protocol + // notes) see exactly what the model sees, rather than an empty set. + let visible_tool_names: std::collections::HashSet = + prompt_tools.iter().map(|t| t.name.to_string()).collect(); + // Match the main-agent turn (`session/turn.rs::build_system_prompt`) + // by supplying the dispatcher's protocol instructions here. Dynamic + // prompt builders route tools through `render_tools(ctx)`, which + // appends `ctx.dispatcher_instructions` after the tool catalogue — + // passing an empty string drops the `## Tool Use Protocol` block and + // leaves PFormat/Json sub-agents with no call-format guidance. + let dispatcher_instructions = { + use crate::openhuman::agent::dispatcher::{ + NativeToolDispatcher, PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher, + }; + use crate::openhuman::agent::pformat::PFormatRegistry; + use crate::openhuman::context::prompt::ToolCallFormat; + let empty_tools: Vec> = Vec::new(); + match parent.tool_call_format { + ToolCallFormat::PFormat => { + PFormatToolDispatcher::new(PFormatRegistry::new()).prompt_instructions(&empty_tools) + } + ToolCallFormat::Native => NativeToolDispatcher.prompt_instructions(&empty_tools), + ToolCallFormat::Json => XmlToolDispatcher.prompt_instructions(&empty_tools), + } + }; + let prompt_ctx = PromptContext { + workspace_dir: &parent.workspace_dir, + model_name: &model, + agent_id: &definition.id, + tools: &prompt_tools, + skills: &parent.skills, + dispatcher_instructions: &dispatcher_instructions, + learned: crate::openhuman::context::prompt::LearnedContextData::default(), + visible_tool_names: &visible_tool_names, + tool_call_format: parent.tool_call_format, + connected_integrations: &narrowed_integrations, + include_profile: !definition.omit_profile, + include_memory_md: !definition.omit_memory_md, + }; + + let system_prompt = match &definition.system_prompt { + PromptSource::Dynamic(build) => { + // Function-driven builder returns the final prompt text. + build(&prompt_ctx).map_err(|e| SubagentRunError::PromptLoad { + path: format!("", definition.id), + source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()), + })? + } + PromptSource::Inline(_) | PromptSource::File { .. } => { + // Legacy path for TOML-authored agents: load the raw body, + // then wrap it with the canonical section layout. + let archetype_prompt_body = load_prompt_source(&definition.system_prompt, &prompt_ctx)?; + render_subagent_system_prompt( + &parent.workspace_dir, + &model, + &allowed_indices, + &parent.all_tools, + &dynamic_tools, + &archetype_prompt_body, + render_options, + parent.tool_call_format, + &narrowed_integrations, + ) + } + }; // ── Build the user message (with optional context prefix) ────────── // Merge explicit orchestrator context with the parent's auto-loaded @@ -529,7 +661,10 @@ async fn run_typed_mode( ]; // ── Run the inner tool-call loop ─────────────────────────────────── - let (output, iterations, agg_usage) = run_inner_loop( + // Transcript persistence lives INSIDE the loop (one write per + // provider response), mirroring the main-agent turn loop in + // `session/turn.rs`. No post-loop write needed here. + let (output, iterations, _agg_usage) = run_inner_loop( parent.provider.as_ref(), &mut history, &parent.all_tools, @@ -539,21 +674,13 @@ async fn run_typed_mode( &model, temperature, definition.max_iterations, - system_prompt_cache_boundary, task_id, &definition.id, handoff_cache.as_deref(), + &parent, ) .await?; - persist_subagent_transcript( - &parent.workspace_dir, - &definition.id, - &history, - system_prompt_cache_boundary, - &agg_usage, - ); - Ok(SubagentRunOutcome { task_id: task_id.to_string(), agent_id: definition.id.clone(), @@ -593,7 +720,6 @@ async fn run_fork_mode( tracing::debug!( agent_id = %definition.id, prefix_len = fork.message_prefix.len(), - cache_boundary = ?fork.cache_boundary, "[subagent_runner:fork] replaying parent prefix" ); @@ -630,7 +756,9 @@ async fn run_fork_mode( // Fork mode replays the parent's exact tool list — no dynamic // toolkit-scoped tools, so `extra_tools` is empty. let fork_extra_tools: Vec> = Vec::new(); - let (output, iterations, agg_usage) = run_inner_loop( + // Transcript persistence happens per-iteration inside + // `run_inner_loop`; no post-loop write needed. + let (output, iterations, _agg_usage) = run_inner_loop( parent.provider.as_ref(), &mut history, &parent.all_tools, @@ -640,21 +768,13 @@ async fn run_fork_mode( &model, temperature, max_iterations, - fork.cache_boundary, task_id, &definition.id, None, + &parent, ) .await?; - persist_subagent_transcript( - &parent.workspace_dir, - &definition.id, - &history, - fork.cache_boundary, - &agg_usage, - ); - Ok(SubagentRunOutcome { task_id: task_id.to_string(), agent_id: definition.id.clone(), @@ -665,61 +785,6 @@ async fn run_fork_mode( }) } -// ───────────────────────────────────────────────────────────────────────────── -// Session transcript persistence for sub-agents -// ───────────────────────────────────────────────────────────────────────────── - -/// Best-effort: persist the sub-agent's conversation as a session transcript -/// so it can be inspected for debugging and KV cache analysis. -fn persist_subagent_transcript( - workspace_dir: &Path, - agent_id: &str, - history: &[ChatMessage], - cache_boundary: Option, - usage: &AggregatedUsage, -) { - let path = match transcript::resolve_new_transcript_path(workspace_dir, agent_id) { - Ok(p) => p, - Err(err) => { - tracing::debug!( - agent_id = %agent_id, - error = %err, - "[subagent_runner] failed to resolve transcript path" - ); - return; - } - }; - - let now = chrono::Utc::now().to_rfc3339(); - let meta = transcript::TranscriptMeta { - agent_name: agent_id.to_string(), - dispatcher: "native".into(), - cache_boundary, - created: now.clone(), - updated: now, - turn_count: 1, - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - cached_input_tokens: usage.cached_input_tokens, - charged_amount_usd: usage.charged_amount_usd, - }; - - if let Err(err) = transcript::write_transcript(&path, history, &meta, None) { - tracing::debug!( - agent_id = %agent_id, - error = %err, - "[subagent_runner] failed to write transcript" - ); - } else { - tracing::debug!( - agent_id = %agent_id, - messages = history.len(), - path = %path.display(), - "[subagent_runner] transcript written" - ); - } -} - // ───────────────────────────────────────────────────────────────────────────── // Inner tool-call loop (slim version of agent::loop_::tool_loop) // ───────────────────────────────────────────────────────────────────────────── @@ -754,14 +819,58 @@ async fn run_inner_loop( model: &str, temperature: f64, max_iterations: usize, - system_prompt_cache_boundary: Option, task_id: &str, agent_id: &str, handoff_cache: Option<&ResultHandoffCache>, + parent: &ParentExecutionContext, ) -> Result<(String, usize, AggregatedUsage), SubagentRunError> { let max_iterations = max_iterations.max(1); - // ── Text-mode override for skills_agent ──────────────────────────── + // Sub-agent transcript stem — mirrors what + // `persist_subagent_transcript` used to compute on one-shot + // post-loop writes. We compute it once up front so **every + // iteration's** persist call resolves to the same file on disk: + // `{parent_chain}__{unix_ts}_{agent_id}.jsonl`. + let child_session_key = { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let unix_ts = now.as_secs(); + // Nanos component + task_id suffix disambiguate sibling sub-agents + // spawned within the same wall-clock second (tests and fan-out + // flows routinely do this, and a shared stem would overwrite the + // earlier sibling's transcript file). + let nanos = now.subsec_nanos(); + let sanitized: String = agent_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + let task_suffix: String = task_id + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .take(12) + .collect(); + if task_suffix.is_empty() { + format!("{unix_ts}_{nanos:09}_{sanitized}") + } else { + format!("{unix_ts}_{nanos:09}_{sanitized}_{task_suffix}") + } + }; + let transcript_stem = { + let parent_chain = match parent.session_parent_prefix.as_deref() { + Some(prefix) => format!("{}__{}", prefix, parent.session_key), + None => parent.session_key.clone(), + }; + format!("{parent_chain}__{child_session_key}") + }; + + // ── Text-mode override for integrations_agent ──────────────────────────── // // Large Composio toolkits (Notion, Salesforce, HubSpot, GitHub) ship // per-action JSON schemas that are extraordinarily dense — deeply @@ -781,12 +890,12 @@ async fn run_inner_loop( // prose (XmlToolDispatcher format) and parse `` tags out // of the model's free-form response text. // - // Scoped to `skills_agent` because that's the only path where we + // Scoped to `integrations_agent` because that's the only path where we // pass Composio toolkit schemas. Every other typed sub-agent // (welcome, researcher, summarizer, …) uses small built-in tool // sets that stay well under the grammar ceiling and benefit from // native mode's stricter formatting guarantees. - let force_text_mode = agent_id == "skills_agent" && !tool_specs.is_empty(); + let force_text_mode = agent_id == "integrations_agent" && !tool_specs.is_empty(); let supports_native = !force_text_mode && provider.supports_native_tools() && !tool_specs.is_empty(); @@ -817,6 +926,49 @@ async fn run_inner_loop( let mut usage = AggregatedUsage::default(); + // Per-iteration transcript persistence. Mirrors the main-agent + // turn loop: right after each provider response lands (and again + // after the final response is pushed) we flush the full history + // to disk. A crash during tool execution no longer erases the + // sub-agent's response — the bytes are on disk before any tool + // runs. Best-effort: write failures are logged at `debug` and the + // loop continues. + let persist_transcript = |history: &[ChatMessage], usage: &AggregatedUsage| { + let path = match transcript::resolve_keyed_transcript_path( + &parent.workspace_dir, + &transcript_stem, + ) { + Ok(p) => p, + Err(err) => { + tracing::debug!( + agent_id = %agent_id, + error = %err, + "[subagent_runner] failed to resolve transcript path" + ); + return; + } + }; + let now = chrono::Utc::now().to_rfc3339(); + let meta = transcript::TranscriptMeta { + agent_name: agent_id.to_string(), + dispatcher: "native".into(), + created: now.clone(), + updated: now, + turn_count: 1, + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cached_input_tokens: usage.cached_input_tokens, + charged_amount_usd: usage.charged_amount_usd, + }; + if let Err(err) = transcript::write_transcript(&path, history, &meta, None) { + tracing::debug!( + agent_id = %agent_id, + error = %err, + "[subagent_runner] failed to write transcript" + ); + } + }; + for iteration in 0..max_iterations { tracing::debug!( task_id = %task_id, @@ -831,7 +983,6 @@ async fn run_inner_loop( ChatRequest { messages: history.as_slice(), tools: request_tools, - system_prompt_cache_boundary, stream: None, }, model, @@ -888,6 +1039,9 @@ async fn run_inner_loop( "[subagent_runner] no tool calls — returning final response" ); history.push(ChatMessage::assistant(response_text.clone())); + // Persist the final response before returning so the + // transcript always captures the last provider reply. + persist_transcript(history, &usage); return Ok((response_text, iteration + 1, usage)); } @@ -905,6 +1059,11 @@ async fn run_inner_loop( history.push(ChatMessage::assistant(assistant_history_content)); } + // Persist the assistant response + tool-call intents **before** + // executing tools. If the session crashes mid-tool-call we + // still have what the model emitted on disk. + persist_transcript(history, &usage); + // Execute each call, collect outputs. Native mode pushes one // `role=tool` message per call with the structured `tool_call_id` // reference. Text mode has no such reference (the model just @@ -948,7 +1107,7 @@ async fn run_inner_loop( }; // Progressive-disclosure handoff: if this spawn has a cache - // (skills_agent-with-toolkit path) and the result is large + // (integrations_agent-with-toolkit path) and the result is large // and not itself an error / not from the extractor tool, // stash the raw payload and replace it in history with a // short placeholder. The sub-agent can drill in with @@ -1031,6 +1190,14 @@ async fn run_inner_loop( "[Tool results]\n{text_mode_result_block}" ))); } + + // Persist again after tool results have been appended so the + // on-disk transcript reflects each round's complete + // assistant-intent + tool-result pair. Without this, a crash + // between `persist_transcript` at line ~1044 and the next + // iteration's provider call would leave the transcript without + // the tool outputs the next turn will be reasoning from. + persist_transcript(history, &usage); } Err(SubagentRunError::MaxIterationsExceeded(max_iterations)) @@ -1045,7 +1212,7 @@ fn parse_tool_arguments(arguments: &str) -> serde_json::Value { // Oversized-tool-result handoff (progressive disclosure) // ───────────────────────────────────────────────────────────────────────────── // -// Typed sub-agents (skills_agent in particular) regularly call tools that +// Typed sub-agents (integrations_agent in particular) regularly call tools that // return megabyte-scale payloads — `GMAIL_LIST_MESSAGES`, `NOTION_GET_PAGE`, // `GOOGLEDRIVE_LIST_FILES`. The default behaviour pushes that raw blob into // the sub-agent's history as a tool-result message, and the NEXT iteration @@ -1325,7 +1492,6 @@ impl Tool for ExtractFromResultTool { } }); - use futures::stream::StreamExt; let mut map_results: Vec<(usize, _)> = futures::stream::iter(map_futures) .buffer_unordered(MAP_CONCURRENCY) .collect() @@ -1565,9 +1731,7 @@ fn top_k_for_toolkit(toolkit: &str) -> usize { /// correctly while staying within budget. If the model needs deeper /// schema detail it can surface the error and the orchestrator will /// clarify on the next turn. -fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { - use std::fmt::Write as _; - +pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { let mut out = String::new(); out.push_str("## Tool Use Protocol\n\n"); out.push_str( @@ -1602,7 +1766,7 @@ fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String { /// Kept intentionally terse: Composio action schemas routinely contain /// per-parameter descriptions several hundred tokens long, so even a /// "short description" per param balloons to tens of thousands of -/// tokens across a 27-tool skills_agent toolkit and pushes the prompt +/// tokens across a 27-tool integrations_agent toolkit and pushes the prompt /// past the 196 607-token context window. The model can infer usage /// from the parameter names + the tool's overall description; any /// validation mismatch surfaces at call time and the orchestrator can @@ -1653,15 +1817,30 @@ fn first_line_truncated(s: &str, max_chars: usize) -> String { /// /// Filters are applied in this order (shorter-circuit first): /// 1. `disallowed` — explicit deny list. -/// 2. `category_filter` — restrict to `System` or `Skill` category. -/// 3. `skill_filter` — restrict to tools named `{skill}__*`. -/// 4. `scope` — `Wildcard` (everything remaining) or `Named` allowlist. -fn filter_tool_indices( +/// 2. `skill_filter` — restrict to tools named `{skill}__*`. +/// 3. `scope` — `Wildcard` (everything remaining) or `Named` allowlist. +/// +/// Exposed `pub(crate)` so the debug dump path in +/// [`crate::openhuman::context::debug_dump`] shares the exact same +/// filter logic as the live runner — previously debug_dump carried a +/// "standalone copy" which drifted over time. +/// Tools that must never be visible to any agent except `welcome`. +/// +/// `complete_onboarding` flips the onboarding-complete flag in +/// workspace config and is the terminal step of the welcome flow; +/// every other agent must route the user back to the welcome agent +/// rather than call it directly. Central list here so both the main +/// agent builder ([`crate::openhuman::agent::harness::session::builder`]) +/// and the subagent runner apply the same guard. +pub(crate) fn is_welcome_only_tool(name: &str) -> bool { + matches!(name, "complete_onboarding") +} + +pub(crate) fn filter_tool_indices( parent_tools: &[Box], scope: &ToolScope, disallowed: &[String], skill_filter: Option<&str>, - category_filter: Option, ) -> Vec { let disallow_set: HashSet<&str> = disallowed.iter().map(|s| s.as_str()).collect(); let skill_prefix = skill_filter.map(|s| format!("{s}__")); @@ -1674,11 +1853,6 @@ fn filter_tool_indices( if disallow_set.contains(name) { return false; } - if let Some(required) = category_filter { - if tool.category() != required { - return false; - } - } if let Some(prefix) = skill_prefix.as_deref() { if !name.starts_with(prefix) { return false; @@ -1698,14 +1872,24 @@ fn filter_tool_indices( // ───────────────────────────────────────────────────────────────────────────── /// Resolve a [`PromptSource`] to its raw markdown body. Inline sources -/// return immediately; file sources are read from disk relative to the +/// return immediately, `Dynamic` calls the builder with the supplied +/// [`PromptContext`], `File` sources are read from disk relative to the /// workspace `prompts/` directory or the agent crate's bundled prompts. -fn load_prompt_source( +/// +/// Exposed `pub(crate)` so the debug dump path in +/// [`crate::openhuman::context::debug_dump`] loads prompts through the +/// exact same code the runner uses — no parallel body-loading logic. +pub(crate) fn load_prompt_source( source: &PromptSource, - workspace_dir: &std::path::Path, + ctx: &PromptContext<'_>, ) -> Result { + let workspace_dir = ctx.workspace_dir; match source { PromptSource::Inline(body) => Ok(body.clone()), + PromptSource::Dynamic(build) => build(ctx).map_err(|e| SubagentRunError::PromptLoad { + path: format!("", ctx.agent_id), + source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()), + }), PromptSource::File { path } => { // Try the workspace's `agent/prompts/` first (so users can // override built-in prompts), then fall back to the crate's @@ -1778,7 +1962,6 @@ mod tests { tools: ToolScope::Named(names.iter().map(|s| s.to_string()).collect()), disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 5, timeout_secs: None, @@ -1826,7 +2009,7 @@ mod tests { fn filter_named_scope_keeps_only_named() { let parent: Vec> = vec![stub("alpha"), stub("beta"), stub("gamma")]; let def = make_def_named_tools(&["alpha", "gamma"]); - let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None, None); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["alpha", "gamma"]); } @@ -1837,7 +2020,7 @@ mod tests { let mut def = make_def_named_tools(&[]); def.tools = ToolScope::Wildcard; def.disallowed_tools = vec!["beta".into()]; - let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None, None); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["alpha", "gamma"]); } @@ -1852,13 +2035,7 @@ mod tests { ]; let mut def = make_def_named_tools(&[]); def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - Some("notion"), - None, - ); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, Some("notion")); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["notion__search", "notion__read"]); } @@ -1873,203 +2050,11 @@ mod tests { stub("gmail__send"), ]; let def = make_def_named_tools(&["notion__search", "gmail__send"]); - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - Some("notion"), - None, - ); + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, Some("notion")); let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); assert_eq!(names, vec!["notion__search"]); } - /// A stub tool that claims to be a skill-category tool, so we can - /// exercise `filter_tool_indices` / `category_filter` without - /// needing the real skill-bridge runtime. - struct SkillStubTool { - name: &'static str, - } - - #[async_trait] - impl Tool for SkillStubTool { - fn name(&self) -> &str { - self.name - } - fn description(&self) -> &str { - "skill stub" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::success("ok")) - } - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - fn category(&self) -> ToolCategory { - ToolCategory::Skill - } - } - - fn skill_stub(name: &'static str) -> Box { - Box::new(SkillStubTool { name }) - } - - #[test] - fn filter_category_skill_keeps_only_skill_tools() { - let parent: Vec> = vec![ - stub("file_read"), - stub("shell"), - skill_stub("notion__search"), - skill_stub("gmail__send"), - ]; - let def = make_def_named_tools(&[]); // Named([]) - // Wildcard + Skill category → only skill-category tools. - let mut def = def; - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - None, - Some(ToolCategory::Skill), - ); - let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - assert_eq!(names, vec!["notion__search", "gmail__send"]); - } - - #[test] - fn filter_category_system_excludes_skill_tools() { - let parent: Vec> = vec![ - stub("file_read"), - skill_stub("notion__search"), - stub("shell"), - ]; - let mut def = make_def_named_tools(&[]); - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - None, - Some(ToolCategory::System), - ); - let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - assert_eq!(names, vec!["file_read", "shell"]); - } - - #[test] - fn filter_category_and_skill_filter_compose() { - // Category=Skill AND skill_filter=notion → only notion__* tools - // that are also Skill-category. - let parent: Vec> = vec![ - skill_stub("notion__search"), - skill_stub("notion__read"), - skill_stub("gmail__send"), - stub("file_read"), - // A pathological system-category tool with a "notion__" - // name prefix — the category filter should still exclude it. - stub("notion__fake"), - ]; - let mut def = make_def_named_tools(&[]); - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - Some("notion"), - Some(ToolCategory::Skill), - ); - let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - assert_eq!(names, vec!["notion__search", "notion__read"]); - } - - /// End-to-end verification that a sub-agent with - /// `category_filter = "skill"` (like the built-in `skills_agent`) sees - /// the real Composio tools alongside any other `Skill`-category tools - /// and does **not** see `System`-category tools. - /// - /// This is the regression test for "skills subagent has access to - /// composio tools": if any of the composio tool impls forgets to - /// override `category()` and falls back to the default `System`, it - /// gets filtered out here and this test fails. - #[test] - fn skills_subagent_filter_admits_composio_tools() { - use crate::openhuman::composio::client::ComposioClient; - use crate::openhuman::composio::tools::{ - ComposioAuthorizeTool, ComposioExecuteTool, ComposioListConnectionsTool, - ComposioListToolkitsTool, ComposioListToolsTool, - }; - use crate::openhuman::integrations::IntegrationClient; - use std::sync::Arc; - - // Build a throwaway composio client. The filter only touches - // `Tool::name()` and `Tool::category()`, so no HTTP calls happen. - let inner = - IntegrationClient::new("http://127.0.0.1:0".to_string(), "test-token".to_string()); - let client = ComposioClient::new(Arc::new(inner)); - - // Parent registry = the five real Composio tools + a couple of - // plain system-category stubs. We expect exactly the composio - // tools to survive the skills sub-agent's category filter. - let parent: Vec> = vec![ - Box::new(ComposioListToolkitsTool::new(client.clone())), - Box::new(ComposioListConnectionsTool::new(client.clone())), - Box::new(ComposioAuthorizeTool::new(client.clone())), - Box::new(ComposioListToolsTool::new(client.clone())), - Box::new(ComposioExecuteTool::new(client)), - stub("file_read"), - stub("shell"), - ]; - - // Mirror the skills_agent definition: wildcard tool scope, - // category_filter = Skill, no skill_filter. - let mut def = make_def_named_tools(&[]); - def.tools = ToolScope::Wildcard; - let idx = filter_tool_indices( - &parent, - &def.tools, - &def.disallowed_tools, - None, - Some(ToolCategory::Skill), - ); - - let surviving: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); - - // All five composio tools must be present. - for expected in &[ - "composio_list_toolkits", - "composio_list_connections", - "composio_authorize", - "composio_list_tools", - "composio_execute", - ] { - assert!( - surviving.contains(expected), - "skills sub-agent filter dropped composio tool `{}` — \ - did someone remove the `category()` override? \ - surviving = {:?}", - expected, - surviving, - ); - } - - // System-category tools must be filtered out. - assert!(!surviving.contains(&"file_read")); - assert!(!surviving.contains(&"shell")); - - // And we should see exactly 5 survivors, no more, no less. - assert_eq!( - surviving.len(), - 5, - "expected exactly 5 composio tools to pass the skills filter, \ - got {:?}", - surviving, - ); - } - #[test] fn subagent_mode_as_str_roundtrip() { assert_eq!(SubagentMode::Typed.as_str(), "typed"); @@ -2090,7 +2075,6 @@ mod tests { #[derive(Clone)] struct CapturedRequest { messages: Vec, - cache_boundary: Option, tool_count: usize, } @@ -2128,7 +2112,6 @@ mod tests { ) -> anyhow::Result { self.captured.lock().push(CapturedRequest { messages: request.messages.to_vec(), - cache_boundary: request.system_prompt_cache_boundary, tool_count: request.tools.map_or(0, |tools| tools.len()), }); let mut q = self.responses.lock(); @@ -2191,6 +2174,8 @@ mod tests { connected_integrations: vec![], composio_client: None, tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat, + session_key: "0_test".into(), + session_parent_prefix: None, } } @@ -2256,7 +2241,6 @@ mod tests { "summarise X", SubagentRunOptions { skill_filter_override: None, - category_filter_override: None, toolkit_override: None, context: None, task_id: Some("t1".into()), @@ -2337,31 +2321,6 @@ mod tests { assert!(user_msg.content.contains("branch X failed")); } - #[tokio::test] - async fn typed_mode_threads_system_prompt_cache_boundary() { - let provider = ScriptedProvider::new(vec![text_response("ok")]); - let parent = make_parent(provider.clone(), vec![stub("file_read")]); - let def = make_def_named_tools(&[]); - - let _ = with_parent_context(parent, async { - run_subagent( - &def, - "the actual task prompt", - SubagentRunOptions::default(), - ) - .await - }) - .await - .unwrap(); - - let captured = provider.captured.lock(); - assert_eq!(captured.len(), 1); - assert!( - captured[0].cache_boundary.is_some(), - "typed sub-agent request should carry a prompt cache boundary" - ); - } - #[tokio::test] async fn typed_mode_filters_tools_by_skill_filter() { // Parent has tools spanning notion__*, gmail__*, and a generic @@ -2387,7 +2346,6 @@ mod tests { "lookup", SubagentRunOptions { skill_filter_override: Some("notion".into()), - category_filter_override: None, toolkit_override: None, context: None, task_id: None, @@ -2504,7 +2462,6 @@ mod tests { system_prompt: Arc::new("PARENT_SYSTEM_PROMPT_BYTES".into()), tool_specs: Arc::new(vec![parent.all_tool_specs[0].clone()]), message_prefix: Arc::new(prefix.clone()), - cache_boundary: Some(9), fork_task_prompt: "ANALYSE THIS BRANCH".into(), }; @@ -2540,7 +2497,6 @@ mod tests { let appended = first_call.messages.last().unwrap(); assert_eq!(appended.role, "user"); assert_eq!(appended.content, "ANALYSE THIS BRANCH"); - assert_eq!(first_call.cache_boundary, Some(9)); assert_eq!(first_call.tool_count, 1); } diff --git a/src/openhuman/agent/harness/tool_filter.rs b/src/openhuman/agent/harness/tool_filter.rs index 83f86b7a8..8218d7a44 100644 --- a/src/openhuman/agent/harness/tool_filter.rs +++ b/src/openhuman/agent/harness/tool_filter.rs @@ -1,6 +1,6 @@ //! Fuzzy tool-filter for sub-agent delegation. //! -//! When `skills_agent` is spawned with a bound Composio toolkit (e.g. +//! When `integrations_agent` is spawned with a bound Composio toolkit (e.g. //! `toolkit="github"`), the parent-refined task prompt is usually specific //! enough that only a handful of the toolkit's actions are relevant. Github's //! catalogue alone has 500 actions; loading every one into the sub-agent's diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs index 4dc455a53..a96c02e23 100644 --- a/src/openhuman/agent/harness/tool_loop.rs +++ b/src/openhuman/agent/harness/tool_loop.rs @@ -277,7 +277,6 @@ pub(crate) async fn run_tool_call_loop( ChatRequest { messages: &prepared_messages.messages, tools: request_tools, - system_prompt_cache_boundary: None, stream: delta_tx_opt.as_ref(), }, model, diff --git a/src/openhuman/agent/mod.rs b/src/openhuman/agent/mod.rs index e30c1a5a7..3a83a974e 100644 --- a/src/openhuman/agent/mod.rs +++ b/src/openhuman/agent/mod.rs @@ -29,6 +29,12 @@ pub mod memory_loader; pub mod multimodal; pub mod pformat; pub mod progress; +/// Prompt plumbing — types, section builders, and +/// [`SystemPromptBuilder`](prompts::SystemPromptBuilder). Moved from +/// `openhuman::context::prompt` so prompt rendering lives next to the +/// agents that consume it. `openhuman::context::prompt` is retained as +/// a thin re-export shim for now. +pub mod prompts; mod schemas; pub mod triage; pub mod welcome_proactive; diff --git a/src/openhuman/agent/prompts/mod.rs b/src/openhuman/agent/prompts/mod.rs new file mode 100644 index 000000000..95d392f1d --- /dev/null +++ b/src/openhuman/agent/prompts/mod.rs @@ -0,0 +1,2062 @@ +pub mod types; +pub use types::*; + +use crate::openhuman::skills::Skill; +use crate::openhuman::tools::Tool; +use anyhow::Result; +use chrono::Local; +use std::fmt::Write; +use std::hash::{Hash, Hasher}; +use std::path::Path; +use std::sync::OnceLock; + +#[derive(Default)] +pub struct SystemPromptBuilder { + sections: Vec>, +} + +impl SystemPromptBuilder { + pub fn with_defaults() -> Self { + Self { + sections: vec![ + Box::new(IdentitySection), + // User files (PROFILE.md, MEMORY.md) ride right after the + // identity bootstrap so they land in the cache-friendly + // prefix alongside SOUL/IDENTITY. Gated per-agent — see + // `UserFilesSection`. Intentionally separate from + // `IdentitySection` so agents that strip the identity + // preamble via `for_subagent(omit_identity=true)` still + // get their user files (welcome / orchestrator / the + // trigger pair). + Box::new(UserFilesSection), + // User memory sits right after the identity bootstrap so the + // model has rich, persistent context about the user before it + // sees the tool catalogue. Section is empty (and skipped) when + // the tree summarizer has nothing on disk yet. + Box::new(UserMemorySection), + Box::new(ToolsSection), + Box::new(SafetySection), + Box::new(WorkspaceSection), + Box::new(DateTimeSection), + Box::new(RuntimeSection), + ], + } + } + + /// Build a narrow prompt for a sub-agent. + /// + /// The sub-agent's archetype prompt is registered as a dedicated + /// section that always renders first. The remaining sections respect + /// the `omit_*` flags from the [`crate::openhuman::agent::harness::definition::AgentDefinition`]: + /// `omit_identity` skips the project-context dump, `omit_safety_preamble` + /// skips the safety rules, and so on. The `WorkspaceSection` is always + /// included so the sub-agent knows its working directory. + /// + /// `archetype_prompt_text` is the already-loaded body of the + /// `system_prompt` source on the definition (the runner resolves + /// inline vs file before calling this). + /// + /// # KV cache stability + /// + /// `DateTimeSection` is intentionally **not** included here. + /// Repeat spawns of the same sub-agent definition must produce + /// byte-identical system prompts so the inference backend's + /// automatic prefix cache can reuse the prefill from the previous + /// run. Injecting `Local::now()` into the prompt would defeat that + /// goal — if a sub-agent genuinely needs the current time it + /// should receive it via the user message, not the system prompt. + pub fn for_subagent( + archetype_prompt_text: String, + omit_identity: bool, + omit_safety_preamble: bool, + _omit_skills_catalog: bool, + ) -> Self { + let mut sections: Vec> = + vec![Box::new(ArchetypePromptSection::new(archetype_prompt_text))]; + + if !omit_identity { + sections.push(Box::new(IdentitySection)); + } + // User files (PROFILE.md / MEMORY.md) are gated independently of + // `omit_identity` so agents that drop the identity preamble (e.g. + // welcome's `omit_identity = true`) still surface the user's + // onboarding + archivist context when `omit_profile` / + // `omit_memory_md` are opted in. + sections.push(Box::new(UserFilesSection)); + // Tools section is always included — the sub-agent needs to see + // its own (filtered) tool catalogue. + sections.push(Box::new(ToolsSection)); + if !omit_safety_preamble { + sections.push(Box::new(SafetySection)); + } + // Skills catalogue and connected integrations are rendered by + // the individual agent's `prompt.rs` when that agent needs + // them (integrations_agent for the skill-executor voice, + // orchestrator/welcome for the delegator voice). The shared + // builder intentionally does not emit them — keeping + // agent-specific prose scoped to the agent that owns it. + sections.push(Box::new(WorkspaceSection)); + + Self { sections } + } + + /// Build from a fully-assembled prompt string — no section wrapping. + /// + /// Used when the caller has already composed the final prompt (e.g. + /// via a function-driven `PromptSource::Dynamic` builder that calls + /// the `render_*` section helpers itself). The returned builder has + /// a single [`ArchetypePromptSection`] containing the body verbatim. + pub fn from_final_body(body: String) -> Self { + Self { + sections: vec![Box::new(ArchetypePromptSection::new(body))], + } + } + + /// Build from a [`PromptSource::Dynamic`] function pointer. + /// + /// The function is called every time [`Self::build`] runs, with the + /// live [`PromptContext`] the call-site supplies — so late-arriving + /// state like `connected_integrations` (fetched asynchronously at + /// the start of a session) reaches the dynamic renderer instead of + /// being frozen into an empty slice at builder-construction time. + /// + /// KV-cache contract: callers must only invoke `build_system_prompt` + /// once per session (after `fetch_connected_integrations`). The + /// rendered bytes are then frozen for the rest of the session the + /// same way `from_final_body` freezes them — the difference is just + /// *when* the freeze happens. + pub fn from_dynamic( + builder: crate::openhuman::agent::harness::definition::PromptBuilder, + ) -> Self { + Self { + sections: vec![Box::new(DynamicPromptSection::new(builder))], + } + } + + pub fn add_section(mut self, section: Box) -> Self { + self.sections.push(section); + self + } + + /// Render every section in order into a single prompt string. + /// + /// The rendered bytes are intended to be **frozen for the whole + /// session** — callers build the system prompt once at session + /// start and reuse the exact bytes on every subsequent turn so the + /// inference backend's prefix cache hits uniformly. There is no + /// cache-boundary marker to emit because the entire prompt is + /// static from the provider's perspective. + pub fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut output = String::new(); + for section in &self.sections { + let part = section.build(ctx)?; + if part.trim().is_empty() { + continue; + } + output.push_str(part.trim_end()); + output.push_str("\n\n"); + } + Ok(output) + } +} + +/// Sub-agent role prompt — pre-loaded text from an +/// [`crate::openhuman::agent::harness::definition::AgentDefinition`]'s +/// `system_prompt` field. Always rendered first when present. +pub struct ArchetypePromptSection { + body: String, +} + +impl ArchetypePromptSection { + pub fn new(body: String) -> Self { + Self { body } + } +} + +impl PromptSection for ArchetypePromptSection { + fn name(&self) -> &str { + "archetype_prompt" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + if self.body.trim().is_empty() { + return Ok(String::new()); + } + Ok(self.body.clone()) + } +} + +/// Section that defers to a [`crate::openhuman::agent::harness::definition::PromptBuilder`] +/// every time it renders, so dynamic prompts (orchestrator, welcome, +/// integrations_agent, …) get to see the live runtime +/// [`PromptContext`] — including `connected_integrations`, which are +/// fetched asynchronously after the builder itself has been +/// constructed. +pub struct DynamicPromptSection { + builder: crate::openhuman::agent::harness::definition::PromptBuilder, +} + +impl DynamicPromptSection { + pub fn new(builder: crate::openhuman::agent::harness::definition::PromptBuilder) -> Self { + Self { builder } + } +} + +impl PromptSection for DynamicPromptSection { + fn name(&self) -> &str { + "dynamic_prompt" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + (self.builder)(ctx) + } +} + +pub struct IdentitySection; +pub struct ToolsSection; +pub struct SafetySection; +// `SkillsSection` and `ConnectedIntegrationsSection` previously lived +// here and branched on `ctx.agent_id` to pick between the skill- +// executor and delegator voice. They've been removed — each agent's +// `prompt.rs` now renders its own block inline (integrations_agent owns the +// `## Available Skills` + executor-voice `## Connected Integrations` +// blocks, orchestrator owns `## Delegation Guide — Integrations`, +// welcome owns its onboarding-flavoured connected list). +pub struct WorkspaceSection; +pub struct RuntimeSection; +pub struct DateTimeSection; +pub struct UserMemorySection; + +/// Injects the user-specific, session-frozen workspace files +/// (`PROFILE.md` + `MEMORY.md`), each capped at [`USER_FILE_MAX_CHARS`]. +/// +/// Separate from [`IdentitySection`] so agents that strip the project- +/// context preamble (`omit_identity = true` — welcome, orchestrator, +/// the trigger pair) still get their user-file injection at runtime via +/// [`SystemPromptBuilder::for_subagent`], which skips `IdentitySection` +/// entirely when `omit_identity` is on. +/// +/// Cache-stability: static per session — the whole point of the +/// 2000-char cap and the load-once rule documented on +/// [`AgentDefinition::omit_profile`] / `omit_memory_md`. +pub struct UserFilesSection; + +impl PromptSection for IdentitySection { + fn name(&self) -> &str { + "identity" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut prompt = String::from("## Project Context\n\n"); + prompt.push_str( + "The following workspace files define your identity, behavior, and context.\n\n", + ); + // When the visible-tool filter is active the main agent is a pure + // orchestrator: it routes via spawn_subagent, synthesises results, + // and talks to the user. It does NOT need the periodic-task config + // (HEARTBEAT.md) — subagents handle their own concerns. + let is_orchestrator = !ctx.visible_tool_names.is_empty(); + let all_files: &[&str] = &["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"]; + // Orchestrator skips these from the prompt but we still sync them + // to disk so they stay current. + let skip_in_prompt: &[&str] = if is_orchestrator { + &["HEARTBEAT.md"] + } else { + &[] + }; + for file in all_files { + // Always sync to disk so builtin updates ship. + sync_workspace_file(ctx.workspace_dir, file); + if !skip_in_prompt.contains(file) { + inject_workspace_file(&mut prompt, ctx.workspace_dir, file); + } + } + + // PROFILE.md / MEMORY.md injection lives in the dedicated + // `UserFilesSection` (below) so agents that strip the identity + // preamble (`omit_identity = true`) — welcome, orchestrator, the + // trigger pair — still get their user files at runtime via + // `SystemPromptBuilder::for_subagent`, which omits + // `IdentitySection` entirely when `omit_identity` is set. + + Ok(prompt) + } +} + +impl PromptSection for UserFilesSection { + fn name(&self) -> &str { + "user_files" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + // Gate on the per-agent flags derived from + // `AgentDefinition::omit_profile` / `omit_memory_md`. Both files + // are user-specific, potentially growing, and capped at + // [`USER_FILE_MAX_CHARS`] (~1000 tokens) so they can't bloat the + // cached prefix. + // + // KV-cache contract: once injected into a session's rendered + // prompt, the bytes are frozen for the remainder of that + // session — any mid-session archivist write or enrichment + // refresh lands on the NEXT session, never the in-flight one. + let mut out = String::new(); + if ctx.include_profile { + inject_workspace_file_capped( + &mut out, + ctx.workspace_dir, + "PROFILE.md", + USER_FILE_MAX_CHARS, + ); + } + if ctx.include_memory_md { + inject_workspace_file_capped( + &mut out, + ctx.workspace_dir, + "MEMORY.md", + USER_FILE_MAX_CHARS, + ); + } + Ok(out) + } +} + +impl PromptSection for ToolsSection { + fn name(&self) -> &str { + "tools" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut out = String::from("## Tools\n\n"); + let has_filter = !ctx.visible_tool_names.is_empty(); + for tool in ctx.tools { + // Skip tools not in the visible set when a filter is active. + if has_filter && !ctx.visible_tool_names.contains(tool.name) { + continue; + } + + // One rendering shape for every dispatcher: a compact + // P-Format signature (`name[a|b|c]`). The signature comes + // straight from the parameter schema (alphabetical by + // property name — see `pformat` module docs for why) so + // model and parser agree on argument ordering. For + // `Native` dispatchers the provider already has the full + // JSON schema in the API request, so repeating it in the + // prompt is pure token bloat; for `Json` / `PFormat` text + // dispatchers the dispatcher's own `prompt_instructions` + // block (appended below) carries whatever schema detail + // the wire format needs. + let signature = render_pformat_signature_for_prompt(tool); + let _ = writeln!( + out, + "- **{}**: {}\n Call as: `{}`", + tool.name, tool.description, signature + ); + } + if !ctx.dispatcher_instructions.is_empty() { + out.push('\n'); + out.push_str(ctx.dispatcher_instructions); + } + Ok(out) + } +} + +/// Build a P-Format signature line (`name[a|b|c]`) from a `&dyn Tool`. +/// Used by `render_subagent_system_prompt` which operates on `Box` +/// directly (no intermediate `PromptTool`). Mirrors the `PromptTool` variant +/// below — both BTreeMap-iterate the schema's `properties` in the same order. +fn render_pformat_signature_for_box_tool(tool: &dyn crate::openhuman::tools::Tool) -> String { + let schema = tool.parameters_schema(); + let names: Vec = schema + .get("properties") + .and_then(|p| p.as_object()) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + if names.is_empty() { + format!("{}[]", tool.name()) + } else { + format!("{}[{}]", tool.name(), names.join("|")) + } +} + +/// Build a P-Format signature line (`name[a|b|c]`) from a [`PromptTool`]. +/// Local to this module so [`ToolsSection`] doesn't have to depend on +/// the agent crate's `pformat` helper. The two implementations stay in +/// lockstep — both use BTreeMap iteration order on the schema's +/// `properties` field. +fn render_pformat_signature_for_prompt(tool: &PromptTool<'_>) -> String { + let names: Vec = tool + .parameters_schema + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| { + v.get("properties") + .and_then(|p| p.as_object()) + .map(|m| m.keys().cloned().collect()) + }) + .unwrap_or_default(); + if names.is_empty() { + format!("{}[]", tool.name) + } else { + format!("{}[{}]", tool.name, names.join("|")) + } +} + +impl PromptSection for SafetySection { + fn name(&self) -> &str { + "safety" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) + } +} + +impl PromptSection for WorkspaceSection { + fn name(&self) -> &str { + "workspace" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + Ok(format!( + "## Workspace\n\nWorking directory: `{}`", + ctx.workspace_dir.display() + )) + } +} + +impl PromptSection for RuntimeSection { + fn name(&self) -> &str { + "runtime" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let host = + hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + Ok(format!( + "## Runtime\n\nHost: {host} | OS: {} | Model: {}", + std::env::consts::OS, + ctx.model_name + )) + } +} + +impl PromptSection for UserMemorySection { + fn name(&self) -> &str { + "user_memory" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + if ctx.learned.tree_root_summaries.is_empty() { + return Ok(String::new()); + } + + let mut out = String::from("## User Memory\n\n"); + out.push_str( + "Long-term memory distilled by the tree summarizer. \ + Each section is the root summary for a memory namespace, \ + representing everything we've learned about that domain over time. \ + Treat this as durable context: the model has seen these facts before, \ + they should not need to be re-discovered.\n\n", + ); + + for (namespace, body) in &ctx.learned.tree_root_summaries { + let trimmed = body.trim(); + if trimmed.is_empty() { + continue; + } + let _ = writeln!(out, "### {namespace}\n"); + out.push_str(trimmed); + out.push_str("\n\n"); + } + + Ok(out) + } +} + +impl PromptSection for DateTimeSection { + fn name(&self) -> &str { + "datetime" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> Result { + let now = Local::now(); + Ok(format!( + "## Current Date & Time\n\n{} ({})", + now.format("%Y-%m-%d %H:%M:%S"), + now.format("%Z") + )) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section helpers for function-driven prompts +// ───────────────────────────────────────────────────────────────────────────── +// +// Each of the `Section` unit structs above is also available as a free +// `render_*` function that takes the same `PromptContext` and returns +// the section body (or an empty string when the section's gate is +// closed). +// +// These exist so `agents//prompt.rs` builders can assemble their own +// final system prompt, composing the exact sections they care about in +// the order they want — no `SystemPromptBuilder` machinery required. + +/// Render the `## Project Context` identity block +/// (`SOUL.md` / `IDENTITY.md` / optionally `HEARTBEAT.md`). +pub fn render_identity(ctx: &PromptContext<'_>) -> Result { + IdentitySection.build(ctx) +} + +/// Render the `PROFILE.md` + `MEMORY.md` user-file injection. +/// Empty when neither `ctx.include_profile` nor `ctx.include_memory_md` +/// is set. +pub fn render_user_files(ctx: &PromptContext<'_>) -> Result { + UserFilesSection.build(ctx) +} + +/// Render the tree-summariser user-memory block. +pub fn render_user_memory(ctx: &PromptContext<'_>) -> Result { + UserMemorySection.build(ctx) +} + +/// Render the `## Tools` catalogue in the dispatcher's tool-call format. +pub fn render_tools(ctx: &PromptContext<'_>) -> Result { + ToolsSection.build(ctx) +} + +/// Render the static `## Safety` block. +pub fn render_safety() -> String { + SafetySection + .build(&empty_prompt_context_for_static_sections()) + .expect("SafetySection::build is infallible") +} + +// `render_skills` and `render_connected_integrations` helpers are +// gone — `## Available Skills` lives in `integrations_agent/prompt.rs`, and +// the connected-integrations / delegation-guide blocks each live in +// their owning agent's `prompt.rs` so no branching-on-agent-id logic +// needs to exist here. + +/// Render the `## Workspace` block (working directory + file listing +/// bounds) — part of the dynamic, per-request suffix. +pub fn render_workspace(ctx: &PromptContext<'_>) -> Result { + WorkspaceSection.build(ctx) +} + +/// Render the `## Runtime` block (model name, dispatcher format) — +/// dynamic. +pub fn render_runtime(ctx: &PromptContext<'_>) -> Result { + RuntimeSection.build(ctx) +} + +/// Render the `## Current Date & Time` block. Intentionally **not** +/// included in byte-stable sub-agent prompts (`for_subagent`) because +/// injecting `Local::now()` defeats prefix caching. Exposed so full- +/// assembly main-agent builders can opt in. +pub fn render_datetime(ctx: &PromptContext<'_>) -> Result { + DateTimeSection.build(ctx) +} + +/// Build a throwaway `PromptContext` for sections whose `build` only +/// uses static/immutable inputs (currently just `SafetySection`). Keeps +/// the `render_safety()` free function from forcing callers to +/// manufacture a full context when they only need the static text. +fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { + static EMPTY_TOOLS: &[PromptTool<'static>] = &[]; + static EMPTY_SKILLS: &[Skill] = &[]; + static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[]; + // SAFETY: the &HashSet reference must outlive the returned context; + // a leaked OnceLock-style allocation gives us a permanent 'static + // anchor without adding runtime cost on the hot path. + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + let visible = EMPTY_VISIBLE.get_or_init(std::collections::HashSet::new); + PromptContext { + workspace_dir: std::path::Path::new(""), + model_name: "", + agent_id: "", + tools: EMPTY_TOOLS, + skills: EMPTY_SKILLS, + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: EMPTY_INTEGRATIONS, + include_profile: false, + include_memory_md: false, + } +} + +/// Render a narrow, KV-cache-stable system prompt for a typed sub-agent. +/// +/// This is a purpose-built alternative to +/// [`SystemPromptBuilder::for_subagent`] for call sites that only have +/// indices into the parent's `&[Box]` vec (so they can't +/// cheaply build a filtered owning slice for `ToolsSection`). The +/// output mirrors what `for_subagent` would emit with the matching +/// `omit_*` flags, plus a sub-agent-specific calling-convention +/// preamble and a model-only runtime banner. +/// +/// `archetype_body` is the already-loaded archetype markdown — for +/// `PromptSource::Inline` this is the inline string, for +/// `PromptSource::File` this is the file contents loaded by the caller. +/// Callers resolve the source exactly once and hand the body in, so +/// this renderer works uniformly for both definition shapes. +/// +/// `options` carries the per-definition rendering flags (safety, etc.) +/// inverted into positive-sense `include_*` form. +/// [`SubagentRenderOptions::narrow`] preserves the historical behaviour. +/// +/// # KV cache stability +/// +/// The rendered bytes MUST be a pure function of: +/// - the `archetype_body` (archetype role prompt) +/// - the filtered tool set (names, descriptions, schemas) +/// - the workspace directory +/// - the resolved model name +/// - the `options` (all static per definition) +/// +/// Anything that varies across invocations at the *same* call site +/// (e.g. `chrono::Local::now()`, hostnames, pids, turn counters) is +/// forbidden here. Repeat spawns of the same sub-agent within a session +/// must produce byte-identical system prompts so the inference +/// backend's automatic prefix caching can reuse the prefill from the +/// previous run. Time-of-day information, if a sub-agent needs it, +/// belongs in the user message — not the system prompt. +pub fn render_subagent_system_prompt( + workspace_dir: &Path, + model_name: &str, + allowed_indices: &[usize], + parent_tools: &[Box], + extra_tools: &[Box], + archetype_body: &str, + options: SubagentRenderOptions, + tool_call_format: ToolCallFormat, + connected_integrations: &[ConnectedIntegration], +) -> String { + render_subagent_system_prompt_with_format( + workspace_dir, + model_name, + allowed_indices, + parent_tools, + extra_tools, + archetype_body, + options, + tool_call_format, + connected_integrations, + ) +} + +/// Inner renderer that accepts an explicit [`ToolCallFormat`] so callers +/// that know the active dispatcher format can thread it through. The +/// public [`render_subagent_system_prompt`] defaults to PFormat for +/// backwards compatibility. +pub fn render_subagent_system_prompt_with_format( + workspace_dir: &Path, + model_name: &str, + allowed_indices: &[usize], + parent_tools: &[Box], + extra_tools: &[Box], + archetype_body: &str, + options: SubagentRenderOptions, + tool_call_format: ToolCallFormat, + _connected_integrations: &[ConnectedIntegration], +) -> String { + let mut out = String::new(); + + // 1. Archetype role prompt. Works for `PromptSource::Inline`, + // `PromptSource::File`, and `PromptSource::Dynamic` because the + // caller preloaded the body via `load_prompt_source`. + let trimmed = archetype_body.trim(); + if !trimmed.is_empty() { + out.push_str(trimmed); + out.push_str("\n\n"); + } + + // 1b. Optional identity block. Off by default; turned on when the + // definition sets `omit_identity = false`. Renders the same + // OpenClaw bootstrap files the main agent loads, keeping the + // byte layout stable across repeat spawns of the same + // definition within a session. + if options.include_identity { + out.push_str("## Project Context\n\n"); + out.push_str( + "The following workspace files define your identity, behavior, and context.\n\n", + ); + for file in &["SOUL.md", "IDENTITY.md"] { + inject_workspace_file(&mut out, workspace_dir, file); + } + } + + // 1c. PROFILE.md (onboarding enrichment output) and MEMORY.md + // (archivist-curated long-term memory). Each is gated on its own + // flag and capped at `USER_FILE_MAX_CHARS` (~1000 tokens) so a + // growing on-disk file can't push the system prompt out of the + // cache-friendly prefix range. + // + // KV-cache contract: once these files land in a session's + // rendered prompt the bytes are frozen for the remainder of that + // session. Do not re-read them mid-turn — a byte change breaks + // the backend's automatic prefix cache. Mid-session writes to + // either file are intentionally only visible on the NEXT session. + if options.include_profile { + inject_workspace_file_capped(&mut out, workspace_dir, "PROFILE.md", USER_FILE_MAX_CHARS); + } + if options.include_memory_md { + inject_workspace_file_capped(&mut out, workspace_dir, "MEMORY.md", USER_FILE_MAX_CHARS); + } + + // 2. Filtered tool catalogue. Indices are taken in ascending order + // from `allowed_indices`, which itself preserves `parent_tools` + // order, so the rendering is deterministic. We use `.get(i)` + // defensively even though the current caller (subagent_runner) + // only produces in-range indices — a future caller that derives + // indices from a different source must not be able to panic this + // renderer with a stale index. + // + // Rendering uses the caller-specified `tool_call_format` so + // sub-agents and the main dispatcher stay in lockstep. + // Tool catalogue rendering is dispatcher-format-aware: + // + // - **Native**: The provider receives full tool schemas through + // the request body's `tools` field (via `filtered_specs` in the + // sub-agent runner) and emits structured `tool_calls`. Listing + // the same tools again as prose in the system prompt is pure + // duplication — for a integrations_agent spawn with 62 dynamic gmail + // tools, that duplication added ~54k tokens and blew past the + // model's context window. We skip the prose `## Tools` section + // entirely in this mode. + // + // - **PFormat / Json**: Both are prompt-driven formats — the + // model discovers tools by reading the prose `## Tools` section + // and emits text-wrapped tool calls (`name[a|b]` + // for PFormat, `{"name":...}` for Json). + // Neither uses the native `tools` request field, so we MUST + // list each tool in prose — including dynamically-registered + // `extra_tools` — or the model has no way to know they exist. + if !matches!(tool_call_format, ToolCallFormat::Native) { + out.push_str("## Tools\n\n"); + let render_one = |out: &mut String, tool: &dyn Tool| match tool_call_format { + ToolCallFormat::PFormat => { + let sig = render_pformat_signature_for_box_tool(tool); + let _ = writeln!( + out, + "- **{}**: {}\n Call as: `{}`", + tool.name(), + tool.description(), + sig + ); + } + ToolCallFormat::Json => { + let _ = writeln!( + out, + "- **{}**: {}\n Parameters: `{}`", + tool.name(), + tool.description(), + tool.parameters_schema() + ); + } + ToolCallFormat::Native => { + // Unreachable — outer guard skips Native entirely. + } + }; + for &i in allowed_indices { + let Some(tool) = parent_tools.get(i) else { + tracing::warn!( + index = i, + tool_count = parent_tools.len(), + "[context::prompt] dropping out-of-range tool index in subagent render" + ); + continue; + }; + render_one(&mut out, tool.as_ref()); + } + for tool in extra_tools { + render_one(&mut out, tool.as_ref()); + } + } + + // 3. Sub-agent calling-convention preamble — format-aware. + // Sub-agents need the same call format the main dispatcher expects + // so their output parses correctly. + out.push('\n'); + match tool_call_format { + ToolCallFormat::PFormat => { + out.push_str( + "## Tool Use Protocol\n\n\ + Tool calls use **P-Format**: compact, positional, pipe-delimited syntax \ + wrapped in `` tags.\n\n\ + ```\n\ntool_name[arg1|arg2]\n\n```\n\n\ + Arguments are positional — match the order shown in each tool's `Call as:` \ + signature above (alphabetical by parameter name). \ + Escape `|` as `\\|`, `]` as `\\]` inside values. \ + You may emit multiple `` blocks per response.\n\n\ + Use the provided tools to accomplish the task. Reply with a concise, dense \ + final answer when you have one — the parent agent will weave it back into the \ + user-visible response.\n\n", + ); + } + ToolCallFormat::Json => { + out.push_str( + "## Tool Use Protocol\n\n\ + To use a tool, wrap a JSON object in `` tags:\n\n\ + ```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n\ + You may emit multiple `` blocks in a single response.\n\n\ + Use the provided tools to accomplish the task. Reply with a concise, dense \ + final answer when you have one — the parent agent will weave it back into the \ + user-visible response.\n\n", + ); + } + ToolCallFormat::Native => { + out.push_str( + "Use the provided tools via the model's native tool-calling output. \ + Reply with a concise, dense final answer when you have one — the parent \ + agent will weave it back into the user-visible response.\n\n", + ); + } + } + + // 3b. Optional safety preamble. Definitions that do work with real + // side-effects (code_executor, tool_maker, integrations_agent) set + // `omit_safety_preamble = false` so the narrow renderer used to + // silently drop that instruction — we now honour the flag. + // Byte-identical to `SafetySection::build`. + if options.include_safety_preamble { + out.push_str( + "## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.\n\n", + ); + } + + // 3c/3d. `## Available Skills` and `## Connected Integrations` + // are no longer emitted here. Each agent that needs them + // renders its own block in its `prompt.rs` (integrations_agent + // owns the executor voice, orchestrator/welcome own the + // delegator voice). Legacy Inline/File-sourced TOML agents + // that still route through this helper simply don't get + // either block — which matches the fact that none of them + // currently opt in. + + // 4. Workspace so the model knows where it is. Intentionally stable: + // no datetime, no hostname, no pid — see the KV-cache note above. + let _ = writeln!( + out, + "## Workspace\n\nWorking directory: `{}`\n", + workspace_dir.display() + ); + + // 6. Runtime banner — model name only. Stable for the lifetime of + // this sub-agent's definition. + let _ = writeln!(out, "## Runtime\n\nModel: {model_name}"); + + out +} + +/// Ensure the workspace file is up-to-date with the compiled-in default. +/// +/// On first install the file doesn't exist → write it. On subsequent runs +/// we store a hash of the compiled-in content in a sidecar file +/// (`.{filename}.builtin-hash`). If the hash changes (code was updated), +/// the disk file is overwritten so prompt improvements ship automatically. +/// User edits between code releases are preserved — we only overwrite when +/// the built-in default itself changes. +fn sync_workspace_file(workspace_dir: &Path, filename: &str) { + let default_content = default_workspace_file_content(filename); + if default_content.is_empty() { + return; + } + + let path = workspace_dir.join(filename); + let hash_path = workspace_dir.join(format!(".{filename}.builtin-hash")); + + // Compute a simple hash of the current compiled-in content. + let current_hash = { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + default_content.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + + // Read the last-written hash (if any). + let stored_hash = std::fs::read_to_string(&hash_path).unwrap_or_default(); + let stored_hash = stored_hash.trim(); + + if stored_hash == current_hash && path.exists() { + // Built-in hasn't changed and file exists — nothing to do. + return; + } + + // Decide whether to overwrite the existing file. Two safe cases: + // 1. File doesn't exist yet — first install, write the default. + // 2. File exists AND its current hash matches the stored builtin + // hash — the user hasn't edited it since we last wrote it, so + // it's safe to ship the new default. + // Otherwise the file has been hand-edited between releases; leave + // the user's version in place and just update the stored hash so we + // stop re-comparing against the old default on every boot. + let file_exists = path.exists(); + let user_unmodified = if file_exists { + match std::fs::read_to_string(&path) { + Ok(disk) => { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + disk.hash(&mut hasher); + let disk_hash = format!("{:016x}", hasher.finish()); + disk_hash == stored_hash + } + Err(_) => false, + } + } else { + false + }; + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + if !file_exists || user_unmodified { + if let Err(e) = std::fs::write(&path, default_content) { + log::warn!("[agent:prompt] failed to write workspace file {filename}: {e}"); + return; + } + log::info!("[agent:prompt] updated workspace file {filename} (builtin content changed)"); + } else { + log::info!( + "[agent:prompt] keeping user-edited workspace file {filename} (builtin changed but disk contents diverge)" + ); + } + let _ = std::fs::write(&hash_path, ¤t_hash); +} + +/// Inject `filename` from `workspace_dir` into `prompt`, truncated to +/// [`BOOTSTRAP_MAX_CHARS`]. Thin wrapper around +/// [`inject_workspace_file_capped`] for bootstrap-class files +/// (`SOUL.md`, `IDENTITY.md`, `HEARTBEAT.md`). +fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { + inject_workspace_file_capped(prompt, workspace_dir, filename, BOOTSTRAP_MAX_CHARS); +} + +/// Inject `filename` into `prompt` with an explicit character budget. +/// +/// Used directly by callers that want a tighter cap than +/// [`BOOTSTRAP_MAX_CHARS`] — notably `PROFILE.md` and `MEMORY.md` which +/// are user-specific, potentially growing, and do not warrant a full +/// 20K-char budget (see [`USER_FILE_MAX_CHARS`]). +/// +/// Missing / empty files are silently skipped so callers can inject +/// optional files unconditionally without emitting a noisy placeholder. +/// +/// **KV-cache contract:** the output is a pure function of `filename`, +/// file bytes at call time, and `max_chars`. Callers must invoke this +/// once per session — re-reading mid-session breaks the inference +/// backend's automatic prefix cache. See the byte-stability note on +/// [`render_subagent_system_prompt`]. +fn inject_workspace_file_capped( + prompt: &mut String, + workspace_dir: &Path, + filename: &str, + max_chars: usize, +) { + let path = workspace_dir.join(filename); + + match std::fs::read_to_string(&path) { + Ok(content) => { + let trimmed = content.trim(); + if trimmed.is_empty() { + return; + } + let _ = writeln!(prompt, "### {filename}\n"); + let truncated = if trimmed.chars().count() > max_chars { + trimmed + .char_indices() + .nth(max_chars) + .map(|(idx, _)| &trimmed[..idx]) + .unwrap_or(trimmed) + } else { + trimmed + }; + prompt.push_str(truncated); + if truncated.len() < trimmed.len() { + let _ = writeln!( + prompt, + "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" + ); + } else { + prompt.push_str("\n\n"); + } + } + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => { + // Keep prompt focused: missing optional identity/bootstrap files should not + // add noisy placeholders that dilute tool-calling instructions. + } + _ => { + log::debug!("[prompt] failed to read {}: {e}", path.display()); + } + }, + } +} + +fn default_workspace_file_content(filename: &str) -> &'static str { + // The bundled identity files live at `src/openhuman/agent/prompts/` + // (owned by the `agent/` tree because they describe agent identity). + // This module is under `src/openhuman/context/`, so the relative path + // walks up one level and back into `agent/prompts/`. + match filename { + "SOUL.md" => include_str!("SOUL.md"), + "IDENTITY.md" => include_str!("IDENTITY.md"), + "HEARTBEAT.md" => { + "# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n" + } + _ => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::Tool; + use async_trait::async_trait; + use std::collections::HashSet; + use std::sync::LazyLock; + + static NO_FILTER: LazyLock> = LazyLock::new(HashSet::new); + + struct TestTool; + + #[async_trait] + impl Tool for TestTool { + fn name(&self) -> &str { + "test_tool" + } + + fn description(&self) -> &str { + "tool desc" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::openhuman::tools::ToolResult::success("ok")) + } + } + + #[test] + fn prompt_builder_assembles_sections() { + let tools: Vec> = vec![Box::new(TestTool)]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "instr", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + assert!(rendered.contains("## Tools")); + assert!(rendered.contains("test_tool")); + assert!(rendered.contains("instr")); + } + + #[test] + fn identity_section_creates_missing_workspace_files() { + let workspace = + std::env::temp_dir().join(format!("openhuman_prompt_create_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + + let section = IdentitySection; + let _ = section.build(&ctx).unwrap(); + + for file in ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"] { + assert!( + workspace.join(file).exists(), + "expected workspace file to be created: {file}" + ); + } + let soul = std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(); + assert!( + soul.starts_with("# OpenHuman"), + "SOUL.md should be seeded from src/openhuman/agent/prompts/SOUL.md" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn datetime_section_includes_timestamp_and_timezone() { + let tools: Vec> = vec![]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "instr", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + + let rendered = DateTimeSection.build(&ctx).unwrap(); + assert!(rendered.starts_with("## Current Date & Time\n\n")); + + let payload = rendered.trim_start_matches("## Current Date & Time\n\n"); + assert!(payload.chars().any(|c| c.is_ascii_digit())); + assert!(payload.contains(" (")); + assert!(payload.ends_with(')')); + } + + #[test] + fn tools_section_pformat_renders_signature_not_schema() { + // ToolsSection must render `name[arg1|arg2]` signatures when + // `tool_call_format = PFormat`, NOT the verbose JSON schema — + // that's where most of the prompt token saving comes from. + struct ParamTool; + #[async_trait] + impl Tool for ParamTool { + fn name(&self) -> &str { + "make_tea" + } + fn description(&self) -> &str { + "brew a cup of tea" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "kind": { "type": "string" }, + "sugar": { "type": "boolean" } + } + }) + } + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::openhuman::tools::ToolResult::success("ok")) + } + } + + let tools: Vec> = vec![Box::new(ParamTool)]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + + let rendered = ToolsSection.build(&ctx).unwrap(); + // Alphabetical: kind, sugar. + assert!( + rendered.contains("Call as: `make_tea[kind|sugar]`"), + "expected p-format signature in tools section, got:\n{rendered}" + ); + // Should NOT contain the raw JSON schema dump. + assert!( + !rendered.contains("\"properties\""), + "tools section should drop the raw JSON schema in p-format mode, got:\n{rendered}" + ); + } + + #[test] + fn tools_section_uses_pformat_signature_for_every_dispatcher() { + // Tool rendering is uniform across dispatchers: always the + // compact `Call as: name[args]` signature, never a raw JSON + // schema dump. Native tool calls still carry the full schema + // in the provider request; the `Json` / `PFormat` text + // dispatchers supply any extra framing via + // `ctx.dispatcher_instructions`. + let tools: Vec> = vec![Box::new(TestTool)]; + let prompt_tools = PromptTool::from_tools(&tools); + for format in [ + ToolCallFormat::PFormat, + ToolCallFormat::Json, + ToolCallFormat::Native, + ] { + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: format, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = ToolsSection.build(&ctx).unwrap(); + assert!( + rendered.contains("Call as:"), + "{format:?} must use the signature format, got:\n{rendered}" + ); + assert!( + !rendered.contains("Parameters:"), + "{format:?} should never emit the JSON `Parameters:` line, got:\n{rendered}" + ); + } + } + + #[test] + fn user_memory_section_renders_namespaces_with_headings() { + let learned = LearnedContextData { + tree_root_summaries: vec![ + ("user".into(), "Steven prefers terse Rust answers.".into()), + ( + "conversations".into(), + "Recent thread: prompt rework.".into(), + ), + ], + ..Default::default() + }; + let prompt_tools: Vec> = Vec::new(); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned, + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = UserMemorySection.build(&ctx).unwrap(); + assert!(rendered.starts_with("## User Memory\n\n")); + assert!(rendered.contains("### user\n\nSteven prefers terse Rust answers.")); + assert!(rendered.contains("### conversations\n\nRecent thread: prompt rework.")); + } + + #[test] + fn user_memory_section_returns_empty_when_no_summaries() { + // Empty learned context → section returns empty string and is + // skipped by the prompt builder, so the cache boundary stays + // exactly where it was for workspaces with no tree summaries. + let learned = LearnedContextData::default(); + let prompt_tools: Vec> = Vec::new(); + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned, + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = UserMemorySection.build(&ctx).unwrap(); + assert!(rendered.is_empty()); + } + + #[test] + fn render_subagent_system_prompt_renders_workspace_tail() { + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_subagent_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a focused sub-agent.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + + assert!(rendered.contains("## Workspace")); + assert!(rendered.contains("## Runtime")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn subagent_render_options_invert_definition_flags() { + // (omit_identity, omit_safety_preamble, omit_skills_catalog, + // omit_profile, omit_memory_md) + let options = SubagentRenderOptions::from_definition_flags(true, false, true, false, false); + assert!(!options.include_identity); + assert!(options.include_safety_preamble); + assert!(!options.include_skills_catalog); + assert!(options.include_profile); + assert!(options.include_memory_md); + let narrow = SubagentRenderOptions::narrow(); + let default = SubagentRenderOptions::default(); + assert_eq!(narrow.include_identity, default.include_identity); + assert_eq!( + narrow.include_safety_preamble, + default.include_safety_preamble + ); + assert_eq!( + narrow.include_skills_catalog, + default.include_skills_catalog + ); + assert_eq!(narrow.include_profile, default.include_profile); + assert_eq!(narrow.include_memory_md, default.include_memory_md); + // Narrow default = every flag off, including both user files. + assert!(!narrow.include_profile); + assert!(!narrow.include_memory_md); + } + + #[test] + fn render_subagent_system_prompt_honors_identity_safety_and_skills_flags() { + let workspace = + std::env::temp_dir().join(format!("openhuman_prompt_opts_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("SOUL.md"), "# Soul\nContext").unwrap(); + std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nContext").unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt_with_format( + &workspace, + "reasoning-v1", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions { + include_identity: true, + include_safety_preamble: true, + include_skills_catalog: true, + include_profile: false, + include_memory_md: false, + }, + ToolCallFormat::Json, + &[], + ); + + assert!(rendered.contains("## Project Context")); + assert!(rendered.contains("### SOUL.md")); + assert!(rendered.contains("## Safety")); + // Json is a prompt-driven format (the model wraps JSON tool + // calls in `` tags); it does NOT use the provider's + // native function-calling channel. So the prose `## Tools` + // section MUST still be rendered for Json, with each tool's + // parameter schema inline so the model knows what to emit. + // Only `ToolCallFormat::Native` gets the section omitted (see + // the `native` branch below and the `!matches!(…, Native)` + // guard in the renderer). + assert!(rendered.contains("## Tools")); + assert!(rendered.contains("Parameters:")); + assert!(rendered.contains("\"type\"")); + + let native = render_subagent_system_prompt_with_format( + &workspace, + "reasoning-v1", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions::narrow(), + ToolCallFormat::Native, + &[], + ); + assert!(native.contains("native tool-calling output")); + assert!(!native.contains("## Safety")); + // Native is the only format where the prose `## Tools` section + // is intentionally omitted — schemas travel through the + // provider's `tools` field instead. Regression guard against + // the ~54k-token schema duplication from the #447 PR. + assert!(!native.contains("\n## Tools\n")); + assert!(!native.contains("Parameters:")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_injects_profile_md_even_when_identity_omitted() { + // Regression: the welcome agent sets `omit_identity = true` to + // drop the SOUL/IDENTITY preamble (it has its own voice) but it + // still needs PROFILE.md to personalise the greeting. PROFILE.md + // is gated on its own `include_profile` flag so the welcome path + // can opt in without pulling SOUL/IDENTITY back in. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_nosoul_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("SOUL.md"), "# Soul\nShould be hidden").unwrap(); + std::fs::write( + workspace.join("IDENTITY.md"), + "# Identity\nShould be hidden", + ) + .unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nName: Jane Doe\nRole: Data scientist", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the welcome agent.", + SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: false, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + rendered.contains("### PROFILE.md"), + "PROFILE.md header must appear when include_profile=true, got:\n{rendered}" + ); + assert!( + rendered.contains("Jane Doe"), + "PROFILE.md body must be injected when include_profile=true, got:\n{rendered}" + ); + assert!( + !rendered.contains("## Project Context"), + "identity preamble must still be suppressed when include_identity=false" + ); + assert!( + !rendered.contains("### SOUL.md") && !rendered.contains("### IDENTITY.md"), + "SOUL/IDENTITY must still be suppressed when include_identity=false" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_skips_profile_md_when_include_profile_false() { + // Mirror of the opt-in regression above: narrow specialists + // (planner, code_executor, critic, …) set `omit_profile = true` + // and must NOT see PROFILE.md even when the file is on disk — + // otherwise every sub-agent pays the token cost of onboarding + // enrichment output that is irrelevant to their task. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_opt_out_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nName: Jane Doe\nRole: Data scientist", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a narrow specialist.", + SubagentRenderOptions::narrow(), // include_profile defaults to false + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### PROFILE.md"), + "PROFILE.md must NOT appear when include_profile=false, got:\n{rendered}" + ); + assert!( + !rendered.contains("Jane Doe"), + "PROFILE.md body must NOT be leaked when include_profile=false" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_injects_profile_md_when_identity_included() { + // When identity is on, PROFILE.md must still be injected alongside + // SOUL/IDENTITY — the split must not regress the non-welcome path. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_with_identity_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("SOUL.md"), "# Soul\nctx").unwrap(); + std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nctx").unwrap(); + std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nhello").unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions { + include_identity: true, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: false, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!(rendered.contains("## Project Context")); + assert!(rendered.contains("### SOUL.md")); + assert!(rendered.contains("### IDENTITY.md")); + assert!(rendered.contains("### PROFILE.md")); + assert!(rendered.contains("hello")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_silently_skips_missing_profile_md() { + // Pre-onboarding workspaces have no PROFILE.md. The renderer must + // not emit a noisy "[File not found: PROFILE.md]" placeholder or + // an orphan "### PROFILE.md" header — the subagent prompt stays + // focused on tools. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_profile_missing_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the welcome agent.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### PROFILE.md"), + "empty/missing PROFILE.md should not emit a header, got:\n{rendered}" + ); + assert!( + !rendered.contains("[File not found: PROFILE.md]"), + "missing PROFILE.md should be silent, not a noisy placeholder" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn welcome_agent_definition_flags_still_load_profile_md() { + // End-to-end-ish check against the real welcome agent flags: the + // agent.toml sets omit_identity=true/omit_skills_catalog=true/ + // omit_safety_preamble=true/omit_profile=false. Mirror that exact + // combo and verify PROFILE.md still lands in the rendered prompt. + // If someone flips `omit_profile` back to its default (true), this + // test breaks. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_welcome_flags_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nTimezone: PST\nRole: Crypto trader", + ) + .unwrap(); + + // Match `src/openhuman/agent/agents/welcome/agent.toml` exactly. + let options = SubagentRenderOptions::from_definition_flags( + true, // omit_identity + true, // omit_safety_preamble + true, // omit_skills_catalog + false, // omit_profile — welcome opts IN to PROFILE.md + false, // omit_memory_md — welcome opts IN to MEMORY.md too + ); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "# Welcome Agent\n\nYou are the welcome agent.", + options, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + rendered.contains("### PROFILE.md"), + "welcome agent (omit_profile=false) must load PROFILE.md, got:\n{rendered}" + ); + assert!( + rendered.contains("Crypto trader"), + "PROFILE.md body must reach the welcome agent prompt" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn narrow_subagent_definition_flags_skip_profile_md() { + // Inverse of `welcome_agent_definition_flags_still_load_profile_md`: + // a narrow specialist (e.g. `code_executor`, `critic`) leaves + // `omit_profile` at its default `true`. PROFILE.md must NOT be + // injected even when present on disk — the narrow runner is + // task-focused and should not pay the token cost. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_narrow_flags_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nTimezone: PST\nRole: Crypto trader", + ) + .unwrap(); + + // Mirrors e.g. `critic/agent.toml` — all omit_* default-true. + let options = SubagentRenderOptions::from_definition_flags(true, true, true, true, true); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a narrow specialist.", + options, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### PROFILE.md"), + "narrow specialist (omit_profile=true) must NOT load PROFILE.md, got:\n{rendered}" + ); + assert!( + !rendered.contains("Crypto trader"), + "narrow specialist must not leak PROFILE.md body" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_injects_memory_md_when_enabled() { + // Opt-in agents with `omit_memory_md = false` must see MEMORY.md + // (archivist-curated long-term memory) in their rendered prompt. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_memory_on_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("MEMORY.md"), + "# Long-term memory\nUser prefers terse Rust answers.", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the welcome agent.", + SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: false, + include_memory_md: true, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!( + rendered.contains("### MEMORY.md"), + "MEMORY.md header must appear when include_memory_md=true, got:\n{rendered}" + ); + assert!( + rendered.contains("terse Rust answers"), + "MEMORY.md body must be injected when include_memory_md=true" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn render_subagent_system_prompt_skips_memory_md_when_disabled() { + // Narrow specialists with `omit_memory_md = true` (the default) + // must NOT see MEMORY.md even when it exists on disk. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_memory_off_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("MEMORY.md"), + "# Long-term memory\nUser prefers terse Rust answers.", + ) + .unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are a narrow specialist.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + + assert!( + !rendered.contains("### MEMORY.md"), + "MEMORY.md must NOT appear when include_memory_md=false, got:\n{rendered}" + ); + assert!( + !rendered.contains("terse Rust answers"), + "MEMORY.md body must not leak when include_memory_md=false" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn profile_md_and_memory_md_are_capped_at_user_file_max_chars() { + // Both PROFILE.md and MEMORY.md are user-specific files that can + // grow over time. Injection caps them at USER_FILE_MAX_CHARS + // (~1000 tokens each) so the system prompt footprint stays + // bounded. Test both files at once to pin the shared budget. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_user_cap_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + let big = "x".repeat(USER_FILE_MAX_CHARS + 500); + std::fs::write(workspace.join("PROFILE.md"), &big).unwrap(); + std::fs::write(workspace.join("MEMORY.md"), &big).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the orchestrator.", + SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: true, + }, + ToolCallFormat::PFormat, + &[], + ); + + assert!(rendered.contains("### PROFILE.md")); + assert!(rendered.contains("### MEMORY.md")); + // Each file gets its own truncation marker mentioning the cap. + let marker = format!("[... truncated at {USER_FILE_MAX_CHARS} chars"); + assert_eq!( + rendered.matches(marker.as_str()).count(), + 2, + "both PROFILE.md and MEMORY.md must emit the truncation marker at \ + USER_FILE_MAX_CHARS — found:\n{rendered}" + ); + // Sanity-check the cap is genuinely tighter than the bootstrap cap. + assert!(USER_FILE_MAX_CHARS < BOOTSTRAP_MAX_CHARS); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn rendered_subagent_system_prompt_is_byte_stable_across_repeat_calls() { + // KV-cache contract: two spawns of the same sub-agent definition + // against the same workspace must produce byte-identical system + // prompts. If PROFILE.md or MEMORY.md are re-read with a + // different-typed truncation path, or if either cap drifts, the + // bytes differ and the backend's automatic prefix cache busts. + // This test pins the invariant end-to-end. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_byte_stable_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nJane Doe").unwrap(); + std::fs::write(workspace.join("MEMORY.md"), "# Memory\nRecent: shipped v1").unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let opts = SubagentRenderOptions { + include_identity: false, + include_safety_preamble: false, + include_skills_catalog: false, + include_profile: true, + include_memory_md: true, + }; + + let first = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the orchestrator.", + opts, + ToolCallFormat::PFormat, + &[], + ); + let second = render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + &[], + "You are the orchestrator.", + opts, + ToolCallFormat::PFormat, + &[], + ); + + assert_eq!( + first, second, + "repeat spawns must produce byte-identical prompts" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { + // Regression pin for the review finding: the runtime Tauri chat + // path spins welcome/trigger_* via `Agent::from_config_for_agent` + // → `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`, + // which deliberately drops `IdentitySection`. Before + // `UserFilesSection` existed, our PROFILE/MEMORY injection lived + // inside `IdentitySection::build` and got dropped along with it, + // so the first Tauri turn never saw the user's onboarding output + // even though the subagent_runner path and the debug dumper did. + // + // This test exercises the exact builder call-site the runtime + // uses for welcome (`omit_identity = true`, both user-file flags + // opted in via PromptContext) and pins that the rendered prompt + // contains both files. + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_for_subagent_user_files_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write( + workspace.join("PROFILE.md"), + "# User Profile\nJane Doe — crypto trader in PST.", + ) + .unwrap(); + std::fs::write( + workspace.join("MEMORY.md"), + "# Long-term memory\nShipped v1 last sprint; prefers terse Rust.", + ) + .unwrap(); + + let tools: Vec> = vec![]; + let prompt_tools = PromptTool::from_tools(&tools); + let ctx = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: true, + include_memory_md: true, + }; + + // Mirror the welcome agent runtime path: + // `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`. + let builder = SystemPromptBuilder::for_subagent( + "You are the welcome agent.".into(), + true, // omit_identity — drops SOUL/IDENTITY preamble + true, // omit_safety_preamble + true, // omit_skills_catalog + ); + let rendered = builder.build(&ctx).unwrap(); + + assert!( + !rendered.contains("## Project Context"), + "identity preamble must still be suppressed when omit_identity=true" + ); + assert!( + rendered.contains("### PROFILE.md") && rendered.contains("Jane Doe"), + "welcome runtime path must inject PROFILE.md despite omit_identity=true, got:\n{rendered}" + ); + assert!( + rendered.contains("### MEMORY.md") && rendered.contains("terse Rust"), + "welcome runtime path must inject MEMORY.md despite omit_identity=true, got:\n{rendered}" + ); + + // Mirror the narrow-specialist runtime path (code_executor, + // critic, …): both flags off → user files must stay out. + let ctx_narrow = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + agent_id: "", + tools: &prompt_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let narrow = builder.build(&ctx_narrow).unwrap(); + assert!( + !narrow.contains("### PROFILE.md") && !narrow.contains("### MEMORY.md"), + "narrow specialist runtime path must NOT leak user files, got:\n{narrow}" + ); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn sync_workspace_file_updates_hash_and_inject_workspace_file_truncates() { + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_workspace_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + sync_workspace_file(&workspace, "SOUL.md"); + let hash_path = workspace.join(".SOUL.md.builtin-hash"); + assert!(workspace.join("SOUL.md").exists()); + assert!(hash_path.exists()); + let original_hash = std::fs::read_to_string(&hash_path).unwrap(); + + std::fs::write(workspace.join("SOUL.md"), "user override").unwrap(); + sync_workspace_file(&workspace, "SOUL.md"); + assert_eq!(std::fs::read_to_string(&hash_path).unwrap(), original_hash); + assert_eq!( + std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(), + "user override" + ); + + std::fs::write( + workspace.join("BIG.md"), + "x".repeat(BOOTSTRAP_MAX_CHARS + 50), + ) + .unwrap(); + let mut prompt = String::new(); + inject_workspace_file(&mut prompt, &workspace, "BIG.md"); + assert!(prompt.contains("### BIG.md")); + assert!(prompt.contains("[... truncated at")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { + let plain = PromptTool::new("shell", "run commands"); + assert_eq!(plain.name, "shell"); + assert!(plain.parameters_schema.is_none()); + + let with_schema = + PromptTool::with_schema("http_request", "fetch data", "{\"type\":\"object\"}".into()); + assert_eq!( + with_schema.parameters_schema.as_deref(), + Some("{\"type\":\"object\"}") + ); + + let ctx = PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "model", + agent_id: "", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData { + tree_root_summaries: vec![ + ("user".into(), "kept".into()), + ("empty".into(), " ".into()), + ], + ..Default::default() + }, + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + include_profile: false, + include_memory_md: false, + }; + let rendered = UserMemorySection.build(&ctx).unwrap(); + assert!(rendered.contains("### user")); + assert!(!rendered.contains("### empty")); + assert_eq!(default_workspace_file_content("missing"), ""); + } +} diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs new file mode 100644 index 000000000..f9c098ec8 --- /dev/null +++ b/src/openhuman/agent/prompts/types.rs @@ -0,0 +1,232 @@ +//! Data types shared across the prompt-plumbing pipeline. +//! +//! Everything in this file is pure data (structs, enums, traits, +//! constants). The rendering logic — section implementations, +//! `SystemPromptBuilder`, `render_subagent_system_prompt` — lives in +//! the sibling `mod.rs` so type edits don't pull in the whole 2 000-line +//! renderer. + +use crate::openhuman::skills::Skill; +use crate::openhuman::tools::Tool; +use anyhow::Result; +use std::path::Path; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +pub(crate) const BOOTSTRAP_MAX_CHARS: usize = 20_000; + +/// Tight per-file budget for user-specific, potentially growing files — +/// currently `PROFILE.md` (onboarding enrichment output) and `MEMORY.md` +/// (archivist-curated long-term memory). Caps the prompt footprint so +/// either file can reach at most ~1000 tokens (a few % of a typical +/// context window) regardless of how large the on-disk version has +/// grown. +pub(crate) const USER_FILE_MAX_CHARS: usize = 2_000; + +/// Per-namespace cap when injecting tree summarizer root summaries into +/// the prompt. ~8 000 chars ≈ 2 000 tokens — that's the floor the user +/// asked for ("at least 2000 tokens of user memory") for a single +/// namespace, and matches what the tree summarizer's `Day` level +/// already enforces upstream. +pub(crate) const USER_MEMORY_PER_NAMESPACE_MAX_CHARS: usize = 8_000; + +/// Hard ceiling across all namespaces, so a workspace with 30 namespaces +/// doesn't burn the entire context window. ~32 000 chars ≈ 8 000 tokens. +pub(crate) const USER_MEMORY_TOTAL_MAX_CHARS: usize = 32_000; + +// ───────────────────────────────────────────────────────────────────────────── +// Learned context (pre-fetched, not blocking) +// ───────────────────────────────────────────────────────────────────────────── + +/// Pre-fetched learned context data for prompt sections (avoids blocking the runtime). +#[derive(Debug, Clone, Default)] +pub struct LearnedContextData { + /// Recent observations from the learning subsystem. + pub observations: Vec, + /// Recognized patterns. + pub patterns: Vec, + /// Learned user profile entries. + pub user_profile: Vec, + /// Pre-fetched root-level summaries from the tree summarizer, one per + /// namespace that has a root node on disk. Each entry is + /// `(namespace, body)`. Empty when the tree summarizer hasn't run. + pub tree_root_summaries: Vec<(String, String)>, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connected integrations (Composio toolkits) +// ───────────────────────────────────────────────────────────────────────────── + +/// An external integration (e.g. a Composio OAuth-backed toolkit) +/// surfaced in the system prompt so the orchestrator knows which +/// services are available — both **already connected** and **available +/// to authorize**. +#[derive(Debug, Clone)] +pub struct ConnectedIntegration { + /// Toolkit slug, e.g. `"gmail"`, `"notion"`. + pub toolkit: String, + /// Human-readable one-line description of what this integration can do. + pub description: String, + /// Per-action catalogue (only populated when `connected == true`). + pub tools: Vec, + /// Whether the user has an active OAuth connection for this + /// toolkit. When `false`, the toolkit is in the backend allowlist + /// but no authorization has been completed yet — `tools` is empty + /// and the orchestrator must point the user at Settings instead of + /// attempting to delegate. + pub connected: bool, +} + +/// A single action available on a connected integration. +#[derive(Debug, Clone)] +pub struct ConnectedIntegrationTool { + /// Action slug, e.g. `"GMAIL_SEND_EMAIL"`. + pub name: String, + /// One-line description of the action. + pub description: String, + /// JSON schema for the action's parameters. `None` when the backend + /// didn't supply a schema. + pub parameters: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tool descriptor + call-format +// ───────────────────────────────────────────────────────────────────────────── + +/// A lightweight tool descriptor for prompt rendering. +/// +/// Shared shape so every call-site that builds a system prompt can feed +/// the same rendering pipeline — main agents (which own `Box`), +/// sub-agents, and channel runtimes (which only have `(name, +/// description)` tuples) all adapt to this. +#[derive(Debug, Clone)] +pub struct PromptTool<'a> { + pub name: &'a str, + pub description: &'a str, + pub parameters_schema: Option, +} + +impl<'a> PromptTool<'a> { + pub fn new(name: &'a str, description: &'a str) -> Self { + Self { + name, + description, + parameters_schema: None, + } + } + + pub fn with_schema(name: &'a str, description: &'a str, parameters_schema: String) -> Self { + Self { + name, + description, + parameters_schema: Some(parameters_schema), + } + } + + /// Adapt a `Box` slice into a `Vec>`. + pub fn from_tools(tools: &'a [Box]) -> Vec> { + tools + .iter() + .map(|t| PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + }) + .collect() + } +} + +/// How the tool catalogue should render each tool entry. Driven by the +/// dispatcher choice on the agent — JSON-schema rendering is the +/// historic format; P-Format is the new default text protocol. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ToolCallFormat { + /// `tool_name[arg1|arg2|...]` — compact, positional. Default. + #[default] + PFormat, + /// Legacy JSON-in-tag rendering with full schemas. + Json, + /// Provider supplies structured tool calls — catalogue is + /// informational. Renders in the same JSON-schema form as `Json`. + Native, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Prompt context (everything a section needs) +// ───────────────────────────────────────────────────────────────────────────── + +pub struct PromptContext<'a> { + pub workspace_dir: &'a Path, + pub model_name: &'a str, + /// Id of the agent this prompt is being built for. + pub agent_id: &'a str, + pub tools: &'a [PromptTool<'a>], + pub skills: &'a [Skill], + pub dispatcher_instructions: &'a str, + /// Pre-fetched learned context (empty when learning is disabled). + pub learned: LearnedContextData, + /// When non-empty, only tools in this set are rendered. Skills + /// section is also omitted when a filter is active. + pub visible_tool_names: &'a std::collections::HashSet, + pub tool_call_format: ToolCallFormat, + /// Active Composio integrations the user has connected. + pub connected_integrations: &'a [ConnectedIntegration], + /// When `true`, inject `PROFILE.md` (onboarding enrichment output). + pub include_profile: bool, + /// When `true`, inject `MEMORY.md` (archivist-curated long-term + /// memory). Capped at [`USER_FILE_MAX_CHARS`] and frozen per session. + pub include_memory_md: bool, +} + +// ───────────────────────────────────────────────────────────────────────────── +// PromptSection trait + rendered output +// ───────────────────────────────────────────────────────────────────────────── + +pub trait PromptSection: Send + Sync { + fn name(&self) -> &str; + fn build(&self, ctx: &PromptContext<'_>) -> Result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sub-agent render options (per-definition flags) +// ───────────────────────────────────────────────────────────────────────────── + +/// Per-definition rendering flags passed into the sub-agent prompt +/// renderer. Mirrors the `omit_*` fields on +/// [`crate::openhuman::agent::harness::definition::AgentDefinition`] +/// but inverted into positive-sense `include_*` form. +#[derive(Debug, Clone, Copy, Default)] +pub struct SubagentRenderOptions { + pub include_safety_preamble: bool, + pub include_identity: bool, + pub include_skills_catalog: bool, + pub include_profile: bool, + pub include_memory_md: bool, +} + +impl SubagentRenderOptions { + /// Build the narrow default (every section off). + pub fn narrow() -> Self { + Self::default() + } + + /// Construct from per-definition `omit_*` flags, inverting into the + /// positive-sense `include_*` shape. + pub fn from_definition_flags( + omit_identity: bool, + omit_safety_preamble: bool, + omit_skills_catalog: bool, + omit_profile: bool, + omit_memory_md: bool, + ) -> Self { + Self { + include_identity: !omit_identity, + include_safety_preamble: !omit_safety_preamble, + include_skills_catalog: !omit_skills_catalog, + include_profile: !omit_profile, + include_memory_md: !omit_memory_md, + } + } +} diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index 8f9f4feed..466f7eb81 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -157,7 +157,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result anyhow::Result Option { match &def.system_prompt { PromptSource::Inline(body) if !body.is_empty() => Some(body.clone()), + PromptSource::Dynamic(build) => { + use crate::openhuman::context::prompt::{ + ConnectedIntegration, LearnedContextData, PromptContext, PromptTool, ToolCallFormat, + }; + let empty_tools: Vec> = Vec::new(); + let empty_integrations: Vec = Vec::new(); + let empty_visible: std::collections::HashSet = std::collections::HashSet::new(); + let ctx = PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "", + agent_id: &def.id, + tools: &empty_tools, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &empty_visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &empty_integrations, + include_profile: false, + include_memory_md: false, + }; + match build(&ctx) { + Ok(body) if !body.is_empty() => Some(body), + Ok(_) => None, + Err(e) => { + tracing::warn!( + agent_id = %def.id, + error = %e, + "[triage::evaluator] dynamic prompt builder failed" + ); + None + } + } + } _ => None, } } @@ -418,7 +458,7 @@ mod tests { .find(|b| b.id == TRIGGER_TRIAGE_AGENT_ID) .expect("trigger_triage built-in must be registered"); let mut def: AgentDefinition = toml::from_str(builtin.toml).expect("TOML must parse"); - def.system_prompt = PromptSource::Inline(builtin.prompt.to_string()); + def.system_prompt = PromptSource::Dynamic(builtin.prompt_fn); let body = extract_inline_prompt(&def).expect("body should be present"); assert!( body.to_lowercase().contains("trigger"), diff --git a/src/openhuman/channels/runtime/dispatch.rs b/src/openhuman/channels/runtime/dispatch.rs index 4ffd91b5a..26b7eba7a 100644 --- a/src/openhuman/channels/runtime/dispatch.rs +++ b/src/openhuman/channels/runtime/dispatch.rs @@ -531,7 +531,6 @@ mod scoping_tests { tools: scope, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 8, timeout_secs: None, @@ -546,7 +545,7 @@ mod scoping_tests { /// `ToolScope::Wildcard` must yield `None` — the prompt builder /// treats `None` as "no filter, every tool visible", which is the - /// correct behaviour for agents like `skills_agent` that want the + /// correct behaviour for agents like `integrations_agent` that want the /// full skill-category catalogue. Even when extras are present, a /// wildcard agent should not start filtering. #[test] diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index d5e3582ca..76e2227df 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -178,7 +178,7 @@ pub async fn start_channels(config: Config) -> Result<()> { )); } // Composio tool descriptions are intentionally excluded from the main - // agent prompt — those tools are only available to the skills_agent + // agent prompt — those tools are only available to the integrations_agent // subagent via category_filter = "skill". tool_descs.push(( "schedule", @@ -215,7 +215,7 @@ pub async fn start_channels(config: Config) -> Result<()> { None, ); // Filter out Skill-category tools (e.g. Composio, Apify) from the - // main agent prompt — those are only available to the skills_agent + // main agent prompt — those are only available to the integrations_agent // subagent via category_filter = "skill". let non_skill_tools: Vec<&Box> = tools_registry .iter() diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index 8e3f8046a..c93b23994 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -6,10 +6,10 @@ //! tool-calling path can validate arguments before they hit the wire. //! //! These are constructed **dynamically at spawn time** by the sub-agent -//! runner when `skills_agent` is spawned with a `toolkit` argument — +//! runner when `integrations_agent` is spawned with a `toolkit` argument — //! one tool per action in the chosen toolkit. The generic //! [`ComposioExecuteTool`](super::tools::ComposioExecuteTool) dispatcher -//! is deliberately excluded from `skills_agent`'s tool list in that +//! is deliberately excluded from `integrations_agent`'s tool list in that //! path so the model doesn't see two ways to call the same action. //! //! Lifetime: these tools live for the duration of a single sub-agent diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index dbcd59b38..ee3ccb960 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -49,7 +49,9 @@ pub mod types; pub use action_tool::ComposioActionTool; pub use bus::{register_composio_trigger_subscriber, ComposioTriggerSubscriber}; pub use client::{build_composio_client, ComposioClient}; -pub use ops::{fetch_connected_integrations, invalidate_connected_integrations_cache}; +pub use ops::{ + fetch_connected_integrations, fetch_toolkit_actions, invalidate_connected_integrations_cache, +}; pub use periodic::{record_sync_success, start_periodic_sync}; pub use providers::{ all_providers as all_composio_providers, get_provider as get_composio_provider, diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index caf9ab45d..b8d6756b8 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -512,7 +512,7 @@ async fn fetch_connected_integrations_uncached( // Pull the backend allowlist — every toolkit the orchestrator can // possibly suggest, regardless of whether the user has authorized // it yet. This is the universe of valid `toolkit` arguments to - // `spawn_subagent(skills_agent, …)`. + // `spawn_subagent(integrations_agent, …)`. // // On transient backend errors we return `None` instead of a // degraded `Some(Vec::new())` so `fetch_connected_integrations` @@ -571,7 +571,7 @@ async fn fetch_connected_integrations_uncached( // caching connected entries with empty `tools` vectors // would cause `subagent_runner::run_typed_mode` to // build zero dynamic Composio action tools for a - // toolkit-scoped `skills_agent` spawn, silently + // toolkit-scoped `integrations_agent` spawn, silently // leaving the sub-agent with nothing callable. return None; } @@ -639,6 +639,56 @@ async fn fetch_connected_integrations_uncached( Some(integrations) } +/// Just-in-time fetch of every available action for a single Composio +/// toolkit, returned in the [`ConnectedIntegrationTool`] shape the +/// `integrations_agent` spawn path expects. +/// +/// Unlike [`fetch_connected_integrations`] (which bulk-fetches every +/// connected toolkit's tools once per session and caches the result), +/// this helper is uncached and scoped to a single toolkit — meant to +/// be called at `integrations_agent` spawn time so the sub-agent's +/// prompt always reflects the toolkit's current action catalogue. +/// +/// The filter `starts_with("{TOOLKIT}_")` matches +/// `fetch_connected_integrations_uncached`'s own namespacing rule so +/// siblings like `github` / `git` don't leak into each other's buckets. +/// +/// Returns an empty vec when the backend has no actions for the +/// toolkit (valid steady state for a freshly-authorised integration +/// whose catalogue hasn't been published yet). Returns `Err` only for +/// transport / auth failures the caller should surface to the user. +pub async fn fetch_toolkit_actions( + client: &ComposioClient, + toolkit: &str, +) -> anyhow::Result> { + let toolkit_slug = toolkit.trim(); + if toolkit_slug.is_empty() { + anyhow::bail!("fetch_toolkit_actions: toolkit must not be empty"); + } + tracing::debug!(toolkit = %toolkit_slug, "[composio] fetch_toolkit_actions"); + let resp = client + .list_tools(Some(&[toolkit_slug.to_string()])) + .await + .map_err(|e| anyhow::anyhow!("list_tools failed for toolkit `{toolkit_slug}`: {e}"))?; + let action_prefix = format!("{}_", toolkit_slug.to_uppercase()); + let actions: Vec = resp + .tools + .into_iter() + .filter(|t| t.function.name.starts_with(&action_prefix)) + .map(|t| ConnectedIntegrationTool { + name: t.function.name, + description: t.function.description.unwrap_or_default(), + parameters: t.function.parameters, + }) + .collect(); + tracing::debug!( + toolkit = %toolkit_slug, + action_count = actions.len(), + "[composio] fetch_toolkit_actions: done" + ); + Ok(actions) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index 1feca1128..49d4350c9 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -93,8 +93,11 @@ impl Tool for ComposioListConnectionsTool { "composio_list_connections" } fn description(&self) -> &str { - "List the user's active Composio OAuth connections. Each entry has \ - {id, toolkit, status, createdAt}. Status is typically ACTIVE or CONNECTED." + "List the user's **currently-connected** Composio integrations. \ + Only entries with status ACTIVE / CONNECTED are returned; pending, \ + revoked, or failed connections are filtered out. Use this to detect \ + newly-authorised integrations mid-session. Each entry has \ + {id, toolkit, status, createdAt}." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": {}, "additionalProperties": false }) @@ -108,9 +111,23 @@ impl Tool for ComposioListConnectionsTool { async fn execute(&self, _args: Value) -> anyhow::Result { tracing::debug!("[composio] tool list_connections.execute"); match self.client.list_connections().await { - Ok(resp) => Ok(ToolResult::success( - serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), - )), + Ok(mut resp) => { + // Filter server-side-indistinguishable states here — + // callers should only ever see integrations the user + // can actually act on. Matches the same ACTIVE / + // CONNECTED allowlist used by + // `fetch_connected_integrations_uncached` so the tool + // output and the prompt's Delegation Guide agree on + // what counts as "connected". + resp.connections.retain(|c| { + let status = c.status.trim(); + status.eq_ignore_ascii_case("ACTIVE") + || status.eq_ignore_ascii_case("CONNECTED") + }); + Ok(ToolResult::success( + serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), + )) + } Err(e) => Ok(ToolResult::error(format!( "composio_list_connections failed: {e}" ))), diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 0ccc8830d..87505ae96 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -628,10 +628,17 @@ impl Config { } } - if let Ok(enabled) = std::env::var("OPENHUMAN_WEB_SEARCH_ENABLED") - .or_else(|_| std::env::var("WEB_SEARCH_ENABLED")) - { - self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true"); + // `OPENHUMAN_WEB_SEARCH_ENABLED` is intentionally ignored — + // web search is unconditionally registered in the tool set. + // Only the provider / API-key / budget knobs remain + // environment-configurable. Emit a one-shot deprecation warning + // if the caller still sets it so stale scripts surface clearly. + if std::env::var_os("OPENHUMAN_WEB_SEARCH_ENABLED").is_some() { + log::warn!( + "[config] OPENHUMAN_WEB_SEARCH_ENABLED is deprecated and ignored — \ + web search is always registered; use OPENHUMAN_WEB_SEARCH_PROVIDER / \ + API-key / budget env vars instead." + ); } if let Ok(provider) = std::env::var("OPENHUMAN_WEB_SEARCH_PROVIDER") @@ -1406,26 +1413,6 @@ mod tests { } } - #[test] - fn apply_env_overrides_web_search_enabled_parses_values() { - let _g = ENV_LOCK.lock().unwrap(); - clear_env(&["OPENHUMAN_WEB_SEARCH_ENABLED", "WEB_SEARCH_ENABLED"]); - let mut cfg = Config::default(); - unsafe { - std::env::set_var("OPENHUMAN_WEB_SEARCH_ENABLED", "true"); - } - cfg.apply_env_overrides(); - assert!(cfg.web_search.enabled); - unsafe { - std::env::set_var("OPENHUMAN_WEB_SEARCH_ENABLED", "0"); - } - cfg.apply_env_overrides(); - assert!(!cfg.web_search.enabled); - unsafe { - std::env::remove_var("OPENHUMAN_WEB_SEARCH_ENABLED"); - } - } - #[test] fn apply_env_overrides_web_search_provider_and_api_keys() { let _g = ENV_LOCK.lock().unwrap(); diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index ab3997c33..06bf58ecf 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -135,8 +135,6 @@ impl Default for BrowserConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] pub struct HttpRequestConfig { - #[serde(default)] - pub enabled: bool, #[serde(default)] pub allowed_domains: Vec, #[serde(default = "default_http_max_response_size")] @@ -155,8 +153,6 @@ fn default_http_timeout_secs() -> u64 { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct WebSearchConfig { - #[serde(default = "default_true")] - pub enabled: bool, /// Search provider. Valid values: `duckduckgo` (default, free), `brave` (requires `brave_api_key`), /// `parallel` (requires `parallel_api_key`). #[serde(default = "default_web_search_provider")] @@ -189,7 +185,6 @@ fn default_web_search_timeout_secs() -> u64 { impl Default for WebSearchConfig { fn default() -> Self { Self { - enabled: true, provider: default_web_search_provider(), brave_api_key: None, parallel_api_key: None, diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 603d33735..4a078ca6c 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -17,7 +17,7 @@ pub const MODEL_CODING_V1: &str = "coding-v1"; /// and synthesise the final answer from sub-agent outputs. Reasoning-tier /// models are tuned for that decision-heavy workload, so we pin the main /// agent to `reasoning-v1` by default. Sub-agents that actually execute tool -/// calls (e.g. `skills_agent`) explicitly ride on the `agentic` tier via +/// calls (e.g. `integrations_agent`) explicitly ride on the `agentic` tier via /// their `ModelSpec::Hint("agentic")` — see `builtin_definitions.rs`. pub const DEFAULT_MODEL: &str = MODEL_REASONING_V1; diff --git a/src/openhuman/context/debug_dump.rs b/src/openhuman/context/debug_dump.rs index 55b939217..934f13c5b 100644 --- a/src/openhuman/context/debug_dump.rs +++ b/src/openhuman/context/debug_dump.rs @@ -1,114 +1,70 @@ -//! Debug helper that renders the exact system prompt the context engine -//! would produce for a given agent. +//! Debug helper that renders the exact system prompt a live session +//! would see for a given agent. //! -//! This is the canonical entry point shared by: +//! Instead of re-implementing prompt assembly, this module routes +//! through [`Agent::from_config_for_agent`] — the same entry point the +//! Tauri web channel, CLI, and `welcome_proactive` all use — and then +//! calls [`Agent::build_system_prompt`] on the constructed session. The +//! output is byte-identical to what the LLM would receive on turn 1 of +//! that agent. //! -//! * the `openhuman agent dump-prompt` CLI (see [`crate::core::agent_cli`]) -//! * `scripts/debug-agent-prompts.sh` (loops over every built-in) -//! * any future JSON-RPC / tests that need to inspect the assembled prompt +//! Entry points: +//! * [`dump_agent_prompt`] — dump a single agent by id. +//! * [`dump_all_agent_prompts`] — dump every registered agent in one call. //! -//! The function assembles a **real** tool registry (via -//! [`crate::openhuman::tools::all_tools_with_runtime`]) and a **real** -//! [`AgentDefinitionRegistry`], then feeds them through the exact same -//! prompt builders used at runtime — so what you see here is byte-identical -//! to what the LLM would see at spawn time. -//! -//! # Targets -//! -//! * `"main"` (or any non-matching id when `--force-main` is set) → -//! the orchestrator / main-agent prompt assembled via -//! [`super::prompt::SystemPromptBuilder::with_defaults`]. This includes -//! the workspace identity files, tools visible to the main agent, and -//! the skills catalogue. -//! -//! * Any built-in or custom sub-agent id (e.g. `"skills_agent"`, -//! `"orchestrator"`, `"code_executor"`) → -//! [`super::prompt::render_subagent_system_prompt`] with the narrow -//! tool filter and per-definition `omit_*` flags the real runner applies. -//! -//! # Composio coverage -//! -//! When `Config::composio.enabled` is true, the Composio meta-tools -//! (`composio_list_toolkits`, `composio_list_connections`, -//! `composio_authorize`, `composio_list_tools`, `composio_execute`) are -//! registered into the tool list with `ToolCategory::Skill`. Agents whose -//! definition sets `category_filter = Skill` (notably `skills_agent`) will -//! render those tools in their system prompt, so this dump is the fastest -//! way to verify Composio is reaching the skills agent end-to-end. +//! `integrations_agent` is special: it is platform-parameterised and +//! has no meaningful prompt without a `toolkit` argument. Callers must +//! supply one (e.g. `"gmail"`, `"notion"`) via +//! [`DumpPromptOptions::toolkit`]; `dump_all_agent_prompts` expands +//! `integrations_agent` into one dump per currently-connected Composio +//! toolkit. use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::sync::Arc; use anyhow::{anyhow, Context, Result}; -use crate::openhuman::agent::dispatcher::{PFormatToolDispatcher, ToolDispatcher}; use crate::openhuman::agent::harness::definition::{ - AgentDefinition, AgentDefinitionRegistry, PromptSource, ToolScope, -}; -use crate::openhuman::agent::host_runtime::{self, RuntimeAdapter}; -use crate::openhuman::agent::pformat; -use crate::openhuman::composio::client::ComposioClient; -use crate::openhuman::composio::tools::{ - ComposioAuthorizeTool, ComposioExecuteTool, ComposioListConnectionsTool, - ComposioListToolkitsTool, ComposioListToolsTool, + AgentDefinition, AgentDefinitionRegistry, PromptSource, }; +use crate::openhuman::agent::harness::session::Agent; +use crate::openhuman::composio::ComposioActionTool; use crate::openhuman::config::Config; use crate::openhuman::context::prompt::{ - extract_cache_boundary, render_subagent_system_prompt, ConnectedIntegration, - LearnedContextData, PromptContext, PromptTool, SubagentRenderOptions, SystemPromptBuilder, - ToolCallFormat, USER_MEMORY_PER_NAMESPACE_MAX_CHARS, USER_MEMORY_TOTAL_MAX_CHARS, + LearnedContextData, PromptContext, PromptTool, ToolCallFormat, }; -use crate::openhuman::integrations::IntegrationClient; -use crate::openhuman::memory::{self, Memory}; -use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::{self, Tool, ToolCategory}; +use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec}; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- +/// Id reserved for the Composio-backed integrations specialist. +const INTEGRATIONS_AGENT_ID: &str = "integrations_agent"; + /// Inputs for [`dump_agent_prompt`]. #[derive(Debug, Clone)] pub struct DumpPromptOptions { - /// Target agent id — either `"main"` for the main/orchestrator agent, - /// or any id registered in [`AgentDefinitionRegistry`]. + /// Target agent id (any id registered in [`AgentDefinitionRegistry`]). pub agent_id: String, - /// Optional per-spawn skill filter override (e.g. `Some("notion".into())`). - /// Ignored for `"main"`. - pub skill_filter: Option, - /// Optional override for the workspace directory. When `None`, the - /// value from the loaded [`Config`] is used. + /// Composio toolkit to bind this dump to (e.g. `"gmail"`, + /// `"notion"`). **Required** when `agent_id == "integrations_agent"` + /// — the integrations specialist has no meaningful prompt without a + /// toolkit. Must match a currently-connected integration. + pub toolkit: Option, + /// Optional override for the workspace directory. pub workspace_dir_override: Option, - /// Optional override for the resolved model name. When `None`, the - /// value from the loaded [`Config`] is used. Only affects the - /// `## Runtime` line of the rendered prompt. + /// Optional override for the resolved model name. pub model_override: Option, - /// When `true`, always inject the five Composio meta-tool stubs - /// (`composio_list_toolkits`, `composio_list_connections`, - /// `composio_authorize`, `composio_list_tools`, `composio_execute`) - /// into the tool registry before rendering, even if the user is not - /// signed in or `config.composio` is disabled. - /// - /// This is strictly a **dump-time** debug aid: the stubs are real - /// `Tool` impls so their names, descriptions, and parameter schemas - /// render byte-identically to what a signed-in user would see — but - /// calling `execute()` on them would hit a dummy localhost endpoint, - /// so they're safe for prompt inspection only, not for running - /// agents against. Use this to answer "what would `skills_agent` - /// see if Composio were reachable right now?" on a fresh dev - /// machine without wiring up OAuth. - pub stub_composio: bool, } impl DumpPromptOptions { pub fn new(agent_id: impl Into) -> Self { Self { agent_id: agent_id.into(), - skill_filter: None, + toolkit: None, workspace_dir_override: None, model_override: None, - stub_composio: false, } } } @@ -118,1098 +74,438 @@ impl DumpPromptOptions { pub struct DumpedPrompt { /// Echoed from [`DumpPromptOptions::agent_id`]. pub agent_id: String, - /// `"main"` or `"subagent"` — which rendering path produced `text`. + /// Composio toolkit this dump was scoped to (set for + /// `integrations_agent`, `None` for everything else). Lets the CLI + /// / harness differentiate per-toolkit dumps on disk. + pub toolkit: Option, + /// Always `"session"` — dumps come from the live session path. pub mode: &'static str, - /// Resolved model name used in the `## Runtime` section. + /// Resolved model name. pub model: String, /// Workspace directory used for identity file injection. pub workspace_dir: PathBuf, - /// The final rendered system prompt (cache boundary marker already - /// stripped — `cache_boundary` below holds the byte offset instead). + /// The final rendered system prompt — frozen bytes that would be + /// sent verbatim on every turn of a live session. pub text: String, - /// Byte offset of the cache boundary, if the builder inserted one. - /// This is the same value that gets threaded into - /// `ChatRequest::system_prompt_cache_boundary` at runtime. - pub cache_boundary: Option, - /// Every tool that made it into the rendered prompt, in order. - /// Useful for quick assertions in scripts (e.g. "does the - /// skills_agent dump contain `composio_execute`?"). + /// Tool names that made it into the rendered prompt, in order. pub tool_names: Vec, - /// Number of `ToolCategory::Skill` tools included in the dump. + /// Number of `ToolCategory::Skill` tools in the dump. pub skill_tool_count: usize, } -/// Render and return the system prompt for the requested agent. -/// -/// Builds a real tool registry from the loaded [`Config`], loads the -/// full agent-definition registry (built-ins + `agents/*.toml` overrides), -/// resolves the target agent, and runs it through the matching prompt -/// renderer. See the module docs for the full behaviour contract. +/// Render and return the system prompt for a single agent via the +/// real [`Agent::from_config_for_agent`] construction path. pub async fn dump_agent_prompt(options: DumpPromptOptions) -> Result { - tracing::debug!( - agent_id = %options.agent_id, - skill_filter = ?options.skill_filter, - "[debug_dump] rendering prompt" - ); - - // ── Load config + workspace path ────────────────────────────────── - let mut config = Config::load_or_init() - .await - .context("loading Config for prompt dump")?; - config.apply_env_overrides(); - - if let Some(ref override_dir) = options.workspace_dir_override { - config.workspace_dir = override_dir.clone(); - } - let workspace_dir = config.workspace_dir.clone(); - std::fs::create_dir_all(&workspace_dir).ok(); - - let model_name = options.model_override.clone().unwrap_or_else(|| { - config - .default_model - .clone() - .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.to_string()) - }); - - tracing::debug!( - workspace_dir = %workspace_dir.display(), - model = %model_name, - composio_enabled = config.composio.enabled, - "[debug_dump] resolved environment" - ); - - // ── Build a real tool registry ───────────────────────────────────── - let security = Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &workspace_dir, - )); - let runtime: Arc = Arc::from( - host_runtime::create_runtime(&config.runtime) - .context("creating host runtime for prompt dump")?, - ); - let mem: Arc = Arc::from( - memory::create_memory(&config.memory, &workspace_dir, config.api_key.as_deref()) - .context("creating memory backend for prompt dump")?, - ); - - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() - } else { - None - }; - let composio_entity_id = if config.composio.enabled { - Some(config.composio.entity_id.as_str()) - } else { - None - }; - - let mut tools_vec: Vec> = tools::all_tools_with_runtime( - Arc::new(config.clone()), - &security, - runtime, - mem, - composio_key, - composio_entity_id, - &config.browser, - &config.http_request, - &workspace_dir, - &config.agents, - config.api_key.as_deref(), - &config, - ); - - // When requested, inject the Composio meta-tool stubs if (and only - // if) the real registry didn't already bring them in. This lets a - // dev machine without OAuth credentials dump the *intended* prompt - // for `skills_agent` — the names, descriptions and schemas are the - // same bytes a signed-in user would see. See - // [`DumpPromptOptions::stub_composio`] for the safety contract. - if options.stub_composio && !tools_vec.iter().any(|t| t.name().starts_with("composio_")) { - tracing::debug!("[debug_dump] injecting composio meta-tool stubs"); - tools_vec.extend(build_composio_stub_tools()); - } - - tracing::debug!( - tool_count = tools_vec.len(), - "[debug_dump] assembled tool registry" - ); - - // ── Fetch connected integrations ──────────────────────────────────── - let connected_integrations = fetch_connected_integrations_for_dump(&config).await; - - // Load the agent definition registry once. Both the main-agent and - // sub-agent paths consult it now: after #525/#526 the "main" dump - // routes through the same definition-driven filter as every other - // agent so what `dump-prompt main` shows matches what the runtime - // dispatch path actually feeds the LLM. - let registry = AgentDefinitionRegistry::load(&workspace_dir) - .context("loading agent definition registry for prompt dump")?; - - // ── Main agent path ──────────────────────────────────────────────── - if options.agent_id == "main" || options.agent_id == "orchestrator_main" { - let orchestrator = registry.get("orchestrator").cloned().ok_or_else(|| { - anyhow!( - "orchestrator definition not in registry — cannot render main dump. \ - Known agents: [{}]", - registry - .list() - .iter() - .map(|d| d.id.as_str()) - .collect::>() - .join(", ") - ) - })?; - return render_main_agent_dump( - &workspace_dir, - &model_name, - &tools_vec, - &connected_integrations, - ®istry, - &orchestrator, - ); - } - - // ── Sub-agent path ──────────────────────────────────────────────── - let definition = registry - .get(&options.agent_id) - .cloned() - .ok_or_else(|| { - let known: Vec<&str> = registry.list().iter().map(|d| d.id.as_str()).collect(); - anyhow!( - "unknown agent id `{}`. Known agents: [{}] — or pass `main` for the orchestrator prompt.", - options.agent_id, - known.join(", ") - ) - })?; - - render_subagent_dump( - &definition, - &workspace_dir, - &model_name, - &tools_vec, - options.skill_filter.as_deref(), - &connected_integrations, + let config = load_dump_config( + options.workspace_dir_override.clone(), + options.model_override.clone(), ) + .await?; + + // Ensure the registry is populated — `from_config_for_agent` + // errors for any non-orchestrator id when the global registry + // hasn't been initialised. + AgentDefinitionRegistry::init_global(&config.workspace_dir) + .context("initialising AgentDefinitionRegistry for prompt dump")?; + + if options.agent_id == INTEGRATIONS_AGENT_ID { + let toolkit = options.toolkit.as_deref().ok_or_else(|| { + anyhow!( + "integrations_agent requires a `toolkit` argument — e.g. \ + `gmail`, `notion`. See `composio list_connection` for \ + the currently-connected toolkits." + ) + })?; + render_integrations_agent(&config, toolkit).await + } else { + render_via_session(&config, &options.agent_id).await + } +} + +/// Dump every registered agent's system prompt in one shot. +/// +/// The synthetic `fork` archetype is skipped (byte-stable replay, no +/// standalone prompt). `integrations_agent` is expanded into one dump +/// per currently-connected Composio toolkit — if the user has gmail + +/// notion connected, `dump_all_agent_prompts` returns an entry for +/// `integrations_agent@gmail` and another for `integrations_agent@notion`. +/// When no toolkit is connected, `integrations_agent` is omitted +/// entirely (there's nothing meaningful to render). +/// +/// Order follows [`AgentDefinitionRegistry::list`], with +/// `integrations_agent` replaced in place by its per-toolkit expansion. +pub async fn dump_all_agent_prompts( + workspace_dir_override: Option, + model_override: Option, +) -> Result> { + let config = load_dump_config(workspace_dir_override, model_override).await?; + + AgentDefinitionRegistry::init_global(&config.workspace_dir) + .context("initialising AgentDefinitionRegistry for prompt dump")?; + + let registry = AgentDefinitionRegistry::global() + .ok_or_else(|| anyhow!("AgentDefinitionRegistry missing after init"))?; + + let ids: Vec = registry + .list() + .iter() + .filter(|d| d.id != "fork") + .map(|d| d.id.clone()) + .collect(); + + let mut results = Vec::with_capacity(ids.len()); + for id in ids { + if id == INTEGRATIONS_AGENT_ID { + let toolkits = connected_toolkits_for(&config).await?; + if toolkits.is_empty() { + log::info!("[debug_dump] skipping integrations_agent — no connected toolkits"); + continue; + } + for toolkit in toolkits { + let dumped = render_integrations_agent(&config, &toolkit) + .await + .with_context(|| { + format!("rendering integrations_agent prompt for toolkit `{toolkit}`") + })?; + results.push(dumped); + } + continue; + } + + let dumped = render_via_session(&config, &id) + .await + .with_context(|| format!("rendering prompt for agent `{id}`"))?; + results.push(dumped); + } + Ok(results) } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- -/// Build the five Composio meta-tools with a dummy client wired to -/// `http://127.0.0.1:0`. Rendering only calls `name()`, `description()`, -/// and `parameters_schema()` — all of which are static, pure accessors -/// — so the dummy backend URL is never contacted. **Do not** actually -/// execute these tools: calling `.execute()` on a stub would try to -/// POST against the dummy URL. -/// -/// This is only used by [`dump_agent_prompt`] when -/// [`DumpPromptOptions::stub_composio`] is `true`, to let prompt -/// engineers inspect the skills_agent prompt on an unauthed machine. -fn build_composio_stub_tools() -> Vec> { - let inner = Arc::new(IntegrationClient::new( - "http://127.0.0.1:0".to_string(), - "debug-dump-stub-token".to_string(), - )); - let client = ComposioClient::new(inner); - - vec![ - Box::new(ComposioListToolkitsTool::new(client.clone())), - Box::new(ComposioListConnectionsTool::new(client.clone())), - Box::new(ComposioAuthorizeTool::new(client.clone())), - Box::new(ComposioListToolsTool::new(client.clone())), - Box::new(ComposioExecuteTool::new(client)), - ] +async fn load_dump_config( + workspace_dir_override: Option, + model_override: Option, +) -> Result { + let mut config = Config::load_or_init() + .await + .context("loading Config for prompt dump")?; + config.apply_env_overrides(); + if let Some(override_dir) = workspace_dir_override { + config.workspace_dir = override_dir; + } + std::fs::create_dir_all(&config.workspace_dir).ok(); + if let Some(model) = model_override { + config.default_model = Some(model); + } + Ok(config) } -/// Fetch connected integrations for the prompt dump. -/// -/// Delegates to [`crate::openhuman::composio::fetch_connected_integrations`]. -/// The dump script often overrides `OPENHUMAN_WORKSPACE` to a throwaway -/// temp dir which causes config resolution to miss the real user's auth -/// token. We try the caller-supplied config first, then fall back to -/// [`Config::load_from_default_paths`] which bypasses the env var. -async fn fetch_connected_integrations_for_dump(config: &Config) -> Vec { - use crate::openhuman::composio::fetch_connected_integrations; +/// Build a real [`Agent`] via `from_config_for_agent`, populate live +/// connected integrations, and render the turn-1 system prompt. +async fn render_via_session(config: &Config, agent_id: &str) -> Result { + let mut agent = Agent::from_config_for_agent(config, agent_id) + .with_context(|| format!("building session agent for `{agent_id}`"))?; - let result = fetch_connected_integrations(config).await; - if !result.is_empty() { - return result; - } + // Match turn-1 behaviour: fetch the user's active Composio + // connections so the rendered prompt mirrors what the LLM actually + // sees. Best-effort — failures degrade to an empty integration + // list, same as the live runtime. + agent.fetch_connected_integrations().await; - // Fallback: load config from the default user paths (bypasses - // OPENHUMAN_WORKSPACE) so the real auth token is found. - match Config::load_from_default_paths().await { - Ok(c) => fetch_connected_integrations(&c).await, - Err(e) => { - tracing::debug!( - error = %e, - "[debug_dump] fallback config load failed, skipping integrations" - ); - Vec::new() - } - } -} + let text = agent + .build_system_prompt(LearnedContextData::default()) + .with_context(|| format!("rendering system prompt for `{agent_id}`"))?; -fn render_main_agent_dump( - workspace_dir: &Path, - model_name: &str, - tools_vec: &[Box], - connected_integrations: &[ConnectedIntegration], - registry: &AgentDefinitionRegistry, - orchestrator_def: &AgentDefinition, -) -> Result { - // Synthesise per-turn delegation tools the same way `dispatch:: - // resolve_target_agent` does at runtime, so the dump reflects the - // exact tool surface the orchestrator's LLM sees in production: - // the agent's `[tools] named` list ∪ the names of the synthesised - // delegate_* tools (research, plan, run_code, … plus - // delegate_ per connected Composio integration). - let extras = crate::openhuman::tools::orchestrator_tools::collect_orchestrator_tools( - orchestrator_def, - registry, - connected_integrations, - ); + let tools = agent.tools(); + let tool_names: Vec = tools.iter().map(|t| t.name().to_string()).collect(); + let skill_tool_count = tools + .iter() + .filter(|t| t.category() == ToolCategory::Skill) + .count(); - // Build the prompt tool list from the global registry plus the - // synthesised extras. The extras Vec must outlive `prompt_tools` - // because PromptTool borrows tool name + description fields, and - // both sources need to live until `build_with_cache_metadata` - // finishes consuming the PromptContext. - let mut prompt_tools = PromptTool::from_tools(tools_vec); - let extras_prompts = PromptTool::from_tools(&extras); - prompt_tools.extend(extras_prompts); + Ok(DumpedPrompt { + agent_id: agent_id.to_string(), + toolkit: None, + mode: "session", + model: agent.model_name().to_string(), + workspace_dir: agent.workspace_dir().to_path_buf(), + text, + tool_names, + skill_tool_count, + }) +} - // Build the visibility whitelist from the orchestrator definition's - // `[tools] named` list unioned with every synthesised extra. When - // the orchestrator uses `ToolScope::Wildcard` (legacy / custom - // workspace overrides), we fall back to an empty filter — which the - // prompt builder treats as "no filter, every tool visible" — so the - // dump preserves the legacy unscoped behaviour for those agents. - let visible_filter: HashSet = match &orchestrator_def.tools { - ToolScope::Named(names) => { - let mut set: HashSet = names.iter().cloned().collect(); - for tool in &extras { - set.insert(tool.name().to_string()); - } - set +/// Render the integrations_agent prompt bound to a single Composio +/// toolkit. Mirrors the subagent_runner's per-toolkit path: strips +/// Skill-category parent tools, injects one [`ComposioActionTool`] per +/// action in the toolkit, and narrows the `connected_integrations` +/// slice to only the requested toolkit before calling the agent's +/// dynamic prompt builder. +async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result { + let mut agent = Agent::from_config_for_agent(config, INTEGRATIONS_AGENT_ID) + .with_context(|| format!("building integrations_agent session for `{toolkit}`"))?; + agent.fetch_connected_integrations().await; + + let mut integration = agent + .connected_integrations() + .iter() + .find(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(toolkit)) + .cloned() + .ok_or_else(|| { + let connected: Vec = agent + .connected_integrations() + .iter() + .filter(|ci| ci.connected) + .map(|ci| ci.toolkit.clone()) + .collect(); + anyhow!( + "toolkit `{toolkit}` is not connected. Connected toolkits: [{}]", + connected.join(", ") + ) + })?; + + let composio_client = agent + .composio_client() + .cloned() + .ok_or_else(|| anyhow!("composio client unavailable — is the user signed in?"))?; + + // Refresh the action catalogue for this toolkit at prompt-generation + // time so the dump reflects the **current** backend state rather + // than the session-start bulk fetch's snapshot (which can return an + // empty list for some toolkits even when the per-toolkit endpoint + // returns actions). Mirrors subagent_runner's typed-mode fallback: + // an empty fresh list or a network error keeps the cached catalogue + // rather than blanking it. + match crate::openhuman::composio::fetch_toolkit_actions(&composio_client, &integration.toolkit) + .await + { + Ok(actions) if !actions.is_empty() => { + integration.tools = actions; + } + Ok(_) => { + log::debug!( + "[debug_dump] fresh list_tools for `{}` returned empty; keeping cached catalogue ({} actions)", + integration.toolkit, + integration.tools.len() + ); + } + Err(e) => { + log::warn!( + "[debug_dump] fresh list_tools for `{}` failed ({e}); keeping cached catalogue ({} actions)", + integration.toolkit, + integration.tools.len() + ); + } + } + + // Build the tool list that subagent_runner would produce for a + // real spawn. Tool visibility honours the TOML scope on the + // `integrations_agent` definition — `named = [...]` narrows, and + // `wildcard = {}` means "every parent tool". The dynamic + // ComposioActionTools for the bound toolkit are added after. + let definition_snapshot = AgentDefinitionRegistry::global() + .and_then(|reg| reg.get(INTEGRATIONS_AGENT_ID).cloned()) + .ok_or_else(|| anyhow!("integrations_agent definition missing from registry"))?; + let base_tools: Vec> = match &definition_snapshot.tools { + crate::openhuman::agent::harness::definition::ToolScope::Named(names) => { + let allow: HashSet<&str> = names.iter().map(|s| s.as_str()).collect(); + agent + .tools() + .iter() + .filter(|t| allow.contains(t.name())) + .map(|t| clone_tool_as_prompt_proxy(t.as_ref())) + .collect() } - ToolScope::Wildcard => HashSet::new(), + crate::openhuman::agent::harness::definition::ToolScope::Wildcard => agent + .tools() + .iter() + .map(|t| clone_tool_as_prompt_proxy(t.as_ref())) + .collect(), }; + let action_tools: Vec> = integration + .tools + .iter() + .map(|action| -> Box { + Box::new(ComposioActionTool::new( + composio_client.clone(), + action.name.clone(), + action.description.clone(), + action.parameters.clone(), + )) + }) + .collect(); + let mut rendered_tools: Vec> = base_tools; + rendered_tools.extend(action_tools); - // Construct a real PFormatToolDispatcher so the dump includes the - // exact "Tool Use Protocol" preamble the runtime would inject. - // Without this the catalogue still renders with p-format - // signatures (because `tool_call_format = PFormat`), but the - // model doesn't see the protocol description, which is the - // single most important piece of context for *teaching* the - // model how to emit calls correctly. - let pformat_registry = pformat::build_registry(tools_vec); - let pformat_dispatcher = PFormatToolDispatcher::new(pformat_registry); - let dispatcher_instructions = pformat_dispatcher.prompt_instructions(tools_vec); + let prompt_tools: Vec> = rendered_tools + .iter() + .map(|t| PromptTool { + name: t.name(), + description: t.description(), + parameters_schema: Some(t.parameters_schema().to_string()), + }) + .collect(); - // Hydrate the same user-memory blob the runtime would inject on the - // first turn. The dump intentionally bypasses `Agent::fetch_learned_context` - // (which needs a live `Memory` backend and the `learning_enabled` - // flag), but the tree-summarizer side is pure filesystem reads, so - // we can mirror the runtime path byte-for-byte. This is what makes - // `openhuman agent dump-prompt --agent main` show the user memory - // section instead of an empty placeholder when summaries exist on - // disk. - let tree_root_summaries = - crate::openhuman::tree_summarizer::store::collect_root_summaries_with_caps( - workspace_dir, - USER_MEMORY_PER_NAMESPACE_MAX_CHARS, - USER_MEMORY_TOTAL_MAX_CHARS, - ); - tracing::debug!( - namespace_count = tree_root_summaries.len(), - "[debug_dump] hydrated user memory from tree summarizer" - ); - let learned = LearnedContextData { - tree_root_summaries, - ..Default::default() + // Narrow the connected_integrations slice to just the bound + // toolkit so the prompt's Connected Integrations / tool catalogue + // doesn't leak peer toolkits into this sub-agent's context. + let narrow_integrations = vec![integration.clone()]; + + let registry = AgentDefinitionRegistry::global() + .ok_or_else(|| anyhow!("AgentDefinitionRegistry missing after init"))?; + let definition: AgentDefinition = registry + .get(INTEGRATIONS_AGENT_ID) + .cloned() + .ok_or_else(|| anyhow!("integrations_agent definition not in registry"))?; + let build = match &definition.system_prompt { + PromptSource::Dynamic(f) => *f, + _ => { + return Err(anyhow!( + "integrations_agent must use PromptSource::Dynamic; got {:?}", + match &definition.system_prompt { + PromptSource::Inline(_) => "Inline", + PromptSource::File { .. } => "File", + PromptSource::Dynamic(_) => "Dynamic", + } + )); + } }; - // NOTE: the dump runs outside the agent lifecycle — there is no - // live Agent, ToolDispatcher, Memory backend, or SkillEngine. We - // reconstruct the best filesystem-based approximation: - // - // skills: &[] — the dump doesn't boot the QuickJS - // runtime, so installed skills are absent. The - // main agent at runtime would populate this. - // tool_call_format: PFormat — matches the runtime global default. - // If the user sets `agent.tool_dispatcher = "xml"` - // in config, the dump won't reflect that. - // learned: tree_root_summaries only — the learning - // subsystem's observations/patterns/user_profile - // entries require a live Memory backend. - // - // For a byte-exact match you'd need to boot a full Agent and call - // `build_system_prompt`. The dump is intentionally cheaper. + let empty_visible: HashSet = HashSet::new(); + let model_name = definition.model.resolve(agent.model_name()).to_string(); let ctx = PromptContext { - workspace_dir, - model_name, - agent_id: "orchestrator", + workspace_dir: agent.workspace_dir(), + model_name: &model_name, + agent_id: INTEGRATIONS_AGENT_ID, tools: &prompt_tools, - skills: &[], - dispatcher_instructions: &dispatcher_instructions, - learned, - visible_tool_names: &visible_filter, + skills: agent.skills(), + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &empty_visible, tool_call_format: ToolCallFormat::PFormat, - connected_integrations, - include_profile: !orchestrator_def.omit_profile, - include_memory_md: !orchestrator_def.omit_memory_md, + connected_integrations: &narrow_integrations, + include_profile: !definition.omit_profile, + include_memory_md: !definition.omit_memory_md, }; - let rendered = SystemPromptBuilder::with_defaults() - .build_with_cache_metadata(&ctx) - .context("building main-agent prompt")?; + let mut text = build(&ctx) + .with_context(|| format!("building integrations_agent prompt for toolkit `{toolkit}`"))?; - // Report the tool names that survived the visibility filter so - // tests and the CLI dump output reflect what the LLM actually - // sees, not the raw global registry. When the filter is empty - // (Wildcard scope), every tool from registry + extras is reported. - let visible_predicate = |name: &str| -> bool { - if visible_filter.is_empty() { - true - } else { - visible_filter.contains(name) - } - }; - let tool_names: Vec = tools_vec + // Mirror the runner's text-mode mutation: when integrations_agent + // has any tools the runner appends `build_text_mode_tool_instructions` + // to the system message (see `subagent_runner::run_fork_mode` / + // `run_typed_mode`, `force_text_mode` branch). Reproduce it here so + // the dump matches what the LLM actually receives on turn 1. + if !rendered_tools.is_empty() { + let tool_specs: Vec = rendered_tools + .iter() + .map(|t| ToolSpec { + name: t.name().to_string(), + description: t.description().to_string(), + parameters: t.parameters_schema().clone(), + }) + .collect(); + text.push_str("\n\n"); + text.push_str( + &crate::openhuman::agent::harness::subagent_runner::build_text_mode_tool_instructions( + &tool_specs, + ), + ); + } + + let tool_names: Vec = rendered_tools .iter() - .chain(extras.iter()) - .filter(|t| visible_predicate(t.name())) .map(|t| t.name().to_string()) .collect(); - let skill_tool_count = tools_vec + let skill_tool_count = rendered_tools .iter() - .chain(extras.iter()) - .filter(|t| visible_predicate(t.name()) && t.category() == ToolCategory::Skill) + .filter(|t| t.category() == ToolCategory::Skill) .count(); Ok(DumpedPrompt { - agent_id: "main".into(), - mode: "main", - model: model_name.to_string(), - workspace_dir: workspace_dir.to_path_buf(), - text: rendered.text, - cache_boundary: rendered.cache_boundary, + agent_id: INTEGRATIONS_AGENT_ID.to_string(), + toolkit: Some(integration.toolkit.clone()), + mode: "session", + model: model_name, + workspace_dir: agent.workspace_dir().to_path_buf(), + text, tool_names, skill_tool_count, }) } -fn render_subagent_dump( - definition: &AgentDefinition, - workspace_dir: &Path, - model_name: &str, - tools_vec: &[Box], - skill_filter_override: Option<&str>, - connected_integrations: &[ConnectedIntegration], -) -> Result { - // Resolve the archetype prompt body. Inline sources short-circuit - // immediately; file sources walk the workspace override directory - // first, mirroring `subagent_runner::load_prompt_source`. - let archetype_body = match &definition.system_prompt { - PromptSource::Inline(body) => body.clone(), - PromptSource::File { path } => { - let workspace_path = workspace_dir.join("agent").join("prompts").join(path); - if workspace_path.is_file() { - std::fs::read_to_string(&workspace_path).with_context(|| { - format!("reading archetype prompt at {}", workspace_path.display()) - })? - } else { - let workspace_root_path = workspace_dir.join(path); - if workspace_root_path.is_file() { - std::fs::read_to_string(&workspace_root_path).with_context(|| { - format!( - "reading archetype prompt at {}", - workspace_root_path.display() - ) - })? - } else { - // Fall back to the repository-bundled location so the dump - // works on throwaway workspaces (e.g. the script's mktemp - // directory) that haven't had identity files synced yet. - let bundled_path = Path::new("src/openhuman/agent/prompts").join(path); - if bundled_path.is_file() { - std::fs::read_to_string(&bundled_path).with_context(|| { - format!( - "reading bundled archetype prompt at {}", - bundled_path.display() - ) - })? - } else { - tracing::warn!( - path = %path, - "[debug_dump] archetype prompt file not found, using empty body" - ); - String::new() - } - } - } - } - }; - - let model = definition.model.resolve(model_name); - let effective_skill_filter = skill_filter_override.or(definition.skill_filter.as_deref()); - - // Apply exactly the same filter the real runner uses so the dump - // reflects what the sub-agent actually sees. - let allowed_indices = filter_tool_indices_for_dump( - tools_vec, - &definition.tools, - &definition.disallowed_tools, - effective_skill_filter, - definition.category_filter, - ); - - let options = SubagentRenderOptions::from_definition_flags( - definition.omit_identity, - definition.omit_safety_preamble, - definition.omit_skills_catalog, - definition.omit_profile, - definition.omit_memory_md, - ); - - // Debug dump runs outside the agent lifecycle, so there's no - // dynamic per-action toolkit to inject and no live dispatcher to - // read the format from. Use empty extra_tools and the legacy - // PFormat default to preserve existing dump output for - // contributors who diff against committed snapshots. - let no_extra_tools: Vec> = Vec::new(); - let raw = render_subagent_system_prompt( - workspace_dir, - &model, - &allowed_indices, - tools_vec, - &no_extra_tools, - &archetype_body, - options, - crate::openhuman::context::prompt::ToolCallFormat::PFormat, - connected_integrations, - ); - let rendered = extract_cache_boundary(&raw); - - let tool_names: Vec = allowed_indices - .iter() - .map(|&i| tools_vec[i].name().to_string()) - .collect(); - let skill_tool_count = allowed_indices - .iter() - .filter(|&&i| tools_vec[i].category() == ToolCategory::Skill) - .count(); - - tracing::debug!( - agent_id = %definition.id, - tool_count = tool_names.len(), - skill_tool_count, - cache_boundary = ?rendered.cache_boundary, - "[debug_dump] sub-agent render complete" - ); - - Ok(DumpedPrompt { - agent_id: definition.id.clone(), - mode: "subagent", - model, - workspace_dir: workspace_dir.to_path_buf(), - text: rendered.text, - cache_boundary: rendered.cache_boundary, - tool_names, - skill_tool_count, +/// Wrap a `&dyn Tool` as a `Box` proxy that forwards +/// `name()` / `description()` / `parameters_schema()` / `category()` +/// — enough surface for prompt rendering. `execute` is intentionally +/// left as a no-op error since dumps never call it. +fn clone_tool_as_prompt_proxy(source: &dyn Tool) -> Box { + Box::new(PromptProxyTool { + name: source.name().to_string(), + description: source.description().to_string(), + schema: source.parameters_schema(), + category: source.category(), }) } -/// Standalone copy of the filter logic in -/// [`crate::openhuman::agent::harness::subagent_runner`] so this debug -/// module does not depend on crate-private items. Kept in lockstep with -/// the real `filter_tool_indices` — if you change the order or semantics -/// there, update this function too (and the unit tests below). -fn filter_tool_indices_for_dump( - parent_tools: &[Box], - scope: &ToolScope, - disallowed: &[String], - skill_filter: Option<&str>, - category_filter: Option, -) -> Vec { - let disallow_set: HashSet<&str> = disallowed.iter().map(|s| s.as_str()).collect(); - let skill_prefix = skill_filter.map(|s| format!("{s}__")); - - parent_tools - .iter() - .enumerate() - .filter(|(_, tool)| { - let name = tool.name(); - if disallow_set.contains(name) { - return false; - } - if let Some(required) = category_filter { - if tool.category() != required { - return false; - } - } - if let Some(prefix) = skill_prefix.as_deref() { - if !name.starts_with(prefix) { - return false; - } - } - match scope { - ToolScope::Wildcard => true, - ToolScope::Named(allowed) => allowed.iter().any(|n| n == name), - } - }) - .map(|(i, _)| i) - .collect() +struct PromptProxyTool { + name: String, + description: String, + schema: serde_json::Value, + category: ToolCategory, } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::agent::harness::definition::{ - DefinitionSource, ModelSpec, PromptSource, SandboxMode, - }; - use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; - use async_trait::async_trait; - - /// Minimal tool stub with a configurable category — enough for the - /// filter/render unit tests below. - struct StubTool { - name: &'static str, - category: ToolCategory, +#[async_trait::async_trait] +impl Tool for PromptProxyTool { + fn name(&self) -> &str { + &self.name } - - #[async_trait] - impl Tool for StubTool { - fn name(&self) -> &str { + fn description(&self) -> &str { + &self.description + } + fn parameters_schema(&self) -> serde_json::Value { + self.schema.clone() + } + fn category(&self) -> ToolCategory { + self.category + } + fn permission_level(&self) -> crate::openhuman::tools::PermissionLevel { + crate::openhuman::tools::PermissionLevel::None + } + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Err(anyhow!( + "PromptProxyTool (`{}`) is a render-only stub — execute is not callable", self.name - } - fn description(&self) -> &str { - "stub tool used by debug_dump tests" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ "type": "object" }) - } - fn category(&self) -> ToolCategory { - self.category - } - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::None - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::success("ok")) - } - } - - fn skills_agent_def() -> AgentDefinition { - AgentDefinition { - id: "skills_agent".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::Inline( - "# Skills Agent\n\nYou execute skill-category tools.".into(), - ), - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: false, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.4, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: Some(ToolCategory::Skill), - extra_tools: vec![], - max_iterations: 8, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - } - } - - #[test] - fn filter_respects_category_filter() { - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "composio_execute", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - ]; - - let indices = filter_tool_indices_for_dump( - &tools, - &ToolScope::Wildcard, - &[], - None, - Some(ToolCategory::Skill), - ); - - let names: Vec<&str> = indices.iter().map(|&i| tools[i].name()).collect(); - assert_eq!(names, vec!["composio_execute", "notion__create_page"]); - } - - #[test] - fn render_skills_agent_dump_contains_composio_tool() { - // Simulates: `openhuman agent dump-prompt --agent skills_agent` - // with a stub registry that mirrors what the real Composio - // integration registers. This guards the end-to-end property - // the user cares about: skills_agent must see composio tools. - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "composio_list_toolkits", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "composio_execute", - category: ToolCategory::Skill, - }), - ]; - - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_dump_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let definition = skills_agent_def(); - let dumped = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .expect("skills_agent prompt should render"); - - assert_eq!(dumped.mode, "subagent"); - assert!( - dumped.tool_names.iter().any(|n| n == "composio_execute"), - "skills_agent dump missing composio_execute; got: {:?}", - dumped.tool_names - ); - assert!( - !dumped.tool_names.iter().any(|n| n == "shell"), - "skills_agent dump should not include system tools; got: {:?}", - dumped.tool_names - ); - assert!( - dumped.text.contains("composio_execute"), - "rendered prompt body missing composio_execute — composio toolkit is not reaching the skills agent" - ); - assert!( - dumped.text.contains("## Safety"), - "skills_agent dump should include the safety preamble (omit_safety_preamble = false)" - ); - assert_eq!(dumped.skill_tool_count, 2); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_with_skill_filter_narrows_to_one_integration() { - let tools: Vec> = vec![ - Box::new(StubTool { - name: "composio_execute", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "gmail__send_email", - category: ToolCategory::Skill, - }), - ]; - - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_dump_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let definition = skills_agent_def(); - let dumped = render_subagent_dump( - &definition, - &workspace, - "reasoning-v1", - &tools, - Some("notion"), - &[], - ) - .expect("filtered dump should render"); - - assert_eq!(dumped.tool_names, vec!["notion__create_page"]); - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn dump_prompt_options_new_sets_expected_defaults() { - let options = DumpPromptOptions::new("skills_agent"); - assert_eq!(options.agent_id, "skills_agent"); - assert_eq!(options.skill_filter, None); - assert_eq!(options.workspace_dir_override, None); - assert_eq!(options.model_override, None); - assert!(!options.stub_composio); - } - - #[test] - fn composio_stub_tools_have_expected_names() { - let names: Vec = build_composio_stub_tools() - .into_iter() - .map(|tool| tool.name().to_string()) - .collect(); - assert_eq!( - names, - vec![ - "composio_list_toolkits", - "composio_list_connections", - "composio_authorize", - "composio_list_tools", - "composio_execute", - ] - ); - } - - /// Helper: build a minimal AgentDefinition + registry pair the - /// `render_main_agent_dump` tests can use. The "orchestrator" entry - /// here is wildcard-scoped with no subagents so the dump runs in - /// its legacy unfiltered shape (every tool from `tools_vec` shows - /// up). Tests that exercise the per-agent filter use - /// `orchestrator_def_with_named_scope` below. - fn wildcard_orchestrator_def() -> AgentDefinition { - AgentDefinition { - id: "orchestrator".into(), - when_to_use: "test".into(), - display_name: None, - system_prompt: PromptSource::Inline(String::new()), - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.4, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 8, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - } - } - - fn registry_with_orchestrator(orch: AgentDefinition) -> AgentDefinitionRegistry { - let mut reg = AgentDefinitionRegistry::default(); - reg.insert(orch); - reg - } - - /// Wildcard scope path: the dump should report every tool from - /// `tools_vec` (no filter applied) and produce the standard - /// system-prompt skeleton with the Tool Use Protocol section. This - /// preserves the legacy main-dump assertions for orchestrator - /// definitions that opt out of the named filter. - #[test] - fn render_main_agent_dump_wildcard_scope_shows_full_tool_set() { - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_main_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nContext").unwrap(); - std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nContext").unwrap(); - std::fs::write(workspace.join("USER.md"), "# User\nContext").unwrap(); - std::fs::write(workspace.join("HEARTBEAT.md"), "# Heartbeat\nContext").unwrap(); - - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - ]; - - let orch = wildcard_orchestrator_def(); - let registry = registry_with_orchestrator(orch.clone()); - let dumped = - render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[], ®istry, &orch) - .unwrap(); - assert_eq!(dumped.mode, "main"); - assert_eq!(dumped.model, "reasoning-v1"); - assert_eq!(dumped.tool_names, vec!["shell", "notion__create_page"]); - assert_eq!(dumped.skill_tool_count, 1); - assert!(dumped.text.contains("## Tools")); - assert!(dumped.text.contains("Tool Use Protocol")); - assert!(dumped.cache_boundary.is_some()); - - let _ = std::fs::remove_dir_all(workspace); - } - - /// Named-scope path (the new behaviour after #525/#526): the - /// orchestrator definition restricts the LLM to a small whitelist, - /// and the dump must reflect that — `shell` is in `tools_vec` but - /// not in the orchestrator's `named` list, so it must NOT appear - /// in `tool_names`. This is the regression guard for the - /// "render_main_agent_dump uses empty_filter so the catalogue - /// leaks" bug at the heart of #526. - #[test] - fn render_main_agent_dump_named_scope_filters_to_whitelist() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_debug_main_named_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "query_memory", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "GMAIL_SEND_EMAIL", - category: ToolCategory::Skill, - }), - ]; - - let mut orch = wildcard_orchestrator_def(); - orch.tools = ToolScope::Named(vec!["query_memory".into(), "ask_user_clarification".into()]); - let registry = registry_with_orchestrator(orch.clone()); - - let dumped = - render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[], ®istry, &orch) - .unwrap(); - - // `shell` and `GMAIL_SEND_EMAIL` are in the global tools_vec - // but NOT in the orchestrator's named whitelist → must be - // excluded from the dump output. Only `query_memory` survives - // (the other named entry, `ask_user_clarification`, isn't in - // tools_vec at all so nothing to render for it). - assert_eq!(dumped.tool_names, vec!["query_memory"]); - assert_eq!(dumped.skill_tool_count, 0); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn filter_respects_named_scope_and_disallowed_tools() { - let tools: Vec> = vec![ - Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - }), - Box::new(StubTool { - name: "notion__create_page", - category: ToolCategory::Skill, - }), - Box::new(StubTool { - name: "gmail__send_email", - category: ToolCategory::Skill, - }), - ]; - - let indices = filter_tool_indices_for_dump( - &tools, - &ToolScope::Named(vec!["shell".into(), "gmail__send_email".into()]), - &["shell".into()], - None, - None, - ); - - let names: Vec<&str> = indices.iter().map(|&i| tools[i].name()).collect(); - assert_eq!(names, vec!["gmail__send_email"]); - } - - #[test] - fn render_subagent_dump_supports_file_prompt_fallbacks() { - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_file_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - })]; - - let definition = AgentDefinition { - id: "file_agent".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::File { - path: "USER.md".into(), - }, - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.0, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 2, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - }; - - let dumped = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(dumped.text.contains("## Tools")); - assert!(dumped.text.contains("OpenHuman")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_dump_handles_missing_file_prompt_without_panicking() { - let workspace = - std::env::temp_dir().join(format!("openhuman_debug_missing_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - })]; - - let definition = AgentDefinition { - id: "missing_prompt".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::File { - path: "does-not-exist.md".into(), - }, - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.0, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 2, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - }; - - let dumped = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(dumped.text.contains("## Tools")); - assert!(!dumped.text.contains("does-not-exist")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_dump_prefers_workspace_prompt_locations() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_debug_workspace_prompt_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(workspace.join("agent/prompts")).unwrap(); - std::fs::write( - workspace.join("agent/prompts/custom.md"), - "Workspace agent prompt", - ) - .unwrap(); - std::fs::write(workspace.join("root.md"), "Workspace root prompt").unwrap(); - - let tools: Vec> = vec![Box::new(StubTool { - name: "shell", - category: ToolCategory::System, - })]; - - let mut definition = AgentDefinition { - id: "workspace_file".into(), - when_to_use: "t".into(), - display_name: None, - system_prompt: PromptSource::File { - path: "custom.md".into(), - }, - omit_identity: true, - omit_memory_context: true, - omit_safety_preamble: true, - omit_skills_catalog: true, - omit_profile: true, - omit_memory_md: true, - model: ModelSpec::Inherit, - temperature: 0.0, - tools: ToolScope::Wildcard, - disallowed_tools: vec![], - skill_filter: None, - category_filter: None, - extra_tools: vec![], - max_iterations: 2, - timeout_secs: None, - sandbox_mode: SandboxMode::None, - background: false, - uses_fork_context: false, - subagents: vec![], - delegate_name: None, - source: DefinitionSource::Builtin, - }; - - let agent_prompt = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(agent_prompt.text.contains("Workspace agent prompt")); - - definition.id = "workspace_root".into(); - definition.system_prompt = PromptSource::File { - path: "root.md".into(), - }; - let root_prompt = - render_subagent_dump(&definition, &workspace, "reasoning-v1", &tools, None, &[]) - .unwrap(); - assert!(root_prompt.text.contains("Workspace root prompt")); - - let _ = std::fs::remove_dir_all(workspace); + )) } } + +/// Return the slugs of every currently-connected Composio toolkit. +/// Used by [`dump_all_agent_prompts`] to decide how many times to +/// render `integrations_agent`. Empty when the user is not signed in +/// or has no active connections. +async fn connected_toolkits_for(config: &Config) -> Result> { + // Spin up a throwaway integrations_agent session just so we can + // reuse its `fetch_connected_integrations` cache — the call is + // deduped backend-side via `INTEGRATIONS_CACHE`, so repeated + // invocations in `dump_all_agent_prompts` only hit the wire once. + let mut agent = Agent::from_config_for_agent(config, INTEGRATIONS_AGENT_ID) + .with_context(|| "building integrations_agent probe session for toolkit discovery")?; + agent.fetch_connected_integrations().await; + Ok(agent + .connected_integrations() + .iter() + .filter(|ci| ci.connected) + .map(|ci| ci.toolkit.clone()) + .collect()) +} + +// `config` usage is currently limited to plumbing; keep the path-level +// reference alive so future hooks (overrides, scoped workspaces) can +// extend from here without touching every call site. +#[allow(dead_code)] +fn _keep_path_imports_alive(_p: &Path) {} diff --git a/src/openhuman/context/manager.rs b/src/openhuman/context/manager.rs index f68d50350..eadcd62df 100644 --- a/src/openhuman/context/manager.rs +++ b/src/openhuman/context/manager.rs @@ -33,7 +33,7 @@ use std::sync::Arc; use super::pipeline::{ ContextPipeline, ContextPipelineConfig, PipelineOutcome, SessionMemoryHandle, }; -use super::prompt::{PromptContext, RenderedPrompt, SystemPromptBuilder}; +use super::prompt::{PromptContext, SystemPromptBuilder}; use super::session_memory::SessionMemoryConfig; use super::summarizer::{Summarizer, SummaryStats}; use crate::openhuman::config::ContextConfig; @@ -230,20 +230,15 @@ impl ContextManager { /// Assemble the opening system prompt for a session using the /// manager's default [`SystemPromptBuilder`]. + /// + /// The returned bytes are the full system prompt, intended to be + /// built once at session start and reused verbatim on every turn — + /// the inference backend's prefix cache picks up the stable prefix + /// automatically, so no boundary marker is emitted. pub fn build_system_prompt(&self, ctx: &PromptContext<'_>) -> Result { self.default_prompt_builder.build(ctx) } - /// Assemble the opening system prompt for a session using the - /// manager's default builder and preserve cache-boundary metadata - /// for provider request prefix caching. - pub fn build_system_prompt_with_cache_metadata( - &self, - ctx: &PromptContext<'_>, - ) -> Result { - self.default_prompt_builder.build_with_cache_metadata(ctx) - } - /// Assemble the system prompt via a caller-supplied builder. /// /// Sub-agents pass `SystemPromptBuilder::for_subagent(...)` and diff --git a/src/openhuman/context/mod.rs b/src/openhuman/context/mod.rs index e255d7a15..b4a0e0ee9 100644 --- a/src/openhuman/context/mod.rs +++ b/src/openhuman/context/mod.rs @@ -47,8 +47,8 @@ pub use microcompact::{ pub use pipeline::{ContextPipeline, ContextPipelineConfig, PipelineOutcome}; pub use prompt::{ ArchetypePromptSection, DateTimeSection, IdentitySection, LearnedContextData, PromptContext, - PromptSection, PromptTool, RuntimeSection, SafetySection, SkillsSection, SystemPromptBuilder, - ToolsSection, WorkspaceSection, + PromptSection, PromptTool, RuntimeSection, SafetySection, SystemPromptBuilder, ToolsSection, + WorkspaceSection, }; pub use session_memory::{ SessionMemoryConfig, SessionMemoryState, ARCHIVIST_EXTRACTION_PROMPT, DEFAULT_MIN_TOKEN_GROWTH, diff --git a/src/openhuman/context/prompt.rs b/src/openhuman/context/prompt.rs index ea98ff140..2220807f2 100644 --- a/src/openhuman/context/prompt.rs +++ b/src/openhuman/context/prompt.rs @@ -1,2388 +1,11 @@ -use crate::openhuman::skills::Skill; -use crate::openhuman::tools::Tool; -use anyhow::Result; -use chrono::Local; -use std::fmt::Write; -use std::path::Path; - -const BOOTSTRAP_MAX_CHARS: usize = 20_000; - -/// Tight per-file budget for user-specific, potentially growing files — -/// currently `PROFILE.md` (onboarding enrichment output) and `MEMORY.md` -/// (archivist-curated long-term memory). Caps the prompt footprint so -/// either file can reach at most ~1000 tokens (a few % of a typical -/// context window) regardless of how large the on-disk version has -/// grown. Matches the [`render_subagent_system_prompt`] byte-stability -/// contract because it is a compile-time constant. -const USER_FILE_MAX_CHARS: usize = 2_000; -const CACHE_BOUNDARY_MARKER: &str = ""; - -/// Per-namespace cap when injecting tree summarizer root summaries into -/// the prompt. ~8 000 chars ≈ 2 000 tokens — that's the floor the user -/// asked for ("at least 2000 tokens of user memory") for a single -/// namespace, and matches what the tree summarizer's `Day` level -/// already enforces upstream. -pub(crate) const USER_MEMORY_PER_NAMESPACE_MAX_CHARS: usize = 8_000; - -/// Hard ceiling across all namespaces, so a workspace with 30 namespaces -/// doesn't burn the entire context window. ~32 000 chars ≈ 8 000 tokens. -pub(crate) const USER_MEMORY_TOTAL_MAX_CHARS: usize = 32_000; - -/// Pre-fetched learned context data for prompt sections (avoids blocking the runtime). -#[derive(Debug, Clone, Default)] -pub struct LearnedContextData { - /// Recent observations from the learning subsystem. - pub observations: Vec, - /// Recognized patterns. - pub patterns: Vec, - /// Learned user profile entries. - pub user_profile: Vec, - /// Pre-fetched root-level summaries from the tree summarizer, one per - /// namespace that has a root node on disk. - /// - /// Each entry is `(namespace, body)`. The body is the markdown body of - /// `memory/namespaces/{ns}/tree/root.md` — already truncated to a - /// per-namespace cap by the fetcher so the section can render without - /// any further sizing logic. - /// - /// Empty when the tree summarizer has never run on this workspace - /// (the section then renders nothing and is dropped from the prompt). - pub tree_root_summaries: Vec<(String, String)>, -} - -/// An external integration (e.g. a Composio OAuth-backed toolkit) -/// surfaced in the system prompt so the orchestrator knows which -/// services are available — both **already connected** and **available -/// to authorize**. Delegation guidance differs by status: -/// -/// - **Connected**: the orchestrator may `spawn_subagent(skills_agent, -/// toolkit=…)` directly. `tools` carries the per-action catalogue -/// used to dynamically register native tool-calling schemas inside -/// the spawned sub-agent. -/// - **Not connected**: the orchestrator must NOT delegate. Instead it -/// tells the user to authorize the toolkit in Settings → -/// Integrations. `tools` is empty in this case. -#[derive(Debug, Clone)] -pub struct ConnectedIntegration { - /// Toolkit slug, e.g. `"gmail"`, `"notion"`. - pub toolkit: String, - /// Human-readable one-line description of what this integration can do. - pub description: String, - /// Per-action catalogue (only populated when `connected == true`). - pub tools: Vec, - /// Whether the user has an active OAuth connection for this - /// toolkit. When `false`, the toolkit is in the backend allowlist - /// but no authorization has been completed yet — `tools` is empty - /// and the orchestrator must point the user at Settings instead - /// of attempting to delegate. - pub connected: bool, -} - -/// A single action available on a connected integration. -#[derive(Debug, Clone)] -pub struct ConnectedIntegrationTool { - /// Action slug, e.g. `"GMAIL_SEND_EMAIL"`. - pub name: String, - /// One-line description of the action. - pub description: String, - /// JSON schema for the action's parameters, carried through from - /// the backend tool-list response. `None` when the backend didn't - /// supply a schema. Consumed by [`ComposioActionTool`] when the - /// subagent runner dynamically registers per-action tools for a - /// `skills_agent(toolkit=…)` spawn — the schema becomes the - /// native tool-calling signature the LLM sees. - pub parameters: Option, -} - -/// A lightweight tool descriptor for prompt rendering. -/// -/// Shared shape so every call-site that builds a system prompt — main -/// agents (which own `Box`), sub-agents, and channel runtimes -/// (which only have `(name, description)` tuples from their tool -/// registries) — can feed the same [`ToolsSection`] implementation -/// instead of each writing its own. Callers adapt their own tool -/// representation into a `Vec>` at the PromptContext -/// construction site via a one-line `.iter().map(...).collect()` or via -/// [`PromptTool::from_tools`]. -/// -/// `parameters_schema` is optional because channel runtimes don't have -/// full JSON schemas at prompt-build time; the tools section renders -/// the schema line only when it's present. -#[derive(Debug, Clone)] -pub struct PromptTool<'a> { - pub name: &'a str, - pub description: &'a str, - pub parameters_schema: Option, -} - -impl<'a> PromptTool<'a> { - pub fn new(name: &'a str, description: &'a str) -> Self { - Self { - name, - description, - parameters_schema: None, - } - } - - pub fn with_schema(name: &'a str, description: &'a str, parameters_schema: String) -> Self { - Self { - name, - description, - parameters_schema: Some(parameters_schema), - } - } - - /// Adapt a `Box` slice into a `Vec>`. The - /// returned vector borrows names and descriptions from the original - /// tools, so it must not outlive them. Main-agent call-sites use - /// this one-liner to build the slice passed into [`PromptContext::tools`]. - pub fn from_tools(tools: &'a [Box]) -> Vec> { - tools - .iter() - .map(|t| PromptTool { - name: t.name(), - description: t.description(), - parameters_schema: Some(t.parameters_schema().to_string()), - }) - .collect() - } -} - -/// How the [`ToolsSection`] should render each tool entry. Driven by -/// the dispatcher choice on the agent — JSON-schema rendering is the -/// historic format; P-Format is the new default text protocol. -/// -/// `Native` is for providers that ship structured tool calls directly, -/// in which case the catalogue body is informational only and the -/// renderer falls back to JSON-schema (it's the most descriptive form). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum ToolCallFormat { - /// `tool_name[arg1|arg2|...]` — compact, positional, ~80% fewer - /// tokens than JSON. The default. - #[default] - PFormat, - /// Legacy JSON-in-tag rendering. Each tool entry shows the full - /// JSON schema. Kept for backwards compatibility with prompts - /// tuned against the old format. - Json, - /// Provider supplies structured tool calls — the catalogue is - /// informational. Renders in the same JSON-schema form as `Json`. - Native, -} - -pub struct PromptContext<'a> { - pub workspace_dir: &'a Path, - pub model_name: &'a str, - /// Id of the agent this prompt is being built for (e.g. `orchestrator`, - /// `skills_agent`, `welcome`). Drives per-agent rendering decisions — - /// notably [`ConnectedIntegrationsSection`], which dumps the full - /// Composio tool catalog only for `skills_agent` and leaves delegating - /// agents with a toolkit-only summary. Empty string means "unknown", - /// which is treated as a delegating agent. - pub agent_id: &'a str, - pub tools: &'a [PromptTool<'a>], - pub skills: &'a [Skill], - pub dispatcher_instructions: &'a str, - /// Pre-fetched learned context (empty when learning is disabled). - pub learned: LearnedContextData, - /// When non-empty, only tools in this set are rendered in the prompt. - /// Skills section is omitted when a filter is active (the main agent - /// delegates skill work to sub-agents). - pub visible_tool_names: &'a std::collections::HashSet, - /// How [`ToolsSection`] should render each tool entry. Defaults to - /// [`ToolCallFormat::PFormat`] when not set. - pub tool_call_format: ToolCallFormat, - /// Active Composio integrations the user has connected. Rendered by - /// [`ConnectedIntegrationsSection`] so the orchestrator knows which - /// external services are available via the Skills Agent. - pub connected_integrations: &'a [ConnectedIntegration], - /// When `true`, inject `PROFILE.md` (onboarding enrichment output) - /// into [`IdentitySection`]. Mirrors the inverse of the agent - /// definition's `omit_profile` flag — opt-in per agent so narrow - /// specialists (planner, code_executor, …) stay lean, while - /// user-facing agents (welcome, orchestrator, triggers) personalise. - pub include_profile: bool, - /// When `true`, inject `MEMORY.md` (archivist-curated long-term - /// memory) into [`IdentitySection`]. Mirrors the inverse of the - /// agent definition's `omit_memory_md` flag. Capped at - /// [`USER_FILE_MAX_CHARS`] and frozen per session — see the - /// KV-cache note on the injection site. - pub include_memory_md: bool, -} - -pub trait PromptSection: Send + Sync { - fn name(&self) -> &str; - fn build(&self, ctx: &PromptContext<'_>) -> Result; -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RenderedPrompt { - pub text: String, - pub cache_boundary: Option, -} - -#[derive(Default)] -pub struct SystemPromptBuilder { - sections: Vec>, -} - -impl SystemPromptBuilder { - pub fn with_defaults() -> Self { - Self { - sections: vec![ - Box::new(IdentitySection), - // User files (PROFILE.md, MEMORY.md) ride right after the - // identity bootstrap so they land in the cache-friendly - // prefix alongside SOUL/IDENTITY. Gated per-agent — see - // `UserFilesSection`. Intentionally separate from - // `IdentitySection` so agents that strip the identity - // preamble via `for_subagent(omit_identity=true)` still - // get their user files (welcome / orchestrator / the - // trigger pair). - Box::new(UserFilesSection), - // User memory sits right after the identity bootstrap so the - // model has rich, persistent context about the user before it - // sees the tool catalogue. Section is empty (and skipped) when - // the tree summarizer has nothing on disk yet. - Box::new(UserMemorySection), - Box::new(ToolsSection), - // Connected integrations sit after tools so the orchestrator - // sees which external services are available via the Skills - // Agent. Empty when the user has no active Composio connections. - Box::new(ConnectedIntegrationsSection), - Box::new(SafetySection), - Box::new(SkillsSection), - Box::new(WorkspaceSection), - Box::new(DateTimeSection), - Box::new(RuntimeSection), - ], - } - } - - /// Build a narrow prompt for a sub-agent. - /// - /// The sub-agent's archetype prompt is registered as a dedicated - /// section that always renders first. The remaining sections respect - /// the `omit_*` flags from the [`crate::openhuman::agent::harness::definition::AgentDefinition`]: - /// `omit_identity` skips the project-context dump, `omit_safety_preamble` - /// skips the safety rules, and so on. The `WorkspaceSection` is always - /// included so the sub-agent knows its working directory. - /// - /// `archetype_prompt_text` is the already-loaded body of the - /// `system_prompt` source on the definition (the runner resolves - /// inline vs file before calling this). - /// - /// # KV cache stability - /// - /// `DateTimeSection` is intentionally **not** included here. - /// Repeat spawns of the same sub-agent definition must produce - /// byte-identical system prompts so the inference backend's - /// automatic prefix cache can reuse the prefill from the previous - /// run. Injecting `Local::now()` into the prompt would defeat that - /// goal — if a sub-agent genuinely needs the current time it - /// should receive it via the user message, not the system prompt. - pub fn for_subagent( - archetype_prompt_text: String, - omit_identity: bool, - omit_safety_preamble: bool, - omit_skills_catalog: bool, - ) -> Self { - let mut sections: Vec> = - vec![Box::new(ArchetypePromptSection::new(archetype_prompt_text))]; - - if !omit_identity { - sections.push(Box::new(IdentitySection)); - } - // User files (PROFILE.md / MEMORY.md) are gated independently of - // `omit_identity` so agents that drop the identity preamble (e.g. - // welcome's `omit_identity = true`) still surface the user's - // onboarding + archivist context when `omit_profile` / - // `omit_memory_md` are opted in. The section builds to an empty - // string when both flags are off, so the existing empty-section - // skip in `build_with_cache_metadata` keeps the prompt layout - // byte-identical for narrow specialists. - sections.push(Box::new(UserFilesSection)); - // Tools section is always included — the sub-agent needs to see - // its own (filtered) tool catalogue. - sections.push(Box::new(ToolsSection)); - if !omit_safety_preamble { - sections.push(Box::new(SafetySection)); - } - if !omit_skills_catalog { - sections.push(Box::new(SkillsSection)); - } - sections.push(Box::new(WorkspaceSection)); - - Self { sections } - } - - pub fn add_section(mut self, section: Box) -> Self { - self.sections.push(section); - self - } - - pub fn build(&self, ctx: &PromptContext<'_>) -> Result { - Ok(self.build_with_cache_metadata(ctx)?.text) - } - - pub fn build_with_cache_metadata(&self, ctx: &PromptContext<'_>) -> Result { - let mut output = String::new(); - let mut cache_boundary_inserted = false; - for section in &self.sections { - let part = section.build(ctx)?; - if part.trim().is_empty() { - continue; - } - // Insert cache boundary marker before the first dynamic section. - // Static sections (identity, tools, safety, skills) are cacheable; - // dynamic sections (workspace, datetime, runtime) change per request. - if !cache_boundary_inserted && is_dynamic_section(section.name()) { - output.push_str(CACHE_BOUNDARY_MARKER); - output.push_str("\n\n"); - cache_boundary_inserted = true; - } - output.push_str(part.trim_end()); - output.push_str("\n\n"); - } - Ok(extract_cache_boundary(&output)) - } -} - -pub fn extract_cache_boundary(rendered: &str) -> RenderedPrompt { - if let Some(marker_idx) = rendered.find(CACHE_BOUNDARY_MARKER) { - let mut text = rendered.to_string(); - let end = marker_idx + CACHE_BOUNDARY_MARKER.len(); - text.replace_range(marker_idx..end, ""); - if text[marker_idx..].starts_with("\n\n") { - text.replace_range(marker_idx..marker_idx + 2, ""); - } - return RenderedPrompt { - text, - cache_boundary: Some(marker_idx), - }; - } - - RenderedPrompt { - text: rendered.to_string(), - cache_boundary: None, - } -} - -/// Sub-agent role prompt — pre-loaded text from an -/// [`crate::openhuman::agent::harness::definition::AgentDefinition`]'s -/// `system_prompt` field. Always rendered first when present. -pub struct ArchetypePromptSection { - body: String, -} - -impl ArchetypePromptSection { - pub fn new(body: String) -> Self { - Self { body } - } -} - -impl PromptSection for ArchetypePromptSection { - fn name(&self) -> &str { - "archetype_prompt" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - if self.body.trim().is_empty() { - return Ok(String::new()); - } - Ok(self.body.clone()) - } -} - -pub struct IdentitySection; -pub struct ToolsSection; -pub struct SafetySection; -pub struct SkillsSection; -pub struct ConnectedIntegrationsSection; -pub struct WorkspaceSection; -pub struct RuntimeSection; -pub struct DateTimeSection; -pub struct UserMemorySection; - -/// Injects the user-specific, session-frozen workspace files -/// (`PROFILE.md` + `MEMORY.md`), each capped at [`USER_FILE_MAX_CHARS`]. -/// -/// Separate from [`IdentitySection`] so agents that strip the project- -/// context preamble (`omit_identity = true` — welcome, orchestrator, -/// the trigger pair) still get their user-file injection at runtime via -/// [`SystemPromptBuilder::for_subagent`], which skips `IdentitySection` -/// entirely when `omit_identity` is on. -/// -/// Cache-stability: static per session — the whole point of the -/// 2000-char cap and the load-once rule documented on -/// [`AgentDefinition::omit_profile`] / `omit_memory_md`. -pub struct UserFilesSection; - -impl PromptSection for IdentitySection { - fn name(&self) -> &str { - "identity" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - let mut prompt = String::from("## Project Context\n\n"); - prompt.push_str( - "The following workspace files define your identity, behavior, and context.\n\n", - ); - // When the visible-tool filter is active the main agent is a pure - // orchestrator: it routes via spawn_subagent, synthesises results, - // and talks to the user. It does NOT need the periodic-task config - // (HEARTBEAT.md) — subagents handle their own concerns. - let is_orchestrator = !ctx.visible_tool_names.is_empty(); - let all_files: &[&str] = &["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"]; - // Orchestrator skips these from the prompt but we still sync them - // to disk so they stay current. - let skip_in_prompt: &[&str] = if is_orchestrator { - &["HEARTBEAT.md"] - } else { - &[] - }; - for file in all_files { - // Always sync to disk so builtin updates ship. - sync_workspace_file(ctx.workspace_dir, file); - if !skip_in_prompt.contains(file) { - inject_workspace_file(&mut prompt, ctx.workspace_dir, file); - } - } - - // PROFILE.md / MEMORY.md injection lives in the dedicated - // `UserFilesSection` (below) so agents that strip the identity - // preamble (`omit_identity = true`) — welcome, orchestrator, the - // trigger pair — still get their user files at runtime via - // `SystemPromptBuilder::for_subagent`, which omits - // `IdentitySection` entirely when `omit_identity` is set. - - Ok(prompt) - } -} - -impl PromptSection for UserFilesSection { - fn name(&self) -> &str { - "user_files" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - // Gate on the per-agent flags derived from - // `AgentDefinition::omit_profile` / `omit_memory_md`. Both files - // are user-specific, potentially growing, and capped at - // [`USER_FILE_MAX_CHARS`] (~1000 tokens) so they can't bloat the - // cached prefix. - // - // KV-cache contract: once injected into a session's rendered - // prompt, the bytes are frozen for the remainder of that - // session — any mid-session archivist write or enrichment - // refresh lands on the NEXT session, never the in-flight one. - let mut out = String::new(); - if ctx.include_profile { - inject_workspace_file_capped( - &mut out, - ctx.workspace_dir, - "PROFILE.md", - USER_FILE_MAX_CHARS, - ); - } - if ctx.include_memory_md { - inject_workspace_file_capped( - &mut out, - ctx.workspace_dir, - "MEMORY.md", - USER_FILE_MAX_CHARS, - ); - } - Ok(out) - } -} - -impl PromptSection for ToolsSection { - fn name(&self) -> &str { - "tools" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - let mut out = String::from("## Tools\n\n"); - let has_filter = !ctx.visible_tool_names.is_empty(); - for tool in ctx.tools { - // Skip tools not in the visible set when a filter is active. - if has_filter && !ctx.visible_tool_names.contains(tool.name) { - continue; - } - - match ctx.tool_call_format { - ToolCallFormat::PFormat => { - // P-Format renders a positional signature line: - // `**name[a|b]**: description`. The signature comes - // straight from the parameter schema (alphabetical - // by property name — see `pformat` module docs for - // why), so the model and the parser agree on - // argument ordering. We deliberately do NOT print - // the full JSON schema here: that's exactly the - // ~25-token-per-tool overhead p-format exists to - // eliminate. - let signature = render_pformat_signature_for_prompt(tool); - let _ = writeln!( - out, - "- **{}**: {}\n Call as: `{}`", - tool.name, tool.description, signature - ); - } - ToolCallFormat::Json | ToolCallFormat::Native => { - if let Some(schema) = &tool.parameters_schema { - let _ = writeln!( - out, - "- **{}**: {}\n Parameters: `{}`", - tool.name, tool.description, schema - ); - } else { - let _ = writeln!(out, "- **{}**: {}", tool.name, tool.description); - } - } - } - } - if !ctx.dispatcher_instructions.is_empty() { - out.push('\n'); - out.push_str(ctx.dispatcher_instructions); - } - Ok(out) - } -} - -/// Build a P-Format signature line (`name[a|b|c]`) from a `&dyn Tool`. -/// Used by `render_subagent_system_prompt` which operates on `Box` -/// directly (no intermediate `PromptTool`). Mirrors the `PromptTool` variant -/// below — both BTreeMap-iterate the schema's `properties` in the same order. -fn render_pformat_signature_for_box_tool(tool: &dyn crate::openhuman::tools::Tool) -> String { - let schema = tool.parameters_schema(); - let names: Vec = schema - .get("properties") - .and_then(|p| p.as_object()) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - if names.is_empty() { - format!("{}[]", tool.name()) - } else { - format!("{}[{}]", tool.name(), names.join("|")) - } -} - -/// Build a P-Format signature line (`name[a|b|c]`) from a [`PromptTool`]. -/// Local to this module so [`ToolsSection`] doesn't have to depend on -/// the agent crate's `pformat` helper. The two implementations stay in -/// lockstep — both use BTreeMap iteration order on the schema's -/// `properties` field. -fn render_pformat_signature_for_prompt(tool: &PromptTool<'_>) -> String { - let names: Vec = tool - .parameters_schema - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .and_then(|v| { - v.get("properties") - .and_then(|p| p.as_object()) - .map(|m| m.keys().cloned().collect()) - }) - .unwrap_or_default(); - if names.is_empty() { - format!("{}[]", tool.name) - } else { - format!("{}[{}]", tool.name, names.join("|")) - } -} - -impl PromptSection for SafetySection { - fn name(&self) -> &str { - "safety" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) - } -} - -impl PromptSection for SkillsSection { - fn name(&self) -> &str { - "skills" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - // When a visible-tool filter is active the main agent delegates - // all skill work to sub-agents — skip the skills catalog. - if ctx.skills.is_empty() || !ctx.visible_tool_names.is_empty() { - return Ok(String::new()); - } - - let mut prompt = String::from("## Available Skills\n\n\n"); - for skill in ctx.skills { - let location = skill.location.clone().unwrap_or_else(|| { - ctx.workspace_dir - .join("skills") - .join(&skill.name) - .join("SKILL.md") - }); - let _ = writeln!( - prompt, - " \n {}\n {}\n {}\n ", - skill.name, - skill.description, - location.display() - ); - } - prompt.push_str(""); - Ok(prompt) - } -} - -impl PromptSection for ConnectedIntegrationsSection { - fn name(&self) -> &str { - "connected_integrations" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - if ctx.connected_integrations.is_empty() { - return Ok(String::new()); - } - - // Skill-executing agents (`skills_agent` and its specialisations) - // need the full per-action catalog so they can emit Composio calls - // directly. Every other agent (main/orchestrator/welcome/planner/…) - // is a delegator — it only needs a Delegation Guide: a toolkit - // list + the exact `spawn_subagent` invocation to use. Dumping - // the full tool catalog into a delegator's system prompt wastes - // thousands of tokens and teaches the model to call actions - // that aren't actually in its tool list. - let is_skill_executor = ctx.agent_id == "skills_agent"; - - if is_skill_executor { - // skills_agent only ever sees CONNECTED integrations — - // unconnected ones are filtered out by the sub-agent - // runner before we get here, but we double-filter for - // safety in case some other caller invokes the section - // directly. - let mut out = String::from( - "## Connected Integrations\n\n\ - You have direct access to the following external services. \ - The corresponding action tools are in your tool list with \ - their typed parameter schemas — call them by name.\n\n", - ); - for integration in ctx.connected_integrations.iter().filter(|ci| ci.connected) { - let _ = writeln!( - out, - "- **{}** — {}", - integration.toolkit, integration.description, - ); - } - out.push('\n'); - return Ok(out); - } - - // Delegating agent path — render a single unified Delegation - // Guide. Every integration in the backend allowlist is listed - // with the same spawn snippet, regardless of whether the user - // has authorized it yet. Auth state is the `spawn_subagent` - // pre-flight's responsibility: - // - // - Connected toolkit → spawn proceeds normally. - // - In allowlist but not connected → pre-flight returns a - // structured error telling the orchestrator to ask the - // user to authorize it in Settings → Integrations and NOT - // to retry. - // - Not in allowlist → pre-flight returns a different error - // listing the valid toolkits. - // - // Keeping the prompt small and uniform — one bullet per - // integration, no auth-state branching in prose — and - // trusting the runtime check as the single source of truth - // for which toolkits are actually callable. - let mut out = String::from( - "## Delegation Guide — Integrations\n\n\ - For any task that touches one of these external services, \ - delegate to `skills_agent` with the matching `toolkit` \ - argument. The sub-agent receives the full action catalogue \ - for that integration as native tool schemas — do not \ - attempt to call integration actions directly from this \ - agent.\n\n\ - If a spawn returns an authorization error, surface it to \ - the user and ask them to authorize the integration in \ - **Settings → Integrations** before retrying.\n\n", - ); - for integration in ctx.connected_integrations { - let _ = writeln!( - out, - "- **{}** — {}\n Delegate with: `spawn_subagent(agent_id=\"skills_agent\", toolkit=\"{}\", prompt=)`", - integration.toolkit, integration.description, integration.toolkit, - ); - } - out.push('\n'); - - Ok(out) - } -} - -impl PromptSection for WorkspaceSection { - fn name(&self) -> &str { - "workspace" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - Ok(format!( - "## Workspace\n\nWorking directory: `{}`", - ctx.workspace_dir.display() - )) - } -} - -impl PromptSection for RuntimeSection { - fn name(&self) -> &str { - "runtime" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - let host = - hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); - Ok(format!( - "## Runtime\n\nHost: {host} | OS: {} | Model: {}", - std::env::consts::OS, - ctx.model_name - )) - } -} - -impl PromptSection for UserMemorySection { - fn name(&self) -> &str { - "user_memory" - } - - fn build(&self, ctx: &PromptContext<'_>) -> Result { - if ctx.learned.tree_root_summaries.is_empty() { - return Ok(String::new()); - } - - let mut out = String::from("## User Memory\n\n"); - out.push_str( - "Long-term memory distilled by the tree summarizer. \ - Each section is the root summary for a memory namespace, \ - representing everything we've learned about that domain over time. \ - Treat this as durable context: the model has seen these facts before, \ - they should not need to be re-discovered.\n\n", - ); - - for (namespace, body) in &ctx.learned.tree_root_summaries { - let trimmed = body.trim(); - if trimmed.is_empty() { - continue; - } - let _ = writeln!(out, "### {namespace}\n"); - out.push_str(trimmed); - out.push_str("\n\n"); - } - - Ok(out) - } -} - -impl PromptSection for DateTimeSection { - fn name(&self) -> &str { - "datetime" - } - - fn build(&self, _ctx: &PromptContext<'_>) -> Result { - let now = Local::now(); - Ok(format!( - "## Current Date & Time\n\n{} ({})", - now.format("%Y-%m-%d %H:%M:%S"), - now.format("%Z") - )) - } -} - -/// Returns true for sections whose content changes between requests. -/// Static sections (identity, tools, safety, skills) are placed before -/// the cache boundary; dynamic sections (workspace, datetime, runtime) after. -fn is_dynamic_section(name: &str) -> bool { - matches!(name, "workspace" | "datetime" | "runtime") -} - -/// Per-definition rendering flags passed into -/// [`render_subagent_system_prompt`]. Mirrors the `omit_*` fields on -/// [`crate::openhuman::agent::harness::definition::AgentDefinition`] so -/// the runner can thread each definition's preferences through without -/// growing the function signature. -/// -/// KV-cache-stable as long as the flags are read from a definition that -/// does not change mid-session. -#[derive(Debug, Clone, Copy, Default)] -pub struct SubagentRenderOptions { - /// When `false`, include the standard `## Safety` block. Mirrors - /// `AgentDefinition::omit_safety_preamble`. Defaults to `false` - /// (omit) because the narrow sub-agent renderer historically - /// skipped this section entirely. - pub include_safety_preamble: bool, - /// When `false`, skip the identity/project-context dump. Mirrors - /// `AgentDefinition::omit_identity`. Defaults to `false`; setting - /// this to `true` is uncommon because sub-agents usually run - /// narrow and the identity block pushes too many tokens. - pub include_identity: bool, - /// When `false`, skip the skills catalogue. Mirrors - /// `AgentDefinition::omit_skills_catalog`. Defaults to `false` - /// for the same reason as `include_identity`. - pub include_skills_catalog: bool, - /// When `true`, inject the user's `PROFILE.md` (onboarding - /// enrichment output) into the sub-agent prompt. Mirrors the - /// inverse of `AgentDefinition::omit_profile`. Defaults to `false` - /// — opt-in per agent so narrow specialists stay lean. - pub include_profile: bool, - /// When `true`, inject the archivist-curated `MEMORY.md` long-term - /// memory file. Mirrors the inverse of - /// `AgentDefinition::omit_memory_md`. Capped at - /// [`USER_FILE_MAX_CHARS`] and frozen per session (see KV-cache - /// contract on [`render_subagent_system_prompt`]). - pub include_memory_md: bool, -} - -impl SubagentRenderOptions { - /// Build the narrow default (every section off) — matches the - /// historical behaviour of the purpose-built renderer before the - /// flags were threaded through. - pub fn narrow() -> Self { - Self::default() - } - - /// Construct from the per-definition flags, inverting them into the - /// positive-sense `include_*` shape used by the renderer. - pub fn from_definition_flags( - omit_identity: bool, - omit_safety_preamble: bool, - omit_skills_catalog: bool, - omit_profile: bool, - omit_memory_md: bool, - ) -> Self { - Self { - include_identity: !omit_identity, - include_safety_preamble: !omit_safety_preamble, - include_skills_catalog: !omit_skills_catalog, - include_profile: !omit_profile, - include_memory_md: !omit_memory_md, - } - } -} - -/// Render a narrow, KV-cache-stable system prompt for a typed sub-agent. -/// -/// This is a purpose-built alternative to -/// [`SystemPromptBuilder::for_subagent`] for call sites that only have -/// indices into the parent's `&[Box]` vec (so they can't -/// cheaply build a filtered owning slice for `ToolsSection`). The -/// output mirrors what `for_subagent` would emit with the matching -/// `omit_*` flags, plus a sub-agent-specific calling-convention -/// preamble and a model-only runtime banner. -/// -/// `archetype_body` is the already-loaded archetype markdown — for -/// `PromptSource::Inline` this is the inline string, for -/// `PromptSource::File` this is the file contents loaded by the caller. -/// Callers resolve the source exactly once and hand the body in, so -/// this renderer works uniformly for both definition shapes. -/// -/// `options` carries the per-definition rendering flags (safety, etc.) -/// inverted into positive-sense `include_*` form. -/// [`SubagentRenderOptions::narrow`] preserves the historical behaviour. -/// -/// # KV cache stability -/// -/// The rendered bytes MUST be a pure function of: -/// - the `archetype_body` (archetype role prompt) -/// - the filtered tool set (names, descriptions, schemas) -/// - the workspace directory -/// - the resolved model name -/// - the `options` (all static per definition) -/// -/// Anything that varies across invocations at the *same* call site -/// (e.g. `chrono::Local::now()`, hostnames, pids, turn counters) is -/// forbidden here. Repeat spawns of the same sub-agent within a session -/// must produce byte-identical system prompts so the inference -/// backend's automatic prefix caching can reuse the prefill from the -/// previous run. Time-of-day information, if a sub-agent needs it, -/// belongs in the user message — not the system prompt. -pub fn render_subagent_system_prompt( - workspace_dir: &Path, - model_name: &str, - allowed_indices: &[usize], - parent_tools: &[Box], - extra_tools: &[Box], - archetype_body: &str, - options: SubagentRenderOptions, - tool_call_format: ToolCallFormat, - connected_integrations: &[ConnectedIntegration], -) -> String { - render_subagent_system_prompt_with_format( - workspace_dir, - model_name, - allowed_indices, - parent_tools, - extra_tools, - archetype_body, - options, - tool_call_format, - connected_integrations, - ) -} - -/// Inner renderer that accepts an explicit [`ToolCallFormat`] so callers -/// that know the active dispatcher format can thread it through. The -/// public [`render_subagent_system_prompt`] defaults to PFormat for -/// backwards compatibility. -pub fn render_subagent_system_prompt_with_format( - workspace_dir: &Path, - model_name: &str, - allowed_indices: &[usize], - parent_tools: &[Box], - extra_tools: &[Box], - archetype_body: &str, - options: SubagentRenderOptions, - tool_call_format: ToolCallFormat, - connected_integrations: &[ConnectedIntegration], -) -> String { - let mut out = String::new(); - - // 1. Archetype role prompt. Works for both `PromptSource::Inline` - // and `PromptSource::File` because the caller preloaded the - // body via `load_prompt_source`. - let trimmed = archetype_body.trim(); - if !trimmed.is_empty() { - out.push_str(trimmed); - out.push_str("\n\n"); - } - - // 1b. Optional identity block. Off by default; turned on when the - // definition sets `omit_identity = false`. Renders the same - // OpenClaw bootstrap files the main agent loads, keeping the - // byte layout stable across repeat spawns of the same - // definition within a session. - if options.include_identity { - out.push_str("## Project Context\n\n"); - out.push_str( - "The following workspace files define your identity, behavior, and context.\n\n", - ); - for file in &["SOUL.md", "IDENTITY.md"] { - inject_workspace_file(&mut out, workspace_dir, file); - } - } - - // 1c. PROFILE.md (onboarding enrichment output) and MEMORY.md - // (archivist-curated long-term memory). Each is gated on its own - // flag and capped at `USER_FILE_MAX_CHARS` (~1000 tokens) so a - // growing on-disk file can't push the system prompt out of the - // cache-friendly prefix range. - // - // KV-cache contract: once these files land in a session's - // rendered prompt the bytes are frozen for the remainder of that - // session. Do not re-read them mid-turn — a byte change breaks - // the backend's automatic prefix cache. Mid-session writes to - // either file are intentionally only visible on the NEXT session. - if options.include_profile { - inject_workspace_file_capped(&mut out, workspace_dir, "PROFILE.md", USER_FILE_MAX_CHARS); - } - if options.include_memory_md { - inject_workspace_file_capped(&mut out, workspace_dir, "MEMORY.md", USER_FILE_MAX_CHARS); - } - - // 2. Filtered tool catalogue. Indices are taken in ascending order - // from `allowed_indices`, which itself preserves `parent_tools` - // order, so the rendering is deterministic. We use `.get(i)` - // defensively even though the current caller (subagent_runner) - // only produces in-range indices — a future caller that derives - // indices from a different source must not be able to panic this - // renderer with a stale index. - // - // Rendering uses the caller-specified `tool_call_format` so - // sub-agents and the main dispatcher stay in lockstep. - // Tool catalogue rendering is dispatcher-format-aware: - // - // - **Native**: The provider receives full tool schemas through - // the request body's `tools` field (via `filtered_specs` in the - // sub-agent runner) and emits structured `tool_calls`. Listing - // the same tools again as prose in the system prompt is pure - // duplication — for a skills_agent spawn with 62 dynamic gmail - // tools, that duplication added ~54k tokens and blew past the - // model's context window. We skip the prose `## Tools` section - // entirely in this mode. - // - // - **PFormat / Json**: Both are prompt-driven formats — the - // model discovers tools by reading the prose `## Tools` section - // and emits text-wrapped tool calls (`name[a|b]` - // for PFormat, `{"name":...}` for Json). - // Neither uses the native `tools` request field, so we MUST - // list each tool in prose — including dynamically-registered - // `extra_tools` — or the model has no way to know they exist. - if !matches!(tool_call_format, ToolCallFormat::Native) { - out.push_str("## Tools\n\n"); - let render_one = |out: &mut String, tool: &dyn Tool| match tool_call_format { - ToolCallFormat::PFormat => { - let sig = render_pformat_signature_for_box_tool(tool); - let _ = writeln!( - out, - "- **{}**: {}\n Call as: `{}`", - tool.name(), - tool.description(), - sig - ); - } - ToolCallFormat::Json => { - let _ = writeln!( - out, - "- **{}**: {}\n Parameters: `{}`", - tool.name(), - tool.description(), - tool.parameters_schema() - ); - } - ToolCallFormat::Native => { - // Unreachable — outer guard skips Native entirely. - } - }; - for &i in allowed_indices { - let Some(tool) = parent_tools.get(i) else { - tracing::warn!( - index = i, - tool_count = parent_tools.len(), - "[context::prompt] dropping out-of-range tool index in subagent render" - ); - continue; - }; - render_one(&mut out, tool.as_ref()); - } - for tool in extra_tools { - render_one(&mut out, tool.as_ref()); - } - } - - // 3. Sub-agent calling-convention preamble — format-aware. - // Sub-agents need the same call format the main dispatcher expects - // so their output parses correctly. - out.push('\n'); - match tool_call_format { - ToolCallFormat::PFormat => { - out.push_str( - "## Tool Use Protocol\n\n\ - Tool calls use **P-Format**: compact, positional, pipe-delimited syntax \ - wrapped in `` tags.\n\n\ - ```\n\ntool_name[arg1|arg2]\n\n```\n\n\ - Arguments are positional — match the order shown in each tool's `Call as:` \ - signature above (alphabetical by parameter name). \ - Escape `|` as `\\|`, `]` as `\\]` inside values. \ - You may emit multiple `` blocks per response.\n\n\ - Use the provided tools to accomplish the task. Reply with a concise, dense \ - final answer when you have one — the parent agent will weave it back into the \ - user-visible response.\n\n", - ); - } - ToolCallFormat::Json => { - out.push_str( - "## Tool Use Protocol\n\n\ - To use a tool, wrap a JSON object in `` tags:\n\n\ - ```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n\ - You may emit multiple `` blocks in a single response.\n\n\ - Use the provided tools to accomplish the task. Reply with a concise, dense \ - final answer when you have one — the parent agent will weave it back into the \ - user-visible response.\n\n", - ); - } - ToolCallFormat::Native => { - out.push_str( - "Use the provided tools via the model's native tool-calling output. \ - Reply with a concise, dense final answer when you have one — the parent \ - agent will weave it back into the user-visible response.\n\n", - ); - } - } - - // 3b. Optional safety preamble. Definitions that do work with real - // side-effects (code_executor, tool_maker, skills_agent) set - // `omit_safety_preamble = false` so the narrow renderer used to - // silently drop that instruction — we now honour the flag. - // Byte-identical to `SafetySection::build`. - if options.include_safety_preamble { - out.push_str( - "## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.\n\n", - ); - } - - // 3c. Optional skills catalogue. Off by default because sub-agents - // usually skip skills entirely. Kept here so a custom - // definition can opt in without falling back to the general - // builder. The renderer intentionally takes no `skills` slice - // — the caller would have to extend this helper before - // enabling this flag for real, which keeps the common (narrow) - // path free of extra arguments. - if options.include_skills_catalog { - out.push_str("## Available Skills\n\n"); - out.push_str( - "Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n", - ); - } - - // 3d. Connected integrations — short toolkit header only. The - // per-action catalogue is intentionally NOT rendered here: - // when the sub-agent runner spawns `skills_agent` with a - // `toolkit` argument it dynamically registers per-action - // tools whose JSON schemas travel through the provider's - // native tool-calling channel, making a prose enumeration - // redundant (and a token sink). For sub-agents that can - // delegate (`spawn_subagent` in their tool list), the - // wording instead points them at the Skills Agent. - if !connected_integrations.is_empty() { - let has_spawn = allowed_indices.iter().any(|&i| { - parent_tools - .get(i) - .map_or(false, |t| t.name() == "spawn_subagent") - }); - - out.push_str("## Connected Integrations\n\n"); - if has_spawn { - out.push_str( - "The user has the following external services connected. \ - To interact with any of these, delegate to the **Skills Agent** \ - (`skills_agent`) via `spawn_subagent`, passing the matching \ - `toolkit` argument.\n\n", - ); - } else { - out.push_str( - "You have direct access to the following external service. \ - Action tools are available in your tool list with their \ - typed parameter schemas — call them by name.\n\n", - ); - } - - for integration in connected_integrations { - let _ = writeln!( - out, - "- **{}** — {}", - integration.toolkit, integration.description, - ); - } - out.push('\n'); - } - - // 4. Insert the cache boundary before the dynamic tail. Typed - // sub-agents keep the narrow/static instructions above this - // marker and thread the resulting byte offset through the - // provider request so repeat spawns can reuse prompt prefill. - out.push_str(CACHE_BOUNDARY_MARKER); - out.push_str("\n\n"); - - // 5. Workspace so the model knows where it is. Intentionally stable: - // no datetime, no hostname, no pid — see the KV-cache note above. - let _ = writeln!( - out, - "## Workspace\n\nWorking directory: `{}`\n", - workspace_dir.display() - ); - - // 6. Runtime banner — model name only. Stable for the lifetime of - // this sub-agent's definition. - let _ = writeln!(out, "## Runtime\n\nModel: {model_name}"); - - out -} - -/// Ensure the workspace file is up-to-date with the compiled-in default. -/// -/// On first install the file doesn't exist → write it. On subsequent runs -/// we store a hash of the compiled-in content in a sidecar file -/// (`.{filename}.builtin-hash`). If the hash changes (code was updated), -/// the disk file is overwritten so prompt improvements ship automatically. -/// User edits between code releases are preserved — we only overwrite when -/// the built-in default itself changes. -fn sync_workspace_file(workspace_dir: &Path, filename: &str) { - let default_content = default_workspace_file_content(filename); - if default_content.is_empty() { - return; - } - - let path = workspace_dir.join(filename); - let hash_path = workspace_dir.join(format!(".{filename}.builtin-hash")); - - // Compute a simple hash of the current compiled-in content. - let current_hash = { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - default_content.hash(&mut hasher); - format!("{:016x}", hasher.finish()) - }; - - // Read the last-written hash (if any). - let stored_hash = std::fs::read_to_string(&hash_path).unwrap_or_default(); - - if stored_hash.trim() == current_hash && path.exists() { - // Built-in hasn't changed and file exists — nothing to do. - return; - } - - // Either first install, or the compiled-in default changed → write it. - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&path, default_content) { - log::warn!("[agent:prompt] failed to write workspace file {filename}: {e}"); - return; - } - let _ = std::fs::write(&hash_path, ¤t_hash); - log::info!("[agent:prompt] updated workspace file {filename} (builtin content changed)"); -} - -/// Inject `filename` from `workspace_dir` into `prompt`, truncated to -/// [`BOOTSTRAP_MAX_CHARS`]. Thin wrapper around -/// [`inject_workspace_file_capped`] for bootstrap-class files -/// (`SOUL.md`, `IDENTITY.md`, `HEARTBEAT.md`). -fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { - inject_workspace_file_capped(prompt, workspace_dir, filename, BOOTSTRAP_MAX_CHARS); -} - -/// Inject `filename` into `prompt` with an explicit character budget. -/// -/// Used directly by callers that want a tighter cap than -/// [`BOOTSTRAP_MAX_CHARS`] — notably `PROFILE.md` and `MEMORY.md` which -/// are user-specific, potentially growing, and do not warrant a full -/// 20K-char budget (see [`USER_FILE_MAX_CHARS`]). -/// -/// Missing / empty files are silently skipped so callers can inject -/// optional files unconditionally without emitting a noisy placeholder. -/// -/// **KV-cache contract:** the output is a pure function of `filename`, -/// file bytes at call time, and `max_chars`. Callers must invoke this -/// once per session — re-reading mid-session breaks the inference -/// backend's automatic prefix cache. See the byte-stability note on -/// [`render_subagent_system_prompt`]. -fn inject_workspace_file_capped( - prompt: &mut String, - workspace_dir: &Path, - filename: &str, - max_chars: usize, -) { - let path = workspace_dir.join(filename); - - match std::fs::read_to_string(&path) { - Ok(content) => { - let trimmed = content.trim(); - if trimmed.is_empty() { - return; - } - let _ = writeln!(prompt, "### {filename}\n"); - let truncated = if trimmed.chars().count() > max_chars { - trimmed - .char_indices() - .nth(max_chars) - .map(|(idx, _)| &trimmed[..idx]) - .unwrap_or(trimmed) - } else { - trimmed - }; - prompt.push_str(truncated); - if truncated.len() < trimmed.len() { - let _ = writeln!( - prompt, - "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" - ); - } else { - prompt.push_str("\n\n"); - } - } - Err(_) => { - // Keep prompt focused: missing optional identity/bootstrap files should not - // add noisy placeholders that dilute tool-calling instructions. - } - } -} - -fn default_workspace_file_content(filename: &str) -> &'static str { - // The bundled identity files live at `src/openhuman/agent/prompts/` - // (owned by the `agent/` tree because they describe agent identity). - // This module is under `src/openhuman/context/`, so the relative path - // walks up one level and back into `agent/prompts/`. - match filename { - "SOUL.md" => include_str!("../agent/prompts/SOUL.md"), - "IDENTITY.md" => include_str!("../agent/prompts/IDENTITY.md"), - "HEARTBEAT.md" => { - "# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n" - } - _ => "", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::tools::traits::Tool; - use async_trait::async_trait; - use std::collections::HashSet; - use std::sync::LazyLock; - - static NO_FILTER: LazyLock> = LazyLock::new(HashSet::new); - - struct TestTool; - - #[async_trait] - impl Tool for TestTool { - fn name(&self) -> &str { - "test_tool" - } - - fn description(&self) -> &str { - "tool desc" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - - async fn execute( - &self, - _args: serde_json::Value, - ) -> anyhow::Result { - Ok(crate::openhuman::tools::ToolResult::success("ok")) - } - } - - #[test] - fn prompt_builder_assembles_sections() { - let tools: Vec> = vec![Box::new(TestTool)]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "instr", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = SystemPromptBuilder::with_defaults() - .build_with_cache_metadata(&ctx) - .unwrap(); - assert!(rendered.text.contains("## Tools")); - assert!(rendered.text.contains("test_tool")); - assert!(rendered.text.contains("instr")); - assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER)); - assert!(rendered.cache_boundary.is_some()); - } - - #[test] - fn identity_section_creates_missing_workspace_files() { - let workspace = - std::env::temp_dir().join(format!("openhuman_prompt_create_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: &workspace, - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let section = IdentitySection; - let _ = section.build(&ctx).unwrap(); - - for file in ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md"] { - assert!( - workspace.join(file).exists(), - "expected workspace file to be created: {file}" - ); - } - let soul = std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(); - assert!( - soul.starts_with("# OpenHuman"), - "SOUL.md should be seeded from src/openhuman/agent/prompts/SOUL.md" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn datetime_section_includes_timestamp_and_timezone() { - let tools: Vec> = vec![]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "instr", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let rendered = DateTimeSection.build(&ctx).unwrap(); - assert!(rendered.starts_with("## Current Date & Time\n\n")); - - let payload = rendered.trim_start_matches("## Current Date & Time\n\n"); - assert!(payload.chars().any(|c| c.is_ascii_digit())); - assert!(payload.contains(" (")); - assert!(payload.ends_with(')')); - } - - #[test] - fn extract_cache_boundary_removes_marker_and_returns_offset() { - let rendered = extract_cache_boundary("static\n\n\n\ndynamic\n"); - assert_eq!(rendered.text, "static\n\ndynamic\n"); - assert_eq!(rendered.cache_boundary, Some("static\n\n".len())); - } - - #[test] - fn tools_section_pformat_renders_signature_not_schema() { - // ToolsSection must render `name[arg1|arg2]` signatures when - // `tool_call_format = PFormat`, NOT the verbose JSON schema — - // that's where most of the prompt token saving comes from. - struct ParamTool; - #[async_trait] - impl Tool for ParamTool { - fn name(&self) -> &str { - "make_tea" - } - fn description(&self) -> &str { - "brew a cup of tea" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "kind": { "type": "string" }, - "sugar": { "type": "boolean" } - } - }) - } - async fn execute( - &self, - _args: serde_json::Value, - ) -> anyhow::Result { - Ok(crate::openhuman::tools::ToolResult::success("ok")) - } - } - - let tools: Vec> = vec![Box::new(ParamTool)]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let rendered = ToolsSection.build(&ctx).unwrap(); - // Alphabetical: kind, sugar. - assert!( - rendered.contains("Call as: `make_tea[kind|sugar]`"), - "expected p-format signature in tools section, got:\n{rendered}" - ); - // Should NOT contain the raw JSON schema dump. - assert!( - !rendered.contains("\"properties\""), - "tools section should drop the raw JSON schema in p-format mode, got:\n{rendered}" - ); - } - - #[test] - fn tools_section_json_renders_full_schema() { - // The legacy `Json` mode must keep emitting full schemas so - // existing prompts that depend on the verbose form are not - // silently changed. - let tools: Vec> = vec![Box::new(TestTool)]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::Json, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - - let rendered = ToolsSection.build(&ctx).unwrap(); - assert!( - rendered.contains("Parameters:"), - "JSON mode should still print Parameters lines, got:\n{rendered}" - ); - assert!( - rendered.contains("\"type\""), - "JSON mode should print the schema body, got:\n{rendered}" - ); - } - - #[test] - fn user_memory_section_renders_namespaces_with_headings() { - let learned = LearnedContextData { - tree_root_summaries: vec![ - ("user".into(), "Steven prefers terse Rust answers.".into()), - ( - "conversations".into(), - "Recent thread: prompt rework.".into(), - ), - ], - ..Default::default() - }; - let prompt_tools: Vec> = Vec::new(); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned, - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = UserMemorySection.build(&ctx).unwrap(); - assert!(rendered.starts_with("## User Memory\n\n")); - assert!(rendered.contains("### user\n\nSteven prefers terse Rust answers.")); - assert!(rendered.contains("### conversations\n\nRecent thread: prompt rework.")); - } - - #[test] - fn user_memory_section_returns_empty_when_no_summaries() { - // Empty learned context → section returns empty string and is - // skipped by the prompt builder, so the cache boundary stays - // exactly where it was for workspaces with no tree summaries. - let learned = LearnedContextData::default(); - let prompt_tools: Vec> = Vec::new(); - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned, - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = UserMemorySection.build(&ctx).unwrap(); - assert!(rendered.is_empty()); - } - - #[test] - fn render_subagent_system_prompt_includes_cache_boundary_before_workspace() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_subagent_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = extract_cache_boundary(&render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a focused sub-agent.", - SubagentRenderOptions::narrow(), - ToolCallFormat::PFormat, - &[], - )); - - assert!( - rendered.cache_boundary.is_some(), - "typed sub-agent prompts should expose an explicit cache boundary" - ); - assert!(rendered.text.contains("## Workspace")); - assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER)); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn extract_cache_boundary_without_marker_returns_original_text() { - let rendered = extract_cache_boundary("hello"); - assert_eq!(rendered.text, "hello"); - assert_eq!(rendered.cache_boundary, None); - } - - #[test] - fn subagent_render_options_invert_definition_flags() { - // (omit_identity, omit_safety_preamble, omit_skills_catalog, - // omit_profile, omit_memory_md) - let options = SubagentRenderOptions::from_definition_flags(true, false, true, false, false); - assert!(!options.include_identity); - assert!(options.include_safety_preamble); - assert!(!options.include_skills_catalog); - assert!(options.include_profile); - assert!(options.include_memory_md); - let narrow = SubagentRenderOptions::narrow(); - let default = SubagentRenderOptions::default(); - assert_eq!(narrow.include_identity, default.include_identity); - assert_eq!( - narrow.include_safety_preamble, - default.include_safety_preamble - ); - assert_eq!( - narrow.include_skills_catalog, - default.include_skills_catalog - ); - assert_eq!(narrow.include_profile, default.include_profile); - assert_eq!(narrow.include_memory_md, default.include_memory_md); - // Narrow default = every flag off, including both user files. - assert!(!narrow.include_profile); - assert!(!narrow.include_memory_md); - } - - #[test] - fn render_subagent_system_prompt_honors_identity_safety_and_skills_flags() { - let workspace = - std::env::temp_dir().join(format!("openhuman_prompt_opts_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nContext").unwrap(); - std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nContext").unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt_with_format( - &workspace, - "reasoning-v1", - &[0], - &tools, - &[], - "You are a specialist.", - SubagentRenderOptions { - include_identity: true, - include_safety_preamble: true, - include_skills_catalog: true, - include_profile: false, - include_memory_md: false, - }, - ToolCallFormat::Json, - &[], - ); - - assert!(rendered.contains("## Project Context")); - assert!(rendered.contains("### SOUL.md")); - assert!(rendered.contains("## Safety")); - assert!(rendered.contains("## Available Skills")); - // Json is a prompt-driven format (the model wraps JSON tool - // calls in `` tags); it does NOT use the provider's - // native function-calling channel. So the prose `## Tools` - // section MUST still be rendered for Json, with each tool's - // parameter schema inline so the model knows what to emit. - // Only `ToolCallFormat::Native` gets the section omitted (see - // the `native` branch below and the `!matches!(…, Native)` - // guard in the renderer). - assert!(rendered.contains("## Tools")); - assert!(rendered.contains("Parameters:")); - assert!(rendered.contains("\"type\"")); - - let native = render_subagent_system_prompt_with_format( - &workspace, - "reasoning-v1", - &[0], - &tools, - &[], - "You are a specialist.", - SubagentRenderOptions::narrow(), - ToolCallFormat::Native, - &[], - ); - assert!(native.contains("native tool-calling output")); - assert!(!native.contains("## Safety")); - // Native is the only format where the prose `## Tools` section - // is intentionally omitted — schemas travel through the - // provider's `tools` field instead. Regression guard against - // the ~54k-token schema duplication from the #447 PR. - assert!(!native.contains("\n## Tools\n")); - assert!(!native.contains("Parameters:")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_injects_profile_md_even_when_identity_omitted() { - // Regression: the welcome agent sets `omit_identity = true` to - // drop the SOUL/IDENTITY preamble (it has its own voice) but it - // still needs PROFILE.md to personalise the greeting. PROFILE.md - // is gated on its own `include_profile` flag so the welcome path - // can opt in without pulling SOUL/IDENTITY back in. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_nosoul_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nShould be hidden").unwrap(); - std::fs::write( - workspace.join("IDENTITY.md"), - "# Identity\nShould be hidden", - ) - .unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nName: Jane Doe\nRole: Data scientist", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the welcome agent.", - SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: false, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - rendered.contains("### PROFILE.md"), - "PROFILE.md header must appear when include_profile=true, got:\n{rendered}" - ); - assert!( - rendered.contains("Jane Doe"), - "PROFILE.md body must be injected when include_profile=true, got:\n{rendered}" - ); - assert!( - !rendered.contains("## Project Context"), - "identity preamble must still be suppressed when include_identity=false" - ); - assert!( - !rendered.contains("### SOUL.md") && !rendered.contains("### IDENTITY.md"), - "SOUL/IDENTITY must still be suppressed when include_identity=false" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_skips_profile_md_when_include_profile_false() { - // Mirror of the opt-in regression above: narrow specialists - // (planner, code_executor, critic, …) set `omit_profile = true` - // and must NOT see PROFILE.md even when the file is on disk — - // otherwise every sub-agent pays the token cost of onboarding - // enrichment output that is irrelevant to their task. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_opt_out_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nName: Jane Doe\nRole: Data scientist", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a narrow specialist.", - SubagentRenderOptions::narrow(), // include_profile defaults to false - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### PROFILE.md"), - "PROFILE.md must NOT appear when include_profile=false, got:\n{rendered}" - ); - assert!( - !rendered.contains("Jane Doe"), - "PROFILE.md body must NOT be leaked when include_profile=false" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_injects_profile_md_when_identity_included() { - // When identity is on, PROFILE.md must still be injected alongside - // SOUL/IDENTITY — the split must not regress the non-welcome path. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_with_identity_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("SOUL.md"), "# Soul\nctx").unwrap(); - std::fs::write(workspace.join("IDENTITY.md"), "# Identity\nctx").unwrap(); - std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nhello").unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a specialist.", - SubagentRenderOptions { - include_identity: true, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: false, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!(rendered.contains("## Project Context")); - assert!(rendered.contains("### SOUL.md")); - assert!(rendered.contains("### IDENTITY.md")); - assert!(rendered.contains("### PROFILE.md")); - assert!(rendered.contains("hello")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_silently_skips_missing_profile_md() { - // Pre-onboarding workspaces have no PROFILE.md. The renderer must - // not emit a noisy "[File not found: PROFILE.md]" placeholder or - // an orphan "### PROFILE.md" header — the subagent prompt stays - // focused on tools. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_profile_missing_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the welcome agent.", - SubagentRenderOptions::narrow(), - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### PROFILE.md"), - "empty/missing PROFILE.md should not emit a header, got:\n{rendered}" - ); - assert!( - !rendered.contains("[File not found: PROFILE.md]"), - "missing PROFILE.md should be silent, not a noisy placeholder" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn welcome_agent_definition_flags_still_load_profile_md() { - // End-to-end-ish check against the real welcome agent flags: the - // agent.toml sets omit_identity=true/omit_skills_catalog=true/ - // omit_safety_preamble=true/omit_profile=false. Mirror that exact - // combo and verify PROFILE.md still lands in the rendered prompt. - // If someone flips `omit_profile` back to its default (true), this - // test breaks. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_welcome_flags_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nTimezone: PST\nRole: Crypto trader", - ) - .unwrap(); - - // Match `src/openhuman/agent/agents/welcome/agent.toml` exactly. - let options = SubagentRenderOptions::from_definition_flags( - true, // omit_identity - true, // omit_safety_preamble - true, // omit_skills_catalog - false, // omit_profile — welcome opts IN to PROFILE.md - false, // omit_memory_md — welcome opts IN to MEMORY.md too - ); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "# Welcome Agent\n\nYou are the welcome agent.", - options, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - rendered.contains("### PROFILE.md"), - "welcome agent (omit_profile=false) must load PROFILE.md, got:\n{rendered}" - ); - assert!( - rendered.contains("Crypto trader"), - "PROFILE.md body must reach the welcome agent prompt" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn narrow_subagent_definition_flags_skip_profile_md() { - // Inverse of `welcome_agent_definition_flags_still_load_profile_md`: - // a narrow specialist (e.g. `code_executor`, `critic`) leaves - // `omit_profile` at its default `true`. PROFILE.md must NOT be - // injected even when present on disk — the narrow runner is - // task-focused and should not pay the token cost. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_narrow_flags_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nTimezone: PST\nRole: Crypto trader", - ) - .unwrap(); - - // Mirrors e.g. `critic/agent.toml` — all omit_* default-true. - let options = SubagentRenderOptions::from_definition_flags(true, true, true, true, true); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a narrow specialist.", - options, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### PROFILE.md"), - "narrow specialist (omit_profile=true) must NOT load PROFILE.md, got:\n{rendered}" - ); - assert!( - !rendered.contains("Crypto trader"), - "narrow specialist must not leak PROFILE.md body" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_injects_memory_md_when_enabled() { - // Opt-in agents with `omit_memory_md = false` must see MEMORY.md - // (archivist-curated long-term memory) in their rendered prompt. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_memory_on_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("MEMORY.md"), - "# Long-term memory\nUser prefers terse Rust answers.", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the welcome agent.", - SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: false, - include_memory_md: true, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!( - rendered.contains("### MEMORY.md"), - "MEMORY.md header must appear when include_memory_md=true, got:\n{rendered}" - ); - assert!( - rendered.contains("terse Rust answers"), - "MEMORY.md body must be injected when include_memory_md=true" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn render_subagent_system_prompt_skips_memory_md_when_disabled() { - // Narrow specialists with `omit_memory_md = true` (the default) - // must NOT see MEMORY.md even when it exists on disk. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_memory_off_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("MEMORY.md"), - "# Long-term memory\nUser prefers terse Rust answers.", - ) - .unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are a narrow specialist.", - SubagentRenderOptions::narrow(), - ToolCallFormat::PFormat, - &[], - ); - - assert!( - !rendered.contains("### MEMORY.md"), - "MEMORY.md must NOT appear when include_memory_md=false, got:\n{rendered}" - ); - assert!( - !rendered.contains("terse Rust answers"), - "MEMORY.md body must not leak when include_memory_md=false" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn profile_md_and_memory_md_are_capped_at_user_file_max_chars() { - // Both PROFILE.md and MEMORY.md are user-specific files that can - // grow over time. Injection caps them at USER_FILE_MAX_CHARS - // (~1000 tokens each) so the system prompt footprint stays - // bounded. Test both files at once to pin the shared budget. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_user_cap_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - let big = "x".repeat(USER_FILE_MAX_CHARS + 500); - std::fs::write(workspace.join("PROFILE.md"), &big).unwrap(); - std::fs::write(workspace.join("MEMORY.md"), &big).unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let rendered = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the orchestrator.", - SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: true, - }, - ToolCallFormat::PFormat, - &[], - ); - - assert!(rendered.contains("### PROFILE.md")); - assert!(rendered.contains("### MEMORY.md")); - // Each file gets its own truncation marker mentioning the cap. - let marker = format!("[... truncated at {USER_FILE_MAX_CHARS} chars"); - assert_eq!( - rendered.matches(marker.as_str()).count(), - 2, - "both PROFILE.md and MEMORY.md must emit the truncation marker at \ - USER_FILE_MAX_CHARS — found:\n{rendered}" - ); - // Sanity-check the cap is genuinely tighter than the bootstrap cap. - assert!(USER_FILE_MAX_CHARS < BOOTSTRAP_MAX_CHARS); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn rendered_subagent_system_prompt_is_byte_stable_across_repeat_calls() { - // KV-cache contract: two spawns of the same sub-agent definition - // against the same workspace must produce byte-identical system - // prompts. If PROFILE.md or MEMORY.md are re-read with a - // different-typed truncation path, or if either cap drifts, the - // bytes differ and the backend's automatic prefix cache busts. - // This test pins the invariant end-to-end. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_byte_stable_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("PROFILE.md"), "# User Profile\nJane Doe").unwrap(); - std::fs::write(workspace.join("MEMORY.md"), "# Memory\nRecent: shipped v1").unwrap(); - - let tools: Vec> = vec![Box::new(TestTool)]; - let opts = SubagentRenderOptions { - include_identity: false, - include_safety_preamble: false, - include_skills_catalog: false, - include_profile: true, - include_memory_md: true, - }; - - let first = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the orchestrator.", - opts, - ToolCallFormat::PFormat, - &[], - ); - let second = render_subagent_system_prompt( - &workspace, - "test-model", - &[0], - &tools, - &[], - "You are the orchestrator.", - opts, - ToolCallFormat::PFormat, - &[], - ); - - assert_eq!( - first, second, - "repeat spawns must produce byte-identical prompts" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { - // Regression pin for the review finding: the runtime Tauri chat - // path spins welcome/trigger_* via `Agent::from_config_for_agent` - // → `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`, - // which deliberately drops `IdentitySection`. Before - // `UserFilesSection` existed, our PROFILE/MEMORY injection lived - // inside `IdentitySection::build` and got dropped along with it, - // so the first Tauri turn never saw the user's onboarding output - // even though the subagent_runner path and the debug dumper did. - // - // This test exercises the exact builder call-site the runtime - // uses for welcome (`omit_identity = true`, both user-file flags - // opted in via PromptContext) and pins that the rendered prompt - // contains both files. - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_for_subagent_user_files_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("PROFILE.md"), - "# User Profile\nJane Doe — crypto trader in PST.", - ) - .unwrap(); - std::fs::write( - workspace.join("MEMORY.md"), - "# Long-term memory\nShipped v1 last sprint; prefers terse Rust.", - ) - .unwrap(); - - let tools: Vec> = vec![]; - let prompt_tools = PromptTool::from_tools(&tools); - let ctx = PromptContext { - workspace_dir: &workspace, - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: true, - include_memory_md: true, - }; - - // Mirror the welcome agent runtime path: - // `SystemPromptBuilder::for_subagent(body, omit_identity=true, …)`. - let builder = SystemPromptBuilder::for_subagent( - "You are the welcome agent.".into(), - true, // omit_identity — drops SOUL/IDENTITY preamble - true, // omit_safety_preamble - true, // omit_skills_catalog - ); - let rendered = builder.build(&ctx).unwrap(); - - assert!( - !rendered.contains("## Project Context"), - "identity preamble must still be suppressed when omit_identity=true" - ); - assert!( - rendered.contains("### PROFILE.md") && rendered.contains("Jane Doe"), - "welcome runtime path must inject PROFILE.md despite omit_identity=true, got:\n{rendered}" - ); - assert!( - rendered.contains("### MEMORY.md") && rendered.contains("terse Rust"), - "welcome runtime path must inject MEMORY.md despite omit_identity=true, got:\n{rendered}" - ); - - // Mirror the narrow-specialist runtime path (code_executor, - // critic, …): both flags off → user files must stay out. - let ctx_narrow = PromptContext { - workspace_dir: &workspace, - model_name: "test-model", - agent_id: "", - tools: &prompt_tools, - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let narrow = builder.build(&ctx_narrow).unwrap(); - assert!( - !narrow.contains("### PROFILE.md") && !narrow.contains("### MEMORY.md"), - "narrow specialist runtime path must NOT leak user files, got:\n{narrow}" - ); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn sync_workspace_file_updates_hash_and_inject_workspace_file_truncates() { - let workspace = std::env::temp_dir().join(format!( - "openhuman_prompt_workspace_{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace).unwrap(); - - sync_workspace_file(&workspace, "SOUL.md"); - let hash_path = workspace.join(".SOUL.md.builtin-hash"); - assert!(workspace.join("SOUL.md").exists()); - assert!(hash_path.exists()); - let original_hash = std::fs::read_to_string(&hash_path).unwrap(); - - std::fs::write(workspace.join("SOUL.md"), "user override").unwrap(); - sync_workspace_file(&workspace, "SOUL.md"); - assert_eq!(std::fs::read_to_string(&hash_path).unwrap(), original_hash); - assert_eq!( - std::fs::read_to_string(workspace.join("SOUL.md")).unwrap(), - "user override" - ); - - std::fs::write( - workspace.join("BIG.md"), - "x".repeat(BOOTSTRAP_MAX_CHARS + 50), - ) - .unwrap(); - let mut prompt = String::new(); - inject_workspace_file(&mut prompt, &workspace, "BIG.md"); - assert!(prompt.contains("### BIG.md")); - assert!(prompt.contains("[... truncated at")); - - let _ = std::fs::remove_dir_all(workspace); - } - - #[test] - fn dynamic_section_classification_matches_cache_boundary_rules() { - assert!(is_dynamic_section("workspace")); - assert!(is_dynamic_section("datetime")); - assert!(is_dynamic_section("runtime")); - assert!(!is_dynamic_section("tools")); - assert!(!is_dynamic_section("identity")); - } - - #[test] - fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { - let plain = PromptTool::new("shell", "run commands"); - assert_eq!(plain.name, "shell"); - assert!(plain.parameters_schema.is_none()); - - let with_schema = - PromptTool::with_schema("http_request", "fetch data", "{\"type\":\"object\"}".into()); - assert_eq!( - with_schema.parameters_schema.as_deref(), - Some("{\"type\":\"object\"}") - ); - - let ctx = PromptContext { - workspace_dir: Path::new("/tmp"), - model_name: "model", - agent_id: "", - tools: &[], - skills: &[], - dispatcher_instructions: "", - learned: LearnedContextData { - tree_root_summaries: vec![ - ("user".into(), "kept".into()), - ("empty".into(), " ".into()), - ], - ..Default::default() - }, - visible_tool_names: &NO_FILTER, - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &[], - include_profile: false, - include_memory_md: false, - }; - let rendered = UserMemorySection.build(&ctx).unwrap(); - assert!(rendered.contains("### user")); - assert!(!rendered.contains("### empty")); - assert_eq!(default_workspace_file_content("missing"), ""); - } -} +//! Compat shim — prompt plumbing has moved to [`crate::openhuman::agent::prompts`]. +//! +//! This file used to hold the full prompt rendering pipeline (type +//! definitions, section builders, `SystemPromptBuilder`, +//! `render_subagent_system_prompt`). All of that now lives under +//! `agent::prompts` so prompt logic sits next to the agents that +//! consume it. This module stays around as a stable import path for +//! the rest of the tree — `use crate::openhuman::context::prompt::...` +//! keeps working unchanged. + +pub use crate::openhuman::agent::prompts::*; diff --git a/src/openhuman/providers/reliable.rs b/src/openhuman/providers/reliable.rs index 6578ab197..05a20c818 100644 --- a/src/openhuman/providers/reliable.rs +++ b/src/openhuman/providers/reliable.rs @@ -576,7 +576,6 @@ impl Provider for ReliableProvider { let req = ChatRequest { messages: request.messages, tools: request.tools, - system_prompt_cache_boundary: request.system_prompt_cache_boundary, stream: stream_this_attempt, }; match provider.chat(req, current_model, temperature).await { diff --git a/src/openhuman/providers/traits.rs b/src/openhuman/providers/traits.rs index 5907ec94e..b0d735b92 100644 --- a/src/openhuman/providers/traits.rs +++ b/src/openhuman/providers/traits.rs @@ -114,15 +114,15 @@ pub enum ProviderDelta { } /// Request payload for provider chat calls. +/// +/// The system prompt is built once at session start and frozen for the +/// rest of the session — the inference backend's automatic prefix +/// cache covers the whole thing, so there is no explicit cache-boundary +/// to thread through the request. #[derive(Debug, Clone, Copy)] pub struct ChatRequest<'a> { pub messages: &'a [ChatMessage], pub tools: Option<&'a [ToolSpec]>, - /// Byte offset in the system prompt where static (cacheable) content ends - /// and dynamic content begins. Providers that support prompt caching can - /// apply `cache_control` to the prefix before this boundary. - /// `None` means no cache boundary is known. - pub system_prompt_cache_boundary: Option, /// Optional sink for `ProviderDelta` events. When `Some`, providers /// that support streaming will ask the upstream API for SSE and /// forward fine-grained events here. Providers without a streaming @@ -825,7 +825,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::user("Hello")], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; @@ -844,7 +843,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::user("Hello")], tools: None, - system_prompt_cache_boundary: None, stream: None, }; @@ -946,7 +944,6 @@ mod tests { ChatMessage::system("BASE_SYSTEM_PROMPT"), ], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; @@ -970,7 +967,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::system("BASE"), ChatMessage::user("Hello")], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; @@ -994,7 +990,6 @@ mod tests { let request = ChatRequest { messages: &[ChatMessage::user("Hello")], tools: Some(&tools), - system_prompt_cache_boundary: None, stream: None, }; diff --git a/src/openhuman/tools/impl/agent/complete_onboarding.rs b/src/openhuman/tools/impl/agent/complete_onboarding.rs index ffc100bbb..e11f8c6ef 100644 --- a/src/openhuman/tools/impl/agent/complete_onboarding.rs +++ b/src/openhuman/tools/impl/agent/complete_onboarding.rs @@ -143,7 +143,7 @@ impl Tool for CompleteOnboardingTool { \"composio\": true,\n\ \"browser\": false,\n\ \"web_search\": true,\n\ - \"http_request\": false,\n\ + \"http_request\": true,\n\ \"local_ai\": true\n\ },\n\ \"memory\": { \"backend\": \"sqlite\", \"auto_save\": true },\n\ @@ -418,8 +418,8 @@ pub(crate) fn build_status_snapshot( "integrations": { "composio": composio_enabled, "browser": config.browser.enabled, - "web_search": config.web_search.enabled, - "http_request": config.http_request.enabled, + "web_search": true, + "http_request": true, "local_ai": config.local_ai.enabled, }, "memory": { diff --git a/src/openhuman/tools/impl/agent/dispatch.rs b/src/openhuman/tools/impl/agent/dispatch.rs index 0bc7d2618..900b42503 100644 --- a/src/openhuman/tools/impl/agent/dispatch.rs +++ b/src/openhuman/tools/impl/agent/dispatch.rs @@ -54,8 +54,8 @@ pub(crate) async fn dispatch_subagent( ); // Propagate the per-call toolkit scope into the subagent runner so - // that `SkillDelegationTool`s can narrow `skills_agent` to a single - // Composio toolkit (e.g. `delegate_gmail` → skills_agent + + // that `SkillDelegationTool`s can narrow `integrations_agent` to a single + // Composio toolkit (e.g. `delegate_gmail` → integrations_agent + // toolkit="gmail"). Earlier code plumbed this through // `skill_filter_override` (which matches `{skill}__` QuickJS-style // names), but Composio actions are named `GMAIL_*` / `NOTION_*` — @@ -64,7 +64,6 @@ pub(crate) async fn dispatch_subagent( // check, restricted to skill-category tools. let options = SubagentRunOptions { skill_filter_override: None, - category_filter_override: None, toolkit_override: skill_filter.map(str::to_string), context: None, task_id: Some(task_id.clone()), diff --git a/src/openhuman/tools/impl/agent/skill_delegation.rs b/src/openhuman/tools/impl/agent/skill_delegation.rs index 2b3f09ca4..f9c761364 100644 --- a/src/openhuman/tools/impl/agent/skill_delegation.rs +++ b/src/openhuman/tools/impl/agent/skill_delegation.rs @@ -56,7 +56,7 @@ impl Tool for SkillDelegationTool { } super::dispatch_subagent( - "skills_agent", + "integrations_agent", &self.tool_name, &prompt, Some(&self.skill_id), diff --git a/src/openhuman/tools/impl/agent/spawn_subagent.rs b/src/openhuman/tools/impl/agent/spawn_subagent.rs index f7fadc3e7..a1ad0857f 100644 --- a/src/openhuman/tools/impl/agent/spawn_subagent.rs +++ b/src/openhuman/tools/impl/agent/spawn_subagent.rs @@ -22,7 +22,7 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::current_parent; use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; @@ -75,7 +75,7 @@ impl Tool for SpawnSubagentTool { fn description(&self) -> &str { "Delegate a task to a specialised sub-agent. See the Delegation \ Guide in the system prompt for available agent_ids and when to \ - use each. When delegating to `skills_agent`, you MUST also pass \ + use each. When delegating to `integrations_agent`, you MUST also pass \ `toolkit=\"\"` naming the Composio integration the \ sub-task targets (e.g. `gmail`, `notion`); the sub-agent will \ only see that toolkit's actions." @@ -120,14 +120,9 @@ impl Tool for SpawnSubagentTool { "type": "string", "description": "Optional context blob from prior task results. Rendered as a `[Context]` block before the prompt." }, - "category_filter": { - "type": "string", - "enum": ["system", "skill"], - "description": "Optional tool-category restriction. `skill` scopes the sub-agent to integration tools (for example Composio-backed SaaS actions); `system` scopes it to built-in Rust tools. Overrides the definition's `category_filter` for this single spawn." - }, "toolkit": { "type": "string", - "description": "Composio toolkit slug to scope this spawn to — e.g. `gmail`, `notion`, `slack`. REQUIRED when `agent_id = \"skills_agent\"`. Narrows the sub-agent's visible Composio actions AND its Connected Integrations prompt section to only that toolkit's catalogue, so the sub-agent's context window only carries the platform it was asked to operate on. Must match a currently-connected integration (see the Delegation Guide)." + "description": "Composio toolkit slug to scope this spawn to — e.g. `gmail`, `notion`, `slack`. REQUIRED when `agent_id = \"integrations_agent\"`. Narrows the sub-agent's visible Composio actions AND its Connected Integrations prompt section to only that toolkit's catalogue, so the sub-agent's context window only carries the platform it was asked to operate on. Must match a currently-connected integration (see the Delegation Guide)." }, "mode": { "type": "string", @@ -164,17 +159,6 @@ impl Tool for SpawnSubagentTool { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let category_filter_override = match args.get("category_filter").and_then(|v| v.as_str()) { - Some("system") => Some(ToolCategory::System), - Some("skill") => Some(ToolCategory::Skill), - Some(other) => { - return Ok(ToolResult::error(format!( - "spawn_subagent: unknown category_filter '{other}' (expected 'system' or 'skill')" - ))); - } - None => None, - }; - let toolkit_override = args .get("toolkit") .and_then(|v| v.as_str()) @@ -222,14 +206,14 @@ impl Tool for SpawnSubagentTool { } }; - // ── skills_agent toolkit gate ────────────────────────────────── - // skills_agent is a platform-parameterised specialist. Every + // ── integrations_agent toolkit gate ────────────────────────────────── + // integrations_agent is a platform-parameterised specialist. Every // spawn MUST name a CONNECTED toolkit so the sub-agent only // sees one integration's tool catalogue instead of all of // them. We split validation into three cases so the model // gets a precise, actionable error on every failure mode — // nothing reaches the LLM loop unless the spawn is valid. - if definition.id == "skills_agent" { + if definition.id == "integrations_agent" { let parent_ctx = current_parent(); let allowlist: Vec<&crate::openhuman::context::prompt::ConnectedIntegration> = parent_ctx @@ -242,10 +226,19 @@ impl Tool for SpawnSubagentTool { .map(|ci| ci.toolkit.clone()) .collect(); + tracing::debug!( + target: "spawn_subagent", + toolkit = ?toolkit_override, + allowlist_count = allowlist.len(), + connected_count = connected_slugs.len(), + connected = ?connected_slugs, + "[spawn_subagent] integrations_agent gate: validating toolkit" + ); + match toolkit_override.as_deref() { None => { return Ok(ToolResult::error(format!( - "spawn_subagent(skills_agent): the `toolkit` argument is required. \ + "spawn_subagent(integrations_agent): the `toolkit` argument is required. \ Pass one of the currently-connected toolkits: [{}]. \ See the Delegation Guide in your system prompt for which toolkit \ matches each task.", @@ -260,7 +253,7 @@ impl Tool for SpawnSubagentTool { None => { // Toolkit isn't even in the backend allowlist. return Ok(ToolResult::error(format!( - "spawn_subagent(skills_agent): toolkit '{tk}' is not in \ + "spawn_subagent(integrations_agent): toolkit '{tk}' is not in \ the backend allowlist. Valid toolkits: [{}]. Check the \ Delegation Guide in your system prompt for the exact slug.", allowlist @@ -292,7 +285,11 @@ impl Tool for SpawnSubagentTool { ))); } Some(_) => { - // Connected — fall through to spawn. + tracing::debug!( + target: "spawn_subagent", + toolkit = %tk, + "[spawn_subagent] integrations_agent gate: toolkit connected, proceeding with spawn" + ); } } } @@ -316,7 +313,6 @@ impl Tool for SpawnSubagentTool { // ── Run the sub-agent ────────────────────────────────────────── let options = SubagentRunOptions { skill_filter_override: None, - category_filter_override, toolkit_override, context, task_id: Some(task_id.clone()), diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index fd4d37425..b371d735f 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -132,25 +132,28 @@ pub fn all_tools_with_runtime( ))); } - if http_config.enabled { - tools.push(Box::new(HttpRequestTool::new( - security.clone(), - http_config.allowed_domains.clone(), - http_config.max_response_size, - http_config.timeout_secs, - ))); - } + // HTTP request — always registered. `http_request.allowed_domains` + // + `security` still gate which hosts are reachable; there is no + // enable flag because every session needs basic HTTP as a baseline + // capability. + tools.push(Box::new(HttpRequestTool::new( + security.clone(), + http_config.allowed_domains.clone(), + http_config.max_response_size, + http_config.timeout_secs, + ))); - // Web search tool (enabled by default for GLM and other models) - if root_config.web_search.enabled { - tools.push(Box::new(WebSearchTool::new( - root_config.web_search.provider.clone(), - root_config.web_search.brave_api_key.clone(), - root_config.web_search.parallel_api_key.clone(), - root_config.web_search.max_results, - root_config.web_search.timeout_secs, - ))); - } + // Web search — always registered. Provider / API-key / budget + // knobs still come from `config.web_search`, but there is no + // enable flag: every session needs research as a baseline + // capability. + tools.push(Box::new(WebSearchTool::new( + root_config.web_search.provider.clone(), + root_config.web_search.brave_api_key.clone(), + root_config.web_search.parallel_api_key.clone(), + root_config.web_search.max_results, + root_config.web_search.timeout_secs, + ))); // Vision tools are always available tools.push(Box::new(ScreenshotTool::new(security.clone()))); diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index dabc0bf93..9242df71b 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -44,7 +44,7 @@ use super::{ArchetypeDelegationTool, SkillDelegationTool, Tool}; /// Each [`SubagentEntry::Skills`] wildcard expands to one /// [`SkillDelegationTool`] per connected Composio integration in /// `connected_integrations`. The synthesised tool routes to the generic -/// `skills_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set. +/// `integrations_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set. /// /// Entries that reference unknown agent ids (not in the registry) are /// logged at `warn` and skipped — the orchestrator still builds, just @@ -128,17 +128,17 @@ pub fn collect_orchestrator_tools( // on brand-new or poorly-populated toolkits. let description = if integration.description.trim().is_empty() { format!( - "Delegate to the skills agent with the `{}` integration pre-selected.", + "Delegate to the integrations_agent with the `{}` integration pre-selected.", integration.toolkit ) } else { format!( - "Delegate to the skills agent using `{}`. {}", + "Delegate to the integrations_agent using `{}`. {}", integration.toolkit, integration.description ) }; log::debug!( - "[orchestrator_tools] registering skill delegation tool: {} -> skills_agent (skill_filter={})", + "[orchestrator_tools] registering skill delegation tool: {} -> integrations_agent (skill_filter={})", tool_name, slug ); @@ -202,7 +202,6 @@ mod tests { tools: ToolScope::Wildcard, disallowed_tools: vec![], skill_filter: None, - category_filter: None, extra_tools: vec![], max_iterations: 8, timeout_secs: None, diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index f08828682..c8319232c 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -129,6 +129,8 @@ fn stub_parent_context() -> ParentExecutionContext { connected_integrations: vec![], composio_client: None, tool_call_format: openhuman_core::openhuman::context::prompt::ToolCallFormat::PFormat, + session_key: "test-session".into(), + session_parent_prefix: None, } } @@ -192,7 +194,6 @@ async fn fork_and_parent_contexts_are_visible_only_within_scope() { system_prompt: Arc::new("hello".into()), tool_specs: Arc::new(vec![]), message_prefix: Arc::new(vec![ChatMessage::system("hello")]), - cache_boundary: Some(5), fork_task_prompt: "do thing".into(), }; @@ -200,7 +201,6 @@ async fn fork_and_parent_contexts_are_visible_only_within_scope() { let inner = current_fork().expect("fork context should be visible"); assert_eq!(*inner.system_prompt, "hello"); assert_eq!(inner.fork_task_prompt, "do thing"); - assert_eq!(inner.cache_boundary, Some(5)); assert_eq!(inner.message_prefix.len(), 1); }) .await;