fix(gateway): reject null + DO-NOT-PASS-NULL remediate for decision/reflect

Smoke-6 found the agent calling note(scope='decision', context=null,
chosen=null, rationale=null) eight times in a row. Root cause split
across two surfaces:

1. The MCP tool schema declared these fields as `str | None = None`,
   producing a JSON schema of `anyOf [string, null]`. minimax-m2.7 read
   that and decided null was a valid value — passed it on every retry.

2. The remediate text used `<placeholder>` syntax for the example
   without telling the agent "don't pass null" explicitly.

Fixes:
- roboco/api/schemas/v2/do.py NoteRequest: context, chosen, rationale,
  what_done, what_learned, what_struggled now typed `str = ""` (no None).
  Pydantic on the route rejects literal null with 422 BEFORE the gateway
  sees it. Empty string still counts as missing at the gate.
- roboco/mcp/do_server.py note(): matching signature changes so the
  MCP tool schema declares the fields as `string` not `anyOf[string,null]`.
- roboco/services/gateway/content_actions.py: remediate text now opens
  with "DO NOT pass null" and the example uses concrete values (redis vs
  postgres) instead of <angle bracket> placeholders. Reflect remediate
  also gets the don't-pass-null intro and concrete values.

8 new tests pin "schema rejects null for each of the 6 string fields"
plus "empty defaults work for unscoped notes".
This commit is contained in:
Renn F
2026-05-14 05:27:45 +02:00
parent 21007e122f
commit 3bbaf0d645
4 changed files with 135 additions and 26 deletions
@@ -0,0 +1,93 @@
"""Smoke-6: NoteRequest rejects null for decision/reflect string fields.
Original bug: minimax-m2.7 read the MCP tool schema, saw
`context: anyOf[string, null]`, and decided null was valid. Pydantic
on the route accepted it (because the field WAS `str | None`), passed
it through, and the server-side gate looped on `incomplete_input`.
Fix tightens the schema: those fields are now `str = ""`. The MCP
schema generator emits `string` (not `string | null`), and Pydantic
on the route rejects literal null with 422. Empty string is still
treated as missing at the gateway gate.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from roboco.api.schemas.v2.do import NoteRequest
def test_note_request_accepts_omitted_decision_fields() -> None:
"""A bare note (scope='note') with no structured fields builds cleanly."""
req = NoteRequest(text="just an observation")
assert req.context == ""
assert req.chosen == ""
assert req.rationale == ""
assert req.what_done == ""
assert req.what_learned == ""
assert req.what_struggled == ""
def test_note_request_rejects_null_context() -> None:
"""Passing literal null for context fails Pydantic validation."""
with pytest.raises(ValidationError) as exc_info:
NoteRequest.model_validate({"text": "x", "scope": "decision", "context": None})
msg = str(exc_info.value)
assert "context" in msg.lower()
def test_note_request_rejects_null_chosen() -> None:
with pytest.raises(ValidationError) as exc_info:
NoteRequest.model_validate({"text": "x", "scope": "decision", "chosen": None})
assert "chosen" in str(exc_info.value).lower()
def test_note_request_rejects_null_rationale() -> None:
with pytest.raises(ValidationError) as exc_info:
NoteRequest.model_validate(
{"text": "x", "scope": "decision", "rationale": None}
)
assert "rationale" in str(exc_info.value).lower()
def test_note_request_rejects_null_what_done() -> None:
with pytest.raises(ValidationError) as exc_info:
NoteRequest.model_validate({"text": "x", "scope": "reflect", "what_done": None})
assert "what_done" in str(exc_info.value).lower()
def test_note_request_rejects_null_what_learned() -> None:
with pytest.raises(ValidationError) as exc_info:
NoteRequest.model_validate(
{"text": "x", "scope": "reflect", "what_learned": None}
)
assert "what_learned" in str(exc_info.value).lower()
def test_note_request_rejects_null_what_struggled() -> None:
with pytest.raises(ValidationError) as exc_info:
NoteRequest.model_validate(
{"text": "x", "scope": "reflect", "what_struggled": None}
)
assert "what_struggled" in str(exc_info.value).lower()
def test_note_request_accepts_non_empty_strings() -> None:
"""A fully-filled decision request validates."""
req = NoteRequest.model_validate(
{
"text": "Going with X",
"scope": "decision",
"context": "We have a choice between A and B",
"options": [
{"name": "A", "pros": "fast", "cons": "fragile"},
{"name": "B", "pros": "robust", "cons": "slow"},
],
"chosen": "A",
"rationale": "speed beats robustness for this experiment",
}
)
assert req.context == "We have a choice between A and B"
assert req.chosen == "A"
assert req.rationale.startswith("speed")