mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
121 lines
5.1 KiB
Python
121 lines
5.1 KiB
Python
"""SpawnAgent: the native replacement for the CLI's built-in Agent tool. The child is a
|
|
fresh session inheriting the parent's model/cwd/dashboard; sync waits and returns the last
|
|
assistant text, background returns immediately. The builtin stays blocked so the model only
|
|
ever sees the two-arg schema."""
|
|
import asyncio
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from typing import Dict, List
|
|
|
|
from pytest import MonkeyPatch, raises
|
|
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.core.models import AgentSession, Message
|
|
|
|
|
|
def seed_parent() -> AgentSession:
|
|
parent = AgentSession(name="parent", model="opus-4-8", cwd="/tmp/pw", dashboard_id="dashX")
|
|
agent_manager.sessions[parent.id] = parent
|
|
return parent
|
|
|
|
|
|
def test_spawn_agent_sync_returns_child_answer(monkeypatch: MonkeyPatch) -> None:
|
|
parent = seed_parent()
|
|
|
|
async def fake_loop(session_id: str, prompt: str, **kwargs: object) -> None:
|
|
s = agent_manager.sessions[session_id]
|
|
s.messages.append(Message(role="assistant", content="child says done", branch_id=s.active_branch_id))
|
|
s.status = "completed"
|
|
|
|
monkeypatch.setattr(agent_manager, "run_agent_loop", fake_loop)
|
|
result = asyncio.run(agent_manager.spawn_agent(prompt="do the thing", parent_session_id=parent.id))
|
|
|
|
child = agent_manager.sessions[result["session_id"]]
|
|
assert result["response"] == "child says done"
|
|
assert child.mode == "sub-agent"
|
|
assert child.parent_session_id == parent.id
|
|
assert child.model == parent.model
|
|
assert child.cwd == parent.cwd
|
|
assert child.dashboard_id == "dashX"
|
|
assert child.messages[0].role == "user" and child.messages[0].content == "do the thing"
|
|
|
|
|
|
def test_spawn_agent_background_returns_immediately(monkeypatch: MonkeyPatch) -> None:
|
|
parent = seed_parent()
|
|
started: List[str] = []
|
|
|
|
async def slow_loop(session_id: str, prompt: str, **kwargs: object) -> None:
|
|
started.append(session_id)
|
|
await asyncio.sleep(30)
|
|
|
|
monkeypatch.setattr(agent_manager, "run_agent_loop", slow_loop)
|
|
|
|
async def run() -> Dict:
|
|
result = await asyncio.wait_for(
|
|
agent_manager.spawn_agent(prompt="long task", parent_session_id=parent.id, run_in_background=True),
|
|
timeout=2.0,
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
agent_manager.tasks[result["session_id"]].cancel()
|
|
return result
|
|
|
|
result = asyncio.run(run())
|
|
assert result["background"] is True
|
|
assert started == [result["session_id"]]
|
|
|
|
|
|
def test_spawn_agent_unknown_parent_raises() -> None:
|
|
with raises(ValueError):
|
|
asyncio.run(agent_manager.spawn_agent(prompt="x", parent_session_id="nope-" + "0" * 28))
|
|
|
|
|
|
def test_builtin_subagent_tools_stay_blocked() -> None:
|
|
# The CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) must not be offered: out of the catalog AND hard-blocked at the SDK layer.
|
|
from backend.apps.agents.manager.prompt.tool_catalog import FULL_TOOLS
|
|
from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools
|
|
assert "Agent" not in FULL_TOOLS
|
|
assert "Task" not in FULL_TOOLS
|
|
blocked = merge_hard_blocked_tools([])
|
|
assert "Agent" in blocked and "Task" in blocked and "mcp__claude_ai_*" in blocked
|
|
|
|
|
|
def test_effective_disallowed_reaches_the_sdk_deny_list() -> None:
|
|
# Regression: a plain assignment once DISCARDED the computed denies (Cron*/Skill/web-swap/per-tool MCP), leaving the runtime gate as the only wall. The merge must keep them AND append the hard blocks, deduped.
|
|
from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools
|
|
computed = ["CronCreate", "CronList", "CronDelete", "Skill", "mcp__gmail__DeleteEmail", "Task"]
|
|
merged = merge_hard_blocked_tools(computed)
|
|
for t in computed:
|
|
assert t in merged
|
|
assert "Agent" in merged and "mcp__claude_ai_*" in merged
|
|
assert merged.count("Task") == 1
|
|
assert merged[:len(computed)] == computed
|
|
|
|
|
|
def test_spawn_server_schema_is_prompt_plus_background_only() -> None:
|
|
from backend.apps.agents import spawn_agent_mcp_server as srv
|
|
tool = srv.TOOLS[0]
|
|
assert tool["name"] == "SpawnAgent"
|
|
assert set(tool["inputSchema"]["properties"].keys()) == {"prompt", "run_in_background"}
|
|
assert tool["inputSchema"]["required"] == ["prompt"]
|
|
|
|
|
|
def test_spawn_server_speaks_mcp_stdio() -> None:
|
|
from backend.apps.agents import spawn_agent_mcp_server as srv
|
|
proc = subprocess.Popen(
|
|
[sys.executable, srv.__file__],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
|
|
)
|
|
try:
|
|
msgs = [
|
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
|
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
]
|
|
out, _ = proc.communicate("\n".join(json.dumps(m) for m in msgs) + "\n", timeout=15)
|
|
lines = [json.loads(line) for line in out.strip().splitlines()]
|
|
assert lines[0]["result"]["serverInfo"]["name"] == "openswarm-spawn-agent"
|
|
assert lines[1]["result"]["tools"][0]["name"] == "SpawnAgent"
|
|
finally:
|
|
proc.kill()
|