Files
roboco/roboco/services/gateway/content_actions.py
T
Renn F 3441e37120 [sweep] strip Fxxx audit-ID tokens + trim bloated comments/docstrings + add behavior-change docs
Post-audit sweep over the 135 audit-fix commits since 19a474d3:

1. Stripped every # Fxxx: audit-ID token from comments AND every Fxxx token
   from docstring openings across 211 blocks / ~626 lines. The CEO flagged
   these twice: audit-issue IDs in code confuse future devs/agents. The
   descriptive text is preserved; only the Fxxx token is removed (and bloated
   narrative blocks trimmed to 1-3 lines keeping the one non-obvious invariant).
2. Trimmed bloated comments/docstrings to the concise standard (1-3 lines).
3. Added missing behavior-change docs for the audit-fix batch: prompts/roles
   (documenter, pr_reviewer, qa), user-facing docs (api auth, websockets,
   agent-gateway, megatask, merge-model, task-lifecycle, grok, resilience,
   conventions, panel, security, troubleshooting), and the RAG corpus (cell-pm,
   main-pm, pr-reviewer, qa roles; conventions; messaging-tools; escalation;
   megatask; task-claiming workflows).

Comment/docstring/prose ONLY — zero code-line edits (verified: the diff
contains no def/class/return/if/for/await/assignment/call lines). Gates green:
ruff format + ruff check clean, mypy clean on roboco/. The only pytest failures
are the pre-existing sync_branch tracing-decision gap (B1, 250be5c2) — not
sweep-caused and tracked separately.
2026-06-29 01:25:40 +02:00

1873 lines
74 KiB
Python

"""Smart-wrapped content tools — commit, note, say, dm, evidence.
Each method:
1. Validates input (e.g., commit_validator for commit messages).
2. Auto-injects task_id when the agent has an active claim and the param is missing.
3. Calls the underlying service.
4. Returns a standardized Envelope.
Pure orchestration; no DB writes outside what the underlying services do.
"""
from __future__ import annotations
import contextlib
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar
import structlog
from roboco.config import settings
from roboco.exceptions import GitError
from roboco.foundation.policy import communications as _comms
from roboco.foundation.policy.content import ContentValidationError
from roboco.foundation.policy.content.validators import reject_trivial
from roboco.foundation.policy.journaling import Scope as _Scope
from roboco.services.content_notes import content_type_for_role
from roboco.services.gateway.commit_validator import validate_commit_message
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task
if TYPE_CHECKING:
from uuid import UUID
logger = structlog.get_logger()
def _merge_resumption_fields(
section: dict[str, Any] | None,
*,
done: str,
next: str,
where_to_look: list[str] | None,
) -> dict[str, Any] | None:
"""Fold the top-level resumption fields into the handoff ``section``.
``section: dict[str, Any]`` renders a tool schema with no visible
sub-fields, so a weak model (minimax-m3) emits ``section={}`` and the
resumption gate rejects ``done — Field required`` (the 2026-06-27 PM
respawn-loop meltdown). The top-level ``done`` / ``next`` /
``where_to_look`` string fields are the LLM-facing contract — they show
up in the tool schema as discrete fields the same model fills fine. Here
they fill any keys the explicit ``section`` omits without overwriting
keys the agent already supplied, so a capable model passing ``section``
directly is unaffected. Returns ``None`` when nothing was supplied so the
downstream ``{'summary': text}`` fallback + gate remediation still fire.
"""
merged: dict[str, Any] = dict(section) if section else {}
if done and "done" not in merged:
merged["done"] = done
if next and "next" not in merged:
merged["next"] = next
if where_to_look and "where_to_look" not in merged:
merged["where_to_look"] = where_to_look
return merged or None
# Scope catalog is canonical in foundation.policy.journaling.
# Derived here as a string frozenset for the existing call sites that
# compare strings rather than the Scope enum.
_VALID_NOTE_SCOPES: frozenset[str] = frozenset(s.value for s in _Scope)
_TASK_ID_PREFIX_RE = re.compile(r"^\s*\[[a-zA-Z0-9_-]+\]\s*")
# Content-tool RBAC. These are the same role sets that drive the spawn
# manifest in `role_config.py` (`_DEV_DO`/`_DOC_DO` include "commit";
# `_CELL_PM_DO`/`_MAIN_PM_DO`/`_BOARD_DO` include "notify"). Pre-2026-05-10
# this lookup went through `verb_gates.is_verb_allowed`; the verb-gates
# table has been folded into `roboco.foundation.policy.lifecycle`, but
# `commit` and `notify` are content tools (not lifecycle intents) so they
# live here as explicit role frozensets — not in `_INTENT_VERBS`.
#
# Notification sender + priority allowlists are canonical in
# foundation.policy.communications. Derived as string frozensets here so
# the existing call sites that compare strings keep working.
_COMMIT_ALLOWED_ROLES: frozenset[str] = frozenset({"developer", "documenter"})
_NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
r.value for r in _comms.NOTIFY_SENDER_ROLES
)
# Roles with NO agent-comms surface (CLAUDE.md): auditor (silent observer),
# pr_reviewer (posts review findings on the PR itself — no say/dm), prompter
# and secretary (human-only, restricted to note + evidence — no say/dm/notify).
# The spawn manifest already omits say/dm from these roles' tool surfaces, but
# that is convention-only — this frozenset is the handler-level defence-in-depth
# that refuses any call that bypassed the manifest (direct verb dispatch, test
# harness, future routing change), so the no-comms invariant holds regardless of
# how the call arrived. Matches the explicit role-frozenset gates on commit /
# notify / pitch / playbook / open_session.
_NO_COMMS_ROLES: frozenset[str] = frozenset(
{"auditor", "pr_reviewer", "prompter", "secretary"}
)
def _no_comms_remediate(role: str) -> str:
"""Role-appropriate remediation for a no-comms role blocked at say/dm."""
if role == "auditor":
return "record observations via note(scope='reflect') instead"
if role == "pr_reviewer":
return "post review findings on the PR itself via pr_pass/pr_fail instead"
# prompter / secretary are human-only (note + evidence).
return "use note() to record; this human-only role has no agent-comms surface"
_DECISION_SECTIONS: tuple[tuple[str, str], ...] = (
("context", "Context"),
("options", "Options Considered"),
("chosen", "Chosen"),
("rationale", "Rationale"),
("consequences", "Consequences"),
)
_REFLECT_SECTIONS: tuple[tuple[str, str], ...] = (
("what_done", "What Done"),
("what_learned", "What Learned"),
("what_struggled", "What Struggled"),
("next_steps", "Next Steps"),
)
def _render_option_block(option: dict[str, str] | str) -> str:
"""Render one decision option. Accepts dict or legacy string."""
if isinstance(option, str):
return f"- {option}"
name = option.get("name", "").strip() or "(unnamed)"
pros = option.get("pros", "").strip()
cons = option.get("cons", "").strip()
block = [f"### {name}"]
if pros:
block.append(f"- Pros: {pros}")
if cons:
block.append(f"- Cons: {cons}")
return "\n".join(block)
_SCOPE_SECTIONS: dict[str, tuple[tuple[str, str], ...]] = {
"decision": _DECISION_SECTIONS,
"reflect": _REFLECT_SECTIONS,
}
def _render_section_value(key: str, value: Any) -> str | None:
"""Render one section value or return None to skip the section."""
if key == "options" and isinstance(value, list):
if not value:
return None
return "\n\n".join(_render_option_block(o) for o in value)
if isinstance(value, list):
if not value:
return None
return "\n".join(f"- {item}" for item in value)
rendered = str(value).strip()
return rendered or None
def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -> str:
"""Build the journal entry body. Pre-gateway parity for decision/reflect."""
sections = _SCOPE_SECTIONS.get(scope, ())
if not sections:
return text
body_parts: list[str] = [text.strip()] if text.strip() else []
for key, label in sections:
value = structured.get(key)
if value is None:
continue
rendered = _render_section_value(key, value)
if rendered is None:
continue
body_parts.append(f"## {label}\n{rendered}")
return "\n\n".join(body_parts) if body_parts else text
# Narrative fields that decision/reflect scopes want filled. A
# missing or empty value used to hard-reject the note with `incomplete_input`
# — and since that kind counts toward the do-server circuit breaker, three
# well-intentioned-but-thin notes in a row tripped it. We now default the
# field instead so the note is always recorded (audit value preserved) and
# the breaker never fires on a note. The placeholder makes the gap visible in
# the panel rather than silently dropping the section.
_NARRATIVE_PLACEHOLDER = "(not provided)"
_DECISION_NARRATIVE_FIELDS: tuple[str, ...] = ("context", "chosen", "rationale")
_REFLECT_NARRATIVE_FIELDS: tuple[str, ...] = (
"what_done",
"what_learned",
"what_struggled",
)
# List-typed structured fields. A lone scalar is wrapped into a one-element
# list here too (defense-in-depth — the route schema coerces first, but the
# service is called directly from the choreographer and tests).
_LIST_FIELDS: tuple[str, ...] = ("options", "consequences", "next_steps")
_SCOPE_NARRATIVE_FIELDS: dict[str, tuple[str, ...]] = {
"decision": _DECISION_NARRATIVE_FIELDS,
"reflect": _REFLECT_NARRATIVE_FIELDS,
}
def _scalar_field_missing(value: Any) -> bool:
"""True when a required scalar/text field is empty/whitespace."""
return not value or not str(value).strip()
def _coerce_scalar_to_list(value: Any) -> Any:
"""Wrap a lone str/dict into a one-element list; pass lists/None through."""
if value is None or isinstance(value, list):
return value
if isinstance(value, str | dict):
return [value]
return value
def _normalize_structured(scope: str, structured: dict[str, Any]) -> dict[str, Any]:
"""Return a tolerant copy of ``structured`` for decision/reflect scopes.
Stop rejecting thin notes. List-typed fields tolerate a lone
scalar (wrapped into a one-element list), and missing/blank narrative
fields are defaulted to a visible placeholder so the entry still records
instead of returning `incomplete_input` (which trips the circuit breaker).
Other scopes are returned unchanged.
"""
normalized = dict(structured)
for field in _LIST_FIELDS:
if field in normalized:
normalized[field] = _coerce_scalar_to_list(normalized[field])
for field in _SCOPE_NARRATIVE_FIELDS.get(scope, ()):
if _scalar_field_missing(normalized.get(field)):
normalized[field] = _NARRATIVE_PLACEHOLDER
return normalized
def _ownership_violation(task_id: UUID) -> Envelope:
"""Standard envelope for Gate Set D ownership violations.
Pre-gateway, agents could not even see tasks they didn't own; the
gateway exposes task_id parameters so the explicit gate is required.
"""
return Envelope.not_authorized(
message=(f"you are not the assignee of {task_id}; cannot post content to it"),
remediate=(
"only the task's assignee may attach content (commit/note/say/"
"dm/evidence) to it. Use a different task_id or omit task_id "
"for off-task channel posts (say/dm only)."
),
context_briefing={},
)
def _not_active_claimant(task_id: UUID) -> Envelope:
"""Envelope for a caller who holds no active claim on ``task_id``.
The caller may still be the historical ``assigned_to`` (e.g. its claim
was reaped for going silent, or the task was handed to another agent),
but ``active_claimant_id`` no longer points at it. Writing would race the
real claimant, so the write is refused.
"""
return Envelope.not_authorized(
message=(
f"you do not hold the active claim on {task_id}; "
"another agent owns it now or your claim was released"
),
remediate=(
"call i_am_idle() and give_me_work() to pick up fresh work; "
"if you believe this is your task, re-claim it before writing"
),
context_briefing={},
)
@dataclass(frozen=True)
class ContentActionsDeps:
"""Service deps for ContentActions; bundled to keep init signature flat."""
task: Any
git: Any
messaging: Any
a2a: Any
journal: Any
workspace: Any
notifications: Any
# Wave 1 added inbox-read verbs (notify_list/get/ack) that live on
# `NotificationDeliveryService`, not `NotificationService`. Keeping
# them separate so the sender vs receiver concerns stay split.
notification_delivery: Any = None
# evidence() returns journal_highlights for QA/reviewer
# context. Matches the choreographer's EvidenceRepo wiring so both
# paths surface the same shape.
evidence_repo: Any = None
_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset(p.value for p in _comms.Priority)
# The Board roles that may author a pitch (a product proposal for CEO approval).
_PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"})
# Playbook curation RBAC: delivery roles DRAFT; only the Auditor CURATES.
_DRAFT_PLAYBOOK_ROLES: frozenset[str] = frozenset(
{"developer", "qa", "documenter", "cell_pm", "main_pm"}
)
_CURATE_PLAYBOOK_ROLES: frozenset[str] = frozenset({"auditor"})
def _coerce_pitch_cells(target_cells: list[str]) -> list[Any]:
"""Validate target-cell slugs into Team values; raise ValueError on a bad one."""
from roboco.foundation.identity import CELL_TEAMS, Team
cells: list[Any] = []
for c in target_cells:
try:
team = Team(c)
except ValueError as exc:
raise ValueError(f"unknown cell {c!r}") from exc
if team not in CELL_TEAMS:
raise ValueError(f"{c!r} is not a cell team")
cells.append(team)
return cells
class ContentActions:
def __init__(self, deps: ContentActionsDeps) -> None:
self._deps = deps
@property
def task(self) -> Any:
return self._deps.task
@property
def git(self) -> Any:
return self._deps.git
@property
def messaging(self) -> Any:
return self._deps.messaging
@property
def a2a(self) -> Any:
return self._deps.a2a
@property
def journal(self) -> Any:
return self._deps.journal
@property
def workspace(self) -> Any:
return self._deps.workspace
@property
def notifications(self) -> Any:
return self._deps.notifications
@property
def evidence_repo(self) -> Any:
return self._deps.evidence_repo
async def _touch_heartbeat(self, task_id: UUID | None) -> None:
"""Best-effort heartbeat refresh on a content-write success path.
Mirrors the choreographer's rejection-path heartbeat: an agent that
is actively committing / posting progress is alive, so refresh
``last_heartbeat_at`` here too — otherwise the reaper sees the claim
as stale between verb successes. Wrapped in ``suppress`` so a
heartbeat write failure can never alter the response the agent gets.
"""
if task_id is None:
return
with contextlib.suppress(Exception):
await self.task.heartbeat(task_id)
async def _active_claim_violation(
self, agent_id: UUID, task: Any
) -> Envelope | None:
"""Refuse a content write when the caller is not the active claimant.
``assigned_to`` alone is insufficient: a reaped or handed-off agent
keeps ``assigned_to`` until reassignment, but ``active_claimant_id``
is cleared the moment its claim is released. Only the holder of the
active claim may write. A board co-reviewer on a coordination task is
exempt (it shares the task with the other board member by design).
"""
claimant = getattr(task, "active_claimant_id", None)
if claimant == agent_id:
return None
if await self._board_may_co_review(agent_id, task):
return None
return _not_active_claimant(task.id)
@staticmethod
def _reject_soup(value: str, *, field: str, min_chars: int = 3) -> Envelope | None:
"""Universal anti-soup guard for agent free-text.
Returns a remediation Envelope (never a raw 422 — a 422 at the route
trips the do-server circuit breaker) when ``value`` is empty, too short,
or a placeholder/filler token, so soup lands NOWHERE. ``None`` = clean.
"""
try:
reject_trivial(value, field=field, min_chars=min_chars)
except ValueError as exc:
return Envelope.invalid_state(
message=str(exc),
remediate=(
f"write a substantive {field} (>={min_chars} chars, no filler "
"like 'asdf'/'wip'/'tbd'/'...'); state what actually happened."
),
context_briefing={},
)
return None
@classmethod
def _reject_structured_soup(
cls, scope: str, structured: dict[str, Any] | None
) -> Envelope | None:
"""Soup-guard the scope's narrative sub-fields when the agent fills them.
Only *provided, non-empty* fields are checked — an omitted narrative
field keeps its tolerant ``(not provided)`` placeholder default (so a
thin note is never hard-rejected, preserving the do-server breaker
contract), but ``rationale='asdf'`` is soup and lands nowhere.
"""
for field in _SCOPE_NARRATIVE_FIELDS.get(scope, ()):
value = (structured or {}).get(field)
if not (value and str(value).strip()):
continue
if rej := cls._reject_soup(str(value), field=field, min_chars=4):
return rej
return None
@classmethod
def _pr_update_input_check(
cls, title: str | None, body: str | None, reviewers: list[str] | None
) -> Envelope | None:
"""At-least-one-field + anti-soup gate for ``pr_update`` inputs.
Folds the no-op guard and the title/body soup guard into one call so
the verb body keeps its return count under the complexity bound.
"""
if title is None and body is None and reviewers is None:
return Envelope.invalid_state(
message="no fields to update",
remediate=(
"provide at least one of title, body, or reviewers; "
"passing all None has no effect"
),
context_briefing={},
)
for _pf, _pv, _min in (("title", title, 8), ("body", body, 15)):
if _pv is not None and (
rej := cls._reject_soup(_pv, field=_pf, min_chars=_min)
):
return rej
return None
async def commit(
self,
*,
agent_id: UUID,
message: str,
files: list[str] | None = None,
) -> Envelope:
"""Make a git commit on the agent's active task branch.
Auto-prefixes [task-id], validates message via commit_validator,
records progress entry from the commit message.
"""
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role not in _COMMIT_ALLOWED_ROLES:
return Envelope.not_authorized(
message=(
f"role '{caller_role}' may not commit code; only"
" developers and documenters write commits"
),
remediate=(
"PMs delegate code work via delegate(); board members"
" do not write code. If you intended to record an"
" observation, use note() instead."
),
context_briefing={},
)
subject = _strip_task_prefix(message).strip()
result = validate_commit_message(
subject,
min_chars=settings.commit_subject_min_chars,
banned_words=settings.commit_banned_words,
)
if not result.ok:
return Envelope.invalid_state(
message=result.reason or "commit message invalid",
remediate=result.remediate or "",
context_briefing={},
)
t = await self.task.get_active_task_for_agent(agent_id)
if t is None:
return Envelope.invalid_state(
message="no active task; cannot commit",
remediate="call give_me_work() first",
context_briefing={},
)
if reject := await self._active_claim_violation(agent_id, t):
return reject
canonical_prefix = f"[{str(t.id)[:8]}]"
final_message = f"{canonical_prefix} {subject}"
commit_result = await self.git.commit(
branch_name=t.branch_name,
message=final_message,
task_id=t.id,
files=files,
)
sha = commit_result.get("sha", "")
await self.task.add_progress(
t.id, agent_id, f"committed {sha[:8]}: {final_message}"
)
await self._touch_heartbeat(t.id)
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next="continue committing, or open_pr when ready",
context_briefing={},
)
# Board roles co-review board/coordination tasks: a
# board/coordination task is dispatched to BOTH the Product Owner and the
# Head of Marketing, but it carries a single ``assigned_to``. The
# non-assignee reviewer must still be able to record its review note on
# that task, so a board role posting content to a board/coordination task
# is exempt from the strict single-assignee ownership gate.
_BOARD_ROLES: ClassVar[frozenset[str]] = frozenset(
{"product_owner", "head_marketing"}
)
@staticmethod
def _is_coordination_task(task: Any) -> bool:
"""True for a board/fan-out task: carries a product but no repo of its own.
Mirrors the orchestrator's ``_is_coordination_task``. Such a task does
no git work of its own (no ``project_id``) and is the shared subject of
the two-reviewer board review, so both board members may attach content.
"""
return getattr(task, "project_id", None) is None and bool(
getattr(task, "product_id", None)
)
async def _board_may_co_review(self, agent_id: UUID, task: Any) -> bool:
"""True iff a board role is posting to a board/coordination task.
Lets the non-assignee board reviewer record its review on a task held
by the other board member, without widening ownership for any other
role or any project-backed task.
"""
if not self._is_coordination_task(task):
return False
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else ""
return role in self._BOARD_ROLES
async def _verify_explicit_task_ownership(
self, agent_id: UUID, task_id: UUID
) -> Envelope | None:
"""Gate Set D: refuse content posts on tasks the caller does not own.
Only call this for *explicit* task_id (caller passed it themselves).
Auto-fill from get_journal_context_task_for_agent is implicitly
self-owned and does not need a re-check.
Allows ``assigned_to=None`` (post-handoff transient state) so QA /
documenter can still inspect tasks between reassignments. A board role
co-reviewing a board/coordination task is also allowed even when the
task is assigned to the other board member.
"""
t = await self.task.get(task_id)
if t is None:
return Envelope.not_found(message=f"task {task_id} not found")
if t.assigned_to is not None and t.assigned_to != agent_id:
if await self._board_may_co_review(agent_id, t):
return None
return _ownership_violation(task_id)
# assigned_to is stale across a reap/handoff (persists until
# reassignment; active_claimant_id is cleared on release). Require
# the active claim so a reaped agent can't keep posting.
if t.assigned_to == agent_id:
return await self._active_claim_violation(agent_id, t)
return None
async def note(
self,
*,
agent_id: UUID,
text: str,
scope: str = "note",
task_id: UUID | None = None,
structured: dict[str, Any] | None = None,
section: dict[str, Any] | None = None,
done: str = "",
next: str = "",
where_to_look: list[str] | None = None,
) -> Envelope:
"""Write a journal entry, or (scope='handoff') the role's note section.
scope ∈ note|decision|reflect|learning|struggle write the JOURNAL;
scope='handoff' writes the agent's dedicated SECTION (dev_notes /
quick_context / auditor_notes …) from ``section`` (or a summary from
``text`` when ``section`` is omitted).
``structured`` carries scope-specific fields:
- decision: context, options[], chosen, rationale, consequences
- reflect: what_done, what_learned, what_struggled, next_steps
For ``scope='handoff'`` the resumption section (PM / coordinator
roles) can be authored two ways: the nested ``section`` dict, OR the
top-level ``done`` / ``next`` / ``where_to_look`` string fields. The
top-level path is the LLM-facing contract — ``section: dict[str, Any]``
renders a tool schema with no visible sub-fields, so a weak model
(minimax-m3) emits ``section={}`` and the resumption gate rejects
``done — Field required``; the top-level typed strings show up in the
tool schema as discrete fields the same model fills fine (proven on
the decision scope). Top-level fields fill any keys the explicit
``section`` omits without overwriting supplied ones.
The note is always recorded. List-typed fields tolerate a
lone scalar (wrapped into a one-element list) and missing decision/
reflect narrative fields default to a visible placeholder, so a
well-intentioned note is never hard-rejected (which previously tripped
the do-server circuit breaker on repeated ``incomplete_input``).
Non-None fields are formatted into the entry content as markdown
sections so the panel's Decisions / Reflections views render
them as named blocks instead of a one-line phrase. The ``title``
is taken from ``structured["title"]`` when present, otherwise
from the first line of ``text``.
"""
if scope == "handoff":
# Section write (dev_notes / quick_context / auditor_notes / …), not
# a journal entry. Content quality is enforced by the content model
# (apply_structured_note), so skip the journal-text soup check.
return await self._record_section_handoff(
agent_id=agent_id,
text=text,
task_id=task_id,
structured=_merge_resumption_fields(
section, done=done, next=next, where_to_look=where_to_look
),
)
return await self._write_journal_note(
agent_id=agent_id,
text=text,
scope=scope,
task_id=task_id,
structured=structured,
)
async def _write_journal_note(
self,
*,
agent_id: UUID,
text: str,
scope: str,
task_id: UUID | None,
structured: dict[str, Any] | None,
) -> Envelope:
"""Validate + persist a journal entry for the non-handoff scopes
(note|decision|reflect|learning|struggle). Extracted from ``note`` so
both stay under the cyclomatic-complexity bound.
"""
if rej := self._reject_soup(text, field="note", min_chars=8):
return rej
if scope not in _VALID_NOTE_SCOPES:
return Envelope.invalid_state(
message=f"invalid scope {scope!r}",
remediate=f"scope must be one of: {sorted(_VALID_NOTE_SCOPES)}",
context_briefing={},
)
if rej := self._reject_structured_soup(scope, structured):
return rej
if task_id is not None:
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
return reject
else:
t = await self.task.get_journal_context_task_for_agent(agent_id)
if t is not None:
task_id = t.id
# Tolerate thin notes instead of rejecting them. List-typed
# fields accept a lone scalar; missing decision/reflect narrative fields
# are defaulted to a visible placeholder. The note is always recorded so
# the audit trail survives and a well-intentioned note never trips the
# do-server circuit breaker on repeated `incomplete_input`.
s = _normalize_structured(scope, structured or {})
title = (s.get("title") or text.split("\n", 1)[0])[:200]
content = _render_journal_content(scope, text, s)
await self.journal.write_entry(
agent_id=agent_id,
task_id=task_id,
scope=scope,
title=title,
content=content,
)
await self._touch_heartbeat(task_id)
return Envelope.ok(
status="noted",
task_id=str(task_id) if task_id else None,
next="continue",
context_briefing={},
)
async def _caller_role(self, agent_id: UUID) -> str:
agent = await self.task.agent_for(agent_id)
return str(agent.role) if agent is not None else ""
async def draft_playbook(
self,
*,
agent_id: UUID,
title: str,
problem: str,
procedure: str,
tags: list[str] | None = None,
source_task_id: UUID | None = None,
) -> Envelope:
"""Draft a curated playbook (delivery roles); the Auditor approves it."""
role = await self._caller_role(agent_id)
if role not in _DRAFT_PLAYBOOK_ROLES:
return Envelope.not_authorized(
message=f"role {role!r} may not draft a playbook",
remediate="Only delivery roles draft playbooks; the Auditor curates.",
context_briefing={},
)
from roboco.models.playbook import PlaybookCreate
from roboco.services.base import ConflictError
from roboco.services.playbook import get_playbook_service
try:
playbook = await get_playbook_service(self.task.session).draft(
PlaybookCreate(
title=title,
problem=problem,
procedure=procedure,
tags=tags or [],
source_task_id=source_task_id,
),
created_by=agent_id,
)
except ConflictError as exc:
return Envelope.invalid_state(
message=str(exc),
remediate="Use a more distinct title (the slug must be unique).",
context_briefing={},
)
return Envelope.ok(
status="playbook_drafted",
task_id=None,
next="continue",
context_briefing={
"playbook_id": str(playbook.id),
"playbook_status": "draft",
},
)
async def approve_playbook(self, *, agent_id: UUID, playbook_id: UUID) -> Envelope:
"""Auditor approves a draft playbook (-> approved + indexed)."""
return await self._curate_playbook(
agent_id=agent_id, playbook_id=playbook_id, action="approve"
)
async def reject_playbook(
self, *, agent_id: UUID, playbook_id: UUID, reason: str
) -> Envelope:
"""Auditor rejects a playbook (-> archived, with a reason)."""
return await self._curate_playbook(
agent_id=agent_id,
playbook_id=playbook_id,
action="reject",
reason=reason,
)
async def archive_playbook(self, *, agent_id: UUID, playbook_id: UUID) -> Envelope:
"""Auditor archives an approved playbook (-> archived, retired)."""
return await self._curate_playbook(
agent_id=agent_id,
playbook_id=playbook_id,
action="archive",
)
async def _curate_playbook(
self,
*,
agent_id: UUID,
playbook_id: UUID,
action: str,
reason: str = "",
) -> Envelope:
"""Shared Auditor-only curation path for approve / reject / archive."""
role = await self._caller_role(agent_id)
if role not in _CURATE_PLAYBOOK_ROLES:
return Envelope.not_authorized(
message=f"role {role!r} may not curate playbooks",
remediate="Only the Auditor approves/rejects/archives playbooks.",
context_briefing={},
)
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import get_playbook_service
svc = get_playbook_service(self.task.session)
try:
if action == "approve":
playbook = await svc.approve(playbook_id, approver_id=agent_id)
status = "playbook_approved"
elif action == "archive":
playbook = await svc.archive(playbook_id, approver_id=agent_id)
status = "playbook_archived"
else:
playbook = await svc.reject(
playbook_id, approver_id=agent_id, reason=reason or action
)
status = "playbook_archived"
except NotFoundError:
return Envelope.not_found(message=f"playbook {playbook_id} not found")
except ConflictError as exc:
# A status-precondition violation (approve/reject on a non-draft,
# archive on a non-approved) is a clean invalid_state, not a 500 —
# the agent gets a remediate hint to re-fetch the playbook's
# current status before re-trying.
return Envelope.invalid_state(
message=str(exc),
remediate=(
"Only a draft can be approved/rejected; only an approved "
"playbook can be archived. Re-list drafts/approved to see "
"the playbook's current status before re-trying."
),
context_briefing={"playbook_id": str(playbook_id)},
)
# Commit the status change BEFORE touching the RAG index: the index write
# runs through its own auto-committing connection, so indexing before the
# status commit would durably land (or drop) a playbook in the corpus even
# if this transaction rolled back — a divergence agents surface in
# briefings. ``get_db`` commits the session again after the route returns
# (a no-op on the now-clean transaction); this explicit commit is what
# gates the index.
await self.task.session.commit()
if action == "approve":
await svc.index_approved(playbook)
else:
await svc.unindex_playbook(playbook)
return Envelope.ok(
status=status,
task_id=None,
next="continue",
context_briefing={
"playbook_id": str(playbook.id),
"playbook_status": str(playbook.status),
},
)
async def _record_section_handoff(
self,
*,
agent_id: UUID,
text: str,
task_id: UUID | None,
structured: dict[str, Any] | None,
) -> Envelope:
"""Write the agent's dedicated note SECTION — the structured-content
counterpart to a journal note. ``note()`` only ever wrote the journal;
this is how a developer / PM / auditor authors dev_notes / quick_context
/ auditor_notes (etc.).
Routes by role to the right content type, persists through the
``apply_structured_note`` chokepoint, and also drops a journal trail
entry so the write shows in the activity log (and the auditor's
session has a signal). Validation failures return a remediation
Envelope, never a raw 422.
"""
agent = await self.task.agent_for(agent_id)
role = str(agent.role) if agent is not None else ""
content_type = content_type_for_role(role)
if content_type is None:
return Envelope.invalid_state(
message=f"role {role!r} has no dedicated note section",
remediate=(
"only developer / qa / documenter / pr_reviewer / auditor / "
"cell_pm / main_pm author a section — use scope='note' (or "
"decision/reflect/learning/struggle) for a journal entry"
),
context_briefing={},
)
if task_id is not None:
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
return reject
else:
t = await self.task.get_journal_context_task_for_agent(agent_id)
if t is None:
return Envelope.invalid_state(
message="no task to attach the section note to",
remediate="pass task_id='<the task whose section you write>'",
context_briefing={},
)
task_id = t.id
payload: dict[str, Any] = dict(structured) if structured else {"summary": text}
try:
await self.task.record_section_note(task_id, content_type, payload)
except ContentValidationError as exc:
return Envelope.invalid_state(
message=f"section note rejected: {exc.field}{exc.reason}",
remediate=(
f"provide the section's fields via structured=... for content "
f"type {content_type!r} (resumption needs done+next; auditor "
"needs summary+severity; others need a substantive summary), "
"then retry"
),
context_briefing={},
)
await self.journal.write_entry(
agent_id=agent_id,
task_id=task_id,
scope="note",
title=text.split("\n", 1)[0][:200] if text else f"{content_type} note",
content=text or "(structured section note)",
)
await self._touch_heartbeat(task_id)
return Envelope.ok(
status="noted",
task_id=str(task_id),
next="continue",
context_briefing={},
)
async def pitch(
self,
*,
agent_id: UUID,
title: str,
slug: str,
problem: str,
proposed_solution: str,
target_cells: list[str],
) -> Envelope:
"""Board (PO / Head of Marketing) proposes a product for the CEO to approve.
A pitch is content, not a lifecycle transition: it records the Board's
proposal. On CEO approval the system provisions a repo per target cell,
registers the projects, and seeds the first Main-PM task.
"""
for _pf, _pv, _min in (
("title", title, 5),
("slug", slug, 2),
("problem", problem, 15),
("proposed_solution", proposed_solution, 15),
):
if rej := self._reject_soup(_pv, field=_pf, min_chars=_min):
return rej
from pydantic import ValidationError as PydanticValidationError
from roboco.models.pitch import PitchCreate
from roboco.services.base import ConflictError, ValidationError
from roboco.services.pitch import get_pitch_service
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role not in _PITCH_ROLES:
return Envelope.not_authorized(
message=(
f"role {caller_role!r} cannot pitch; only the Board "
"(product_owner / head_marketing) may propose products"
),
remediate="this verb is Board-only",
context_briefing={},
)
try:
create = PitchCreate(
title=title,
slug=slug,
problem=problem,
proposed_solution=proposed_solution,
target_cells=_coerce_pitch_cells(target_cells),
)
pitch = await get_pitch_service(self.task.session).create(
create, created_by=agent_id
)
except (
ConflictError,
ValidationError,
PydanticValidationError,
ValueError,
) as exc:
detail = getattr(exc, "message", None) or str(exc)
return Envelope.invalid_state(
message=detail,
remediate="fix the pitch fields and retry",
context_briefing={},
)
return Envelope.ok(
status="proposed",
task_id=str(pitch.id),
next="await the CEO's approval in the Pitches queue",
context_briefing={},
)
async def say(
self,
*,
agent_id: UUID,
channel: str,
text: str,
task_id: UUID | None = None,
) -> Envelope:
"""Post to a channel. task_id auto-injected if you have an active task.
Channel-write RBAC is enforced inside `messaging.post_to_channel`
(which forwards the agent's slug to `send_message` so
`validate_channel_access` runs). A denial bubbles up as
`ChannelAccessDeniedError`; we convert it into a friendly
`not_authorized` Envelope listing the agent's writable channels.
"""
if rej := self._reject_soup(text, field="message", min_chars=2):
return rej
from roboco.enforcement.channel_access import (
ChannelAccessDeniedError,
get_agent_channels,
)
# Spec §5.5: silent / no-comms roles — defense-in-depth runtime guard.
# The spawn manifest already omits `say` from these roles' tool
# surfaces, but that is convention-only. This guard refuses any
# call that bypassed the manifest (direct verb dispatch, test
# harness, future routing change) so the no-comms rule holds
# regardless of how the call arrived. Covers auditor (silent
# observer), pr_reviewer (posts findings on the PR), and the
# human-only prompter / secretary (note + evidence only).
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role in _NO_COMMS_ROLES:
return Envelope.not_authorized(
message=(
f"role '{caller_role}' is a silent / no-comms role;"
" say is not permitted"
),
remediate=_no_comms_remediate(caller_role),
context_briefing={},
)
if task_id is not None:
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
return reject
else:
t = await self.task.get_journal_context_task_for_agent(agent_id)
if t is not None:
task_id = t.id
try:
await self.messaging.post_to_channel(
agent_id=agent_id,
channel_slug=channel,
content=text,
task_id=task_id,
)
except ChannelAccessDeniedError as e:
writable = get_agent_channels(e.agent_id, action="write")
writable_str = ", ".join(writable) if writable else "(none)"
return Envelope.not_authorized(
message=(
f"agent '{e.agent_id}' may not write to channel '{e.channel_slug}'"
),
remediate=f"channels you may write to: {writable_str}",
context_briefing={},
)
return Envelope.ok(
status="posted",
task_id=str(task_id) if task_id else None,
next="continue",
context_briefing={},
)
async def dm(
self,
*,
agent_id: UUID,
recipient: str,
text: str,
task_id: UUID | None = None,
skill: str | None = None,
) -> Envelope:
"""A2A direct message. Requires task_id (active or explicit)."""
if rej := self._reject_soup(text, field="message", min_chars=2):
return rej
# Spec §5.5: silent / no-comms roles — defense-in-depth runtime guard.
# See say() for rationale. Mirrored here because dm() is the other
# channel through which a no-comms role could "speak". Covers auditor,
# pr_reviewer, and the human-only prompter / secretary.
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role in _NO_COMMS_ROLES:
return Envelope.not_authorized(
message=(
f"role '{caller_role}' is a silent / no-comms role;"
" dm is not permitted"
),
remediate=_no_comms_remediate(caller_role),
context_briefing={},
)
if task_id is not None:
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
return reject
else:
t = await self.task.get_journal_context_task_for_agent(agent_id)
if t is not None:
task_id = t.id
if task_id is None:
return Envelope.invalid_state(
message="dm requires a task_id (no active task and none provided)",
remediate="provide task_id explicitly or claim a task first",
context_briefing={},
)
# Catch A2A access denials and return an Envelope. If the
# error escapes here it's caught by FastAPI's middleware and
# rendered as RobocoError.to_dict() — a dict-shaped 'error'
# field that breaks do_server's circuit-breaker frozenset
# check (a TypeError: unhashable type: 'dict').
from roboco.enforcement.a2a_access import A2AAccessDeniedError
try:
await self.a2a.send(
from_agent=agent_id,
to_agent=recipient,
task_id=task_id,
body=text,
skill=skill,
)
except A2AAccessDeniedError as e:
remediate = e.route_hint or e.reason
return Envelope.not_authorized(
message=e.message,
remediate=remediate,
context_briefing={},
)
return Envelope.ok(
status="sent",
task_id=str(task_id),
next="continue",
context_briefing={},
)
async def notify(
self,
*,
agent_id: UUID,
target: str,
text: str,
priority: str = "normal",
task_id: UUID | None = None,
) -> Envelope:
"""Send a formal ack-required notification (PMs and Board only).
Distinct from `say` (channel post, no ack) and `dm` (informal A2A):
a notification is a formal signal that the recipient must
acknowledge. Pre-gateway, NotificationService restricted senders
to PMs/Board; the gateway re-asserts that gate here because the
do.py router is shared by all roles (no router-level dep).
``target`` is an agent slug ("be-dev-1", "main-pm", "ceo");
NotificationService resolves it to a UUID at insert time.
``priority`` is one of normal|high|urgent. ``task_id`` is
auto-filled from the caller's active task when omitted, but
omission is permitted for off-task notifications (e.g., Board
broadcasts).
"""
from roboco.models import NotificationPriority
if rej := self._reject_soup(text, field="notification", min_chars=5):
return rej
if priority not in _VALID_NOTIFY_PRIORITIES:
return Envelope.invalid_state(
message=f"invalid priority {priority!r}",
remediate=(
f"priority must be one of: {sorted(_VALID_NOTIFY_PRIORITIES)}"
),
context_briefing={},
)
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role not in _NOTIFY_ALLOWED_ROLES:
return Envelope.not_authorized(
message=(
f"role {caller_role!r} cannot send formal notifications; "
"only PMs and Board may issue ack-required signals"
),
remediate=(
"use say() for channel posts or dm() for informal A2A. "
"notify() is reserved for cell_pm, main_pm, "
"product_owner, and head_marketing."
),
context_briefing={},
)
if task_id is not None:
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
return reject
else:
t = await self.task.get_journal_context_task_for_agent(agent_id)
if t is not None:
task_id = t.id
# A dependency block is a "wait silently" situation — never a CEO signal.
# An agent must not page the CEO to relax or escalate a task that is
# simply waiting on an unfinished upstream; that wait clears on its own.
# Also reject human-only recipients (prompter/secretary) — they have no
# agent ack path, so an ack-required signal would sit permanently unacked
# and suppress later same-purpose notifications via the dedup query.
if reject := await self._reject_disallowed_recipient(target, task_id):
return reject
await self.notifications.send_ack_notification(
from_agent=agent_id,
to_agent=target,
body=text,
priority=NotificationPriority(priority),
task_id=task_id,
)
return Envelope.ok(
status="sent",
task_id=str(task_id) if task_id else None,
next="continue",
context_briefing={},
)
async def _reject_disallowed_recipient(
self, target: str, task_id: UUID | None
) -> Envelope | None:
"""Rejection envelope for a notify() recipient the design disallows.
Two cases, checked in order:
1. F048 — a human-only recipient (prompter/secretary) with no agent ack
path. The knowledge-share path already excludes all three human-only
roles (learning.py); the general notify path did not, so an
ack-required ALERT could reach a human-driven role and sit permanently
unacked (polluting the panel's pending-ack view and, via the dedup
query's ``~acked_by.contains``, permanently suppressing any later
same-purpose notification from the same sender to that human role).
The CEO is human too but acks via the panel, so it is NOT rejected
here (its only disallowed case — a dependency-block page — is case 2).
2. A CEO notification about an open dependency block — pure noise; the
wait clears when the upstream completes.
"""
from roboco.agents_config import get_agent_role
recipient_role = get_agent_role(target)
if recipient_role in ("prompter", "secretary"):
return Envelope.not_authorized(
message=(
f"cannot notify {target!r} — the {recipient_role} is a"
" human-only role with no agent ack path; an ack-required"
" signal would sit permanently unacked and suppress later"
" same-purpose notifications via the dedup query"
),
remediate=(
"use say() to a channel the human reads, or escalate via the"
" CEO route. ack-required notify() targets must be agents"
" (or the CEO, who acks via the panel)"
),
context_briefing={},
)
return await self._reject_ceo_dependency_notify(target, task_id)
async def _reject_ceo_dependency_notify(
self, target: str, task_id: UUID | None
) -> Envelope | None:
"""Rejection envelope if this is a CEO notification about a dep block.
A dependency block clears when the upstream completes — paging the CEO
about it is pure noise and burn. Returns None when the notification is
allowed (non-CEO target, no task, or no open dependency).
"""
from roboco.agents_config import is_ceo
if task_id is None or not is_ceo(target):
return None
dep_block = await self._dependency_block_reason(task_id)
if not dep_block:
return None
return Envelope.invalid_state(
message=f"cannot notify the CEO about a dependency block — {dep_block}",
remediate=(
"a dependency block clears automatically when the upstream task "
"completes — do not notify or escalate. Call i_am_idle() and "
"wait; the task resumes on its own."
),
context_briefing={},
)
async def _dependency_block_reason(self, task_id: UUID) -> str | None:
"""Reason string if ``task_id`` is waiting on an unfinished dependency.
Used to refuse CEO notifications about a dependency block: such a block
is resolved by the upstream completing, not by a human, so paging the
CEO is pure noise and burn.
"""
task = await self.task.get(task_id)
if task is None:
return None
dep_ids = list(task.dependency_ids or [])
if not dep_ids:
return None
unmet = await self.task.unmet_dependency_ids(dep_ids)
if unmet:
noun = "dependency" if len(unmet) == 1 else "dependencies"
return f"{len(unmet)} {noun} not yet completed"
return None
async def _is_caller_dependency(self, agent_id: UUID, task: Any) -> bool:
"""True when ``task`` is a dependency of a task the caller is assigned to.
A dependent agent (e.g. a frontend cell waiting on a UX design task)
must be able to inspect what it is blocked on; read-only evidence is the
right tool, and the strict cross-agent ownership gate would otherwise
reject it.
"""
assigned = await self.task.list_assigned_for_agent(agent_id)
return any(task.id in (a.dependency_ids or []) for a in assigned)
async def evidence(
self,
*,
agent_id: UUID,
task_id: UUID,
) -> Envelope:
"""Inspect a task's PR diff, commits, files.
Fetches the dev branch into the agent's workspace before diffing.
Allows inspection when the caller is the assignee, the task is
unassigned, the caller co-reviews a shared board task, or the task is a
dependency the caller is waiting on — strict ownership only blocks
snooping an unrelated, actively-owned task.
``files_changed`` and ``pr_diff_summary`` are pulled from git (against
the branch's parent — the authoritative source) rather than the latest
commit's delta, so reviewers see the full multi-commit change set.
"""
t = await self.task.get(task_id)
if t is None:
return Envelope.not_found(message=f"task {task_id} not found")
# Reads are allowed for the assignee, an unassigned task, a board
# co-reviewer of a shared coordination task, OR a caller whose own work
# depends on this task. Strict ownership only blocks snooping an
# unrelated, actively-owned task.
if (
t.assigned_to is not None
and t.assigned_to != agent_id
and not await self._board_may_co_review(agent_id, t)
and not await self._is_caller_dependency(agent_id, t)
):
return _ownership_violation(task_id)
if t.branch_name and t.work_session_id:
await self.workspace.fetch_branch_for_inspection(
agent_id=agent_id, branch_name=t.branch_name
)
diff = ""
files_changed: list[str] = []
if t.branch_name:
diff = await self.git.diff(
branch_name=t.branch_name, actor_agent_id=agent_id
)
files_changed = await self.git.list_changed_files(
branch_name=t.branch_name, actor_agent_id=agent_id
)
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
ev = build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff,
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="continue",
evidence=ev.as_dict(),
context_briefing={},
)
# =========================================================================
# Wave 1 — pre-gateway parity restoration
# =========================================================================
_SESSION_OPENER_ROLES: ClassVar[frozenset[str]] = frozenset(
{"cell_pm", "main_pm", "product_owner", "head_marketing", "ceo"}
)
_PROGRESS_ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(
{"in_progress", "verifying", "awaiting_qa", "awaiting_documentation"}
)
async def _progress_precondition_reject(
self, agent_id: UUID, task: Any
) -> Envelope | None:
"""Ownership + active-claim + active-status gate for progress().
Returns the rejection envelope, or None when all preconditions hold.
Extracted so ``progress`` stays under the return-count bound.
"""
if task.assigned_to != agent_id:
return _ownership_violation(task.id)
if reject := await self._active_claim_violation(agent_id, task):
return reject
if str(task.status) not in self._PROGRESS_ACTIVE_STATUSES:
return Envelope.invalid_state(
message=(
f"task is in {task.status!r}; progress updates only valid "
f"in active statuses ({sorted(self._PROGRESS_ACTIVE_STATUSES)})"
),
remediate=(
"use evidence(task_id) to re-read state; if you're past "
"i_am_done, the run has moved on — call i_am_idle()"
),
context_briefing={},
)
return None
async def progress(
self,
*,
agent_id: UUID,
task_id: UUID,
message: str,
plan_step: str | None = None,
percentage: int | None = None,
) -> Envelope:
"""Append a progress update; % is derived from the plan checklist.
Pass ``plan_step`` (a sub_task id or 1-based order) as you
finish each plan step — it is marked complete and the % is
computed from completed/total (the agent cannot set it). A
narrative entry without ``plan_step`` is allowed for important
mid-step documentation and carries the current derived %.
``percentage`` is only a fallback for tasks with no checklist.
Omitting ``plan_step`` on a task that *has* steps is accepted (a
product decision — narrative mid-step updates are valid) but logs a
soft warning so the gap is visible. It is never rejected.
Caller must be the active claimant and the task must be in an
active status — same constraints as the pre-gateway handler, plus
the single-claimant guard so a reaped/handed-off assignee cannot
keep writing.
"""
if rej := self._reject_soup(message, field="progress update", min_chars=5):
return rej
t = await self.task.get(task_id)
if t is None:
return Envelope.not_found(message=f"task {task_id} not found")
if reject := await self._progress_precondition_reject(agent_id, t):
return reject
result = await self.task.record_plan_progress(
task_id=task_id,
agent_id=agent_id,
message=message,
plan_step=plan_step,
fallback_percentage=percentage,
)
if result is None:
return Envelope.not_found(message=f"task {task_id} not found")
if result["step_resolved"] is False:
valid = result["valid_steps"]
return Envelope.invalid_state(
message=f"plan_step {plan_step!r} does not match any plan step",
remediate=(
"pass a sub_task id or its 1-based order. Valid steps: "
f"{valid}. Re-read them with evidence(task_id)."
),
context_briefing={},
)
if plan_step is None and result["valid_steps"]:
logger.warning(
"progress() called without plan_step on a stepped task",
task_id=str(task_id),
agent_id=str(agent_id),
valid_steps=result["valid_steps"],
)
await self._touch_heartbeat(task_id)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=f"progress {result['percentage']}% — continue",
context_briefing={},
)
async def open_session(
self,
*,
agent_id: UUID,
task_id: UUID,
channel: str,
topic: str,
relationship_type: str = "discussion",
group_id: UUID | None = None,
) -> Envelope:
"""PM-or-up creates a discussion session linked to a task.
Backs the `open_session` do-verb. The
underlying service de-duplicates: if an ancestor of this task
already has a primary session in the same channel, it reuses
that session instead of opening a new one.
"""
if rej := self._reject_soup(topic, field="topic", min_chars=5):
return rej
from roboco.models.session import (
SessionForTasksCreate,
SessionTaskRelationshipType,
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else ""
if role_str not in self._SESSION_OPENER_ROLES:
return Envelope.not_authorized(
message=(f"role {role_str!r} cannot open sessions; PM roles only"),
remediate=(
"ask your PM to open_session for you, or escalate_up if "
"no session exists for the work you need to discuss"
),
context_briefing={},
)
try:
rel_type = SessionTaskRelationshipType(relationship_type)
except ValueError:
rel_type = SessionTaskRelationshipType.DISCUSSION
req = SessionForTasksCreate(
task_ids=[task_id],
channel_slug=channel,
relationship_type=rel_type,
group_id=group_id,
)
# ``topic`` isn't part of SessionForTasksCreateRequest yet — pre-gateway
# the session topic was implicit (group name). For now we attach it to
# the envelope so the panel + journal can surface it; threading it
# into the session row is Wave 2 work.
_ = topic
session, links = await self.messaging.create_session_for_tasks(
req=req, pm_agent_id=agent_id
)
return Envelope.ok(
status="session_open",
task_id=str(task_id),
next="continue",
evidence={
"session_id": str(session.id),
"channel": channel,
"topic": topic,
"link_count": len(links),
},
context_briefing={},
)
async def link_session(
self,
*,
agent_id: UUID,
session_id: UUID,
task_id: UUID,
is_primary: bool = False,
relationship_type: str = "discussion",
) -> Envelope:
"""Link an existing session to a task (idempotent).
Caller must own the task — prevents cross-agent session-link spam.
"""
from roboco.models.session import SessionTaskRelationshipType
t = await self.task.get(task_id)
if t is None:
return Envelope.not_found(message=f"task {task_id} not found")
if t.assigned_to is not None and t.assigned_to != agent_id:
return _ownership_violation(task_id)
try:
rel = SessionTaskRelationshipType(relationship_type)
except ValueError:
return Envelope.invalid_state(
message=(f"invalid relationship_type {relationship_type!r}"),
remediate=(
"use one of: discussion | planning | review | retrospective"
),
context_briefing={},
)
link = await self.messaging.link_session_to_task(
session_id=session_id,
task_id=task_id,
added_by=agent_id,
is_primary=is_primary,
relationship_type=rel,
)
return Envelope.ok(
status="session_linked",
task_id=str(task_id),
next="continue",
evidence={
"session_id": str(session_id),
"link_id": str(link.id),
"is_primary": is_primary,
},
context_briefing={},
)
async def notify_list(
self,
*,
agent_id: UUID,
unread_only: bool = True,
pending_ack_only: bool = False,
limit: int = 20,
) -> Envelope:
"""Read this agent's notification inbox.
Closes the pre-gateway parity gap that left `i_am_idle()` deadlocked:
the verb is documented to soft-block on unread notifications, but
previously there was no way for the agent to read or acknowledge them.
"""
items = await self._deps.notification_delivery.list_for_agent(
agent_id=agent_id,
unread_only=unread_only,
pending_ack_only=pending_ack_only,
type_filter=None,
limit=limit,
)
notifications = [
{
"id": str(n.id),
"type": str(n.type),
"priority": str(n.priority),
"subject": n.subject,
"body": n.body,
"requires_ack": n.requires_ack,
"timestamp": n.timestamp.isoformat() if n.timestamp else None,
"from_agent": str(n.from_agent) if n.from_agent else None,
}
for n in items
]
return Envelope.ok(
status="ok",
task_id=None,
next="continue",
evidence={"notifications": notifications, "count": len(notifications)},
context_briefing={},
)
async def notify_get(
self,
*,
agent_id: UUID,
notification_id: UUID,
) -> Envelope:
"""Read one notification (also marks it read)."""
try:
n = await self._deps.notification_delivery.get_for_recipient_and_mark_read(
notification_id=notification_id,
agent_id=agent_id,
)
except Exception:
return Envelope.not_found(
message=f"notification {notification_id} not found"
)
return Envelope.ok(
status="ok",
task_id=None,
next="continue",
evidence={
"id": str(n.id),
"type": str(n.type),
"priority": str(n.priority),
"subject": n.subject,
"body": n.body,
"requires_ack": n.requires_ack,
"from_agent": str(n.from_agent) if n.from_agent else None,
},
context_briefing={},
)
async def channels(self, *, agent_id: UUID) -> Envelope:
"""Return the channels this agent can read / write.
Pre-gateway parity for ``roboco_channel_list``. Stops the
invented-channel-slug pattern (e.g. ``backend-dev``, ``backend``)
observed on smoke runs — the LLM sees the closed set in the
response and can pattern-match valid slugs from it.
"""
from roboco.enforcement.channel_access import get_agent_channels
agent = await self.task.agent_for(agent_id)
slug = getattr(agent, "slug", "") or ""
if not slug:
return Envelope.not_found(
message=f"agent {agent_id} not in registry",
)
readable = sorted(get_agent_channels(slug, action="read"))
writable = sorted(get_agent_channels(slug, action="write"))
return Envelope.ok(
status="ok",
task_id=None,
next="continue",
evidence={
"writable": writable,
"readable": readable,
"note": (
"Use the slug verbatim (no leading '#'). Inventing slugs "
"returns 'Channel not found'."
),
},
context_briefing={},
)
_PM_ROLES_FOR_PR_UPDATE: ClassVar[frozenset[str]] = frozenset(
{"cell_pm", "main_pm"}
)
@staticmethod
def _pr_update_is_authorized(agent_id: UUID, task: Any, agent: Any) -> bool:
"""True iff caller is the task's assignee, main_pm, or cell_pm on team.
Extracted so pr_update stays under xenon's cyclomatic-complexity
bound — the three branches plus the team-string compare push the
verb itself over the line when inlined.
"""
if task.assigned_to == agent_id:
return True
role_str = str(agent.role) if agent is not None else ""
if role_str == "main_pm":
return True
if role_str != "cell_pm" or agent is None:
return False
if agent.team is None or task.team is None:
return False
return str(agent.team) == str(task.team)
async def pr_update(
self,
*,
agent_id: UUID,
task_id: UUID,
title: str | None = None,
body: str | None = None,
reviewers: list[str] | None = None,
) -> Envelope:
"""Update an existing PR's title, body, and/or requested reviewers.
Dogfooding surfaced this gap: agents who needed to edit a PR's
title/body or assign a reviewer after ``open_pr`` had no verb
for it and got bash-shimmed by the ``gh pr edit`` guard. This
verb is the gateway-native replacement.
Authorization: caller must be the task's ``assigned_to`` OR a
PM on the task's team (cell_pm.team == task.team, or main_pm
which is cross-team).
Preconditions:
- task must exist (else not_found)
- task.pr_number must be set (else invalid_state, remediate
'call open_pr')
- at least one of title/body/reviewers must be non-None (else
invalid_state — schema-level check is the first line of
defense; this guard catches direct gateway calls)
"""
if rej := self._pr_update_input_check(title, body, reviewers):
return rej
t = await self.task.get(task_id)
if t is None:
return Envelope.not_found(message=f"task {task_id} not found")
if t.pr_number is None:
return Envelope.invalid_state(
message=f"task {task_id} has no PR open",
remediate=(
"call open_pr(task_id) first; pr_update only edits an "
"already-open PR"
),
context_briefing={},
)
agent = await self.task.agent_for(agent_id)
if not self._pr_update_is_authorized(agent_id, t, agent):
role_str = str(agent.role) if agent is not None else ""
return Envelope.not_authorized(
message=(
f"role {role_str!r} is neither the assignee nor a PM on "
f"this task's team; cannot update PR"
),
remediate=(
"only the task's assignee or a PM on the task's team "
"may edit the PR; ask the assignee or your PM to call "
"pr_update instead"
),
context_briefing={},
)
try:
result = await self.git.update_pr_for_task(
task_id,
title=title,
body=body,
reviewers=reviewers,
)
except GitError as exc:
return Envelope.invalid_state(
message=str(exc),
remediate=(
"check the PR number on the task and retry; if the PR "
"was closed externally, the task should be reset"
),
context_briefing={},
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="continue working, or i_am_done when ready",
evidence=result,
context_briefing={},
)
async def notify_ack(
self,
*,
agent_id: UUID,
notification_id: UUID,
) -> Envelope:
"""Acknowledge a notification.
Returns ``not_authorized`` if the caller isn't a recipient.
"""
try:
n = await self._deps.notification_delivery.acknowledge(
notification_id=notification_id,
agent_id=agent_id,
ack_type="received",
)
except ValueError as exc:
return Envelope.not_authorized(
message=str(exc),
remediate="only recipients of a notification can ack it",
context_briefing={},
)
if n is None:
return Envelope.not_found(
message=f"notification {notification_id} not found"
)
return Envelope.ok(
status="acked",
task_id=None,
next="continue",
evidence={"id": str(notification_id), "acked": True},
context_briefing={},
)
async def read_messages(self, *, agent_id: UUID) -> Envelope:
"""Mark all of the caller's unread A2A direct messages as read.
Clears the A2A side of ``i_am_idle``'s unread soft-block: zeroes the
per-conversation unread counter and stamps ``read_at`` on the inbound
messages. Notifications are separate (notify_list / notify_get /
notify_ack).
"""
cleared = await self.a2a.mark_all_read(agent_id)
return Envelope.ok(
status="read",
task_id=None,
next="retry i_am_idle() — your A2A inbox is clear",
evidence={"conversations_cleared": cleared},
context_briefing={},
)
def _strip_task_prefix(msg: str) -> str:
"""Strip any [task-id] prefix the agent supplied; gateway re-adds canonical."""
return _TASK_ID_PREFIX_RE.sub("", msg)