From fd344511a5b9110bbe9c5e158bb15c7d9e5fd6dc Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 8 May 2026 08:03:32 +0200 Subject: [PATCH] feat(gateway): add Envelope introspection (current_state + valid_next_verbs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix, agents had no way to introspect what verbs were valid from a task's current state — the 2026-05-08 trace showed them spamming escalate_to_ceo/complete/unblock/resume against a `claimed` task and racking up rejections. Pre-gateway agents could ground reasoning in VALID_TRANSITIONS[status] from a doc; the gateway hid that. Now every Envelope carries: - current_state: the task's status string (or None for tool-discovery envelopes that aren't task-bound) - valid_next_verbs: the verbs the caller can usefully call right now, sourced from verb_gates.valid_next_verbs(role, task) Wired into i_will_work_on (highest-traffic verb) for both the OK path and the wrong-state rejection path. Remaining lifecycle verbs to be wired in subsequent commits (Task 3.9). --- .../services/gateway/choreographer/_impl.py | 10 +++- roboco/services/gateway/envelope.py | 21 +++++++ .../test_choreographer_impl_branches.py | 54 +++++++++++++++++ .../gateway/test_envelope_introspection.py | 60 +++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/unit/gateway/test_envelope_introspection.py diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 51ad7efb..139f9dfc 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -152,6 +152,11 @@ class Choreographer: failures must NEVER block the verb (the agent's response is the contract; the audit row is observability-only). + Introspection (`current_state` + `valid_next_verbs`) is applied + at the call site via `Envelope.with_introspection(task, role)` + rather than here — this keeps the rejection path's signature + narrow and lets the helper stay framework-clean. + Stashes ``correlation_id`` from the structlog contextvars (bound by ``CorrelationIdMiddleware`` for the inbound request) and a per-attempt id into the audit row's ``details`` JSONB. The @@ -472,6 +477,8 @@ class Choreographer: task_id=task_id, verb="i_will_work_on", ) + agent = await self.task.agent_for(agent_id) + role = str(agent.role) if agent is not None else "developer" status = str(t.status) briefing = await self._briefing_for(agent_id, task_id) @@ -503,6 +510,7 @@ class Choreographer: ) if rejection is not None: + rejection.with_introspection(task=t, role=role) return await self._emit_rejection( rejection, agent_id=agent_id, @@ -519,7 +527,7 @@ class Choreographer: " then submit_for_qa(task_id) and i_am_done(task_id)" ), context_briefing=briefing, - ) + ).with_introspection(task=t, role=role) @staticmethod def _with_briefing(env: Envelope, briefing: dict[str, Any]) -> Envelope: diff --git a/roboco/services/gateway/envelope.py b/roboco/services/gateway/envelope.py index 7e68dbc3..70f92047 100644 --- a/roboco/services/gateway/envelope.py +++ b/roboco/services/gateway/envelope.py @@ -38,6 +38,13 @@ class Envelope: # Carried back to the agent so the same id flows MCP -> API -> agent # and ops can join logs across the full hop. correlation_id: str | None = None + # Introspection — populated by `with_introspection(task, role)` so + # agents can see the task's current status and the verbs they can + # usefully call next without trial-and-error against the gateway. + # Both default to None when no task context is available (e.g. for + # tool-discovery envelopes). + current_state: str | None = None + valid_next_verbs: list[str] | None = None @classmethod def ok( @@ -106,6 +113,18 @@ class Envelope: def not_found(cls, *, message: str) -> Envelope: return cls(error="not_found", message=message, context_briefing={}) + def with_introspection(self, *, task: Any, role: str) -> Envelope: + """Populate `current_state` and `valid_next_verbs` from a task + role. + + Returns self for chaining. Imports verb_gates lazily so envelope.py + stays importable from any layer without dragging in the gates table. + """ + from roboco.services.gateway.verb_gates import valid_next_verbs + + self.current_state = str(getattr(task, "status", "") or "") or None + self.valid_next_verbs = valid_next_verbs(role, task) + return self + def as_dict(self) -> dict[str, Any]: """Wire-format dict. Drops None fields except `error` (always present).""" out: dict[str, Any] = { @@ -116,6 +135,8 @@ class Envelope: "context_briefing": self.context_briefing, "error": self.error, "correlation_id": self.correlation_id, + "current_state": self.current_state, + "valid_next_verbs": self.valid_next_verbs, } if self.error is not None: out["message"] = self.message diff --git a/tests/unit/gateway/test_choreographer_impl_branches.py b/tests/unit/gateway/test_choreographer_impl_branches.py index 0bdb5c52..602981e6 100644 --- a/tests/unit/gateway/test_choreographer_impl_branches.py +++ b/tests/unit/gateway/test_choreographer_impl_branches.py @@ -995,3 +995,57 @@ async def test_submit_up_not_assigned_rejected() -> None: body = env.as_dict() assert body["error"] == "not_authorized" assert "not assigned" in body["message"] + + +# --------------------------------------------------------------------------- +# Task 3: Envelope introspection — verb returns carry current_state + +# valid_next_verbs so agents stop trial-and-erroring. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_i_will_work_on_envelope_carries_introspection_on_success() -> None: + """Successful claim+start path stamps current_state + valid_next_verbs.""" + agent_id = uuid4() + task_id = uuid4() + task_svc = _wire_dev_task_svc(task_id, status="pending", assigned_to=agent_id) + claimed_task = MagicMock( + status="in_progress", + assigned_to=agent_id, + plan="ok plan", + id=task_id, + title="t", + task_type="code", + ) + task_svc.claim.return_value = claimed_task + task_svc.set_plan.return_value = claimed_task + task_svc.start.return_value = claimed_task + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + env = await c.i_will_work_on(agent_id, task_id, plan="ok plan") + body = env.as_dict() + assert body["error"] is None + assert body["current_state"] == "in_progress" + assert isinstance(body["valid_next_verbs"], list) + assert "commit" in body["valid_next_verbs"] + assert "i_am_done" in body["valid_next_verbs"] + + +@pytest.mark.asyncio +async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> None: + """A wrong-state rejection still stamps current_state + valid_next_verbs + so the agent learns what verbs are actually valid right now.""" + agent_id = uuid4() + task_id = uuid4() + task_svc = _wire_dev_task_svc( + task_id, status="completed", assigned_to=agent_id + ) + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + env = await c.i_will_work_on(agent_id, task_id, plan="x") + body = env.as_dict() + assert body["error"] == "invalid_state" + assert body["current_state"] == "completed" + assert isinstance(body["valid_next_verbs"], list) + # Lifecycle verbs are NOT in the list for a completed task. + assert "i_will_work_on" not in body["valid_next_verbs"] diff --git a/tests/unit/gateway/test_envelope_introspection.py b/tests/unit/gateway/test_envelope_introspection.py new file mode 100644 index 00000000..aa0d4c49 --- /dev/null +++ b/tests/unit/gateway/test_envelope_introspection.py @@ -0,0 +1,60 @@ +"""Envelope must carry current_state + valid_next_verbs after Task 3.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from roboco.services.gateway.envelope import Envelope + + +def test_envelope_ok_carries_introspection_when_task_supplied() -> None: + task = SimpleNamespace(id="abc", status="in_progress", task_type="code") + env = Envelope.ok( + status="in_progress", + task_id="abc", + next="commit(message='...')", + ).with_introspection(task=task, role="developer") + body = env.as_dict() + assert body["current_state"] == "in_progress" + assert "commit" in body["valid_next_verbs"] + assert "i_am_done" in body["valid_next_verbs"] + + +def test_envelope_error_carries_introspection_too() -> None: + task = SimpleNamespace(id="abc", status="claimed", task_type="code") + env = Envelope.invalid_state( + message="task is in claimed, expected awaiting_pm_review", + remediate="wait for QA + docs to complete", + ).with_introspection(task=task, role="main_pm") + body = env.as_dict() + assert body["error"] == "invalid_state" + assert body["current_state"] == "claimed" + # main_pm on a claimed task DOES see `delegate` (legal in claimed), + # but the lifecycle-spam pattern verbs we want to suppress on + # `pending` are absent there. Pin the shape rather than the contents. + assert isinstance(body["valid_next_verbs"], list) + + +def test_envelope_without_introspection_omits_fields() -> None: + """Backwards-compat: callers that don't supply a task get None.""" + env = Envelope.ok(status="ok", task_id=None, next="continue") + body = env.as_dict() + assert body["current_state"] is None + assert body["valid_next_verbs"] is None + + +def test_with_introspection_returns_self_for_chaining() -> None: + task = SimpleNamespace(id="x", status="pending", task_type="code") + env = Envelope.ok(status="pending", task_id="x", next="i_will_work_on") + result = env.with_introspection(task=task, role="developer") + assert result is env + + +def test_with_introspection_handles_unknown_role() -> None: + """Unknown role -> empty list, not None.""" + task = SimpleNamespace(id="x", status="pending", task_type="code") + env = Envelope.ok(status="pending", task_id="x", next="???") + env.with_introspection(task=task, role="space_marine") + body = env.as_dict() + assert body["valid_next_verbs"] == [] + assert body["current_state"] == "pending"