mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] agents: a parent reads a child's work from our record instead of interviewing the child model (ENG-389)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6zrBsUCNzpMBnov3rTVYV
This commit is contained in:
co-authored by
Claude Opus 5
parent
feb353d0c3
commit
13fd0b7eaf
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user