mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): add Envelope introspection (current_state + valid_next_verbs)
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).
This commit is contained in:
@@ -152,6 +152,11 @@ class Choreographer:
|
|||||||
failures must NEVER block the verb (the agent's response is the
|
failures must NEVER block the verb (the agent's response is the
|
||||||
contract; the audit row is observability-only).
|
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
|
Stashes ``correlation_id`` from the structlog contextvars (bound
|
||||||
by ``CorrelationIdMiddleware`` for the inbound request) and a
|
by ``CorrelationIdMiddleware`` for the inbound request) and a
|
||||||
per-attempt id into the audit row's ``details`` JSONB. The
|
per-attempt id into the audit row's ``details`` JSONB. The
|
||||||
@@ -472,6 +477,8 @@ class Choreographer:
|
|||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
verb="i_will_work_on",
|
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)
|
status = str(t.status)
|
||||||
briefing = await self._briefing_for(agent_id, task_id)
|
briefing = await self._briefing_for(agent_id, task_id)
|
||||||
|
|
||||||
@@ -503,6 +510,7 @@ class Choreographer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if rejection is not None:
|
if rejection is not None:
|
||||||
|
rejection.with_introspection(task=t, role=role)
|
||||||
return await self._emit_rejection(
|
return await self._emit_rejection(
|
||||||
rejection,
|
rejection,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
@@ -519,7 +527,7 @@ class Choreographer:
|
|||||||
" then submit_for_qa(task_id) and i_am_done(task_id)"
|
" then submit_for_qa(task_id) and i_am_done(task_id)"
|
||||||
),
|
),
|
||||||
context_briefing=briefing,
|
context_briefing=briefing,
|
||||||
)
|
).with_introspection(task=t, role=role)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _with_briefing(env: Envelope, briefing: dict[str, Any]) -> Envelope:
|
def _with_briefing(env: Envelope, briefing: dict[str, Any]) -> Envelope:
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class Envelope:
|
|||||||
# Carried back to the agent so the same id flows MCP -> API -> agent
|
# Carried back to the agent so the same id flows MCP -> API -> agent
|
||||||
# and ops can join logs across the full hop.
|
# and ops can join logs across the full hop.
|
||||||
correlation_id: str | None = None
|
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
|
@classmethod
|
||||||
def ok(
|
def ok(
|
||||||
@@ -106,6 +113,18 @@ class Envelope:
|
|||||||
def not_found(cls, *, message: str) -> Envelope:
|
def not_found(cls, *, message: str) -> Envelope:
|
||||||
return cls(error="not_found", message=message, context_briefing={})
|
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]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
"""Wire-format dict. Drops None fields except `error` (always present)."""
|
"""Wire-format dict. Drops None fields except `error` (always present)."""
|
||||||
out: dict[str, Any] = {
|
out: dict[str, Any] = {
|
||||||
@@ -116,6 +135,8 @@ class Envelope:
|
|||||||
"context_briefing": self.context_briefing,
|
"context_briefing": self.context_briefing,
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
"correlation_id": self.correlation_id,
|
"correlation_id": self.correlation_id,
|
||||||
|
"current_state": self.current_state,
|
||||||
|
"valid_next_verbs": self.valid_next_verbs,
|
||||||
}
|
}
|
||||||
if self.error is not None:
|
if self.error is not None:
|
||||||
out["message"] = self.message
|
out["message"] = self.message
|
||||||
|
|||||||
@@ -995,3 +995,57 @@ async def test_submit_up_not_assigned_rejected() -> None:
|
|||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
assert body["error"] == "not_authorized"
|
assert body["error"] == "not_authorized"
|
||||||
assert "not assigned" in body["message"]
|
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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user