Files
roboco/roboco/mcp/do_server.py
T
401f8a2cc9 feat(board): Board Programs — the complete twelve-program catalog (Phases 1-3) (#699)
* feat(board): Pest Control — the first project-scoped Board Program

The Product Owner hunts latent defects (what the org records but nobody
reads): a weekly cycle — accelerated off-schedule when the trailing-7-day
rework rate crosses pest_rework_threshold, with the cheap dedup/scope gates
evaluated before the metrics queries — opens one held exploration task
against the least-recently-explored opted-in project (deterministic
round-robin; opted_in_projects gains a stable ORDER BY), with server-
assembled evidence in the spawn prompt (rework hotspots, recurring-findings
and waived-minor ledger aggregates, all capped) plus prior-cycle LEARN
context. The PO calls the new PO-only propose_bug_hunt verb once: ≤5 items,
evidence required per item, targets validated against pest_control
participation. CEO decides per item — approve materializes a BACKLOG task
(source pest_control, never auto-starts), reject records the reason; both
feed the LEARN ledger by exploration task id; all-terminal completes the
cycle. Telegram queue pushes carry working Approve/Reject handlers
mirroring the roadmap kind. Doctrine: board.md Pest Control section +
product-owner verb entry + regenerated verb tables.

* feat(panel): Pest Control review queue

Command Center gains the pest review queue (per-item approve/reject with
reason, mirroring the roadmap queue); the Programs card and the project
settings participates-in checkboxes pick the new program up registry-driven
— the settings section renders for the first time now that a project-scoped
program exists.

* feat(board): Periscope — HoM market-research brief program

Weekly org-scoped cycle: a solo HoM spawn researches the market (web
research with mandatory source URLs — uncited findings are rejected) and
files one structured brief via the new HoM-only propose_market_brief verb:
headline, cited findings, threats/opportunities, positioning note, all
soup-checked and screened through the injection guard at persist time
(web-derived text later reaches prompts; flags recorded, content never
dropped). A brief is a report, not a proposal: the verb completes the
exploration in the same call (the x_feature asymmetry), the cycle ledger
auto-closes, and the CEO gets a best-effort notification with no
approve/reject surface (periscope deliberately never joins Telegram's
action kinds). The latest brief is injected into the roadmap exploration
prompt — Periscope feeds Printer, the first cross-role program input.

* feat(panel): Market Briefs tab (read-only)

Business page gains a Market Briefs tab listing Periscope briefs —
headline, cited findings, threats/opportunities — read-only by design; a
report has nothing to approve.

* feat(board): Coroner — event-triggered Auditor postmortems

The first EVENT program: no cron — three best-effort hooks open an autopsy
when a task bounces to its 3rd revision (the audit chokepoint), is
cancelled after work started, or is budget-blocked; all gated on arming +
one-open-autopsy dedup, none can fail the underlying transition. A solo
Auditor spawn reads the incident (server-assembled findings + transition
context) and files one propose_postmortem: incident summary, root cause,
failed stage (validated against the real status vocabulary), and ONE
process change — a playbook-kind change drafts via PlaybookService
directly into the normal pending-curation queue; the briefed draft_playbook
manifest grant was deliberately NOT added, preserving the existing
'auditor curates but never drafts' invariant test. Complete-at-propose
(report asymmetry), cycle ledger auto-closes, CEO notified link-only.
Integrated as a union with Periscope across the shared program surfaces.

* feat(panel): Coroner postmortems card

Read-only postmortems list under Business → Programs — incident, root
cause, failed stage, process change; nothing to approve, the process-change
artifact (a draft playbook) rides the existing curation queue.

* feat(board): Sentinel — Auditor drift-watch quality reports

Weekly org-scoped cycle: a solo Auditor spawn receives a server-assembled
drift context (waived-findings trend, open findings by severity,
conventions-violation hotspots, top spend — all capped, pure ORM) and files
one propose_quality_report: headline, 1-7 area-validated items with
evidence and suggested actions, overall assessment. Report semantics —
complete-at-propose, cycle auto-closes, CEO notified display-only (never on
Telegram's approve/reject surface); items are structured so a later
convert-to-task control is cheap. Integration adopts Sentinel's module-
level dict-dispatch for board-program routing (xenon-driven), folding all
prior programs in; app router mounting extracted to a helper for the same
budget.

* feat(panel): Quality Reports tab (read-only)

Business page gains the Sentinel quality-reports tab — headline, per-area
observations with evidence and suggested actions; read-only, a report has
nothing to approve.

* feat(board): Spackle — gap-fill audit program

Biweekly project-scoped PO cycle over the half-shipped surface area: API
routes without panel surfaces (and vice versa), armed flags without docs,
docs promises the code doesn't keep, dead-end tabs — the inventory diffing
is the PO's own read-tool work, ordered by the spawn prompt with file:line
citations required; the server injects only prior-cycle LEARN and the
rotation target. Rotation is now a shared module-level helper
(pick_rotation_target, parameterized by source) both project-scoped
engines use — pest_control delegates to it, behavior-identical, with a
cross-pollution test proving the two programs' rotations stay independent.
propose_gap_fill mirrors the bug-hunt verb (≤5 items, two-sided evidence
required, participation gate); per-item CEO decide materializes BACKLOG
source=spackle tasks; full Telegram kind incl. approve/reject handlers.
All seven program routers now mount from one helper.

* feat(panel): Spackle gap-fill review queue

Command Center gains the gap-fill queue mirroring the pest-control one —
per-item approve/reject with the two-sided gap evidence rendered.

* feat(board): Scales — monthly portfolio rebalance

Org-scoped PO cycle over the stale backlog: the spawn receives a capped
stale-task snapshot (BACKLOG/PENDING unclaimed >30 days) plus the charter
and prior-cycle LEARN, and files one propose_rebalance — 1-7 items, each a
resolvable task_ref with action reprioritize (validated new priority) or
cancel, rationale required. Per-item CEO decide: approve EXECUTES the
action (audited priority update, or the normal cancel path) — the first
program whose materializer mutates existing tasks instead of creating
them; reject records the reason; LEARN by exploration task id;
all-terminal completes the cycle. Full Telegram decide-kind wiring.
Integrated as the eight-program union (registry, dict dispatch, routers
helper, teardown enumerations).

* feat(panel): Scales rebalance review queue

Command Center gains the rebalance queue — per-item approve/reject with
the action, target task, and rationale rendered.

* feat(board): Mirror — quarterly positioning audit

Project-scoped HoM cycle over messaging surfaces: README claims vs shipped
reality, docs-site promises vs code, charter alignment — the audit is the
HoM's own read-tool work with citations required; the server injects the
charter, prior-cycle LEARN, and the shared rotation target. propose_
messaging_fixes mirrors the gap-fill verb (≤5 items, drift evidence naming
claim + contradicting reality, participation gate); per-item CEO decide
materializes BACKLOG source=mirror documentation tasks; full Telegram
decide-kind wiring. Nine-program union across the shared surfaces.

* feat(panel): Mirror messaging-fixes review queue

* feat(board): Megaphone — HoM standing editorial calendar

Cron cycle (3 days, org-scoped, gated on X credentials — drafting content
nobody can post is pointless): the HoM receives a shipped-this-week digest
plus Unreleased changelog bullets and files one propose_editorial_post
(angle-validated, ≤280, brand voice) that materializes a held x_editorial
draft through the SAME X-queue origination chokepoint release posts use —
zero new approval surface, notifications and CEO decide for free.
Complete-at-propose; cycle auto-closes. Ten-program union.

* feat(panel): x_editorial source labels in the X queue surfaces

* feat(board): Librarian — proactive playbook mining

Biweekly org-scoped Auditor cycle: mines recurring non-private learning
journals (≥2-count grouping with a recency fallback) against the existing
playbook-title inventory and files one propose_playbook_drafts — 1-3
drafts, each with the repeated-pattern evidence that justifies it,
duplicate titles rejected in-batch and against the live store. Drafts are
created via PlaybookService directly (the Coroner precedent — the
'auditor curates but never drafts' do-verb invariant stays intact and
tested) and land in the normal pending-curation queue the Auditor's own
triage already surfaces; no new panel surface. Complete-at-propose;
display-only CEO notification. Eleven-program union.

* feat(board): War Room — release campaign planning

EVENT program with a REAL originator (unlike coroner's stub): a release
publish hooks a campaign brief beside the release-post seam, and the CEO's
run-now originates on demand — the cron loop never fires it. The HoM
designs a 2-6 post arc (teaser → launch → follow-up → spotlight; 280-cap,
future strictly-ascending publish_after, stage vocabulary) and one
propose_campaign call materializes each post as a held x_campaign draft
through the X-queue chokepoint. V1 is manual-cadence by design: publish_
after renders as queue guidance and the CEO approves each post at its
moment — nothing auto-posts, ever; the auto-schedule upgrade is a
documented ceiling. Twelve-program union: full registry complete.

* feat(panel): x_campaign labels + publish-after guidance in the X queue

* feat(board): Barfly — adjacent-conversation replies

Cron cycle (2 days, org-scoped, X-credentials gated): the engine searches
X for conversations where RoboCo is relevant but unmentioned (new OAuth-
signed search_recent on the client; queries + candidate cap configurable),
screens every fetched tweet through the injection guard (stored unclamped
— a clamp was truncating the candidate under the envelope, caught by the
dev's own tests), dedupes via the existing x_seen_mentions ledger (no
migration; also prevents double-drafting against the mentions poll), and
opens one held HoM exploration carrying the screened candidates. propose_
conversation_replies enforces candidate-id-only replies (≤5, 280-cap);
each materializes a held x_barfly draft through the X-queue chokepoint,
threaded via a new in_reply_to seam on post_tweet that only x_barfly
drafts use. The X redraft machinery is now dict-dispatch over per-source
extractors with reply-ref carry for x_barfly. Thirteen-program registry.
War Room's test fakes gained the new abstract search_recent stub.

* feat(board): Dogfood — the PO walks the product

The fourteenth and final registry entry, completing the catalog. EVENT
program (release-publish hook beside the war-room hook + CEO run-now, both
through the same real originator; the cron loop never fires it), project-
scoped with shared rotation. The permission surface is the careful part:
the PO's dogfood spawn — and ONLY that spawn — gets the Playwright MCP
mounted, via a task-scoped fail-closed probe mirroring the video-authoring
precedent (a PO spawned for roadmap/pest/scales never sees browser tools;
tested both ways); the PM agent image bakes chromium unconditionally like
the ux image, the mount stays task-gated in code. The walk targets the
rotation target's live surfaces (panel_base_url only when the target is
the org's own project, honest degradation otherwise); propose_friction_
fixes files ≤5 walked-path-evidenced items; per-item CEO decide
materializes BACKLOG source=dogfood tasks; full Telegram decide kind.
Also: megaphone/librarian/war_room arming keys restored to the settings
validator — their panel toggles would have been rejected (dropped in
earlier unions; the same silent-arming class the drill killed once
already).

* feat(panel): Dogfood friction review queue

* chore(board): final whole-branch sweep fixes

The night's closing adversarial pass over the integrated fourteen-program
registry found ONE functional defect — the war-room test fakes' post_tweet
predated Barfly's in_reply_to_tweet_id kwarg (LSP violation, the only red
in an otherwise fully green gate) — plus doc/test drift, all fixed: the
source-parity test completes to fourteen (spackle/mirror were silently
absent while its neighboring comment claimed full coverage), the PO
identity doc gains its missing Dogfood verb, the auditor quick-list gains
propose_postmortem, three stale comments corrected (rotation docstring,
panel registry header, X source enumerations), the dogfood release-hook
gains the exception-swallow test its four sibling hooks already had, and
the CHANGELOG's Unreleased section documents the whole Board Programs
train. Full make quality: exit 0, all gates green.

* docs: full documentation sweep for the Board Programs train

CLAUDE.md's roadmap-engine entry superseded by the Board Program registry
entry (all fourteen programs, arming, scoping, LEARN, guardrails) with the
role verb tables and playwright row refreshed; docs/rag gains the agent-
facing architecture doc plus full propose_* call-shape sections in the
three board role docs, and corrects the strategy-engine section to shipped
reality (only idle→roadmap is wired); docs/map covers the registry + all
twelve engines with flags, gotchas, and drift notes. The 0.27.0 reference
inventory confirmed only the release-executor's canonical set carries the
version — left for the 0.28.0 cut.

* feat(board): human titles + descriptions on every program surface

Raw registry keys rendered as bare panel labels — an operator reading
x_feature had no idea what enabling or running it does. The registry
dataclass gains title/description (test-enforced non-empty for every
entry, unique titles), the API passes them through, and every surface
renders title-with-description-tooltip instead of the key: the Programs
card (label, toggle hint, run-now toast), and the project settings
participates-in/excluded-from checkboxes.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-25 17:13:32 +02:00

1424 lines
57 KiB
Python

"""roboco-do MCP server — smart-wrapped content tools.
Forwards to /api/v1/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/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
from roboco.agents_config import get_agent_team
ORCHESTRATOR_URL = os.environ.get(
"ROBOCO_ORCHESTRATOR_URL",
"http://roboco-orchestrator:8000",
)
# Per-agent SDK loopback for the per-verb circuit breaker.
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
# commit() stages + `git commit`s in-process (no push — push is the flow
# verb open_pr, already covered by flow_server's per-verb timeout), bounded
# server-side by git_commit_timeout_seconds (default 180s: a large changeset,
# e.g. the panel's hundreds of files, can legitimately take that long). The
# shared _TIMEOUT above is tuned for fast content-tool calls (note/dm/
# evidence) and would give up first — client must outlast the server op.
_COMMIT_TIMEOUT = 190
# request_sandbox provisions inline; ensure_sandbox now always provisions the
# project's FULL opted-in set on first call (kills the superset/teardown
# race — see ensure_sandbox's docstring), so an all-three-cold first request
# is the norm, not the rare case. Worst case: 3 x 300s cold pulls
# (SandboxProvisioner._DOCKER_PULL_TIMEOUT_SECONDS) + readiness (~135s) ~=
# 1035s — images are pre-pulled at startup in practice, so cold pulls here
# are the exception, but the timeout must cover the worst case anyway.
_SANDBOX_TIMEOUT = 1080
# request_render runs an inline sidecar render call; the sidecar's own render
# ceiling is video_render_timeout_seconds (default 600s) plus its request
# timeout for uploading the tarball — 660s gives that headroom.
_RENDER_TIMEOUT = 660
# Tight timeout for SDK loopback — local sidecar; gateway path must not stall.
_SDK_TIMEOUT = 2.0
# FastAPI's default missing-route status. Every /api/v1/do/* route returns
# 200 with an Envelope (including not_found rejections), so a 404 from the
# orchestrator is always a manifest-registered tool whose HTTP route is
# missing — synthesize an invalid_state Envelope for it.
_MISSING_ROUTE_STATUS = 404
# Envelope error kinds that count toward the per-verb circuit breaker.
# Mirrors flow_server._CIRCUIT_REJECTION_KINDS — agent_sdk.server is the
# authoritative side; the same set must be applied here so the do-server
# (content tools) gets the same protection as flow-server (intent verbs).
# Dogfooding surfaced the gap: `note(scope='decision')` looped 8 times
# returning `incomplete_input` with no breaker.
_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. 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).
#
# The exact map is authoritative for the codes the handlers actually emit, so
# a known code is never misclassified by an accidental substring — e.g.
# AUTHENTICATION_REQUIRED carries no AUTHORIZED/DENIED/PERMISSION substring and
# under a substring-only rule dropped to ``invalid_state`` instead of
# ``not_authorized`` (#161). The NOT_FOUND family maps to 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). Unknown codes
# fall through to a substring branch so a new RobocoError code still lands on a
# counted kind without a map update. Mirrors flow_server.
_DICT_ERROR_CODE_MAP: dict[str, str | None] = {
"AUTHENTICATION_REQUIRED": "not_authorized",
"JOURNAL_ACCESS_DENIED": "not_authorized",
"PERMISSION_DENIED": "not_authorized",
"INVALID_INPUT": "incomplete_input",
"VALIDATION_ERROR": "incomplete_input",
"NOT_FOUND": None,
"INVALID_STATE": "invalid_state",
"TASK_LIFECYCLE_ERROR": "invalid_state",
"TASK_OWNERSHIP_ERROR": "invalid_state",
"SERVICE_ERROR": "invalid_state",
"FETCH_FAILED": "invalid_state",
"LIST_FAILED": "invalid_state",
"READ_FAILED": "invalid_state",
"SEARCH_FAILED": "invalid_state",
"WRITE_FAILED": "invalid_state",
}
def _classify_dict_error_code(code: str) -> str | None:
upper = code.upper()
if upper in _DICT_ERROR_CODE_MAP:
return _DICT_ERROR_CODE_MAP[upper]
# Unknown code — substring fallback for forward-compat with new codes.
if "NOT_FOUND" in upper:
return None
if (
"DENIED" in upper
or "AUTHORIZED" in upper
or "AUTH" in upper
or "FORBIDDEN" in upper
or "PERMISSION" in upper
):
return "not_authorized"
if "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:
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
def _remediate_for_kind(kind: str, verb: str) -> str:
"""A directed recovery hint for a synthesized Envelope kind.
Mirrors flow_server: the orchestrator's exception handlers return a dict
`error` with no `remediate`; without a hint the agent has no directed next
action and flails until the breaker trips.
"""
if kind == "not_found":
return (
"the call targeted a resource that does not exist; re-fetch state"
" and retry on a current id; do not retry the same id."
)
if kind == "incomplete_input":
return (
f"the {verb} call was rejected as incomplete input — re-issue it"
f" with the missing/invalid fields (see `detail` for the exact"
f" validation errors); do not retry blindly."
)
if kind == "not_authorized":
return (
f"you are not authorized for this {verb} action; use delegate /"
f" escalate_up, or call i_am_blocked(reason=...) if the gate is"
f" genuinely wrong; do not retry the same action."
)
return (
f"service error on {verb} — re-fetch state and re-issue; call"
f" i_am_blocked or i_am_idle if it persists; do not retry blindly."
)
def _normalize_exception_envelope(
payload: dict[str, Any], path: str
) -> dict[str, Any] | None:
"""Synthesize an Envelope-wire-format dict from an exception-handler body.
Mirrors flow_server (#232): FastAPI's exception handlers return ``error``
as a DICT (``{code, message, details?}``) or a bare ``detail`` list (422) —
neither is the Envelope wire format the agent trusts (string ``error`` +
``message`` + ``remediate`` + ``missing``). This lifts the body into a real
Envelope so the agent gets a directed remediate instead of flailing until
the breaker trips. Returns None for a real Envelope (string ``error`` or
success) so those pass through unchanged.
"""
error = payload.get("error")
if isinstance(error, dict):
code = str(error.get("code") or "")
kind = _classify_dict_error_code(code)
if kind is None:
kind = "not_found"
verb = _verb_from_path(path)
message = str(error.get("message") or "") or f"orchestrator error ({code})"
return {
"error": kind,
"message": message,
"remediate": _remediate_for_kind(kind, verb),
"missing": [],
"details": error,
}
if "detail" in payload and "error" not in payload:
verb = _verb_from_path(path)
return {
"error": "incomplete_input",
"message": f"the {verb} call was rejected as incomplete input",
"remediate": _remediate_for_kind("incomplete_input", verb),
"missing": [],
"detail": payload.get("detail"),
}
return None
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.
"""
# X-Agent-Token + X-Agent-Team must travel with every do verb or the
# API's ROBOCO_AGENT_AUTH_REQUIRED gate 401s — mirrors flow_server and
# the ApiClient header path used by the other MCP servers.
headers = {
"X-Agent-ID": AGENT_ID,
"X-Agent-Role": AGENT_ROLE,
"X-Correlation-ID": str(uuid.uuid4()),
}
team = get_agent_team(AGENT_ID)
if team:
headers["X-Agent-Team"] = team
token = os.environ.get("ROBOCO_AGENT_TOKEN")
# See flow_server._build_headers: forwarding the "UNSIGNED" sentinel 401s
# even in dev mode; omit so a missing token is accepted in dev.
if token and token != "UNSIGNED":
headers["X-Agent-Token"] = token
return headers
def _post(
path: str, body: dict[str, Any], *, timeout: float = _TIMEOUT
) -> 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.
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 open, the original rejection is
REPLACED with circuit_open. Dogfooding surfaced the gap: do-server had
no breaker and `note(scope='decision')` looped 8 times returning
incomplete_input.
``timeout`` overrides the default for a slow tool (e.g. commit's
_COMMIT_TIMEOUT) — must always outlast that tool's server-side budget.
"""
with httpx.Client(timeout=timeout) as client:
response = client.post(
f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(),
json=body,
)
# A 404 here usually means a manifest-registered content tool has no
# matching route on the orchestrator: every /api/v1/do/* route returns
# 200 with an Envelope (including not_found rejections), so FastAPI's
# default 404 body (``{"detail": "Not Found"}``) is a missing route —
# synthesize an ``invalid_state`` Envelope so the breaker counts it and
# the agent gets a wiring-gap remediation hint. A 404 carrying a real
# Envelope (``error`` field) is surfaced as-is; a 404 with a
# *descriptive* ``detail`` (not the bare default) is a real resource
# not_found, surfaced as ``not_found`` (#61). Mirrors flow_server._post.
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:
payload_404: dict[str, Any] = body_404
elif (
isinstance(body_404, dict)
and isinstance(body_404.get("detail"), str)
and body_404["detail"] != "Not Found"
):
# A real HTTP 404 with a descriptive detail — a resource
# not_found, not a missing route (#61).
verb = _verb_from_path(path)
payload_404 = {
"error": "not_found",
"message": body_404["detail"],
"remediate": (
f"the {verb} call targeted a resource that does not"
f" exist (HTTP 404: {body_404['detail']}). Re-fetch"
f" state and retry on a current id; do not retry the"
f" same id."
),
"missing": [],
}
else:
verb = _verb_from_path(path)
payload_404 = {
"error": "invalid_state",
"message": (
f"content tool '{verb}' has no route on the"
f" orchestrator (path {path})"
),
"remediate": (
f"the {verb} tool 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='tool {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):
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 so the SDK call is its own connection.
# A non-404 JSON body that is NOT a real Envelope (dict `error` from an
# exception handler, or a 422 `detail` list) is normalized to the Envelope
# wire format so the agent gets a string `error` kind + remediate (#232).
# Mirrors flow_server; the synthesized Envelope still flows through the
# breaker below — its string kind is in the counted set.
normalized = _normalize_exception_envelope(payload, path)
if normalized is not None:
payload = normalized
return _record_and_check_circuit(path, body, payload)
def _verb_from_path(path: str) -> str:
"""Extract the verb name from a do-server path.
``/api/v1/do/<verb>`` → ``<verb>``. Returns the original path if it
doesn't match the expected shape (defensive — 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 content-tool 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. 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:
log.warning(
"do_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):
# Copy so the SDK's envelope dict is not mutated in place. Nest the
# original fixable rejection as ``inner`` so its kind/message/remediate
# survive the substitution — the circuit_open envelope only says the
# breaker tripped, not WHY the verb failed (#60). Mirrors flow_server.
circuit_env: dict[str, Any] = dict(status["circuit_envelope"])
circuit_env["inner"] = payload
# Lift task_id/correlation_id from the original rejection to the top
# level (the SDK's envelope omits them) so the agent's envelope contract
# and ops audit-join still work — not just nested in `inner` (#359).
circuit_env["task_id"] = payload.get("task_id")
circuit_env["correlation_id"] = payload.get("correlation_id")
log.info(
"do_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
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/v1/do/commit",
{"message": message, "files": files},
timeout=_COMMIT_TIMEOUT,
)
def note(
text: str,
scope: str = "note",
task_id: str | None = None,
title: str | None = None,
context: str = "",
options: list[dict[str, str]] | dict[str, str] | None = None,
chosen: str = "",
rationale: str = "",
consequences: list[str] | str | None = None,
what_done: str = "",
what_learned: str = "",
what_struggled: str = "",
next_steps: list[str] | str | None = None,
section: dict[str, Any] | None = None,
done: str = "",
next: str = "",
where_to_look: list[str] | None = None,
) -> dict[str, Any]:
"""Write a journal entry, or (scope='handoff') your note SECTION.
scope in note|decision|reflect|learning|struggle|handoff.
``text`` is always the short summary (one paragraph max). For ``decision``
and ``reflect`` scopes the structured fields are RECOMMENDED — fill what
you can. The note is always recorded; missing narrative fields default to
a visible placeholder rather than being rejected.
- decision: ``context`` (situation), ``options`` (list of dicts
``{name, pros, cons}`` — a single dict is accepted), ``chosen`` (which
option), ``rationale`` (why), ``consequences`` (list of strings — what
this commits us to; a single string is accepted)
- reflect: ``what_done`` (literal output), ``what_learned`` (new info),
``what_struggled`` (where you got stuck), ``next_steps`` (list of
follow-up strings; a single string is accepted)
List-typed fields (``options``, ``consequences``, ``next_steps``) tolerate
a lone value — pass either a list or a single item.
Other journal scopes (note / learning / struggle) just need ``text``.
scope='handoff' writes your dedicated SECTION (dev_notes / quick_context /
auditor_notes) instead of the journal. For a PM/coordinator RESUMPTION
section pass the TOP-LEVEL fields ``done`` (what's been done) and ``next``
(the immediate next step) — these are the required fields and they show
up here as discrete string params; ``where_to_look`` is optional. (Do NOT
pass an empty ``section={}``; the ``section`` dict is the free-form path
for other content types — developer ``{summary, changes}``, auditor
``{summary, severity}``.) Or just ``text`` for a developer summary.
"""
return _post(
"/api/v1/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,
"section": section,
"done": done,
"next": next,
"where_to_look": where_to_look,
},
)
def pitch(
title: str,
slug: str,
problem: str,
proposed_solution: str,
target_cells: list[str],
) -> dict[str, Any]:
"""Board: propose a product. Queues for the CEO's approval, then auto-provisions.
Args:
title: Short product name.
slug: URL-safe id (lowercase letters, digits, hyphens), e.g. 'widget-store'.
problem: The problem this product solves.
proposed_solution: How you propose to solve it.
target_cells: Cells that should build it — any of 'backend', 'frontend',
'ux_ui'.
"""
return _post(
"/api/v1/do/pitch",
{
"title": title,
"slug": slug,
"problem": problem,
"proposed_solution": proposed_solution,
"target_cells": target_cells,
},
)
def propose_roadmap(cycle_goal: str, items: list[dict[str, Any]]) -> dict[str, Any]:
"""Product Owner: propose a themed roadmap cycle (3-7 item drafts).
Call this exactly ONCE per exploration cycle, after exploring the
charter, recent releases, metrics, and each project's state. The CEO
reviews and approves/rejects each item individually; approved items land
in the backlog (nothing auto-starts).
Args:
cycle_goal: One-line theme tying the cycle's items together.
items: 3-7 drafts, each a dict with: title, description,
acceptance_criteria (list[str]), project_slug, team
('backend'|'frontend'|'ux_ui'), priority (int, default 2),
rationale (why this, why now).
"""
return _post(
"/api/v1/do/propose_roadmap",
{"cycle_goal": cycle_goal, "items": items},
)
def propose_bug_hunt(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Product Owner: propose a Pest Control bug hunt (1-5 evidence-backed items).
Call this exactly ONCE per exploration cycle, after hunting through the
findings ledger, rework hotspots (revision_count), and TODO/ponytail debt
for latent defects the org records but nobody reads. The CEO reviews and
approves/rejects each item individually; approved items land in the
backlog (nothing auto-starts).
Args:
items: 1-5 drafts, each a dict with: title, description,
acceptance_criteria (list[str]), project_slug, team
('backend'|'frontend'|'ux_ui'), priority (int, default 2),
evidence (REQUIRED — the file:line / ledger row / metric that
justifies this as a real, live bug; no evidence, no item).
"""
return _post("/api/v1/do/propose_bug_hunt", {"items": items})
def propose_gap_fill(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Product Owner: propose a Spackle gap-fill audit (1-5 evidence-backed items).
Call this exactly ONCE per exploration cycle, after auditing half-shipped
surface area in the target project: API routes with no panel surface (and
vice versa), armed flags with no docs, docs promises the code doesn't
keep, coverage holes by module, dead-end panel tabs. The CEO reviews and
approves/rejects each item individually; approved items land in the
backlog (nothing auto-starts).
Args:
items: 1-5 drafts, each a dict with: title, description,
acceptance_criteria (list[str]), project_slug, team
('backend'|'frontend'|'ux_ui'), priority (int, default 2),
evidence (REQUIRED — must name BOTH sides of the gap, e.g. the
route that exists and the panel surface that doesn't; no
evidence, no item).
"""
return _post("/api/v1/do/propose_gap_fill", {"items": items})
def propose_rebalance(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Product Owner: propose a Scales portfolio-rebalance plan (1-7 items).
Call this exactly ONCE per exploration cycle, after reviewing the
server-assembled stale-backlog snapshot against the company charter.
Each item re-prioritizes or cancels ONE live BACKLOG/PENDING task — this
never drafts a new task. The CEO reviews and approves/rejects each item
individually; approval MUTATES the target task in place (nothing here
changes anything itself).
Args:
items: 1-7 dicts, each with: task_ref (the id8 or exact title of the
live task this item targets), action ('reprioritize' or
'cancel'), new_priority (int 0-3, REQUIRED iff action is
'reprioritize' — 0 is P0/highest, 3 is P3/lowest), rationale
(REQUIRED — why this task should be re-prioritized or cancelled).
"""
return _post("/api/v1/do/propose_rebalance", {"items": items})
def propose_messaging_fixes(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Head of Marketing: propose a Mirror positioning audit (1-5
evidence-backed items).
Call this exactly ONCE per exploration cycle, after auditing the target
project's messaging surfaces (README, docs-site, website) against the
charter and shipped reality: claims the copy makes that the code doesn't
back, and shipped capabilities the copy doesn't mention. The CEO reviews
and approves/rejects each item individually; approved items land in the
backlog as docs tasks (nothing auto-starts).
Args:
items: 1-5 drafts, each a dict with: title, description,
acceptance_criteria (list[str]), project_slug, team
('backend'|'frontend'|'ux_ui'), priority (int, default 2),
evidence (REQUIRED — must name the drifted claim AND the reality
it contradicts; no evidence, no item).
"""
return _post("/api/v1/do/propose_messaging_fixes", {"items": items})
def propose_friction_fixes(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Product Owner: propose a Dogfood friction audit (1-5 evidence-backed items).
Call this exactly ONCE per exploration cycle, after walking the target
project's live surfaces as a real user would (the panel via Playwright,
the docs site, the Telegram flow when reachable) and filing what broke or
felt wrong. The CEO reviews and approves/rejects each item individually;
approved items land in the backlog (nothing auto-starts).
Args:
items: 1-5 drafts, each a dict with: title, description,
acceptance_criteria (list[str]), project_slug, team
('backend'|'frontend'|'ux_ui'), priority (int, default 2),
evidence (REQUIRED — the walked path, the actual clicks/pages,
and what broke or felt wrong; prose only, no screenshots; no
evidence, no item).
"""
return _post("/api/v1/do/propose_friction_fixes", {"items": items})
def propose_feature_spotlight(
feature_slug: str = "",
feature_title: str = "",
body: str = "",
wants_video: bool = False,
video_script: str = "",
skip: bool = False,
skip_reason: str = "",
) -> dict[str, Any]:
"""Head of Marketing: draft ONE feature-spotlight marketing post, or skip.
Call this exactly ONCE per exploration cycle, after investigating the
CHANGELOG, feature-flags ledger, docs/map, charter, and KB to pick a real,
under-publicized capability. The draft is held in the X post queue for the
CEO to edit/approve — nothing auto-posts.
If nothing shipped is genuinely worth spotlighting this cycle, pass
skip=True with a substantive skip_reason instead of forcing a weak post —
a forced spotlight is worse than skipping. A skip still completes the
exploration task (no draft materialized, no feature marked seen) and
counts as this cycle's activity for the engine's cadence, so it won't just
re-fire daily into the same quiet period.
Args:
feature_slug: Stable slug identifying the feature (the dedup key).
Ignored when skip=True.
feature_title: Short human title of the feature. Ignored when
skip=True.
body: The tweet text (plain, <=280 chars, no invented facts). Ignored
when skip=True.
wants_video: Also request a companion video (held separately for CEO
approval, when the video engine is armed for spotlights).
video_script: Optional script for that video; falls back to the
feature title/body when omitted.
skip: True to declare "nothing worth spotlighting this cycle" instead
of authoring a draft.
skip_reason: Required (non-empty, >=8 chars) explanation when
skip=True.
"""
return _post(
"/api/v1/do/propose_feature_spotlight",
{
"feature_slug": feature_slug,
"feature_title": feature_title,
"body": body,
"wants_video": wants_video,
"video_script": video_script,
"skip": skip,
"skip_reason": skip_reason,
},
)
def propose_market_brief(
headline: str,
findings: list[dict[str, Any]],
threats: list[str] | None = None,
opportunities: list[str] | None = None,
positioning_note: str = "",
) -> dict[str, Any]:
"""Head of Marketing: file the Periscope weekly market-research brief.
Call this exactly ONCE per exploration cycle, after researching
competitors, adjacent-tool releases, and positioning shifts (web_search /
web_fetch + the knowledge base). This is a REPORT, not a task queue: the
CEO reads it in the panel — nothing materializes, and there is no
per-item approve/reject.
Args:
headline: One-line summary of the cycle's biggest signal (<=200 chars).
findings: 1-7 dicts, each with: claim (<=500 chars), source_url
(REQUIRED, a real http(s) URL — an uncited claim is noise and the
verb rejects it), relevance (<=300 chars — why this matters to us).
threats: Optional list of up to 5 short threat notes (<=300 chars each).
opportunities: Optional list of up to 5 short opportunity notes
(<=300 chars each).
positioning_note: Optional note (<=500 chars) on a positioning shift
worth acting on.
"""
return _post(
"/api/v1/do/propose_market_brief",
{
"headline": headline,
"findings": findings,
"threats": threats,
"opportunities": opportunities,
"positioning_note": positioning_note,
},
)
def propose_editorial_post(
angle: str,
body: str,
rationale: str,
) -> dict[str, Any]:
"""Head of Marketing: draft ONE Megaphone editorial-calendar post.
Call this exactly ONCE per exploration cycle, after picking ONE angle off
the shipped-this-week digest in your briefing. The draft is held in the
SAME X post queue propose_feature_spotlight uses — nothing auto-posts.
Args:
angle: One of 'dev_log', 'behind_scenes', 'changelog_highlight',
'other'.
body: The tweet text (plain, <=280 chars, no invented facts).
rationale: Why this angle, this cycle (<=300 chars).
"""
return _post(
"/api/v1/do/propose_editorial_post",
{"angle": angle, "body": body, "rationale": rationale},
)
def propose_conversation_replies(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Head of Marketing: draft 1-5 replies to Barfly's screened X conversations.
Call this exactly ONCE per exploration cycle, after reviewing the
candidate conversations already gathered on this task (search results —
RoboCo is relevant but unmentioned). Each reply materializes its own held
draft in the X post queue; the CEO reviews and approves/rejects each one
individually.
Args:
items: 1-5 dicts, each with: tweet_id (REQUIRED — must name one of
this task's real candidate conversations; an invented tweet is
rejected), reply_body (the reply text, <=280 chars), rationale
(REQUIRED — why this conversation is worth replying to).
"""
return _post("/api/v1/do/propose_conversation_replies", {"items": items})
def propose_postmortem(
incident_summary: str,
root_cause: str,
failed_stage: str,
process_change: dict[str, Any],
playbook: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Auditor: author ONE Coroner postmortem for your open autopsy task.
Call this exactly ONCE per autopsy — after reading the incident task's
full journey (evidence(), the findings ledger, journal trail) — then
call i_am_idle(). It completes the autopsy task immediately: there is no
per-item CEO queue like roadmap/pest control, this is a single report.
Args:
incident_summary: What happened, <=500 chars.
root_cause: The systemic cause (not just the symptom), <=800 chars.
failed_stage: The task status where it actually broke down (a real
lifecycle status, e.g. 'awaiting_qa', 'in_progress').
process_change: {kind, description} — kind is one of
'playbook'|'prompt_fix'|'conventions_rule'|'other'; description
(<=800 chars) is the ONE process change you propose.
playbook: Required iff process_change.kind == 'playbook':
{title, body} — drafted immediately as a DRAFT playbook that
rides the normal pending-playbook curation queue.
"""
return _post(
"/api/v1/do/propose_postmortem",
{
"incident_summary": incident_summary,
"root_cause": root_cause,
"failed_stage": failed_stage,
"process_change": process_change,
"playbook": playbook,
},
)
def propose_quality_report(
headline: str,
items: list[dict[str, Any]],
overall_assessment: str,
) -> dict[str, Any]:
"""Auditor: file the Sentinel weekly "state of quality" report.
Call this exactly ONCE per exploration cycle, after assessing org-wide
drift: waiver-accumulation trends, conventions-violation hotspots, and
budget anomalies (the evidence is server-assembled for you in the task
prompt). This is a REPORT, not a task queue: the CEO reads it in the
panel — nothing materializes, and there is no per-item approve/reject.
Args:
headline: One-line summary of the cycle's biggest quality signal
(<=200 chars).
items: 1-7 dicts, each with: area (one of 'waivers', 'findings',
'conventions', 'budget', 'docs', 'other'), observation (<=500
chars — what you found), evidence (<=500 chars — the ledger row
/ metric / file that backs it), suggested_action (<=300 chars —
what should happen next).
overall_assessment: A synthesis across all items (<=800 chars).
"""
return _post(
"/api/v1/do/propose_quality_report",
{
"headline": headline,
"items": items,
"overall_assessment": overall_assessment,
},
)
def propose_playbook_drafts(drafts: list[dict[str, Any]]) -> dict[str, Any]:
"""Auditor: mine journals/learnings for repeated patterns and draft 1-3
playbooks for your open Librarian mining task.
Call this exactly ONCE per mining cycle, after reading the recurring-
learning-topics + existing-playbook-titles evidence server-assembled
into your task prompt. Each draft is created immediately as a real DRAFT
playbook riding the normal pending-playbook curation queue (the same one
your own approve_playbook/reject_playbook curate) — a LATER cycle
curates them, never this same call.
Args:
drafts: 1-3 dicts, each with: title (<=200 chars, must not duplicate
an existing playbook title — case-insensitive), body (<=4000
chars — the procedure itself), pattern_evidence (REQUIRED,
<=500 chars — which repeated journal/learning pattern justifies
this playbook).
"""
return _post("/api/v1/do/propose_playbook_drafts", {"drafts": drafts})
def propose_campaign(campaign_name: str, posts: list[dict[str, Any]]) -> dict[str, Any]:
"""Head of Marketing: propose a War Room campaign (2-6 ordered X posts).
Call this exactly ONCE per exploration cycle, after designing the
campaign arc (teaser -> launch -> follow-up -> spotlight) for the release
named in your briefing, or from scratch on a CEO on-demand cycle. Every
post lands as a held draft in the X post queue; V1 is manual-cadence —
publish_after is GUIDANCE the CEO sees when reviewing each draft, never a
schedule anything acts on. Nothing here posts.
Args:
campaign_name: Short name tying the posts together (<=100 chars).
posts: 2-6 dicts, IN THE ORDER they should run, each with: body (the
tweet text, <=280 chars), publish_after (ISO 8601 datetime,
REQUIRED — must be in the future and strictly later than the
previous post's), stage_label (one of 'teaser', 'launch',
'follow_up', 'spotlight', 'other').
"""
return _post(
"/api/v1/do/propose_campaign",
{"campaign_name": campaign_name, "posts": posts},
)
def propose_video(
composition_id: str,
x_caption: str,
tiktok_caption: str,
platforms: list[str],
input_props: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""UX/UI dev: propose your video's composition + captions. Metadata only —
this does NOT render (rendering happens later, off this path).
Before authoring, read motion/README.md for the design bar and
motion/kit/README.md for the panel-demo kit — build in the panel-demo
register on motion/kit/ (extend motion/compositions/panel-demo/) rather
than starting from scratch or shipping a text card, unless the occasion
has no product visual to show.
Call this exactly ONCE per authoring task, after building the HyperFrames
composition in motion/compositions/<id>/. Then commit + open_pr to send
it through the normal PR-review gate.
After proposing, call request_render and verify the frames it returns
before i_am_done — the composition's SOURCE looking right is not the
same as the RENDERED artifact looking right.
Args:
composition_id: The HyperFrames composition id (the directory name
under motion/compositions/, e.g. 'release-announcement').
x_caption: X post text for this clip (<=280 chars).
tiktok_caption: TikTok caption for this clip (<=2200 chars).
platforms: Target platforms for this clip — any of 'x', 'tiktok'.
input_props: Optional props passed into the composition at render time.
"""
return _post(
"/api/v1/do/propose_video",
{
"composition_id": composition_id,
"x_caption": x_caption,
"tiktok_caption": tiktok_caption,
"platforms": platforms,
"input_props": input_props,
},
)
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/v1/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 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/v1/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/v1/do/evidence", {"task_id": task_id})
def request_sandbox(
services: list[str] | None = None,
extensions: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
"""Provision (or reuse) a throwaway sandbox DB/Redis/Mongo for YOUR active task.
On-demand — nothing is provisioned at spawn. Omit ``services`` to get the
project's whole opted-in set; requesting a service the project didn't opt
into is rejected with the allowed set named. ``extensions`` (e.g.
``{"postgres": ["vector", "postgis"]}``) is an additive per-call override
unioned with the project's standing ``sandbox_extensions`` and bounded by
the opted set + the allowlist — a name outside the allowlist (e.g.
``plpython3u``) is rejected with the allowed set named. Creds come back in
``evidence``, one entry per service: ``{host, port, user, password,
database, env: {ROBOCO_TEST_*: value}, available_extensions?: [...]}`` —
export the ``env`` values verbatim for gate tooling that reads them. The
whole opted-in set is provisioned on first call, so calling this again for
any subset or superset of it is a cheap no-op (same creds, no
re-provisioning); a project that never opted into sandbox services will
reject this.
"""
return _post(
"/api/v1/do/request_sandbox",
{"services": services, "extensions": extensions},
timeout=_SANDBOX_TIMEOUT,
)
def request_render(
composition_id: str | None = None,
orientation: str = "vertical",
frame_count: int = 8,
input_props: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Render your video composition to a strip of preview frames — call
this after building or altering the HyperFrames composition, on a
video-authoring task, to verify the RENDERED artifact rather than just
trusting the source looks right.
Omit ``composition_id`` to use the one already proposed via
propose_video. Returns ``evidence.frames`` — absolute paths to the
rendered frame images. Read EVERY frame with your file tools before
calling i_am_done; if a scene is missing, clipped, or wrong, fix the
composition and call this again. A developer renders from their own
working tree; QA renders from a read-only export of the assembled
branch — either way this never mutates your working tree.
Args:
composition_id: The composition id under motion/compositions/. Omit
to use the task's already-proposed composition_id.
orientation: 'vertical' or 'square'.
frame_count: How many evenly-spaced preview frames to extract (1-32).
input_props: Optional props passed into the composition at render time.
"""
return _post(
"/api/v1/do/request_render",
{
"composition_id": composition_id,
"orientation": orientation,
"frame_count": frame_count,
"input_props": input_props,
},
timeout=_RENDER_TIMEOUT,
)
def draft_playbook(
title: str,
problem: str,
procedure: str,
tags: list[str] | None = None,
source_task_id: str | None = None,
) -> dict[str, Any]:
"""Draft a reusable playbook (when-to-use + procedure) for the company KB.
A learning records "this happened"; a playbook records "here is how to do X".
Delivery roles draft; the Auditor approves. tags aid retrieval;
source_task_id links the task that inspired it.
"""
return _post(
"/api/v1/do/draft_playbook",
{
"title": title,
"problem": problem,
"procedure": procedure,
"tags": tags or [],
"source_task_id": source_task_id,
},
)
def approve_playbook(playbook_id: str) -> dict[str, Any]:
"""Auditor only: approve a draft playbook so it is indexed + auto-suggested."""
return _post("/api/v1/do/approve_playbook", {"playbook_id": playbook_id})
def reject_playbook(playbook_id: str, reason: str) -> dict[str, Any]:
"""Auditor only: reject a playbook (archive it) with a reason."""
return _post(
"/api/v1/do/reject_playbook",
{"playbook_id": playbook_id, "reason": reason},
)
def archive_playbook(playbook_id: str) -> dict[str, Any]:
"""Auditor only: archive (retire) an existing playbook."""
return _post("/api/v1/do/archive_playbook", {"playbook_id": playbook_id})
def curate_vault(task_id: str, narrative: str) -> dict[str, Any]:
"""Auditor only: write a root task-tree's Obsidian-vault narrative section
(what happened, decisions, rework story). No-op error if the vault flag
is off."""
return _post(
"/api/v1/do/curate_vault",
{"task_id": task_id, "narrative": narrative},
)
# ---------- Wave 1 — pre-gateway parity ----------
def progress(
task_id: str,
message: str,
plan_step: str | None = None,
percentage: int | None = None,
) -> dict[str, Any]:
"""Record progress on YOUR active task — the % is computed for you.
Your plan's steps (sub_tasks) ARE the progress checklist. As you
FINISH each step, call this with ``plan_step`` set to that step's id
or its 1-based order; it is marked complete and the percentage is
derived from completed/total — you do NOT set the percentage and
cannot game it.
You may ALSO post a narrative update WITHOUT ``plan_step`` for an
important mid-step milestone (it documents the "why" and carries the
current derived %). Keep these to meaningful moments — not every
tool call.
Args:
task_id: UUID of the task you're working on.
message: One-paragraph summary of what just landed.
plan_step: The sub_task id (or its 1-based order) you just
COMPLETED. Omit for a narrative-only milestone update.
percentage: Ignored when the task has a plan checklist (the norm).
Only used as a fallback for tasks with no sub_tasks.
Populates the panel's Progress tab. Use in addition to ``commit`` —
commits are git refs; progress maps to your plan.
"""
body: dict[str, Any] = {"task_id": task_id, "message": message}
if plan_step is not None:
body["plan_step"] = plan_step
if percentage is not None:
body["percentage"] = percentage
return _post("/api/v1/do/progress", body)
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/v1/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/v1/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/v1/do/notify_ack",
{"notification_id": notification_id},
)
def pr_update(
task_id: str,
title: str | None = None,
body: str | None = None,
reviewers: list[str] | None = None,
) -> dict[str, Any]:
"""Update an existing PR's title, body, and/or requested reviewers.
Use after ``open_pr`` when you need to correct the title/body or
request a reviewer. At least one of ``title``, ``body``, or
``reviewers`` must be provided — passing all three None is rejected
with ``invalid_state`` before any GitHub call.
Args:
task_id: UUID of the task whose PR you're editing.
title: Replacement PR title (omit to leave unchanged).
body: Replacement PR body markdown (omit to leave unchanged).
reviewers: List of agent slugs to request as reviewers (e.g.
``["be-dev-2", "be-qa"]``). The gateway maps slugs to GitHub
usernames where the project records that mapping, otherwise
the slugs are forwarded as-is.
Authorization: caller must be the task's assignee OR a PM on the
task's team (cell_pm same-team, or main_pm cross-team). Anyone else
receives ``not_authorized``.
"""
return _post(
"/api/v1/do/pr_update",
{
"task_id": task_id,
"title": title,
"body": body,
"reviewers": reviewers,
},
)
def read_messages() -> dict[str, Any]:
"""Mark all your unread A2A direct messages as read.
Call this when ``i_am_idle()`` soft-blocks you on unread A2A — it clears
your direct-message inbox so you can idle. Notifications are separate: use
``notify_list`` / ``notify_get`` / ``notify_ack`` for those.
"""
return _post("/api/v1/do/read_messages", {})
def read_a2a() -> dict[str, Any]:
"""Read the bodies of your unread A2A direct messages (and mark them read).
Unlike ``read_messages`` (which only clears the unread counter), this
returns what other agents actually said to you so you can act on it.
"""
return _post("/api/v1/do/read_a2a", {})
# ---------- 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,
"pitch": pitch,
"propose_roadmap": propose_roadmap,
"propose_bug_hunt": propose_bug_hunt,
"propose_gap_fill": propose_gap_fill,
"propose_rebalance": propose_rebalance,
"propose_messaging_fixes": propose_messaging_fixes,
"propose_friction_fixes": propose_friction_fixes,
"propose_feature_spotlight": propose_feature_spotlight,
"propose_market_brief": propose_market_brief,
"propose_editorial_post": propose_editorial_post,
"propose_conversation_replies": propose_conversation_replies,
"propose_postmortem": propose_postmortem,
"propose_quality_report": propose_quality_report,
"propose_playbook_drafts": propose_playbook_drafts,
"propose_campaign": propose_campaign,
"propose_video": propose_video,
"dm": dm,
"notify": notify,
"evidence": evidence,
"request_sandbox": request_sandbox,
"request_render": request_render,
"progress": progress,
"notify_list": notify_list,
"notify_get": notify_get,
"notify_ack": notify_ack,
"pr_update": pr_update,
"read_messages": read_messages,
"read_a2a": read_a2a,
"draft_playbook": draft_playbook,
"approve_playbook": approve_playbook,
"reject_playbook": reject_playbook,
"archive_playbook": archive_playbook,
"curate_vault": curate_vault,
}
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).
``ROBOCO_ALLOW_FULL_TOOLSET`` is a dev/test escape hatch that registers
the full tool set instead of raising (#162); default-off.
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"
)
if os.environ.get("ROBOCO_ALLOW_FULL_TOOLSET"):
log.warning(
"do_server: manifest missing — ROBOCO_ALLOW_FULL_TOOLSET set,"
" registering the full tool set (dev/test only)",
role=AGENT_ROLE,
path=manifest_path,
)
allowed = list(_TOOLS.keys())
else:
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()