mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Follow-ups on the salvaged #63359: - _finalize_child_results carries tool_call_history on subagent_stop (the #62011/#72403 field landed after the PR branched; the shared pipeline must emit it for both delegate_task and plugin-launched children). Lifecycle test updated for the new payload field. - The lifecycle executor uses DaemonThreadPoolExecutor — a wedged or abandoned child must never block interpreter exit at atexit-join time (same rationale as _run_single_child's timeout executor and the async-delegation pool). - delegate_task's batch path keeps live-transcript wiring while routing child construction through the shared _build_child_preserving_parent_tools helper.
256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
"""Contract tests for the public plugin subagent lifecycle API."""
|
|
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from agent.subagent_lifecycle import (
|
|
SubagentLaunchRequest,
|
|
SubagentLifecycleError,
|
|
SubagentLifecycleService,
|
|
SubagentState,
|
|
bind_subagent_parent,
|
|
get_active_subagent_parent,
|
|
)
|
|
|
|
|
|
class FakeChild:
|
|
def __init__(self, ident="sa-test"):
|
|
self._subagent_id = ident
|
|
self._delegate_role = "leaf"
|
|
self._delegate_depth = 1
|
|
self.provider = "test"
|
|
self.model = "test-model"
|
|
self.interrupted = False
|
|
|
|
def interrupt(self, _reason):
|
|
self.interrupted = True
|
|
|
|
|
|
@pytest.fixture
|
|
def lifecycle(monkeypatch):
|
|
parent = SimpleNamespace(session_id="parent-1", enabled_toolsets=["file"])
|
|
counter = iter(range(1000))
|
|
|
|
def build(**_kwargs):
|
|
return FakeChild(f"sa-{next(counter)}")
|
|
|
|
def run(_index, _goal, child, _parent):
|
|
for _ in range(20):
|
|
if child.interrupted:
|
|
return {
|
|
"status": "interrupted",
|
|
"summary": None,
|
|
"api_calls": 0,
|
|
"duration_seconds": 0,
|
|
}
|
|
time.sleep(0.002)
|
|
return {
|
|
"status": "completed",
|
|
"summary": "safe summary",
|
|
"api_calls": 1,
|
|
"duration_seconds": 0.01,
|
|
}
|
|
|
|
monkeypatch.setattr("tools.delegate_tool._build_child_agent", build)
|
|
monkeypatch.setattr("tools.delegate_tool._run_single_child", run)
|
|
return SubagentLifecycleService(lambda: parent)
|
|
|
|
|
|
def test_launch_wait_result_and_handle_round_trip(lifecycle):
|
|
handle = lifecycle.launch(
|
|
SubagentLaunchRequest(goal="x", allowed_toolsets=("file",))
|
|
)
|
|
assert handle.from_dict(handle.to_dict()) == handle
|
|
assert lifecycle.wait(handle, timeout_seconds=1).state is SubagentState.SUCCEEDED
|
|
first = lifecycle.result(handle)
|
|
assert first.ready and first.summary == "safe summary" and first.result_hash
|
|
assert lifecycle.result(handle) == first
|
|
|
|
|
|
def test_duplicate_correlation_and_permission_validation(lifecycle):
|
|
handle = lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same"))
|
|
with pytest.raises(SubagentLifecycleError, match="Duplicate"):
|
|
lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same"))
|
|
with pytest.raises(SubagentLifecycleError, match="broaden"):
|
|
lifecycle.launch(
|
|
SubagentLaunchRequest(goal="x", allowed_toolsets=("terminal",))
|
|
)
|
|
with pytest.raises(SubagentLifecycleError, match="working_directory"):
|
|
lifecycle.launch(SubagentLaunchRequest(goal="x", working_directory="C:/"))
|
|
lifecycle.wait(handle, timeout_seconds=1)
|
|
|
|
|
|
def test_cancel_is_cooperative_and_forged_handle_is_unknown(lifecycle):
|
|
handle = lifecycle.launch(SubagentLaunchRequest(goal="x"))
|
|
assert lifecycle.cancel(handle, reason="test").accepted
|
|
terminal = lifecycle.wait(handle, timeout_seconds=1)
|
|
assert terminal.state is SubagentState.CANCELLED
|
|
forged = handle.__class__(**{**handle.to_dict(), "capability": "forged"})
|
|
assert lifecycle.status(forged).state is SubagentState.UNKNOWN
|
|
assert lifecycle.result(forged).error_classification == "UNKNOWN_HANDLE"
|
|
other_parent = SimpleNamespace(session_id="different-parent")
|
|
other_service = SubagentLifecycleService(lambda: other_parent)
|
|
assert other_service.status(handle).state is SubagentState.UNKNOWN
|
|
|
|
|
|
def test_simultaneous_launches_are_distinct_and_reconnect_is_in_process(lifecycle):
|
|
handles = [lifecycle.launch(SubagentLaunchRequest(goal="x")) for _ in range(10)]
|
|
assert len({h.subagent_id for h in handles}) == 10
|
|
assert lifecycle.reconnect(handles[0]).connected
|
|
for handle in handles:
|
|
lifecycle.wait(handle, timeout_seconds=1)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[
|
|
("capability", []),
|
|
("contract_version", True),
|
|
("subagent_id", None),
|
|
("parent_session_id", []),
|
|
("correlation_id", []),
|
|
("created_at", "yesterday"),
|
|
("provider", []),
|
|
("model", []),
|
|
("role", []),
|
|
("depth", "one"),
|
|
],
|
|
)
|
|
def test_malformed_deserialized_handle_is_unknown(lifecycle, field, value):
|
|
handle = lifecycle.launch(SubagentLaunchRequest(goal="x"))
|
|
malformed = handle.from_dict({**handle.to_dict(), field: value})
|
|
|
|
assert lifecycle.status(malformed).state is SubagentState.UNKNOWN
|
|
assert lifecycle.result(malformed).error_classification == "UNKNOWN_HANDLE"
|
|
lifecycle.wait(handle, timeout_seconds=1)
|
|
|
|
|
|
def test_launch_preserves_parent_tool_resolution(monkeypatch):
|
|
import model_tools
|
|
|
|
parent = SimpleNamespace(session_id="parent-tools", enabled_toolsets=["file"])
|
|
model_tools._last_resolved_tool_names = ["parent_tool"]
|
|
|
|
def build(**_kwargs):
|
|
model_tools._last_resolved_tool_names = ["child_tool"]
|
|
return FakeChild("sa-tools")
|
|
|
|
monkeypatch.setattr("tools.delegate_tool._build_child_agent", build)
|
|
monkeypatch.setattr(
|
|
"tools.delegate_tool._run_single_child",
|
|
lambda *_args, **_kwargs: {
|
|
"status": "completed",
|
|
"summary": "done",
|
|
"api_calls": 0,
|
|
"duration_seconds": 0,
|
|
},
|
|
)
|
|
|
|
service = SubagentLifecycleService(lambda: parent)
|
|
handle = service.launch(SubagentLaunchRequest(goal="x"))
|
|
|
|
assert model_tools._last_resolved_tool_names == ["parent_tool"]
|
|
assert handle.subagent_id == "sa-tools"
|
|
service.wait(handle, timeout_seconds=1)
|
|
|
|
|
|
def test_public_lifecycle_runs_host_aggregation(monkeypatch):
|
|
memory = Mock()
|
|
parent = SimpleNamespace(
|
|
session_id="parent-aggregate",
|
|
enabled_toolsets=["file"],
|
|
_memory_manager=memory,
|
|
_current_turn_id="turn-1",
|
|
session_estimated_cost_usd=1.0,
|
|
session_cost_source="none",
|
|
session_cost_status="unknown",
|
|
)
|
|
child = FakeChild("sa-aggregate")
|
|
child.session_id = "child-session"
|
|
hook = Mock()
|
|
|
|
monkeypatch.setattr("tools.delegate_tool._build_child_agent", lambda **_kwargs: child)
|
|
monkeypatch.setattr(
|
|
"tools.delegate_tool._run_single_child",
|
|
lambda *_args, **_kwargs: {
|
|
"task_index": 0,
|
|
"status": "completed",
|
|
"summary": "aggregated",
|
|
"api_calls": 1,
|
|
"duration_seconds": 0.25,
|
|
"_child_role": "leaf",
|
|
"_child_cost_usd": 2.5,
|
|
},
|
|
)
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", hook)
|
|
|
|
service = SubagentLifecycleService(lambda: parent)
|
|
handle = service.launch(SubagentLaunchRequest(goal="aggregate me"))
|
|
assert service.wait(handle, timeout_seconds=1).state is SubagentState.SUCCEEDED
|
|
|
|
memory.on_delegation.assert_called_once_with(
|
|
task="aggregate me", result="aggregated", child_session_id="child-session"
|
|
)
|
|
hook.assert_called_once_with(
|
|
"subagent_stop",
|
|
parent_session_id="parent-aggregate",
|
|
parent_turn_id="turn-1",
|
|
child_session_id="child-session",
|
|
child_role="leaf",
|
|
child_summary="aggregated",
|
|
child_status="completed",
|
|
# Redacted tool history rides the shared finalization pipeline
|
|
# (#62011/#72403); empty here because the fabricated result carries
|
|
# no tool_trace.
|
|
tool_call_history=[],
|
|
duration_ms=250,
|
|
)
|
|
assert parent.session_estimated_cost_usd == 3.5
|
|
assert parent.session_cost_source == "subagent"
|
|
assert parent.session_cost_status == "estimated"
|
|
|
|
|
|
def test_plugin_context_uses_turn_scoped_parent(monkeypatch):
|
|
from hermes_cli.plugins import PluginContext, PluginManifest
|
|
|
|
parent = SimpleNamespace(session_id="gateway-parent", enabled_toolsets=["file"])
|
|
monkeypatch.setattr(
|
|
"tools.delegate_tool._build_child_agent", lambda **_kwargs: FakeChild("sa-gateway")
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.delegate_tool._run_single_child",
|
|
lambda *_args, **_kwargs: {
|
|
"status": "completed",
|
|
"summary": "done",
|
|
"api_calls": 0,
|
|
"duration_seconds": 0,
|
|
},
|
|
)
|
|
manager = SimpleNamespace(_cli_ref=None)
|
|
ctx = PluginContext(PluginManifest(name="test", source="test"), manager)
|
|
|
|
with bind_subagent_parent(parent):
|
|
handle = ctx.subagent_lifecycle.launch(SubagentLaunchRequest(goal="x"))
|
|
ctx.subagent_lifecycle.wait(handle, timeout_seconds=1)
|
|
|
|
assert handle.parent_session_id == "gateway-parent"
|
|
|
|
|
|
def test_agent_turn_binds_and_clears_lifecycle_parent(monkeypatch):
|
|
from run_agent import AIAgent
|
|
|
|
agent = AIAgent.__new__(AIAgent)
|
|
observed = []
|
|
|
|
def run_conversation(parent, *_args, **_kwargs):
|
|
observed.append(get_active_subagent_parent())
|
|
return {"final_response": "ok"}
|
|
|
|
monkeypatch.setattr("agent.conversation_loop.run_conversation", run_conversation)
|
|
|
|
assert agent.run_conversation("hello") == {"final_response": "ok"}
|
|
assert observed == [agent]
|
|
assert get_active_subagent_parent() is None
|