mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Three Wave 2 gaps from the 2026-05-11 pre-gateway parity design:
G4 — note() decision/reflect now require structured fields at the gateway
(pre-gateway `Field(...)` parity). Returns `incomplete_input` envelope
with field-by-field hints when any required field is missing.
- decision: context (str), options (list[{name,pros,cons}] min len 2),
chosen (str), rationale (str). `consequences` and `next_steps` are
now list[str] (was str). Renderer emits each option as a "### Name
+ Pros / Cons" block instead of a bullet — matches the pre-gateway
DecisionOption sub-shape exposed in `roboco/mcp/schemas/__init__.py`
at `254cc93`.
- reflect: what_done, what_learned, what_struggled (each non-empty
str). next_steps stays optional.
- Bumped tests/unit/gateway/test_content_actions.py with explicit
pass-with-N-options coverage (≥2 floor; 3-option case green).
G5 — i_will_plan now persists sub_tasks alongside approach / risks /
open_questions / technical_considerations. The Plan tab's Sub-Tasks
section was empty because the verb didn't accept the field. Choreographer
server-assigns id + order to each sub_task (pre-gateway build_plan_data
parity) and normalizes every list entry to the EXACT shape
`panel/src/types/index.ts::TaskPlan` consumes:
- SubTask: {id, title, description, completed:false, order,
estimated_hours:null, notes:null}
- Risk: {description, mitigation, severity:null} — accepts the
{risk, mitigation} pre-gateway shape too
- OpenQuestion: {question, answer:null, answered_by:null,
answered_at:null} — accepts a bare string fallback
The normalization lives in three small module-level helpers
(_normalize_sub_task / _normalize_risk / _normalize_open_question)
called from _build_panel_shaped_plan, keeping i_will_plan's branch
count under PLR0912.
G6 — new `channels()` verb returns the agent's readable + writable
channel slugs from foundation.policy.communications. Stops invented
slugs ("backend-dev", "backend") that we kept seeing in smoke runs.
Added to every role's manifest including auditor (read-only access).
Wired through:
- roboco/api/schemas/v2/do.py — list-typed consequences/next_steps,
dict-typed options, ChannelsRequest
- roboco/api/schemas/v2/flow.py — IWillPlanRequest.sub_tasks
- roboco/api/routes/v2/do.py — /channels endpoint
- roboco/api/routes/v2/flow_*.py — pass sub_tasks through
- roboco/services/gateway/content_actions.py — channels() method;
_check_scope_required_fields enforces decision/reflect structure;
_render_option_block emits per-option markdown blocks
- roboco/services/gateway/choreographer/_impl.py — _build_panel_shaped_plan
helper used by i_will_plan
- roboco/services/gateway/role_config.py — _CHANNEL_DISCOVERY tuple
on every role
- roboco/mcp/do_server.py — channels() tool + note() signature with
options as list[dict[str,str]]
- roboco/mcp/flow_server.py — i_will_plan signature with sub_tasks
Frontend: no code change. panel/src/types/index.ts already declares
the exact shape we now write; panel/src/components/tasks/task-detail/
{tab-plan,tab-progress,tab-sessions,tab-notes}.tsx already reads it.
The empty panels we observed were a backend write-side problem, not
a frontend read-side problem — Wave 1 + Wave 2 close it.
Quality: ruff + mypy clean. 505 unit tests pass (added 2 new tests on
decision-scope requirements, updated 3 existing tests to fit the
pre-gateway-parity contract).
Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
433 lines
14 KiB
Python
433 lines
14 KiB
Python
"""roboco-do MCP server — smart-wrapped content tools.
|
|
|
|
Forwards to /api/v2/do/* on the orchestrator. Tools are role-scoped at *spawn*
|
|
time: the orchestrator writes ``do_tools`` into the per-agent manifest and we
|
|
register only those names on this server. The orchestrator's API is not
|
|
role-scoped here (any allowed role can call commit/note/say/dm/notify/evidence),
|
|
so the path is fixed (no role segment). Per-tool role gates (e.g., notify
|
|
restricting to PMs/Board) live inside the gateway verbs.
|
|
|
|
If the manifest is missing or unreadable (local test runs without the bind
|
|
mount) the full registry is registered as a failsafe and a warning is logged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import structlog
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
ORCHESTRATOR_URL = os.environ.get(
|
|
"ROBOCO_ORCHESTRATOR_URL",
|
|
"http://roboco-orchestrator:8000",
|
|
)
|
|
AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
|
|
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"]
|
|
|
|
_TIMEOUT = 30
|
|
|
|
mcp = FastMCP("roboco-do")
|
|
log = structlog.get_logger()
|
|
|
|
|
|
def _build_headers() -> dict[str, str]:
|
|
"""Build per-call headers including a fresh X-Correlation-ID.
|
|
|
|
Mirrors flow_server: each MCP call mints its own correlation id so the
|
|
orchestrator's middleware can bind it to structlog and the audit row,
|
|
and the envelope echoes it back to the agent.
|
|
"""
|
|
return {
|
|
"X-Agent-ID": AGENT_ID,
|
|
"X-Agent-Role": AGENT_ROLE,
|
|
"X-Correlation-ID": str(uuid.uuid4()),
|
|
}
|
|
|
|
|
|
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
"""POST a request to the orchestrator and return the JSON envelope.
|
|
|
|
Mirrors flow_server._post: surfaces the orchestrator's envelope on
|
|
both 2xx and 4xx so the agent always sees ``remediate``. Only
|
|
fabricates a transport_error envelope when the body is unparseable.
|
|
"""
|
|
with httpx.Client(timeout=_TIMEOUT) as client:
|
|
response = client.post(
|
|
f"{ORCHESTRATOR_URL}{path}",
|
|
headers=_build_headers(),
|
|
json=body,
|
|
)
|
|
try:
|
|
payload: dict[str, Any] = response.json()
|
|
except (ValueError, json.JSONDecodeError):
|
|
return {
|
|
"error": "transport_error",
|
|
"message": (
|
|
f"orchestrator returned HTTP {response.status_code}"
|
|
f" with no JSON body for {path}"
|
|
),
|
|
"remediate": (
|
|
"check that the orchestrator is up and the route exists;"
|
|
" contact the human operator if this persists"
|
|
),
|
|
"missing": [],
|
|
}
|
|
return payload
|
|
|
|
|
|
def commit(message: str, files: list[str] | None = None) -> dict[str, Any]:
|
|
"""Make a git commit. [task-id] prefix auto-applied. Validates message."""
|
|
return _post("/api/v2/do/commit", {"message": message, "files": files})
|
|
|
|
|
|
def note(
|
|
text: str,
|
|
scope: str = "note",
|
|
task_id: str | None = None,
|
|
title: str | None = None,
|
|
context: str | None = None,
|
|
options: list[dict[str, str]] | None = None,
|
|
chosen: str | None = None,
|
|
rationale: str | None = None,
|
|
consequences: list[str] | None = None,
|
|
what_done: str | None = None,
|
|
what_learned: str | None = None,
|
|
what_struggled: str | None = None,
|
|
next_steps: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Write a journal entry. scope in note|decision|reflect|learning|struggle.
|
|
|
|
``text`` is always the short summary (one paragraph max). For ``decision``
|
|
and ``reflect`` scopes the structured fields are REQUIRED — pre-gateway
|
|
parity. The gateway returns ``incomplete_input`` if any is missing.
|
|
|
|
- decision: ``context`` (situation), ``options`` (list of ≥2 dicts
|
|
``{name, pros, cons}``), ``chosen`` (which option), ``rationale``
|
|
(why), ``consequences`` (list of strings — what this commits us to)
|
|
- reflect: ``what_done`` (literal output), ``what_learned`` (new info),
|
|
``what_struggled`` (where you got stuck), ``next_steps`` (list of
|
|
follow-up strings)
|
|
|
|
Other scopes (note / learning / struggle) just need ``text``.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/note",
|
|
{
|
|
"text": text,
|
|
"scope": scope,
|
|
"task_id": task_id,
|
|
"title": title,
|
|
"context": context,
|
|
"options": options,
|
|
"chosen": chosen,
|
|
"rationale": rationale,
|
|
"consequences": consequences,
|
|
"what_done": what_done,
|
|
"what_learned": what_learned,
|
|
"what_struggled": what_struggled,
|
|
"next_steps": next_steps,
|
|
},
|
|
)
|
|
|
|
|
|
def say(channel: str, text: str, task_id: str | None = None) -> dict[str, Any]:
|
|
"""Post to a channel. task_id auto-injected if you have an active task.
|
|
|
|
Args:
|
|
channel: Channel slug WITHOUT leading `#`. Valid values:
|
|
- Cell channels: `backend-cell`, `frontend-cell`, `uxui-cell`
|
|
- Cross-cell: `dev-all`, `qa-all`, `pm-all`, `doc-all`
|
|
- Management: `main-pm-board`, `board-private`
|
|
- Broadcast: `announcements`, `all-hands`
|
|
Write access varies by role — gateway returns `not_authorized` if
|
|
you cannot write to the requested channel; the error lists which
|
|
channels you can write to.
|
|
text: Message body.
|
|
task_id: Optional; auto-filled from your active task if omitted.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/say",
|
|
{"channel": channel, "text": text, "task_id": task_id},
|
|
)
|
|
|
|
|
|
def dm(
|
|
recipient: str,
|
|
text: str,
|
|
task_id: str | None = None,
|
|
skill: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""A2A message. Auto-creates conversation; auto-resolves skill if needed.
|
|
|
|
Args:
|
|
recipient: Target agent slug (e.g. `be-pm`, `be-dev-1`, `ceo`).
|
|
text: Message body.
|
|
task_id: Optional; auto-filled from your active task if omitted.
|
|
skill: Optional skill slug to scope the conversation.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/dm",
|
|
{"recipient": recipient, "text": text, "task_id": task_id, "skill": skill},
|
|
)
|
|
|
|
|
|
def notify(
|
|
target: str,
|
|
text: str,
|
|
priority: str = "normal",
|
|
task_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Send a formal ack-required notification (PMs and Board only).
|
|
|
|
Distinct from say (channel post) and dm (informal A2A): notify creates
|
|
a notification the recipient must acknowledge. priority in
|
|
normal|high|urgent. task_id auto-injected from active task when omitted.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/notify",
|
|
{
|
|
"target": target,
|
|
"text": text,
|
|
"priority": priority,
|
|
"task_id": task_id,
|
|
},
|
|
)
|
|
|
|
|
|
def evidence(task_id: str) -> dict[str, Any]:
|
|
"""Inspect a task's PR diff, commits, files. Fetches dev branch into workspace."""
|
|
return _post("/api/v2/do/evidence", {"task_id": task_id})
|
|
|
|
|
|
# ---------- Wave 1 — pre-gateway parity ----------
|
|
|
|
|
|
def progress(task_id: str, message: str, percentage: int) -> dict[str, Any]:
|
|
"""Append a narrative progress update to YOUR active task.
|
|
|
|
Args:
|
|
task_id: UUID of the task you're working on.
|
|
message: One-paragraph summary of what just landed.
|
|
percentage: 0..100 inclusive. Rough completion estimate; bump it as
|
|
you make progress so PM/QA can see velocity.
|
|
|
|
Populates the panel's Progress tab. Use this in addition to ``commit``
|
|
— commits are git refs; progress is narrative.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/progress",
|
|
{"task_id": task_id, "message": message, "percentage": percentage},
|
|
)
|
|
|
|
|
|
def open_session(
|
|
task_id: str,
|
|
channel: str,
|
|
topic: str,
|
|
relationship_type: str = "discussion",
|
|
group_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""PM creates a discussion session linked to a task.
|
|
|
|
Args:
|
|
task_id: UUID of the task this session discusses.
|
|
channel: Channel slug without `#` (e.g. ``backend-cell``).
|
|
topic: Short topic for the session (≤200 chars).
|
|
relationship_type: ``discussion`` | ``planning`` | ``review`` |
|
|
``retrospective``.
|
|
group_id: Optional UUID to place the session under a specific group.
|
|
|
|
Populates the panel's Sessions tab. Only PM-or-up roles can open sessions
|
|
— devs / QA / docs participate via channels and DMs.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/open_session",
|
|
{
|
|
"task_id": task_id,
|
|
"channel": channel,
|
|
"topic": topic,
|
|
"relationship_type": relationship_type,
|
|
"group_id": group_id,
|
|
},
|
|
)
|
|
|
|
|
|
def link_session(
|
|
session_id: str,
|
|
task_id: str,
|
|
is_primary: bool = False,
|
|
relationship_type: str = "discussion",
|
|
) -> dict[str, Any]:
|
|
"""Link an existing session to a task (idempotent).
|
|
|
|
Use when an existing discussion now covers a new task too. You must
|
|
own the task you're linking; cross-agent linking is denied.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/link_session",
|
|
{
|
|
"session_id": session_id,
|
|
"task_id": task_id,
|
|
"is_primary": is_primary,
|
|
"relationship_type": relationship_type,
|
|
},
|
|
)
|
|
|
|
|
|
def notify_list(
|
|
unread_only: bool = True,
|
|
pending_ack_only: bool = False,
|
|
limit: int = 20,
|
|
) -> dict[str, Any]:
|
|
"""Read your notification inbox.
|
|
|
|
Call this when ``i_am_idle()`` soft-blocks you with an "unread A2A or
|
|
@mentions" message — list, read each one with ``notify_get``, ack with
|
|
``notify_ack``, then idle again.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/notify_list",
|
|
{
|
|
"unread_only": unread_only,
|
|
"pending_ack_only": pending_ack_only,
|
|
"limit": limit,
|
|
},
|
|
)
|
|
|
|
|
|
def notify_get(notification_id: str) -> dict[str, Any]:
|
|
"""Read one notification (marks it as read)."""
|
|
return _post(
|
|
"/api/v2/do/notify_get",
|
|
{"notification_id": notification_id},
|
|
)
|
|
|
|
|
|
def notify_ack(notification_id: str) -> dict[str, Any]:
|
|
"""Acknowledge a notification you've handled.
|
|
|
|
The gateway tracks who has acked which notification — required for
|
|
``requires_ack=true`` notifications before ``i_am_idle`` will let you
|
|
exit cleanly.
|
|
"""
|
|
return _post(
|
|
"/api/v2/do/notify_ack",
|
|
{"notification_id": notification_id},
|
|
)
|
|
|
|
|
|
def channels() -> dict[str, Any]:
|
|
"""List the channel slugs you can read / write.
|
|
|
|
Use this BEFORE ``say(channel=...)`` if you're unsure of the slug —
|
|
inventing slugs returns ``Channel not found``. Returns
|
|
``{writable: [...], readable: [...]}``.
|
|
"""
|
|
return _post("/api/v2/do/channels", {})
|
|
|
|
|
|
# ---------- Tool registry ----------
|
|
#
|
|
# Maps the tool name an agent calls (matches manifest entries and the
|
|
# orchestrator's API path) to the Python implementation.
|
|
|
|
_TOOLS: dict[str, Any] = {
|
|
"commit": commit,
|
|
"note": note,
|
|
"say": say,
|
|
"dm": dm,
|
|
"notify": notify,
|
|
"evidence": evidence,
|
|
"progress": progress,
|
|
"open_session": open_session,
|
|
"link_session": link_session,
|
|
"notify_list": notify_list,
|
|
"notify_get": notify_get,
|
|
"notify_ack": notify_ack,
|
|
"channels": channels,
|
|
}
|
|
|
|
|
|
def _load_manifest_do_tools() -> list[str] | None:
|
|
"""Read the spawn manifest and return its ``do_tools`` list.
|
|
|
|
Returns ``None`` when the manifest is missing or unreadable so callers can
|
|
fall back to registering the full tool set. Never raises.
|
|
"""
|
|
manifest_path = Path(
|
|
os.environ.get("ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"),
|
|
)
|
|
if not manifest_path.exists():
|
|
return None
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text())
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
log.warning(
|
|
"do_server: cannot read manifest",
|
|
path=str(manifest_path),
|
|
error=str(exc),
|
|
)
|
|
return None
|
|
do_tools = manifest.get("do_tools")
|
|
if not isinstance(do_tools, list):
|
|
log.warning(
|
|
"do_server: manifest missing do_tools list",
|
|
path=str(manifest_path),
|
|
)
|
|
return None
|
|
return [str(verb) for verb in do_tools]
|
|
|
|
|
|
def _register_tools() -> list[str]:
|
|
"""Register MCP tools according to the manifest. Fails loud if absent.
|
|
|
|
Mirrors flow_server's behaviour: refuse to start if the manifest is
|
|
missing rather than silently exposing the full do-tool set (which
|
|
includes ``commit`` — the role-gate would reject it server-side, but
|
|
the agent shouldn't see it on its tool palette in the first place).
|
|
|
|
Returns the list of tool names actually registered.
|
|
"""
|
|
allowed = _load_manifest_do_tools()
|
|
if allowed is None:
|
|
manifest_path = os.environ.get(
|
|
"ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
|
|
)
|
|
msg = (
|
|
f"do_server: manifest unavailable at {manifest_path};"
|
|
f" refusing to register all-tools fallback for role"
|
|
f" {AGENT_ROLE!r}. Check the orchestrator manifest mount."
|
|
)
|
|
log.error("do_server: manifest missing", role=AGENT_ROLE, path=manifest_path)
|
|
raise RuntimeError(msg)
|
|
unknown = [verb for verb in allowed if verb not in _TOOLS]
|
|
if unknown:
|
|
log.warning(
|
|
"do_server: manifest references unknown do tools",
|
|
role=AGENT_ROLE,
|
|
missing=sorted(unknown),
|
|
)
|
|
names = [verb for verb in allowed if verb in _TOOLS]
|
|
|
|
for verb in names:
|
|
mcp.tool(name=verb)(_TOOLS[verb])
|
|
log.info(
|
|
"do_server: registered tools",
|
|
role=AGENT_ROLE,
|
|
tools=sorted(names),
|
|
)
|
|
return names
|
|
|
|
|
|
_REGISTERED_TOOLS = _register_tools()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run()
|