mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
F068: the do/flow-server circuit breaker only counted rejections whose
`error` field was a STRING in _CIRCUIT_REJECTION_KINDS. A 422 validation
failure (no `error` field, a `detail` list) and a 500/HTTPException
(dict-shaped `error` from the exception handlers) both bypassed the breaker
→ unbounded retries on a storm of either. Added _classify_rejection(payload)
(shared, applied to both servers) mapping all three shapes to a counted kind:
string error (existing), dict error → substring-mapped code
(*DENIED*/*AUTHORIZED*/*FORBIDDEN*/*PERMISSION*→not_authorized,
INVALID_INPUT/*VALIDATION*→incomplete_input, *NOT_FOUND*→None parity, else
→invalid_state), 422 detail→incomplete_input. The dict TypeError defence lives
in the classifier (isinstance, never dict-in-frozenset).
F069: a manifest-registered verb whose HTTP route is missing got FastAPI's raw
`{"detail":"Not Found"}` 404 body — a non-envelope payload the breaker
couldn't classify, so a storm bypassed it. _post now synthesizes an
invalid_state Envelope rejection (with a remediate hint → i_am_blocked/i_am_idle)
for a 404 status, routed through _record_and_check_circuit so the breaker counts
it. A 404 that carries a real Envelope (error field present) is surfaced as-is,
preserving test_flow_post_returns_envelope_on_404. TDD: 422/dict/404 tests in
both server test files; updated test_dict_shaped_error_does_not_crash to assert
the SDK is now called with not_authorized (replacing the pass-through assertion
that encoded the bug).
862 lines
34 KiB
Python
862 lines
34 KiB
Python
"""roboco-flow MCP server — exposes intent verbs to agents.
|
|
|
|
Tools are role-scoped via the agent's spawn manifest. The orchestrator writes
|
|
``/app/tool-manifest.json`` into each spawned agent container with that role's
|
|
``flow_tools`` array; this module reads the manifest at import time and only
|
|
registers the verbs listed there. If the manifest is missing or unreadable
|
|
(e.g. local test runs without the bind mount) the full registry is registered
|
|
as a failsafe and a warning is logged.
|
|
|
|
The orchestrator's API still rejects verbs that don't match the agent's role
|
|
(defence-in-depth), but per-agent registration prevents the model from ever
|
|
*seeing* an off-role verb in its tool palette.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Annotated, Any
|
|
|
|
import httpx
|
|
import structlog
|
|
from mcp.server.fastmcp import FastMCP
|
|
from pydantic import BeforeValidator
|
|
|
|
from roboco.foundation.policy.content.validators import coerce_str_list
|
|
|
|
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input
|
|
# parsing: an LLM emitting a bullet list as ``<item>…</item>`` elements arrives
|
|
# as ``[[["…"]]]`` / ``[{"item": {"$text": "…"}}, …]`` — nested arrays / dicts,
|
|
# not strings. A bare ``list[str]`` annotation hard-rejects element 1 (a list,
|
|
# not a str) at the MCP validation layer BEFORE the verb body runs, surfacing as
|
|
# ``1 validation error for i_will_planArguments technical_considerations.1
|
|
# Input should be a valid string``. The ``BeforeValidator`` flattens it to a
|
|
# flat ``list[str]`` first (same ``coerce_str_list`` used at the intake→DB
|
|
# boundary — see Bug 3 in the MegaTask memory).
|
|
StrList = Annotated[list[str], BeforeValidator(coerce_str_list)]
|
|
|
|
ORCHESTRATOR_URL = os.environ.get(
|
|
"ROBOCO_ORCHESTRATOR_URL",
|
|
"http://roboco-orchestrator:8000",
|
|
)
|
|
# Where the per-agent SDK server lives (per-container loopback). The
|
|
# flow server POSTs /verb/attempted here so the per-verb circuit breaker
|
|
# can record rejections and tell us when to substitute circuit_open.
|
|
SDK_URL = os.environ.get("ROBOCO_SDK_URL", "http://localhost:9000")
|
|
AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
|
|
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"]
|
|
|
|
_TIMEOUT = 30
|
|
# Tight timeout for SDK loopback — the SDK is a local sidecar; anything
|
|
# slower than 2s is unhealthy and the gateway path must not stall on it.
|
|
_SDK_TIMEOUT = 2.0
|
|
# FastAPI's default missing-route status. Every gateway route returns 200
|
|
# with an Envelope (including not_found rejections), so a 404 from the
|
|
# orchestrator is always a manifest-registered verb whose HTTP route is
|
|
# missing — F069 synthesizes an invalid_state Envelope for it.
|
|
_MISSING_ROUTE_STATUS = 404
|
|
|
|
# Envelope error kinds that count toward the per-verb circuit breaker.
|
|
# Mirrors agent_sdk.server._CIRCUIT_REJECTION_KINDS; the SDK is the
|
|
# authoritative side, but we filter here too so we only emit one POST
|
|
# for kinds the SDK will actually count.
|
|
_CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset(
|
|
{"tracing_gap", "invalid_state", "not_authorized", "incomplete_input"}
|
|
)
|
|
|
|
|
|
# Dict-shaped `error.code` values (from FastAPI's exception handlers —
|
|
# `roboco_exception_handler` / `http_exception_handler` / `generic_exception_handler`)
|
|
# mapped to the counted breaker kind they are semantically equivalent to. F068:
|
|
# a 422 / 500 / 4xx-exception storm is retry-storm-worthy but the response body
|
|
# carries `error` as a DICT (not a string kind), so the breaker's string-only
|
|
# check skipped it — unbounded retries. We classify by `error.code` so the SDK
|
|
# actually records the attempt. Kinds not in `_CIRCUIT_REJECTION_KINDS` are
|
|
# never forwarded (the SDK ignores unknown kinds anyway).
|
|
#
|
|
# Classification is substring-based so the many custom RobocoError codes
|
|
# (A2A_ACCESS_DENIED, NO_WRITE_ACCESS, TASK_NOT_OWNED, …) land on the right
|
|
# counted kind without an exhaustive literal map. The NOT_FOUND family returns
|
|
# None — parity with the string-error contract that a `not_found` rejection
|
|
# does NOT count (retrying a missing resource won't help until state changes).
|
|
def _classify_dict_error_code(code: str) -> str | None:
|
|
upper = code.upper()
|
|
if "NOT_FOUND" in upper:
|
|
return None
|
|
if (
|
|
"DENIED" in upper
|
|
or "AUTHORIZED" in upper
|
|
or "FORBIDDEN" in upper
|
|
or "PERMISSION" in upper
|
|
):
|
|
return "not_authorized"
|
|
if upper == "INVALID_INPUT" or "VALIDATION" in upper:
|
|
return "incomplete_input"
|
|
return "invalid_state"
|
|
|
|
|
|
def _classify_rejection(payload: dict[str, Any]) -> str | None:
|
|
"""Return the breaker kind to forward for this payload, or None.
|
|
|
|
The breaker only counts rejections whose kind is in
|
|
``_CIRCUIT_REJECTION_KINDS`` (the SDK's authoritative catalog). Three
|
|
reachable rejection shapes must all map to a counted kind so a storm of
|
|
any of them trips the breaker (F068):
|
|
|
|
1. Envelope rejection: ``error`` is a STRING kind. Forward it if in
|
|
the counted set (existing behaviour). Uncounted string kinds (e.g.
|
|
``not_found``, ``transport_error``, ``circuit_open``) return None —
|
|
preserves the prior contract that those don't touch the SDK.
|
|
2. Exception-handler dict: ``error`` is a DICT
|
|
(``{code, message, details?}`` from ``roboco_exception_handler`` /
|
|
``http_exception_handler`` / ``generic_exception_handler``). Map its
|
|
``code`` to a counted kind — auth/permission/denied → ``not_authorized``,
|
|
``INVALID_INPUT`` / validation → ``incomplete_input``, anything else
|
|
(INTERNAL_ERROR, API_ERROR, TASK_WRONG_STATUS, …) → ``invalid_state``.
|
|
NOT_FOUND-family codes return None (parity with string ``not_found``).
|
|
3. 422 validation failure: no ``error`` field, a ``detail`` list
|
|
(``request_validation_handler``). → ``incomplete_input``.
|
|
|
|
Successful envelopes (``status`` set, ``error`` None) and uncounted
|
|
string kinds return None — the SDK is not touched.
|
|
"""
|
|
error = payload.get("error")
|
|
if isinstance(error, str):
|
|
return error if error in _CIRCUIT_REJECTION_KINDS else None
|
|
if isinstance(error, dict):
|
|
return _classify_dict_error_code(str(error.get("code") or ""))
|
|
if "detail" in payload:
|
|
# 422 request-validation body ({"detail": [...], "body": ...}).
|
|
return "incomplete_input"
|
|
return None
|
|
|
|
|
|
mcp = FastMCP("roboco-flow")
|
|
log = structlog.get_logger()
|
|
|
|
|
|
def _build_headers() -> dict[str, str]:
|
|
"""Build per-call headers including a fresh X-Correlation-ID.
|
|
|
|
The agent runtime is the first hop, so we mint a UUID per MCP call.
|
|
The orchestrator's ``CorrelationIdMiddleware`` will accept this as the
|
|
inbound id and bind it to the structlog context for the request, so
|
|
every log line and the audit row carry the same id and the agent
|
|
receives it back on the envelope.
|
|
"""
|
|
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.
|
|
|
|
The orchestrator returns the standardized envelope on both success
|
|
(2xx) and rejection (4xx). The MCP-side bridge surfaces the envelope
|
|
in either case so agents see ``remediate`` / ``missing`` even on a
|
|
4xx response. Only raises if the response has no parseable body
|
|
(e.g., a 5xx with HTML error page or a network failure).
|
|
|
|
Rejection envelopes (error in _CIRCUIT_REJECTION_KINDS) are forwarded
|
|
to the local SDK's /verb/attempted so the per-verb circuit breaker
|
|
can track them. If the SDK reports the breaker is now open, the
|
|
original rejection is REPLACED with the circuit_open envelope before
|
|
being returned to the agent — preventing further hammering on a verb
|
|
that won't succeed. Successful (ok) envelopes never touch the SDK.
|
|
"""
|
|
with httpx.Client(timeout=_TIMEOUT) as client:
|
|
response = client.post(
|
|
f"{ORCHESTRATOR_URL}{path}",
|
|
headers=_build_headers(),
|
|
json=body,
|
|
)
|
|
# F069: a 404 here means a manifest-registered verb has no matching
|
|
# route on the orchestrator (every gateway route returns 200 with an
|
|
# Envelope — including not_found rejections — so a 404 status with
|
|
# FastAPI's default body (``{"detail": "Not Found"}``, no ``error``
|
|
# field) is always a missing route, never a legit Envelope). That
|
|
# body is a non-envelope payload the breaker can't classify, so a
|
|
# storm of these bypassed the circuit breaker → unbounded retries on
|
|
# a verb that can never succeed. Synthesize an ``invalid_state``
|
|
# Envelope rejection so the breaker counts it (via
|
|
# ``_classify_rejection``) and the agent gets a remediation hint
|
|
# instead of a raw ``detail`` body. A 404 that DOES carry a real
|
|
# Envelope (an ``error`` field — e.g. a proxy re-status a 200
|
|
# rejection to 404) is surfaced as-is.
|
|
if response.status_code == _MISSING_ROUTE_STATUS:
|
|
try:
|
|
body_404 = response.json()
|
|
except (ValueError, json.JSONDecodeError):
|
|
body_404 = None
|
|
if isinstance(body_404, dict) and "error" in body_404:
|
|
# Real Envelope rejection surfaced under a 404 status —
|
|
# surface it as-is so the agent sees the real kind/remediate.
|
|
payload_404: dict[str, Any] = body_404
|
|
else:
|
|
verb = _verb_from_path(path)
|
|
payload_404 = {
|
|
"error": "invalid_state",
|
|
"message": (
|
|
f"verb '{verb}' has no route on the orchestrator for"
|
|
f" role {AGENT_ROLE!r} (path {path})"
|
|
),
|
|
"remediate": (
|
|
f"the {verb} verb is advertised in your manifest but"
|
|
f" its HTTP route is missing — this is a server-side"
|
|
f" wiring gap. Call"
|
|
f" i_am_blocked(reason='verb {verb} 404s: no route')"
|
|
f" or i_am_idle() so the operator can fix the route;"
|
|
f" do not retry."
|
|
),
|
|
"missing": [],
|
|
}
|
|
return _record_and_check_circuit(path, body, payload_404)
|
|
try:
|
|
payload: dict[str, Any] = response.json()
|
|
except (ValueError, json.JSONDecodeError):
|
|
# No JSON body (HTML error page, empty body, etc). Surface the
|
|
# status as a synthetic envelope so the agent gets a remediate
|
|
# hint instead of a Python traceback.
|
|
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": [],
|
|
}
|
|
# Outside the orchestrator client context so the SDK call is its own
|
|
# connection — keeps semantics independent and timeouts separated.
|
|
return _record_and_check_circuit(path, body, payload)
|
|
|
|
|
|
def _verb_from_path(path: str) -> str:
|
|
"""Extract the verb name from a role-scoped flow path.
|
|
|
|
``/api/v1/flow/<role>/<verb>`` → ``<verb>``. Returns the original
|
|
path if it doesn't match the expected shape (defensive — the breaker
|
|
falls open downstream when the verb is unrecognized).
|
|
"""
|
|
return path.rsplit("/", 1)[-1]
|
|
|
|
|
|
def _record_and_check_circuit(
|
|
path: str,
|
|
body: dict[str, Any],
|
|
payload: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Forward a gateway rejection to the SDK breaker; maybe substitute.
|
|
|
|
For successful (ok) envelopes this is a no-op — only rejections of
|
|
kind tracing_gap / invalid_state / not_authorized / incomplete_input
|
|
are reported. When the SDK responds with ``open=true`` we replace the
|
|
original rejection with the wire-format ``circuit_open`` envelope so
|
|
the agent stops retrying.
|
|
|
|
Best-effort: SDK unreachable, slow, or malformed response → return
|
|
the original payload. The breaker is a safety net; it must never
|
|
break the gateway path.
|
|
"""
|
|
# Gateway envelopes use a string `error` (kind); RobocoError-derived
|
|
# exceptions surface a dict-shaped error via FastAPI's middleware, and
|
|
# 422 validation failures carry a `detail` list with no `error` field
|
|
# at all. F068: classify all three rejection shapes so a storm of 500s
|
|
# or 422s counts toward the breaker (previously bypassed → unbounded
|
|
# retries). The dict-shape defence against `TypeError: unhashable type:
|
|
# 'dict'` lives in `_classify_rejection` (isinstance checks, never a
|
|
# `dict in frozenset` membership test).
|
|
rejection_kind = _classify_rejection(payload)
|
|
if rejection_kind is None:
|
|
return payload
|
|
|
|
verb = _verb_from_path(path)
|
|
task_id = body.get("task_id")
|
|
try:
|
|
with httpx.Client(timeout=_SDK_TIMEOUT) as client:
|
|
resp = client.post(
|
|
f"{SDK_URL}/verb/attempted",
|
|
json={
|
|
"verb": verb,
|
|
"task_id": str(task_id) if task_id is not None else None,
|
|
"rejection_kind": rejection_kind,
|
|
},
|
|
)
|
|
status = resp.json()
|
|
except (httpx.HTTPError, OSError, ValueError, json.JSONDecodeError) as exc:
|
|
# Fail open: agent sees the original rejection. Log so operators
|
|
# notice an SDK that's down — the gateway keeps working.
|
|
log.warning(
|
|
"flow_server: SDK /verb/attempted unreachable; breaker bypassed",
|
|
verb=verb,
|
|
task_id=task_id,
|
|
error=str(exc),
|
|
)
|
|
return payload
|
|
|
|
if status.get("open") and isinstance(status.get("circuit_envelope"), dict):
|
|
circuit_env: dict[str, Any] = status["circuit_envelope"]
|
|
log.info(
|
|
"flow_server: circuit_open substituted for rejection",
|
|
verb=verb,
|
|
task_id=task_id,
|
|
attempts=status.get("attempts"),
|
|
limit=status.get("limit"),
|
|
)
|
|
return circuit_env
|
|
return payload
|
|
|
|
|
|
# Board route serves Product Owner + Head Marketing under one prefix.
|
|
# AgentRole values that map to a different URL segment go here; everything
|
|
# else passes through unchanged so route prefix == role name (developer,
|
|
# qa, documenter, cell_pm, main_pm, auditor).
|
|
_ROLE_TO_ROUTE_PREFIX: dict[str, str] = {
|
|
"product_owner": "board",
|
|
"head_marketing": "board",
|
|
}
|
|
_ROUTE_PREFIX = _ROLE_TO_ROUTE_PREFIX.get(AGENT_ROLE, AGENT_ROLE)
|
|
|
|
|
|
def _role_path(verb: str) -> str:
|
|
"""Build the role-scoped /api/v1/flow/<route>/<verb> path."""
|
|
return f"/api/v1/flow/{_ROUTE_PREFIX}/{verb}"
|
|
|
|
|
|
# ---------- Dev verbs ----------
|
|
|
|
|
|
def give_me_work() -> dict[str, Any]:
|
|
"""Get your current task or report idle. Returns task + context_briefing."""
|
|
return _post(_role_path("give_me_work"), {})
|
|
|
|
|
|
def i_will_work_on(
|
|
task_id: str,
|
|
plan: str | None = None,
|
|
steps: list[dict[str, str]] | None = None,
|
|
technical_considerations: StrList | None = None,
|
|
risks: list[dict[str, str]] | None = None,
|
|
open_questions: list[dict[str, str | bool]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Claim/start/recover a task. Works for pending, claimed, needs_revision.
|
|
|
|
On a FRESH claim a developer authors the SAME rich plan a PM does, so the
|
|
task's Plan tab is fully populated for audit/tracing — the gateway's
|
|
``_dev_plan_gate`` rejects a thin one. Re-entry / recovery claims
|
|
(already-claimed, in_progress, needs_revision) do NOT re-supply any of
|
|
this; the gateway short-circuits before the gate.
|
|
|
|
Args:
|
|
task_id: UUID of the task you are claiming.
|
|
plan: 2-4 sentences (>= 150 chars) describing HOW you will implement
|
|
this. Doubles as the plan's "Approach".
|
|
steps: Ordered execution checklist — list of
|
|
``{"title": "...", "description": "..."}`` with every description
|
|
substantive. Becomes the plan's sub-tasks AND the progress
|
|
checklist (completing a step advances %).
|
|
technical_considerations: Bullet list (strings) of architectural /
|
|
library / approach notes.
|
|
risks: List of ``{"risk": "...", "mitigation": "..."}`` entries.
|
|
open_questions: Optional list of ``{"question": "...",
|
|
"answered": false}`` entries.
|
|
"""
|
|
return _post(
|
|
_role_path("i_will_work_on"),
|
|
{
|
|
"task_id": task_id,
|
|
"plan": plan,
|
|
"steps": steps or [],
|
|
"technical_considerations": technical_considerations or [],
|
|
"risks": risks or [],
|
|
"open_questions": open_questions or [],
|
|
},
|
|
)
|
|
|
|
|
|
def open_pr(task_id: str) -> dict[str, Any]:
|
|
"""Push your branch and open a PR.
|
|
|
|
Atomic: validates ALL preconditions (assignee, commits, no-prior-PR)
|
|
BEFORE running any git side effects. After this verb returns success,
|
|
call ``i_am_done(task_id, notes='...')`` to actually submit for QA.
|
|
Renamed from ``submit_for_qa`` (2026-05-08) — the old name suggested
|
|
this verb advanced the lifecycle, but it only opens the PR.
|
|
"""
|
|
return _post(_role_path("open_pr"), {"task_id": task_id})
|
|
|
|
|
|
def i_am_done(task_id: str, notes: str = "") -> dict[str, Any]:
|
|
"""Submit for QA. Strict — PR must be open (call open_pr first)."""
|
|
return _post(_role_path("i_am_done"), {"task_id": task_id, "notes": notes})
|
|
|
|
|
|
def i_am_blocked(
|
|
task_id: str,
|
|
reason: str,
|
|
blocker_type: str | None = None,
|
|
what_needed: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Escalate to PM. Logs a struggle journal entry.
|
|
|
|
Args:
|
|
task_id: UUID of the task you're stuck on.
|
|
reason: One paragraph describing the blocker.
|
|
blocker_type: One of ``external`` | ``internal`` | ``question`` |
|
|
``dependency``. Optional but strongly preferred — the PM
|
|
triages by class. Pre-gateway parity.
|
|
what_needed: Concrete description of what would unblock the
|
|
task. Pre-gateway parity.
|
|
"""
|
|
return _post(
|
|
_role_path("i_am_blocked"),
|
|
{
|
|
"task_id": task_id,
|
|
"reason": reason,
|
|
"blocker_type": blocker_type,
|
|
"what_needed": what_needed,
|
|
},
|
|
)
|
|
|
|
|
|
def unclaim(task_id: str) -> dict[str, Any]:
|
|
"""Release this claim back to pending. Branch survives; task is unassigned."""
|
|
return _post(_role_path("unclaim"), {"task_id": task_id})
|
|
|
|
|
|
def reassign(task_id: str, new_assignee: str) -> dict[str, Any]:
|
|
"""Cell PM: hand a claimed/in_progress task to another dev in your own cell.
|
|
|
|
The branch is keyed to the task, so the work-in-progress survives;
|
|
`new_assignee` is a developer slug in your cell (e.g. `be-dev-2`).
|
|
"""
|
|
return _post(
|
|
_role_path("reassign"), {"task_id": task_id, "new_assignee": new_assignee}
|
|
)
|
|
|
|
|
|
def resume(task_id: str) -> dict[str, Any]:
|
|
"""Resume a paused task. Transitions paused → in_progress for the assignee."""
|
|
return _post(_role_path("resume"), {"task_id": task_id})
|
|
|
|
|
|
def sync_branch(task_id: str) -> dict[str, Any]:
|
|
"""Re-sync your branch onto its base through the gate.
|
|
|
|
Rebases the task's branch onto its resolved parent/base branch (fetch +
|
|
rebase + force-with-lease push). Use this when your branch has fallen
|
|
behind its base and you need to pick up merged work before continuing —
|
|
raw git is denied, so this is the gate-level way to rebase. No lifecycle
|
|
transition: after it returns, keep editing + commit, then open_pr /
|
|
i_am_done as normal. On ``conflicts`` status the envelope's ``next`` tells
|
|
you the rebase aborted and your branch is unchanged — resolve the conflict
|
|
in your working tree first (the gate does not force a conflicted rebase).
|
|
"""
|
|
return _post(_role_path("sync_branch"), {"task_id": task_id})
|
|
|
|
|
|
def i_am_idle() -> dict[str, Any]:
|
|
"""Report no more work. Soft-blocks if you have unread A2A/mentions."""
|
|
return _post(_role_path("i_am_idle"), {})
|
|
|
|
|
|
# ---------- QA verbs ----------
|
|
|
|
|
|
def claim_review(task_id: str) -> dict[str, Any]:
|
|
"""QA: claim a task for review. Returns PR diff + evidence inline."""
|
|
return _post(_role_path("claim_review"), {"task_id": task_id})
|
|
|
|
|
|
def pass_review(
|
|
task_id: str, notes: str, ac_verdicts: StrList | None = None
|
|
) -> dict[str, Any]:
|
|
"""QA: accept the work. notes >= 80 chars; journal:learning required.
|
|
|
|
ac_verdicts: one entry per acceptance criterion (in criterion order) stating
|
|
how you verified it. Every criterion must be covered — a pass is rejected
|
|
until all are. If any criterion does not hold, call fail_review instead.
|
|
"""
|
|
payload: dict[str, Any] = {"task_id": task_id, "notes": notes}
|
|
if ac_verdicts is not None:
|
|
payload["ac_verdicts"] = ac_verdicts
|
|
return _post(_role_path("pass"), payload)
|
|
|
|
|
|
def fail_review(task_id: str, issues: StrList) -> dict[str, Any]:
|
|
"""QA: reject the work with issues. Each issue should be concrete and actionable."""
|
|
return _post(_role_path("fail"), {"task_id": task_id, "issues": issues})
|
|
|
|
|
|
# ---------- PR reviewer verbs ----------
|
|
|
|
|
|
def claim_pr_review(task_id: str) -> dict[str, Any]:
|
|
"""PR reviewer: claim an inbound external/fork-PR review task.
|
|
|
|
Returns the contributor's unified diff inline (read-only — the fork code is
|
|
never checked out or run). Inspect it, then call post_pr_review.
|
|
"""
|
|
return _post(_role_path("claim_pr_review"), {"task_id": task_id})
|
|
|
|
|
|
def post_pr_review(
|
|
task_id: str,
|
|
body: str,
|
|
event: str = "REQUEST_CHANGES",
|
|
findings: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""PR reviewer: post ONE complete change-request to the PR and finish the task.
|
|
|
|
body: a one-paragraph summary. findings: the per-criterion list — each
|
|
{file, line?, severity (blocker|major|minor|nit), expected, actual}. When
|
|
findings are given, the GitHub comment is GENERATED in the RoboCo format
|
|
(summary + a findings table + verdict) — do not hand-format it in body.
|
|
event: REQUEST_CHANGES (default), APPROVE, or COMMENT. The verdict must
|
|
match the findings: to APPROVE a clean PR pass event='APPROVE' (do not rely
|
|
on the default); REQUEST_CHANGES must cite at least one finding, and APPROVE
|
|
may not carry a blocker/major finding. Requires a journal:learning entry
|
|
first.
|
|
"""
|
|
return _post(
|
|
_role_path("post_pr_review"),
|
|
{
|
|
"task_id": task_id,
|
|
"body": body,
|
|
"event": event,
|
|
"findings": findings or [],
|
|
},
|
|
)
|
|
|
|
|
|
# ---------- Doc verbs ----------
|
|
|
|
|
|
def claim_doc_task(task_id: str) -> dict[str, Any]:
|
|
"""Doc: claim a task in awaiting_documentation state."""
|
|
return _post(_role_path("claim_doc_task"), {"task_id": task_id})
|
|
|
|
|
|
def i_documented(task_id: str, notes: str, files: StrList) -> dict[str, Any]:
|
|
"""Doc: mark documentation complete. files=['<doc-path>', ...]."""
|
|
return _post(
|
|
_role_path("i_documented"),
|
|
{"task_id": task_id, "notes": notes, "files": files},
|
|
)
|
|
|
|
|
|
# ---------- PM verbs ----------
|
|
# Cell PM + Main PM share: triage, unblock, complete, escalate_up
|
|
|
|
|
|
def triage() -> dict[str, Any]:
|
|
"""PM: get the most important task to act on next."""
|
|
return _post(_role_path("triage"), {})
|
|
|
|
|
|
def triage_all() -> dict[str, Any]:
|
|
"""Main PM: triage across all teams."""
|
|
return _post(_role_path("triage_all"), {})
|
|
|
|
|
|
def unblock(task_id: str, reason: str, restore: bool = True) -> dict[str, Any]:
|
|
"""PM: unblock a task. `reason` states why the block is cleared and is
|
|
recorded as your decision (no separate note needed). restore=True
|
|
(default) restores pre_block_state."""
|
|
return _post(
|
|
_role_path("unblock"),
|
|
{"task_id": task_id, "reason": reason, "restore": restore},
|
|
)
|
|
|
|
|
|
def complete(task_id: str, notes: str) -> dict[str, Any]:
|
|
"""PM: complete a task. Cell PM auto-merges PR; Main PM opens PR + escalates."""
|
|
return _post(_role_path("complete"), {"task_id": task_id, "notes": notes})
|
|
|
|
|
|
def escalate_up(task_id: str, reason: str) -> dict[str, Any]:
|
|
"""PM/Doc/Dev: escalate to your role's escalation target."""
|
|
return _post(_role_path("escalate_up"), {"task_id": task_id, "reason": reason})
|
|
|
|
|
|
# ---------- Board + Auditor verbs ----------
|
|
# Board (PO + Head Marketing) + Main PM share: escalate_to_ceo
|
|
# Auditor uses triage (already registered above) for read-only anomaly surfacing.
|
|
|
|
|
|
def escalate_to_ceo(task_id: str, reason: str) -> dict[str, Any]:
|
|
"""Board / Main PM: escalate a strategic task to CEO for final approval."""
|
|
return _post(_role_path("escalate_to_ceo"), {"task_id": task_id, "reason": reason})
|
|
|
|
|
|
# ---------- Cell PM + Main PM extras ----------
|
|
# i_will_plan, delegate, submit_up, give_me_work — restore the pre-Phase-4
|
|
# PM lifecycle so PMs can drive parent tasks instead of stalling.
|
|
|
|
|
|
def i_will_plan(
|
|
task_id: str,
|
|
plan: str,
|
|
approach: str = "",
|
|
sub_tasks: list[dict[str, str]] | None = None,
|
|
technical_considerations: StrList | None = None,
|
|
risks: list[dict[str, str]] | None = None,
|
|
open_questions: list[dict[str, str | bool]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""PM: claim+start a pending parent task with a structured plan.
|
|
|
|
Args:
|
|
task_id: UUID of the task you are planning.
|
|
plan: One-paragraph narrative (the agent-facing summary).
|
|
approach: 2-4 sentences describing the high-level approach for the
|
|
Plan tab. Required for non-trivial tasks; empty string is allowed
|
|
but produces an unpopulated Plan view.
|
|
sub_tasks: Decomposition of this task into sub-units, each
|
|
``{"title": "...", "description": "..."}``. The gateway assigns
|
|
stable ids + order server-side. Populates the Plan tab's
|
|
Sub-Tasks section. Pre-gateway parity.
|
|
technical_considerations: Bullet list of architectural / library /
|
|
constraint notes. Each item is a single string.
|
|
risks: List of {"risk": "...", "mitigation": "..."} entries.
|
|
open_questions: List of {"question": "...", "answered": false} entries.
|
|
"""
|
|
return _post(
|
|
_role_path("i_will_plan"),
|
|
{
|
|
"task_id": task_id,
|
|
"plan": plan,
|
|
"approach": approach,
|
|
"sub_tasks": sub_tasks or [],
|
|
"technical_considerations": technical_considerations or [],
|
|
"risks": risks or [],
|
|
"open_questions": open_questions or [],
|
|
},
|
|
)
|
|
|
|
|
|
def delegate(
|
|
parent_task_id: str,
|
|
title: str,
|
|
description: str,
|
|
assigned_to: str,
|
|
team: str,
|
|
task_type: str,
|
|
nature: str,
|
|
acceptance_criteria: StrList,
|
|
estimated_complexity: str = "medium",
|
|
covers_parent_criteria: StrList | None = None,
|
|
) -> dict[str, Any]:
|
|
"""PM: create a subtask of parent_task_id.
|
|
|
|
Args:
|
|
parent_task_id: UUID of the parent task.
|
|
title: Short imperative title.
|
|
description: Multi-paragraph description with context (>=20 chars).
|
|
assigned_to: Agent slug receiving the task (e.g. "be-dev-1").
|
|
team: One of "backend" | "frontend" | "ux_ui" | "board" | "main_pm".
|
|
task_type: One of "code" | "documentation" | "research" | "planning"
|
|
| "design" | "administrative".
|
|
nature: One of "technical" | "non_technical".
|
|
acceptance_criteria: Non-empty list of verifiable outcome strings.
|
|
estimated_complexity: One of "low" | "medium" | "high". Default "medium".
|
|
covers_parent_criteria: The parent task's acceptance-criterion ids this
|
|
subtask is responsible for. Declare these so the gateway can verify
|
|
EVERY parent criterion is claimed by a subtask and satisfied before
|
|
the parent rolls up — split the parent's criteria across subtasks so
|
|
their union covers all of them.
|
|
"""
|
|
return _post(
|
|
_role_path("delegate"),
|
|
{
|
|
"parent_task_id": parent_task_id,
|
|
"title": title,
|
|
"description": description,
|
|
"assigned_to": assigned_to,
|
|
"team": team,
|
|
"task_type": task_type,
|
|
"nature": nature,
|
|
"acceptance_criteria": acceptance_criteria,
|
|
"estimated_complexity": estimated_complexity,
|
|
"covers_parent_criteria": covers_parent_criteria,
|
|
},
|
|
)
|
|
|
|
|
|
def submit_up(task_id: str, notes: str) -> dict[str, Any]:
|
|
"""Cell PM: bubble a finished cell-scope task up to the Main PM."""
|
|
return _post(_role_path("submit_up"), {"task_id": task_id, "notes": notes})
|
|
|
|
|
|
def submit_root(task_id: str, notes: str) -> dict[str, Any]:
|
|
"""Main PM: open the root→master PR and enter the in-path PR-review gate."""
|
|
return _post(_role_path("submit_root"), {"task_id": task_id, "notes": notes})
|
|
|
|
|
|
def claim_gate_review(task_id: str) -> dict[str, Any]:
|
|
"""PR reviewer: claim an assembled-PR review task. Returns the diff inline."""
|
|
return _post(_role_path("claim_gate_review"), {"task_id": task_id})
|
|
|
|
|
|
def pr_pass(task_id: str, notes: str) -> dict[str, Any]:
|
|
"""PR reviewer: pass the assembled PR (→ awaiting_pm_review)."""
|
|
return _post(_role_path("pr_pass"), {"task_id": task_id, "notes": notes})
|
|
|
|
|
|
def pr_fail(task_id: str, issues: StrList) -> dict[str, Any]:
|
|
"""PR reviewer: fail the assembled PR with concrete issues → needs_revision."""
|
|
return _post(_role_path("pr_fail"), {"task_id": task_id, "issues": issues})
|
|
|
|
|
|
# ---------- Tool registry ----------
|
|
#
|
|
# Maps the verb name an agent calls (matches manifest entries and the
|
|
# orchestrator's role-scoped API path) to the Python implementation.
|
|
# ``pass`` and ``fail`` are reserved keywords, so their Python implementations
|
|
# are renamed but registered under the original names.
|
|
|
|
_TOOLS: dict[str, Any] = {
|
|
# dev
|
|
"give_me_work": give_me_work,
|
|
"i_will_work_on": i_will_work_on,
|
|
"open_pr": open_pr,
|
|
"i_am_done": i_am_done,
|
|
"i_am_blocked": i_am_blocked,
|
|
"unclaim": unclaim,
|
|
"reassign": reassign,
|
|
"resume": resume,
|
|
"sync_branch": sync_branch,
|
|
"i_am_idle": i_am_idle,
|
|
# qa — keys are the public MCP tool names (what agents see and prompts
|
|
# advertise). `pass`/`fail` are Python keywords so the IntentSpec uses
|
|
# `pass_review`/`fail_review` internally; the public-name mapping below
|
|
# bridges the two so the manifest's IntentSpec entries register as
|
|
# `mcp__roboco-flow__pass` / `mcp__roboco-flow__fail`.
|
|
"claim_review": claim_review,
|
|
"pass": pass_review,
|
|
"fail": fail_review,
|
|
# pr reviewer (inbound external/fork PRs)
|
|
"claim_pr_review": claim_pr_review,
|
|
"post_pr_review": post_pr_review,
|
|
# pr reviewer (in-path assembled-PR gate)
|
|
"claim_gate_review": claim_gate_review,
|
|
"pr_pass": pr_pass,
|
|
"pr_fail": pr_fail,
|
|
# doc
|
|
"claim_doc_task": claim_doc_task,
|
|
"i_documented": i_documented,
|
|
# pm
|
|
"triage": triage,
|
|
"triage_all": triage_all,
|
|
"unblock": unblock,
|
|
"complete": complete,
|
|
"escalate_up": escalate_up,
|
|
"i_will_plan": i_will_plan,
|
|
"delegate": delegate,
|
|
"submit_up": submit_up,
|
|
"submit_root": submit_root,
|
|
# board / main pm
|
|
"escalate_to_ceo": escalate_to_ceo,
|
|
}
|
|
|
|
|
|
# IntentSpec verb names → MCP public tool names. The IntentSpec layer uses
|
|
# Python-friendly identifiers (no reserved keywords); the MCP layer exposes
|
|
# the user-facing verb name. Dogfooding surfaced this gap: the manifest carried
|
|
# `pass_review`/`fail_review` (IntentSpec names) but flow_server only had
|
|
# `pass`/`fail` keys, so QA's tools were silently dropped at registration.
|
|
_INTENT_TO_PUBLIC: dict[str, str] = {
|
|
"pass_review": "pass",
|
|
"fail_review": "fail",
|
|
}
|
|
|
|
|
|
def _load_manifest_flow_tools() -> list[str] | None:
|
|
"""Read the spawn manifest and return its ``flow_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(
|
|
"flow_server: cannot read manifest",
|
|
path=str(manifest_path),
|
|
error=str(exc),
|
|
)
|
|
return None
|
|
flow_tools = manifest.get("flow_tools")
|
|
if not isinstance(flow_tools, list):
|
|
log.warning(
|
|
"flow_server: manifest missing flow_tools list",
|
|
path=str(manifest_path),
|
|
)
|
|
return None
|
|
return [str(verb) for verb in flow_tools]
|
|
|
|
|
|
def _register_tools() -> list[str]:
|
|
"""Register MCP tools according to the manifest. Fails loud if absent.
|
|
|
|
The manifest is the role-authoritative tool list. Falling back to
|
|
all-verbs registration (the previous behaviour) caused PMs to see
|
|
developer/QA verbs and call them at wrong URLs (404s). We now refuse
|
|
to start without the manifest.
|
|
|
|
Returns the list of verb names actually registered.
|
|
"""
|
|
allowed = _load_manifest_flow_tools()
|
|
if allowed is None:
|
|
manifest_path = os.environ.get(
|
|
"ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
|
|
)
|
|
msg = (
|
|
f"flow_server: manifest unavailable at {manifest_path};"
|
|
f" refusing to register all-verbs fallback (would let"
|
|
f" {AGENT_ROLE!r} call off-role verbs at wrong URLs)."
|
|
f" Check that the orchestrator wrote the manifest to its"
|
|
f" /app/manifests/ directory and that the agent container"
|
|
f" has the bind-mount."
|
|
)
|
|
log.error("flow_server: manifest missing", role=AGENT_ROLE, path=manifest_path)
|
|
raise RuntimeError(msg)
|
|
public = [_INTENT_TO_PUBLIC.get(verb, verb) for verb in allowed]
|
|
unknown = [verb for verb in public if verb not in _TOOLS]
|
|
if unknown:
|
|
log.warning(
|
|
"flow_server: manifest references unimplemented verbs",
|
|
role=AGENT_ROLE,
|
|
missing=sorted(unknown),
|
|
)
|
|
names = [verb for verb in public if verb in _TOOLS]
|
|
|
|
for verb in names:
|
|
mcp.tool(name=verb)(_TOOLS[verb])
|
|
log.info(
|
|
"flow_server: registered tools",
|
|
role=AGENT_ROLE,
|
|
tools=sorted(names),
|
|
)
|
|
return names
|
|
|
|
|
|
_REGISTERED_TOOLS = _register_tools()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run()
|