mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): Wave 2 pre-gateway parity — structured note, sub_tasks, channels
Three Wave 2 gaps from the 2026-05-11 pre-gateway parity design:
G4 — note() decision/reflect now require structured fields at the gateway
(pre-gateway `Field(...)` parity). Returns `incomplete_input` envelope
with field-by-field hints when any required field is missing.
- decision: context (str), options (list[{name,pros,cons}] min len 2),
chosen (str), rationale (str). `consequences` and `next_steps` are
now list[str] (was str). Renderer emits each option as a "### Name
+ Pros / Cons" block instead of a bullet — matches the pre-gateway
DecisionOption sub-shape exposed in `roboco/mcp/schemas/__init__.py`
at `254cc93`.
- reflect: what_done, what_learned, what_struggled (each non-empty
str). next_steps stays optional.
- Bumped tests/unit/gateway/test_content_actions.py with explicit
pass-with-N-options coverage (≥2 floor; 3-option case green).
G5 — i_will_plan now persists sub_tasks alongside approach / risks /
open_questions / technical_considerations. The Plan tab's Sub-Tasks
section was empty because the verb didn't accept the field. Choreographer
server-assigns id + order to each sub_task (pre-gateway build_plan_data
parity) and normalizes every list entry to the EXACT shape
`panel/src/types/index.ts::TaskPlan` consumes:
- SubTask: {id, title, description, completed:false, order,
estimated_hours:null, notes:null}
- Risk: {description, mitigation, severity:null} — accepts the
{risk, mitigation} pre-gateway shape too
- OpenQuestion: {question, answer:null, answered_by:null,
answered_at:null} — accepts a bare string fallback
The normalization lives in three small module-level helpers
(_normalize_sub_task / _normalize_risk / _normalize_open_question)
called from _build_panel_shaped_plan, keeping i_will_plan's branch
count under PLR0912.
G6 — new `channels()` verb returns the agent's readable + writable
channel slugs from foundation.policy.communications. Stops invented
slugs ("backend-dev", "backend") that we kept seeing in smoke runs.
Added to every role's manifest including auditor (read-only access).
Wired through:
- roboco/api/schemas/v2/do.py — list-typed consequences/next_steps,
dict-typed options, ChannelsRequest
- roboco/api/schemas/v2/flow.py — IWillPlanRequest.sub_tasks
- roboco/api/routes/v2/do.py — /channels endpoint
- roboco/api/routes/v2/flow_*.py — pass sub_tasks through
- roboco/services/gateway/content_actions.py — channels() method;
_check_scope_required_fields enforces decision/reflect structure;
_render_option_block emits per-option markdown blocks
- roboco/services/gateway/choreographer/_impl.py — _build_panel_shaped_plan
helper used by i_will_plan
- roboco/services/gateway/role_config.py — _CHANNEL_DISCOVERY tuple
on every role
- roboco/mcp/do_server.py — channels() tool + note() signature with
options as list[dict[str,str]]
- roboco/mcp/flow_server.py — i_will_plan signature with sub_tasks
Frontend: no code change. panel/src/types/index.ts already declares
the exact shape we now write; panel/src/components/tasks/task-detail/
{tab-plan,tab-progress,tab-sessions,tab-notes}.tsx already reads it.
The empty panels we observed were a backend write-side problem, not
a frontend read-side problem — Wave 1 + Wave 2 close it.
Quality: ruff + mypy clean. 505 unit tests pass (added 2 new tests on
decision-scope requirements, updated 3 existing tests to fit the
pre-gateway-parity contract).
Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
This commit is contained in:
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Header, Request
|
|||||||
from roboco.api.deps import get_content_actions
|
from roboco.api.deps import get_content_actions
|
||||||
from roboco.api.routes.v2._role_dep import envelope_to_response
|
from roboco.api.routes.v2._role_dep import envelope_to_response
|
||||||
from roboco.api.schemas.v2.do import (
|
from roboco.api.schemas.v2.do import (
|
||||||
|
ChannelsRequest,
|
||||||
CommitRequest,
|
CommitRequest,
|
||||||
DmRequest,
|
DmRequest,
|
||||||
EvidenceRequest,
|
EvidenceRequest,
|
||||||
@@ -231,3 +232,14 @@ async def do_notify_ack(
|
|||||||
notification_id=body.notification_id,
|
notification_id=body.notification_id,
|
||||||
)
|
)
|
||||||
return envelope_to_response(env, request)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/channels")
|
||||||
|
async def do_channels(
|
||||||
|
request: Request,
|
||||||
|
_body: ChannelsRequest,
|
||||||
|
x_agent_id: _AgentIdHeader,
|
||||||
|
actions: _ContentActionsDep,
|
||||||
|
) -> dict:
|
||||||
|
env = await actions.channels(agent_id=x_agent_id)
|
||||||
|
return envelope_to_response(env, request)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async def i_will_plan(
|
|||||||
body.plan,
|
body.plan,
|
||||||
rich_plan={
|
rich_plan={
|
||||||
"approach": body.approach,
|
"approach": body.approach,
|
||||||
|
"sub_tasks": body.sub_tasks,
|
||||||
"technical_considerations": body.technical_considerations,
|
"technical_considerations": body.technical_considerations,
|
||||||
"risks": body.risks,
|
"risks": body.risks,
|
||||||
"open_questions": body.open_questions,
|
"open_questions": body.open_questions,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async def i_will_plan(
|
|||||||
body.plan,
|
body.plan,
|
||||||
rich_plan={
|
rich_plan={
|
||||||
"approach": body.approach,
|
"approach": body.approach,
|
||||||
|
"sub_tasks": body.sub_tasks,
|
||||||
"technical_considerations": body.technical_considerations,
|
"technical_considerations": body.technical_considerations,
|
||||||
"risks": body.risks,
|
"risks": body.risks,
|
||||||
"open_questions": body.open_questions,
|
"open_questions": body.open_questions,
|
||||||
|
|||||||
@@ -30,17 +30,17 @@ class NoteRequest(BaseModel):
|
|||||||
scope: str = "note"
|
scope: str = "note"
|
||||||
task_id: UUID | None = None
|
task_id: UUID | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
# decision scope
|
# decision scope (all required at gateway when scope='decision')
|
||||||
context: str | None = None
|
context: str | None = None
|
||||||
options: list[str] | None = None
|
options: list[dict[str, str]] | None = None # [{name, pros, cons}, ...]
|
||||||
chosen: str | None = None
|
chosen: str | None = None
|
||||||
rationale: str | None = None
|
rationale: str | None = None
|
||||||
consequences: str | None = None
|
consequences: list[str] | None = None
|
||||||
# reflect scope
|
# reflect scope (what_done/learned/struggled required when scope='reflect')
|
||||||
what_done: str | None = None
|
what_done: str | None = None
|
||||||
what_learned: str | None = None
|
what_learned: str | None = None
|
||||||
what_struggled: str | None = None
|
what_struggled: str | None = None
|
||||||
next_steps: str | None = None
|
next_steps: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SayRequest(BaseModel):
|
class SayRequest(BaseModel):
|
||||||
@@ -121,3 +121,7 @@ class NotifyGetRequest(BaseModel):
|
|||||||
|
|
||||||
class NotifyAckRequest(BaseModel):
|
class NotifyAckRequest(BaseModel):
|
||||||
notification_id: UUID
|
notification_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsRequest(BaseModel):
|
||||||
|
"""No params — caller's identity comes from X-Agent-ID header."""
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class IWillPlanRequest(BaseModel):
|
|||||||
# shows Approach / Sub-Tasks / Technical Considerations / Risks /
|
# shows Approach / Sub-Tasks / Technical Considerations / Risks /
|
||||||
# Open Questions instead of an empty pane. Pre-gateway parity.
|
# Open Questions instead of an empty pane. Pre-gateway parity.
|
||||||
approach: str = ""
|
approach: str = ""
|
||||||
|
sub_tasks: list[dict[str, str]] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of {title, description} — server assigns id + order",
|
||||||
|
)
|
||||||
technical_considerations: list[str] = Field(default_factory=list)
|
technical_considerations: list[str] = Field(default_factory=list)
|
||||||
risks: list[dict[str, str]] = Field(default_factory=list)
|
risks: list[dict[str, str]] = Field(default_factory=list)
|
||||||
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
||||||
|
|||||||
+21
-9
@@ -92,26 +92,27 @@ def note(
|
|||||||
task_id: str | None = None,
|
task_id: str | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
context: str | None = None,
|
context: str | None = None,
|
||||||
options: list[str] | None = None,
|
options: list[dict[str, str]] | None = None,
|
||||||
chosen: str | None = None,
|
chosen: str | None = None,
|
||||||
rationale: str | None = None,
|
rationale: str | None = None,
|
||||||
consequences: str | None = None,
|
consequences: list[str] | None = None,
|
||||||
what_done: str | None = None,
|
what_done: str | None = None,
|
||||||
what_learned: str | None = None,
|
what_learned: str | None = None,
|
||||||
what_struggled: str | None = None,
|
what_struggled: str | None = None,
|
||||||
next_steps: str | None = None,
|
next_steps: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Write a journal entry. scope in note|decision|reflect|learning|struggle.
|
"""Write a journal entry. scope in note|decision|reflect|learning|struggle.
|
||||||
|
|
||||||
``text`` is always the short summary (one paragraph max). For ``decision``
|
``text`` is always the short summary (one paragraph max). For ``decision``
|
||||||
and ``reflect`` scopes, fill the scope-specific structured fields so the
|
and ``reflect`` scopes the structured fields are REQUIRED — pre-gateway
|
||||||
panel renders them as named sections — pre-gateway parity:
|
parity. The gateway returns ``incomplete_input`` if any is missing.
|
||||||
|
|
||||||
- decision: ``context`` (the situation), ``options`` (list of strings,
|
- decision: ``context`` (situation), ``options`` (list of ≥2 dicts
|
||||||
one per alternative considered), ``chosen`` (the alternative you took),
|
``{name, pros, cons}``), ``chosen`` (which option), ``rationale``
|
||||||
``rationale`` (why), ``consequences`` (what this commits us to)
|
(why), ``consequences`` (list of strings — what this commits us to)
|
||||||
- reflect: ``what_done`` (literal output), ``what_learned`` (new info),
|
- reflect: ``what_done`` (literal output), ``what_learned`` (new info),
|
||||||
``what_struggled`` (where you got stuck), ``next_steps`` (follow-ups)
|
``what_struggled`` (where you got stuck), ``next_steps`` (list of
|
||||||
|
follow-up strings)
|
||||||
|
|
||||||
Other scopes (note / learning / struggle) just need ``text``.
|
Other scopes (note / learning / struggle) just need ``text``.
|
||||||
"""
|
"""
|
||||||
@@ -321,6 +322,16 @@ def notify_ack(notification_id: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def channels() -> dict[str, Any]:
|
||||||
|
"""List the channel slugs you can read / write.
|
||||||
|
|
||||||
|
Use this BEFORE ``say(channel=...)`` if you're unsure of the slug —
|
||||||
|
inventing slugs returns ``Channel not found``. Returns
|
||||||
|
``{writable: [...], readable: [...]}``.
|
||||||
|
"""
|
||||||
|
return _post("/api/v2/do/channels", {})
|
||||||
|
|
||||||
|
|
||||||
# ---------- Tool registry ----------
|
# ---------- Tool registry ----------
|
||||||
#
|
#
|
||||||
# Maps the tool name an agent calls (matches manifest entries and the
|
# Maps the tool name an agent calls (matches manifest entries and the
|
||||||
@@ -339,6 +350,7 @@ _TOOLS: dict[str, Any] = {
|
|||||||
"notify_list": notify_list,
|
"notify_list": notify_list,
|
||||||
"notify_get": notify_get,
|
"notify_get": notify_get,
|
||||||
"notify_ack": notify_ack,
|
"notify_ack": notify_ack,
|
||||||
|
"channels": channels,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ def i_will_plan(
|
|||||||
task_id: str,
|
task_id: str,
|
||||||
plan: str,
|
plan: str,
|
||||||
approach: str = "",
|
approach: str = "",
|
||||||
|
sub_tasks: list[dict[str, str]] | None = None,
|
||||||
technical_considerations: list[str] | None = None,
|
technical_considerations: list[str] | None = None,
|
||||||
risks: list[dict[str, str]] | None = None,
|
risks: list[dict[str, str]] | None = None,
|
||||||
open_questions: list[dict[str, str | bool]] | None = None,
|
open_questions: list[dict[str, str | bool]] | None = None,
|
||||||
@@ -341,6 +342,10 @@ def i_will_plan(
|
|||||||
approach: 2-4 sentences describing the high-level approach for the
|
approach: 2-4 sentences describing the high-level approach for the
|
||||||
Plan tab. Required for non-trivial tasks; empty string is allowed
|
Plan tab. Required for non-trivial tasks; empty string is allowed
|
||||||
but produces an unpopulated Plan view.
|
but produces an unpopulated Plan view.
|
||||||
|
sub_tasks: Decomposition of this task into sub-units, each
|
||||||
|
``{"title": "...", "description": "..."}``. The gateway assigns
|
||||||
|
stable ids + order server-side. Populates the Plan tab's
|
||||||
|
Sub-Tasks section. Pre-gateway parity.
|
||||||
technical_considerations: Bullet list of architectural / library /
|
technical_considerations: Bullet list of architectural / library /
|
||||||
constraint notes. Each item is a single string.
|
constraint notes. Each item is a single string.
|
||||||
risks: List of {"risk": "...", "mitigation": "..."} entries.
|
risks: List of {"risk": "...", "mitigation": "..."} entries.
|
||||||
@@ -352,6 +357,7 @@ def i_will_plan(
|
|||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"plan": plan,
|
"plan": plan,
|
||||||
"approach": approach,
|
"approach": approach,
|
||||||
|
"sub_tasks": sub_tasks or [],
|
||||||
"technical_considerations": technical_considerations or [],
|
"technical_considerations": technical_considerations or [],
|
||||||
"risks": risks or [],
|
"risks": risks or [],
|
||||||
"open_questions": open_questions or [],
|
"open_questions": open_questions or [],
|
||||||
|
|||||||
@@ -46,6 +46,93 @@ from roboco.services.gateway.remediation import (
|
|||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sub_task(st: dict[str, Any], order: int) -> dict[str, Any]:
|
||||||
|
"""Shape a sub_task entry to panel/src/types/index.ts::SubTask."""
|
||||||
|
from uuid import uuid4 as _uuid4
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(st.get("id") or _uuid4()),
|
||||||
|
"title": str(st.get("title", "")),
|
||||||
|
"description": st.get("description") or None,
|
||||||
|
"completed": bool(st.get("completed", False)),
|
||||||
|
"order": order,
|
||||||
|
"estimated_hours": st.get("estimated_hours"),
|
||||||
|
"notes": st.get("notes"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_risk(r: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Shape a risk entry to panel/src/types/index.ts (description/mitigation/severity).
|
||||||
|
|
||||||
|
Accepts either {description, mitigation, severity?} (panel shape) or
|
||||||
|
{risk, mitigation} (pre-gateway agent shape).
|
||||||
|
"""
|
||||||
|
description = r.get("description") or r.get("risk") or ""
|
||||||
|
return {
|
||||||
|
"description": str(description),
|
||||||
|
"mitigation": str(r.get("mitigation", "")),
|
||||||
|
"severity": r.get("severity"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_open_question(q: Any) -> dict[str, Any] | None:
|
||||||
|
"""Shape an open_question entry to panel shape.
|
||||||
|
|
||||||
|
Returns None for entries we can't interpret (e.g., None or a number).
|
||||||
|
Accepts a bare string (the agent's short-form question), {question,
|
||||||
|
answered, answer} (pre-gateway shape), or {question, answer,
|
||||||
|
answered_by, answered_at} (panel shape).
|
||||||
|
"""
|
||||||
|
if isinstance(q, str):
|
||||||
|
return {
|
||||||
|
"question": q,
|
||||||
|
"answer": None,
|
||||||
|
"answered_by": None,
|
||||||
|
"answered_at": None,
|
||||||
|
}
|
||||||
|
if not isinstance(q, dict):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"question": str(q.get("question", "")),
|
||||||
|
"answer": q.get("answer"),
|
||||||
|
"answered_by": q.get("answered_by"),
|
||||||
|
"answered_at": q.get("answered_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_panel_shaped_plan(
|
||||||
|
plan_text: str, rich_plan: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the Task.plan dict in the exact shape the panel UI consumes.
|
||||||
|
|
||||||
|
Panel reference: panel/src/types/index.ts::TaskPlan. Each list entry is
|
||||||
|
normalized so the panel renders without optional-field JS errors.
|
||||||
|
"""
|
||||||
|
sub_tasks = [
|
||||||
|
_normalize_sub_task(st, i)
|
||||||
|
for i, st in enumerate(rich_plan.get("sub_tasks") or [])
|
||||||
|
if isinstance(st, dict)
|
||||||
|
]
|
||||||
|
risks = [
|
||||||
|
_normalize_risk(r)
|
||||||
|
for r in (rich_plan.get("risks") or [])
|
||||||
|
if isinstance(r, dict)
|
||||||
|
]
|
||||||
|
open_questions = [
|
||||||
|
normalized
|
||||||
|
for q in (rich_plan.get("open_questions") or [])
|
||||||
|
if (normalized := _normalize_open_question(q)) is not None
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"text": plan_text,
|
||||||
|
"approach": rich_plan.get("approach", ""),
|
||||||
|
"sub_tasks": sub_tasks,
|
||||||
|
"technical_considerations": rich_plan.get("technical_considerations", []),
|
||||||
|
"risks": risks,
|
||||||
|
"open_questions": open_questions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_original_developer(task: Any) -> str | None:
|
def _extract_original_developer(task: Any) -> str | None:
|
||||||
"""Pull the original_developer slug out of a task's quick_context, if any.
|
"""Pull the original_developer slug out of a task's quick_context, if any.
|
||||||
|
|
||||||
@@ -1825,16 +1912,7 @@ class Choreographer:
|
|||||||
"open_questions",
|
"open_questions",
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
effective_plan = {
|
effective_plan = _build_panel_shaped_plan(plan, rich_plan)
|
||||||
"text": plan,
|
|
||||||
"approach": rich_plan.get("approach", ""),
|
|
||||||
"sub_tasks": rich_plan.get("sub_tasks", []),
|
|
||||||
"technical_considerations": rich_plan.get(
|
|
||||||
"technical_considerations", []
|
|
||||||
),
|
|
||||||
"risks": rich_plan.get("risks", []),
|
|
||||||
"open_questions": rich_plan.get("open_questions", []),
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
effective_plan = plan
|
effective_plan = plan
|
||||||
ctx = _ClaimPlanStartContext(
|
ctx = _ClaimPlanStartContext(
|
||||||
|
|||||||
@@ -64,13 +64,29 @@ _REFLECT_SECTIONS: tuple[tuple[str, str], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -> str:
|
def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -> str:
|
||||||
"""Build the journal entry body. Pre-gateway parity for decision/reflect.
|
"""Build the journal entry body. Pre-gateway parity for decision/reflect.
|
||||||
|
|
||||||
For scopes that have a structured shape (``decision``, ``reflect``), append
|
For scopes that have a structured shape (``decision``, ``reflect``), append
|
||||||
a markdown section for each populated field. Other scopes return ``text``
|
a markdown section for each populated field. ``decision.options`` is
|
||||||
unchanged. The original ``text`` always lands first so consumers that
|
rendered as named blocks with Pros/Cons (pre-gateway DecisionOption shape).
|
||||||
only render flat content still see the summary line.
|
Other scopes return ``text`` unchanged. The original ``text`` always lands
|
||||||
|
first so flat-content consumers still see the summary line.
|
||||||
"""
|
"""
|
||||||
sections = (
|
sections = (
|
||||||
_DECISION_SECTIONS
|
_DECISION_SECTIONS
|
||||||
@@ -86,7 +102,11 @@ def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -
|
|||||||
value = structured.get(key)
|
value = structured.get(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
if isinstance(value, list):
|
if key == "options" and isinstance(value, list):
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
rendered = "\n\n".join(_render_option_block(o) for o in value)
|
||||||
|
elif isinstance(value, list):
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
rendered = "\n".join(f"- {item}" for item in value)
|
rendered = "\n".join(f"- {item}" for item in value)
|
||||||
@@ -98,6 +118,60 @@ def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -
|
|||||||
return "\n\n".join(body_parts) if body_parts else text
|
return "\n\n".join(body_parts) if body_parts else text
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-gateway `DecisionLogInput.options` enforced `min_length=2`. Same here.
|
||||||
|
_MIN_DECISION_OPTIONS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _check_scope_required_fields(
|
||||||
|
scope: str, structured: dict[str, Any]
|
||||||
|
) -> tuple[list[str], dict[str, str]]:
|
||||||
|
"""Pre-gateway parity: decision/reflect scopes required structured fields.
|
||||||
|
|
||||||
|
Returns (missing_field_names, field_hints) — both empty when satisfied.
|
||||||
|
"""
|
||||||
|
if scope == "decision":
|
||||||
|
decision_required: tuple[tuple[str, str], ...] = (
|
||||||
|
("context", "What situation led to this decision"),
|
||||||
|
("options", "At least 2 alternatives considered as list[{name,pros,cons}]"),
|
||||||
|
("chosen", "Which option you took"),
|
||||||
|
("rationale", "Why this option (cite trade-offs)"),
|
||||||
|
)
|
||||||
|
missing: list[str] = []
|
||||||
|
hints: dict[str, str] = {}
|
||||||
|
for field, hint in decision_required:
|
||||||
|
value = structured.get(field)
|
||||||
|
if field == "options":
|
||||||
|
if (
|
||||||
|
not isinstance(value, list)
|
||||||
|
or len(value) < _MIN_DECISION_OPTIONS
|
||||||
|
):
|
||||||
|
missing.append(field)
|
||||||
|
hints[field] = (
|
||||||
|
"options must be a list of at least 2 dicts with "
|
||||||
|
"shape {name: str, pros: str, cons: str}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
missing.append(field)
|
||||||
|
hints[field] = hint
|
||||||
|
return missing, hints
|
||||||
|
if scope == "reflect":
|
||||||
|
reflect_required: tuple[tuple[str, str], ...] = (
|
||||||
|
("what_done", "Literal output: what shipped, where (file:line / commit)"),
|
||||||
|
("what_learned", "New info you didn't have before"),
|
||||||
|
("what_struggled", "Where you got stuck (even briefly)"),
|
||||||
|
)
|
||||||
|
missing = []
|
||||||
|
hints = {}
|
||||||
|
for field, hint in reflect_required:
|
||||||
|
value = structured.get(field)
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
missing.append(field)
|
||||||
|
hints[field] = hint
|
||||||
|
return missing, hints
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
|
||||||
def _ownership_violation(task_id: UUID) -> Envelope:
|
def _ownership_violation(task_id: UUID) -> Envelope:
|
||||||
"""Standard envelope for Gate Set D ownership violations.
|
"""Standard envelope for Gate Set D ownership violations.
|
||||||
|
|
||||||
@@ -279,6 +353,19 @@ class ContentActions:
|
|||||||
if t is not None:
|
if t is not None:
|
||||||
task_id = t.id
|
task_id = t.id
|
||||||
s = structured or {}
|
s = structured or {}
|
||||||
|
# Pre-gateway parity: decision and reflect scopes had required fields.
|
||||||
|
missing, hints = _check_scope_required_fields(scope, s)
|
||||||
|
if missing:
|
||||||
|
return Envelope.incomplete_input(
|
||||||
|
missing=missing,
|
||||||
|
field_hints=hints,
|
||||||
|
remediate=(
|
||||||
|
f"re-issue note(scope={scope!r}, ...) with these fields "
|
||||||
|
f"filled: {', '.join(missing)}. Pre-gateway parity — these "
|
||||||
|
f"populate the panel's {scope.capitalize()}s view."
|
||||||
|
),
|
||||||
|
context_briefing={},
|
||||||
|
)
|
||||||
title = (s.get("title") or text.split("\n", 1)[0])[:200]
|
title = (s.get("title") or text.split("\n", 1)[0])[:200]
|
||||||
content = _render_journal_content(scope, text, s)
|
content = _render_journal_content(scope, text, s)
|
||||||
await self.journal.write_entry(
|
await self.journal.write_entry(
|
||||||
@@ -761,6 +848,39 @@ class ContentActions:
|
|||||||
context_briefing={},
|
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={},
|
||||||
|
)
|
||||||
|
|
||||||
async def notify_ack(
|
async def notify_ack(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -34,28 +34,37 @@ class RoleConfig:
|
|||||||
# Wave 1 receivers — every role with inbox access gets notify_list/get/ack
|
# Wave 1 receivers — every role with inbox access gets notify_list/get/ack
|
||||||
# so `i_am_idle()` doesn't soft-block forever on unread notifications.
|
# so `i_am_idle()` doesn't soft-block forever on unread notifications.
|
||||||
_NOTIFY_RECEIVER = ("notify_list", "notify_get", "notify_ack")
|
_NOTIFY_RECEIVER = ("notify_list", "notify_get", "notify_ack")
|
||||||
|
# Wave 2 — channel discovery. Every role gets `channels()` so the LLM stops
|
||||||
|
# inventing slugs ("backend-dev", "backend") that don't exist.
|
||||||
|
_CHANNEL_DISCOVERY = ("channels",)
|
||||||
|
|
||||||
_DEV_FLOW = spec.intents_for_role(spec.Role.DEVELOPER)
|
_DEV_FLOW = spec.intents_for_role(spec.Role.DEVELOPER)
|
||||||
_DEV_DO = ("commit", "note", "say", "dm", "evidence", "progress", *_NOTIFY_RECEIVER)
|
_DEV_DO = (
|
||||||
|
"commit", "note", "say", "dm", "evidence", "progress",
|
||||||
|
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
|
||||||
|
)
|
||||||
|
|
||||||
_QA_FLOW = spec.intents_for_role(spec.Role.QA)
|
_QA_FLOW = spec.intents_for_role(spec.Role.QA)
|
||||||
_QA_DO = ("note", "say", "dm", "evidence", *_NOTIFY_RECEIVER)
|
_QA_DO = ("note", "say", "dm", "evidence", *_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY)
|
||||||
|
|
||||||
_DOC_FLOW = spec.intents_for_role(spec.Role.DOCUMENTER)
|
_DOC_FLOW = spec.intents_for_role(spec.Role.DOCUMENTER)
|
||||||
_DOC_DO = ("commit", "note", "say", "dm", "evidence", "progress", *_NOTIFY_RECEIVER)
|
_DOC_DO = (
|
||||||
|
"commit", "note", "say", "dm", "evidence", "progress",
|
||||||
|
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
|
||||||
|
)
|
||||||
|
|
||||||
_CELL_PM_FLOW = spec.intents_for_role(spec.Role.CELL_PM)
|
_CELL_PM_FLOW = spec.intents_for_role(spec.Role.CELL_PM)
|
||||||
_CELL_PM_DO = (
|
_CELL_PM_DO = (
|
||||||
"note", "say", "dm", "notify", "evidence",
|
"note", "say", "dm", "notify", "evidence",
|
||||||
"open_session", "link_session",
|
"open_session", "link_session",
|
||||||
*_NOTIFY_RECEIVER,
|
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MAIN_PM_FLOW = spec.intents_for_role(spec.Role.MAIN_PM)
|
_MAIN_PM_FLOW = spec.intents_for_role(spec.Role.MAIN_PM)
|
||||||
_MAIN_PM_DO = (
|
_MAIN_PM_DO = (
|
||||||
"note", "say", "dm", "notify", "evidence",
|
"note", "say", "dm", "notify", "evidence",
|
||||||
"open_session", "link_session",
|
"open_session", "link_session",
|
||||||
*_NOTIFY_RECEIVER,
|
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
|
||||||
)
|
)
|
||||||
|
|
||||||
_PRODUCT_OWNER_FLOW = spec.intents_for_role(spec.Role.PRODUCT_OWNER)
|
_PRODUCT_OWNER_FLOW = spec.intents_for_role(spec.Role.PRODUCT_OWNER)
|
||||||
@@ -63,13 +72,13 @@ _HEAD_MARKETING_FLOW = spec.intents_for_role(spec.Role.HEAD_MARKETING)
|
|||||||
_BOARD_DO = (
|
_BOARD_DO = (
|
||||||
"note", "say", "dm", "notify", "evidence",
|
"note", "say", "dm", "notify", "evidence",
|
||||||
"open_session", # Board can open strategic sessions but not link arbitrary
|
"open_session", # Board can open strategic sessions but not link arbitrary
|
||||||
*_NOTIFY_RECEIVER,
|
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
|
||||||
)
|
)
|
||||||
|
|
||||||
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
|
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
|
||||||
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
|
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
|
||||||
# no ack (silent observer — wouldn't ack notifications).
|
# no ack (silent observer — wouldn't ack notifications). channels for read map.
|
||||||
_AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get")
|
_AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get", *_CHANNEL_DISCOVERY)
|
||||||
|
|
||||||
|
|
||||||
ROLE_CONFIGS: dict[str, RoleConfig] = {
|
ROLE_CONFIGS: dict[str, RoleConfig] = {
|
||||||
|
|||||||
@@ -221,7 +221,12 @@ async def test_commit_allows_documenter_role() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_note_reflect_scope_succeeds() -> None:
|
async def test_note_reflect_scope_succeeds() -> None:
|
||||||
"""scope='reflect' is valid; journal.write_entry is called."""
|
"""scope='reflect' is valid; journal.write_entry is called.
|
||||||
|
|
||||||
|
Pre-gateway parity: reflect requires what_done / what_learned /
|
||||||
|
what_struggled (each a non-empty string). The gateway returns
|
||||||
|
`incomplete_input` if any is missing.
|
||||||
|
"""
|
||||||
agent_id = uuid4()
|
agent_id = uuid4()
|
||||||
task_id = uuid4()
|
task_id = uuid4()
|
||||||
task_svc = AsyncMock()
|
task_svc = AsyncMock()
|
||||||
@@ -240,6 +245,11 @@ async def test_note_reflect_scope_succeeds() -> None:
|
|||||||
text="Reflected on approach: went with async generator pattern.",
|
text="Reflected on approach: went with async generator pattern.",
|
||||||
scope="reflect",
|
scope="reflect",
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
structured={
|
||||||
|
"what_done": "Shipped the async generator pattern in service.py:120-180",
|
||||||
|
"what_learned": "asyncio.shield wraps cancellation correctly here",
|
||||||
|
"what_struggled": "Initially missed the cleanup race; commits 3a4f1 fix it",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
|
|
||||||
@@ -250,6 +260,119 @@ async def test_note_reflect_scope_succeeds() -> None:
|
|||||||
assert call_kwargs["scope"] == "reflect"
|
assert call_kwargs["scope"] == "reflect"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_note_reflect_missing_required_fields_returns_incomplete_input() -> None:
|
||||||
|
"""Pre-gateway parity: reflect without structured fields fails fast."""
|
||||||
|
agent_id = uuid4()
|
||||||
|
task_id = uuid4()
|
||||||
|
task_svc = AsyncMock()
|
||||||
|
task_svc.get_active_task_for_agent.return_value = None
|
||||||
|
task_svc.get.return_value = MagicMock(
|
||||||
|
id=task_id, assigned_to=agent_id, status="in_progress"
|
||||||
|
)
|
||||||
|
journal_svc = AsyncMock()
|
||||||
|
|
||||||
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
|
ca = ContentActions(deps)
|
||||||
|
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="bare reflect with no structured fields",
|
||||||
|
scope="reflect",
|
||||||
|
task_id=task_id,
|
||||||
|
)
|
||||||
|
body = env.as_dict()
|
||||||
|
|
||||||
|
assert body["error"] == "incomplete_input"
|
||||||
|
assert {"what_done", "what_learned", "what_struggled"}.issubset(set(body["missing"]))
|
||||||
|
journal_svc.write_entry.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_note_decision_requires_options_and_more() -> None:
|
||||||
|
"""Pre-gateway parity: decision requires context/options(>=2)/chosen/rationale."""
|
||||||
|
agent_id = uuid4()
|
||||||
|
task_id = uuid4()
|
||||||
|
task_svc = AsyncMock()
|
||||||
|
task_svc.get_active_task_for_agent.return_value = None
|
||||||
|
task_svc.get.return_value = MagicMock(
|
||||||
|
id=task_id, assigned_to=agent_id, status="in_progress"
|
||||||
|
)
|
||||||
|
journal_svc = AsyncMock()
|
||||||
|
|
||||||
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
|
ca = ContentActions(deps)
|
||||||
|
|
||||||
|
# Missing everything structured → incomplete_input listing all required.
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="bare decision",
|
||||||
|
scope="decision",
|
||||||
|
task_id=task_id,
|
||||||
|
)
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] == "incomplete_input"
|
||||||
|
assert {"context", "options", "chosen", "rationale"}.issubset(set(body["missing"]))
|
||||||
|
|
||||||
|
# Single option still fails (min 2).
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="decision with one option",
|
||||||
|
scope="decision",
|
||||||
|
task_id=task_id,
|
||||||
|
structured={
|
||||||
|
"context": "needed a queue",
|
||||||
|
"options": [{"name": "redis", "pros": "fast", "cons": "ephemeral"}],
|
||||||
|
"chosen": "redis",
|
||||||
|
"rationale": "speed beats durability for this case",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] == "incomplete_input"
|
||||||
|
assert "options" in body["missing"]
|
||||||
|
|
||||||
|
# Two options + all required → success.
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="real decision",
|
||||||
|
scope="decision",
|
||||||
|
task_id=task_id,
|
||||||
|
structured={
|
||||||
|
"context": "needed a queue",
|
||||||
|
"options": [
|
||||||
|
{"name": "redis", "pros": "fast", "cons": "ephemeral"},
|
||||||
|
{"name": "postgres", "pros": "durable", "cons": "slower writes"},
|
||||||
|
],
|
||||||
|
"chosen": "redis",
|
||||||
|
"rationale": "speed beats durability for ephemeral work",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] is None
|
||||||
|
assert body["status"] == "noted"
|
||||||
|
|
||||||
|
# Three+ options also pass — 2 is the floor, not the ceiling.
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="three-way decision",
|
||||||
|
scope="decision",
|
||||||
|
task_id=task_id,
|
||||||
|
structured={
|
||||||
|
"context": "queue tech choice",
|
||||||
|
"options": [
|
||||||
|
{"name": "redis", "pros": "fast", "cons": "ephemeral"},
|
||||||
|
{"name": "postgres", "pros": "durable", "cons": "slower"},
|
||||||
|
{"name": "rabbitmq", "pros": "ordered", "cons": "ops overhead"},
|
||||||
|
],
|
||||||
|
"chosen": "rabbitmq",
|
||||||
|
"rationale": "ordering matters more than raw speed here",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] is None
|
||||||
|
assert body["status"] == "noted"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_note_invalid_scope_returns_invalid_state() -> None:
|
async def test_note_invalid_scope_returns_invalid_state() -> None:
|
||||||
"""Unknown scope yields invalid_state with valid-scope hint."""
|
"""Unknown scope yields invalid_state with valid-scope hint."""
|
||||||
@@ -283,6 +406,15 @@ async def test_note_auto_fills_task_id_from_active_task() -> None:
|
|||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
text="Decided to use UUIDs instead of integer PKs for portability.",
|
text="Decided to use UUIDs instead of integer PKs for portability.",
|
||||||
scope="decision",
|
scope="decision",
|
||||||
|
structured={
|
||||||
|
"context": "Choosing primary-key strategy for the new tables",
|
||||||
|
"options": [
|
||||||
|
{"name": "int", "pros": "compact", "cons": "leaks volume"},
|
||||||
|
{"name": "uuid", "pros": "portable", "cons": "wider rows"},
|
||||||
|
],
|
||||||
|
"chosen": "uuid",
|
||||||
|
"rationale": "portability matters more than 8 bytes/row",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
|
|
||||||
|
|||||||
@@ -133,10 +133,13 @@ async def test_note_with_task_id_allows_when_assignee() -> None:
|
|||||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
ca = ContentActions(deps)
|
ca = ContentActions(deps)
|
||||||
|
|
||||||
|
# Uses scope='note' (no structured-field requirement) — the test
|
||||||
|
# exercises the ownership gate, not the journal-shape gate (which is
|
||||||
|
# covered separately in test_content_actions.py).
|
||||||
env = await ca.note(
|
env = await ca.note(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
text="Reflecting on my own task",
|
text="Working on my own task",
|
||||||
scope="reflect",
|
scope="note",
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
assert env.error is None
|
assert env.error is None
|
||||||
@@ -155,8 +158,8 @@ async def test_note_without_task_id_skips_ownership_check() -> None:
|
|||||||
|
|
||||||
env = await ca.note(
|
env = await ca.note(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
text="A general reflection note with enough length",
|
text="A general note with enough length",
|
||||||
scope="reflect",
|
scope="note",
|
||||||
)
|
)
|
||||||
assert env.error is None
|
assert env.error is None
|
||||||
journal_svc.write_entry.assert_awaited_once()
|
journal_svc.write_entry.assert_awaited_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user