Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import json
import os
from pathlib import Path
import threading
import time
import types
Expand Down Expand Up @@ -147,6 +148,112 @@ def test_goal_only(self):
self.assertIn("YOUR TASK", prompt)
self.assertNotIn("CONTEXT", prompt)


class TestChildAgentContextIsolation(unittest.TestCase):
def test_child_loads_active_profile_soul_without_ambient_context_or_memory(self):
"""Render a real child's prompt through active-profile home resolution."""
from tempfile import TemporaryDirectory

with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
default_home = root / "hermes-home"
active_home = default_home / "profiles" / "active"
workspace = root / "workspace"
active_memories = active_home / "memories"
active_memories.mkdir(parents=True)
workspace.mkdir()

active_soul = "ACTIVE PROFILE DELEGATED SOUL"
ambient_soul = "AMBIENT DEFAULT SOUL MUST NOT LEAK"
ambient_context = "AMBIENT CONTEXT MUST NOT LEAK"
memory_marker = "PERSISTENT MEMORY MUST STAY DISABLED"
(active_home / "SOUL.md").write_text(active_soul, encoding="utf-8")
(default_home / "SOUL.md").write_text(ambient_soul, encoding="utf-8")
(default_home / "AGENTS.md").write_text(
ambient_context, encoding="utf-8"
)
(active_home / "config.yaml").write_text(
"memory:\n memory_enabled: true\n", encoding="utf-8"
)
(active_memories / "MEMORY.md").write_text(
memory_marker, encoding="utf-8"
)

parent = _make_mock_parent()
parent.enabled_toolsets = ["terminal"]
parent.disabled_toolsets = []
parent.client = None
parent.prefill_messages = None
parent._fallback_chain = None
parent.reasoning_config = None
parent.request_overrides = {}
parent.max_tokens = None
parent.acp_command = None
parent.acp_args = []
parent.session_id = None
parent._current_turn_id = ""
parent.providers_allowed = None
parent.providers_ignored = None
parent.providers_order = None
parent.provider_sort = None
parent.provider_require_parameters = False
parent.provider_data_collection = ""
parent.openrouter_min_coding_score = None
parent._session_db = types.SimpleNamespace(
db_path=active_home / "state.db"
)

tool_defs = [{
"type": "function",
"function": {
"name": "terminal",
"description": "terminal tool",
"parameters": {"type": "object", "properties": {}},
},
}]
child = None
with (
patch.dict(
os.environ,
{
"HERMES_HOME": str(default_home),
"TERMINAL_CWD": str(workspace),
},
),
patch("model_tools.get_tool_definitions", return_value=tool_defs),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
try:
child = _build_child_agent(
task_index=0,
goal="Inspect safely",
context=None,
toolsets=None,
model=None,
max_iterations=10,
parent_agent=parent,
task_count=1,
role="leaf",
)
prompt = child._build_system_prompt()
finally:
if child is not None:
child.close()

self.assertIsNotNone(child)
child_state = vars(child)
self.assertTrue(child_state["load_soul_identity"])
self.assertTrue(child_state["skip_context_files"])
self.assertIsNone(child_state["_memory_store"])
self.assertIsNone(child_state["_memory_manager"])
self.assertIn(active_soul, prompt)
self.assertEqual(prompt.count(active_soul), 1)
self.assertNotIn(ambient_soul, prompt)
self.assertNotIn(ambient_context, prompt)
self.assertNotIn(memory_marker, prompt)


class TestStripBlockedTools(unittest.TestCase):
def test_removes_blocked_toolsets(self):
result = _strip_blocked_tools(["terminal", "file", "delegation", "clarify", "memory", "code_execution"])
Expand Down Expand Up @@ -414,7 +521,7 @@ def test_child_dedicated_db_follows_parents_db_path(self):
self.assertIsInstance(child_db, SessionDB)
self.assertIsNot(child_db, parent_db)
self.assertEqual(
str(child_db.db_path), str(parent_db.db_path)
Path(child_db.db_path).resolve(), Path(parent_db.db_path).resolve()
)
finally:
if child_db is not None:
Expand Down
7 changes: 6 additions & 1 deletion tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,12 @@ def _build_child_agent(
**rt, max_iterations=max_iterations, prefill_messages=getattr(parent_agent, "prefill_messages", None),
enabled_toolsets=child_toolsets, disabled_toolsets=child_disabled_toolsets, quiet_mode=True,
ephemeral_system_prompt=child_prompt, log_prefix=f"[subagent-{task_index}]", platform="subagent",
skip_context_files=True, skip_memory=True, clarify_callback=None,
skip_context_files=True,
# Keep only the active profile's persona: project context and
# memory remain isolated. This adds SOUL.md to the child's
# cached prompt (and therefore to its configured provider),
# but does not add a model call.
load_soul_identity=True, skip_memory=True, clarify_callback=None,
thinking_callback=(
(lambda text: _safe_progress(child_progress_cb, "_thinking", text) if text else None)
if child_progress_cb else None
Expand Down