diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index c6e11ab8..2c914edc 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -329,6 +329,29 @@ async def get_branches(session_id: str): "active_branch_id": session.active_branch_id, } +@agents.router.get("/sessions/{session_id}/work") +async def get_session_work(session_id: str): + """What a session DID, read off our own record: its asks, its tool trail, its final answer. + + The read that makes ReadAgentWork possible, and the whole point is the prompt it replaces. A + parent asking a child model to restate its own work is a reproduction request on a lane whose + filter hunts those, and it costs a model turn to fetch something already on disk (ENG-389). + Shares `render_agent_trail` with the recap and the workflow transcript, so what is safe to send + another model has exactly one definition.""" + from backend.apps.agents.manager.session.history_compaction import ( + get_branch_messages, + render_agent_trail, + ) + session = agent_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return { + "session_id": session_id, + "name": session.name, + "status": session.status, + "work": render_agent_trail(get_branch_messages(session)), + } + @agents.router.post("/sessions/{session_id}/duplicate") async def duplicate_session(session_id: str, body: dict = {}): try: diff --git a/backend/apps/agents/invoke_agent_mcp_server.py b/backend/apps/agents/invoke_agent_mcp_server.py index d359282c..f21db72b 100644 --- a/backend/apps/agents/invoke_agent_mcp_server.py +++ b/backend/apps/agents/invoke_agent_mcp_server.py @@ -1,5 +1,11 @@ #!/usr/bin/env python3 -"""Stdio MCP server exposing the InvokeAgent tool; proxies to /api/invoke-agent/run.""" +"""Stdio MCP server exposing InvokeAgent and ReadAgentWork. + +ReadAgentWork exists because the only way a parent had to learn what a child did was to ASK THE +CHILD MODEL TO SAY IT AGAIN, which is structurally a reproduction request on a lane whose filter is +looking for exactly that: delegation-bearing chats block at 13.0% against a 2.7% baseline (ENG-389). +We already store every child's messages, so the parent reads the record instead of interviewing the +model, and the extraction-shaped prompt is never written at all.""" import json import sys @@ -10,6 +16,7 @@ import urllib.error BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/invoke-agent/run" +WORK_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/agents/sessions/{{}}/work" PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") @@ -17,11 +24,10 @@ TOOLS = [ { "name": "InvokeAgent", "description": ( - "Invoke a copy of an existing agent session with a new message. " - "The invoked agent will have full context of its prior conversation " - "and will process the new message independently. Use this when you " - "need to query another agent about its prior work or ask it to " - "perform a follow-up task." + "Give an existing agent session NEW work: it runs the message with full context of " + "its prior conversation and reports back. Use this to ask another agent to DO " + "something. To find out what it already did, use ReadAgentWork: it reads this app's " + "stored record directly and costs no model turn." ), "inputSchema": { "type": "object", @@ -44,6 +50,26 @@ TOOLS = [ "required": ["session_id", "message"], }, }, + { + "name": "ReadAgentWork", + "description": ( + "Read what another agent session actually did: the requests it was given, the tools it " + "ran with their results, and the answer it finished on. Read straight from this app's " + "own stored record, so it costs no model turn and works even if that agent is busy, " + "stopped, or errored out. Use this for any 'what did it do / what did it find / where " + "did it get to' question about another session." + ), + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "The session ID to read, from a selected Agent Card in the context.", + }, + }, + "required": ["session_id"], + }, + }, ] @@ -83,7 +109,36 @@ def call_backend(session_id: str, message: str) -> dict: return {"error": str(e)} +def read_work(session_id: str) -> dict: + headers = {"Authorization": f"Bearer {BACKEND_AUTH}"} if BACKEND_AUTH else {} + req = urllib.request.Request(WORK_URL.format(session_id), headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body = e.read().decode() if e.fp else str(e) + return {"error": f"HTTP {e.code}: {body}"} + except Exception as e: + return {"error": str(e)} + + +def handle_read_agent_work(arguments: dict) -> dict: + session_id = arguments.get("session_id", "") + if not session_id: + return {"content": [{"type": "text", "text": "Error: session_id is required"}], "isError": True} + result = read_work(session_id) + if "error" in result: + return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True} + head = f"**{result.get('name') or 'Agent'}** (session {session_id}, status: {result.get('status', 'unknown')})" + trail = result.get("work") or "" + if not trail.strip(): + return {"content": [{"type": "text", "text": f"{head}\n\nThat session has not done any work yet."}]} + return {"content": [{"type": "text", "text": f"{head}\n\n{trail}"}]} + + def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name == "ReadAgentWork": + return handle_read_agent_work(arguments) if tool_name != "InvokeAgent": return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index bb2102c2..855d3468 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -30,7 +30,11 @@ def register_builtin_mcp_servers( # With no renderer for a webview and no human for a prompt, we shadow the map once here and let the existing deny short-circuits skip those modules; nothing below may read the un-shadowed one. builtin_perms = apply_unreachable_denies(builtin_perms) browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent"] - invoke_agent_tools = ["InvokeAgent"] + # ReadAgentWork rides InvokeAgent's policy unless set on its own: a user who denied delegation + # denied reading other sessions too, and inheriting is how that stays true without them having + # to find a second toggle (never widen a tool surface silently). + invoke_agent_tools = ["InvokeAgent", "ReadAgentWork"] + builtin_perms.setdefault("ReadAgentWork", builtin_perms.get("InvokeAgent", "always_allow")) # The always-on trio: MCP discovery (the activation gate's one doorway), agent-editable # Settings, and CreateApp. diff --git a/backend/tests/test_read_agent_work.py b/backend/tests/test_read_agent_work.py new file mode 100644 index 00000000..79d8cd35 --- /dev/null +++ b/backend/tests/test_read_agent_work.py @@ -0,0 +1,120 @@ +"""ENG-389: a parent reads a child's work off our own record instead of asking the child model to +say it again. + +The class this closes is not a wording bug. Asking a model to restate its prior output IS a +reproduction request, on a lane whose filter looks for exactly that; delegation-bearing chats block +at 13.0% against a 2.7% baseline. `defuse_extraction_ask` lowers the rate and cannot close the +class, because the 4th real blocked prompt was already well-worded ("Quick handoff: what project +were you working on... Summarize the build plan you landed on"). The fix is that the prompt is +never written.""" + +import json + +from backend.apps.agents import invoke_agent_mcp_server as inv +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.manager.session.history_compaction import ( + get_branch_messages, + render_agent_trail, +) + +P_TOOL_NAMES = [t["name"] for t in inv.TOOLS] + + +def p_session() -> AgentSession: + s = AgentSession(id="child1", name="Build agent", status="completed") + s.messages = [ + Message(role="user", content="Port the exporter to the new schema", branch_id="main"), + Message(role="tool_call", content={"tool": "Bash", "input": {"command": "pytest backend/tests"}}, branch_id="main"), + Message(role="tool_result", content={"tool_name": "Bash", "text": "3 failed, 20 passed"}, branch_id="main"), + Message(role="assistant", content="Three tests fail on the date column; I stopped there.", branch_id="main"), + ] + return s + + +def test_the_tool_exists_and_is_the_one_the_description_points_at(): + assert "ReadAgentWork" in P_TOOL_NAMES + invoke = next(t for t in inv.TOOLS if t["name"] == "InvokeAgent") + assert "ReadAgentWork" in invoke["description"], "InvokeAgent must name the read tool as the alternative" + + +def test_invoke_agent_no_longer_teaches_the_extraction_ask(): + """The tool description used to say 'query another agent about its prior work', which is the + exact shape the filter refuses. A tool that teaches the bad prompt makes every wording gate + downstream a losing game.""" + d = next(t for t in inv.TOOLS if t["name"] == "InvokeAgent")["description"].lower() + assert "query another agent about its prior work" not in d + for shape in ("restate", "reproduce", "verbatim", "say it again", "repeat what"): + assert shape not in d, f"InvokeAgent's description still asks a model to {shape}" + + +def test_no_delegation_prompt_anywhere_asks_a_model_to_restate_its_output(): + """The issue's acceptance criterion, as a grep with teeth. It reads the delegation sources + rather than one function, because the shape only has to survive in ONE of them to keep the + class alive.""" + p_files = [ + "backend/apps/agents/invoke_agent_mcp_server.py", + "backend/apps/agents/spawn_agent_mcp_server.py", + "backend/apps/agents/manager/AgentLaunch.py", + ] + p_bad = ("verbatim", "word for word", "word-for-word", "exactly as you", "repeat what you", + "restate your", "reproduce your", "dump of your") + for path in p_files: + body = open(path, encoding="utf-8").read().lower() + for shape in p_bad: + assert shape not in body, f"{path} still carries an extraction-shaped phrase: {shape!r}" + + +def test_the_work_it_returns_is_the_trail_we_already_store(): + """Reused, not reinvented: what is safe to send another model has one definition, shared with + the session recap and the workflow transcript.""" + s = p_session() + trail = render_agent_trail(get_branch_messages(s)) + assert "Port the exporter" in trail + assert "pytest backend/tests" in trail, "the tool trail is the point; it must survive" + assert "3 failed, 20 passed" in trail + assert "Three tests fail on the date column" in trail, "the run's own outcome must come home" + + +def test_it_never_emits_a_role_tagged_replay(): + """The shape ENG-358 removed from the recap and ENG-396 found in two more renderers. A third + door for it is how the class comes back.""" + trail = render_agent_trail(get_branch_messages(p_session())) + for line in trail.splitlines(): + assert not line.lstrip().startswith(("USER:", "ASSISTANT:", "User:", "Assistant:")) + + +def test_a_missing_session_is_an_error_not_an_empty_success(): + """A read that quietly returns nothing reads to a model as 'that agent did nothing', which is a + lying status, not a missing one.""" + out = inv.handle_read_agent_work({"session_id": ""}) + assert out.get("isError") and "session_id is required" in out["content"][0]["text"] + + +def test_a_session_with_no_work_says_so_rather_than_looking_empty(): + inv.read_work = lambda sid: {"session_id": sid, "name": "Idle", "status": "completed", "work": ""} + out = inv.handle_read_agent_work({"session_id": "x"}) + assert not out.get("isError") + assert "has not done any work yet" in out["content"][0]["text"] + + +def test_read_agent_work_inherits_a_denied_invoke_policy(): + """Never widen a tool surface silently: a user who denied delegation denied this too, unless + they say otherwise.""" + from backend.apps.agents.manager.register_builtin_mcp_servers import register_builtin_mcp_servers + s = p_session() + perms = {"InvokeAgent": "deny"} + servers = {} + register_builtin_mcp_servers(servers, s, perms, None, None) + assert perms["ReadAgentWork"] == "deny" + mods = servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") + assert "invoke" not in mods, "denying InvokeAgent must not leave the module loaded for the read tool" + + +def test_it_is_reachable_by_default(): + """The liveness half: a guard that never fires and a tool nobody can call look identical.""" + from backend.apps.agents.manager.register_builtin_mcp_servers import register_builtin_mcp_servers + servers = {} + perms = {} + register_builtin_mcp_servers(servers, p_session(), perms, None, None) + assert "invoke" in servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") + assert perms["ReadAgentWork"] == "always_allow"