mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 01:16:40 +02:00
fix(tools): tell a waiting parent when stop_agent stops its child
This commit is contained in:
@@ -383,12 +383,15 @@ class AgentCoordinator:
|
|||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
async def cancel_descendants_graceful(self, agent_id: str) -> None:
|
async def cancel_descendants_graceful(self, agent_id: str) -> list[str]:
|
||||||
|
"""Stop a subtree leaves-first and report which agents were stopped."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
order = self._subtree_order_locked(agent_id)
|
order = self._subtree_order_locked(agent_id)
|
||||||
for aid in reversed(order):
|
stopped = list(reversed(order))
|
||||||
|
for aid in stopped:
|
||||||
await self.request_stop(aid)
|
await self.request_stop(aid)
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
|
return stopped
|
||||||
|
|
||||||
async def attach_stream(
|
async def attach_stream(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -866,9 +866,9 @@ _TERMINAL_NOTICE = {
|
|||||||
"message it again."
|
"message it again."
|
||||||
),
|
),
|
||||||
"stopped": (
|
"stopped": (
|
||||||
"[Agent capped] {name} ({agent_id}) hit its turn limit and was stopped "
|
"[Agent stopped] {name} ({agent_id}) was stopped before finishing (turn limit "
|
||||||
"before finishing. It will not send a completion report, so stop waiting "
|
"or an explicit stop). It will not send a completion report, so stop waiting "
|
||||||
"on this child; account for its capped subtask and continue."
|
"on this child; account for its unfinished subtask and continue."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -685,9 +685,16 @@ async def stop_agent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if cascade:
|
if cascade:
|
||||||
await coordinator.cancel_descendants_graceful(target_agent_id)
|
stopped = await coordinator.cancel_descendants_graceful(target_agent_id)
|
||||||
else:
|
else:
|
||||||
await coordinator.request_stop(target_agent_id)
|
await coordinator.request_stop(target_agent_id)
|
||||||
|
stopped = [target_agent_id]
|
||||||
|
|
||||||
|
# The stopper knows what it just did; anyone else waiting on those agents does not.
|
||||||
|
async with coordinator._lock:
|
||||||
|
orphaned = [aid for aid in stopped if coordinator.parent_of.get(aid) not in (None, me)]
|
||||||
|
for aid in orphaned:
|
||||||
|
await notify_parent_on_terminal(coordinator, aid, "stopped")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"stop_agent: target=%s cascade=%s reason=%r",
|
"stop_agent: target=%s cascade=%s reason=%r",
|
||||||
|
|||||||
+49
-1
@@ -22,7 +22,7 @@ from strix.core.execution import (
|
|||||||
notify_parent_on_terminal,
|
notify_parent_on_terminal,
|
||||||
)
|
)
|
||||||
from strix.core.sessions import seed_initial_input
|
from strix.core.sessions import seed_initial_input
|
||||||
from strix.tools.agents_graph.tools import agent_finish
|
from strix.tools.agents_graph.tools import agent_finish, stop_agent
|
||||||
from strix.tools.finish.tool import finish_scan
|
from strix.tools.finish.tool import finish_scan
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +89,22 @@ async def _call_agent_finish(
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_stop_agent(
|
||||||
|
coordinator: AgentCoordinator, agent_id: str, target_agent_id: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ctx = ToolContext(
|
||||||
|
context={"coordinator": coordinator, "agent_id": agent_id},
|
||||||
|
tool_name="stop_agent",
|
||||||
|
tool_call_id="call-1",
|
||||||
|
tool_arguments="{}",
|
||||||
|
)
|
||||||
|
result: str = await stop_agent.on_invoke_tool(
|
||||||
|
ctx, json.dumps({"target_agent_id": target_agent_id})
|
||||||
|
)
|
||||||
|
parsed: dict[str, Any] = json.loads(result)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reserve_stop_notifies_root_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_reserve_stop_notifies_root_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
coordinator = AgentCoordinator()
|
coordinator = AgentCoordinator()
|
||||||
@@ -548,6 +564,38 @@ async def test_agent_finish_report_suppresses_the_terminal_notice(tmp_path: Any)
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_agent_notifies_a_parent_that_is_not_the_stopper(tmp_path: Any) -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.register("child", "recon", parent_id="root")
|
||||||
|
await coordinator.register("grandchild", "sqli", parent_id="child")
|
||||||
|
session = SQLiteSession("child", tmp_path / "agents.db")
|
||||||
|
await coordinator.attach_runtime("child", session=session)
|
||||||
|
|
||||||
|
await _call_stop_agent(coordinator, "root", "grandchild")
|
||||||
|
|
||||||
|
assert coordinator.statuses["grandchild"] == "stopped"
|
||||||
|
assert coordinator.pending_counts.get("child", 0) == 1
|
||||||
|
# The stopper already knows; only the waiting parent needs telling.
|
||||||
|
assert coordinator.pending_counts.get("root", 0) == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_agent_does_not_notify_the_stopping_parent(tmp_path: Any) -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.register("child", "recon", parent_id="root")
|
||||||
|
session = SQLiteSession("root", tmp_path / "agents.db")
|
||||||
|
await coordinator.attach_runtime("root", session=session)
|
||||||
|
|
||||||
|
await _call_stop_agent(coordinator, "root", "child")
|
||||||
|
|
||||||
|
assert coordinator.pending_counts.get("root", 0) == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_notify_parent_on_terminal_ignores_non_terminal_status(tmp_path: Any) -> None:
|
async def test_notify_parent_on_terminal_ignores_non_terminal_status(tmp_path: Any) -> None:
|
||||||
coordinator = AgentCoordinator()
|
coordinator = AgentCoordinator()
|
||||||
|
|||||||
Reference in New Issue
Block a user