Files
roboco/scripts/regenerate_verb_tables.py
a9dee3b34e feat(agents): force agents to the Makefile — deny raw uv/pip/conda/poetry (CEO #15) (#518)
* fix(prompts): point agents at Makefile, drop raw uv run instructions

backend.md:23-26 literally instructed raw uv run ruff/mypy/pytest (copied from
the human-facing CLAUDE.md), so agents bypassed the Makefile's UV_NO_SYNC=1 +
private UV_CACHE_DIR venv-corruption guard. Replace with make targets across
backend/developer/qa/cell_pm + a universal rule in base.md. Regenerate verbs.md
from the updated regen script (baked instruction now make foundation-check) and
align the Makefile drift message. Ships with the bash-guard deny in the next
commit so agents don't loop fighting the guard.

* feat(bash-guard): deny raw uv/pip/conda/poetry, point at Makefile

When a Makefile is present, deny raw uv run/uv pip/uv lock/add/remove, pip/pip3
install/uninstall, conda install/create/run, poetry run/install/add and remediate
to make quality/gate/lint/test. Skipped when no Makefile (Makefile-less projects
not blocked). ROBOCO_GUARD_SKIP_PM=1 (grok path) nudges exit 0 instead of the
run-canceling exit 2. Overrides the prior bare-uv-run-allowed stance by CEO
direction; the /app-targeted blocks above keep priority.

* feat(grok): deny raw uv/pip/conda/poetry via native --deny + PM-skip nudge

Add _RAW_PM_DENY (uv run/pip install/lock/add/remove, pip/pip3 install, conda
install/create/run, poetry run/install/add) to _deny_rules so grok's graceful
native --deny blocks raw package-manager commands (model adapts to make, run
continues — unlike a hook deny which cancels the run). The bash-guard hook
keeps the compound-command fallback (cd x && uv run) and nudges exit 0 there via
ROBOCO_GUARD_SKIP_PM=1 in the grok hook env, never canceling.

* test(bash-guard): align existing tests with W1 Makefile-gate policy

Raw uv run / pip install are now Makefile-gated (W1, CEO item #15), so two
existing bash-guard invariants reverse:

- test_allows_pytest_even_if_suite_uses_requests keeps its HTTP-injection
  allow-path intent but uses bare `python -m pytest` (raw `uv run` is now
  denied); the deny case is covered by test_bash_guard_makefile_guardrail.
- test_allows_pip_install_in_workspace -> test_denies_pip_install_when_makefile_
  present: a workspace clone carries a Makefile, so bare pip install is now
  denied -> agents use `make` / `uv sync --extra dev`. Makefile-less skips
  stay covered.

Gate: 12994 passed, 439 skipped, 94.81% cov (DB env :55432 user renzof);
the lone flaky integration error passes in isolation (DB-state race, not W1).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-15 04:32:27 +02:00

239 lines
9.2 KiB
Python

"""Regenerate per-role verb tables from Pydantic schemas + role_config.
Audit P2-9 / D-04, D-10, D-11, D-29, D-30, D-31. Eliminates the
prompt-drift class: instead of curating verb tables in prose, derive
them from the same schemas the API enforces. When a schema changes,
re-run this script and the prompt-side tables update.
Output: ``agents/prompts/_generated/verbs.md`` — one section per role.
Role prompts reference this file's section instead of duplicating
verb signatures inline.
Usage:
uv run python scripts/regenerate_verb_tables.py
The script reads from:
- ``roboco.services.gateway.role_config.ROLE_CONFIGS`` for role -> verb list
- ``roboco.api.schemas.v1.flow`` for flow verb body schemas
- ``roboco.api.schemas.v1.do`` for content tool body schemas
"""
from __future__ import annotations
import inspect
from pathlib import Path
from typing import Any, get_args, get_origin
from pydantic import BaseModel
from roboco.api.schemas.v1 import do as do_schemas
from roboco.api.schemas.v1 import flow as flow_schemas
from roboco.services.gateway.role_config import ROLE_CONFIGS
_OUT_DIR = Path(__file__).resolve().parents[1] / "agents/prompts/_generated"
_OUT = _OUT_DIR / "verbs.md"
# Map verb name -> request schema class. Verbs without a schema (e.g. an
# empty-body endpoint) get an empty body description. Falls back via name
# similarity for verbs whose schema name diverges from the verb (e.g.
# ``pass`` is a Python keyword so the schema is ``PassReviewRequest``).
_VERB_TO_SCHEMA: dict[str, type[BaseModel]] = {
# Flow — dev
"give_me_work": flow_schemas.GiveMeWorkRequest,
"i_will_work_on": flow_schemas.IWillWorkOnRequest,
"open_pr": flow_schemas.OpenPrRequest,
"i_am_done": flow_schemas.IAmDoneRequest,
"i_am_blocked": flow_schemas.IAmBlockedRequest,
"unclaim": flow_schemas.UnclaimRequest,
"resume": flow_schemas.ResumeRequest,
"i_am_idle": flow_schemas.IAmIdleRequest,
# Flow — qa
"claim_review": flow_schemas.ClaimReviewRequest,
"pass": flow_schemas.PassReviewRequest,
"fail": flow_schemas.FailReviewRequest,
# Flow — doc
"claim_doc_task": flow_schemas.ClaimDocTaskRequest,
"i_documented": flow_schemas.IDocumentedRequest,
# Flow — pm
"triage": flow_schemas.TriageRequest,
"triage_all": flow_schemas.TriageRequest,
"unblock": flow_schemas.UnblockRequest,
"complete": flow_schemas.CompleteRequest,
"escalate_up": flow_schemas.EscalateUpRequest,
"escalate_to_ceo": flow_schemas.EscalateToCeoRequest,
}
def _flow_extra_schemas() -> dict[str, type[BaseModel]]:
"""Pick up schemas defined in flow.py that aren't in the explicit map."""
extras: dict[str, type[BaseModel]] = {}
for name, obj in inspect.getmembers(flow_schemas, inspect.isclass):
if not issubclass(obj, BaseModel) or obj is BaseModel:
continue
# Heuristic: ``IWillPlanRequest`` -> ``i_will_plan``.
if not name.endswith("Request"):
continue
verb_camel = name[: -len("Request")]
verb = _camel_to_snake(verb_camel)
extras.setdefault(verb, obj)
return extras
def _do_schemas() -> dict[str, type[BaseModel]]:
"""Schemas from do.py keyed by content-tool name."""
out: dict[str, type[BaseModel]] = {}
for name, obj in inspect.getmembers(do_schemas, inspect.isclass):
if not issubclass(obj, BaseModel) or obj is BaseModel:
continue
if not name.endswith("Request"):
continue
verb_camel = name[: -len("Request")]
verb = _camel_to_snake(verb_camel)
out.setdefault(verb, obj)
return out
def _camel_to_snake(name: str) -> str:
out: list[str] = []
for i, ch in enumerate(name):
if i and ch.isupper():
out.append("_")
out.append(ch.lower())
return "".join(out)
def _format_field(name: str, field_info: Any) -> str:
"""Render one field as ``name: type [= default]``."""
annot = field_info.annotation
type_str = _annot_str(annot)
if field_info.is_required():
return f"{name}: {type_str}"
default = field_info.default
return f"{name}: {type_str} = {default!r}"
def _annot_str(annot: Any) -> str:
if annot is None or annot is type(None):
return "None"
# Drop Annotated[T, ...] metadata (BeforeValidator, etc.) — render the bare
# type so the prompt table never leaks ``BeforeValidator(func=...)`` repr
# (with a memory address) into agent-facing prompt text (#199).
metadata = getattr(annot, "__metadata__", None)
if metadata is not None:
annot = annot.__origin__
origin = get_origin(annot)
if origin is None:
return getattr(annot, "__name__", str(annot))
args = get_args(annot)
if origin in (list,):
inner = ", ".join(_annot_str(a) for a in args)
return f"list[{inner}]"
# Optional / Union
rendered = " | ".join(_annot_str(a) for a in args)
return rendered
def _signature_for_schema(schema: type[BaseModel]) -> str:
"""Render the schema's required + optional fields as a Python-like signature."""
fields = schema.model_fields
if not fields:
return "()"
parts = [_format_field(name, info) for name, info in fields.items()]
return "(" + ", ".join(parts) + ")"
def _render_role_section(role: str) -> str:
cfg = ROLE_CONFIGS[role]
flow_schemas_map = {**_VERB_TO_SCHEMA, **_flow_extra_schemas()}
do_schemas_map = _do_schemas()
lines: list[str] = [f"## {role}", ""]
lines.append("### Flow verbs")
lines.append("")
lines.append("| Verb | Body schema |")
lines.append("|------|-------------|")
for verb in cfg.flow_tools:
schema = flow_schemas_map.get(verb)
sig = (
_signature_for_schema(schema)
if schema is not None
else "(unknown — no Pydantic schema)"
)
lines.append(f"| `{verb}` | `{verb}{sig}` |")
lines.append("")
lines.append("### Content (do) tools")
lines.append("")
lines.append("| Tool | Body schema |")
lines.append("|------|-------------|")
for tool in cfg.do_tools:
schema = do_schemas_map.get(tool)
sig = _signature_for_schema(schema) if schema is not None else "(see do_server)"
lines.append(f"| `{tool}` | `{tool}{sig}` |")
lines.append("")
return "\n".join(lines)
# Roles whose real tool surface lives in a dedicated agent_sdk driver, NOT in
# role_config's flow_tools/do_tools — so a table rendered from role_config would
# UNDERSTATE them and mislead. The prompter (intake) registers `propose_draft`
# (+ read-only Read/Grep/Glob/Task) in `agent_sdk/intake_driver.py`; the
# secretary registers `read_state`/`read_task`/`submit_directive` in
# `agent_sdk/secretary_driver.py`. Both keep only `note`+`evidence` in
# role_config by design, and neither uses the `_generated/<role>.md` prompt-
# composition path (they run from their own drivers). Skip them so this script
# never emits a thin, misleading table for them.
_DRIVER_BASED_ROLES: frozenset[str] = frozenset({"prompter", "secretary"})
def main() -> None:
sections = [
"<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->",
"<!-- Source: services/gateway/role_config.py + api/schemas/v1/. -->",
"",
"# Per-role verb shapes (autogenerated)",
"",
"Run `make foundation-check` after changing",
"any role config or schema. Role prompts reference this file's sections",
"as the source of truth for verb signatures.",
"",
"Driver-based roles (prompter, secretary) are intentionally omitted — their",
"real tools live in their agent_sdk drivers, not role_config.",
"",
]
for role in ROLE_CONFIGS:
if role in _DRIVER_BASED_ROLES:
continue
sections.append(_render_role_section(role))
_OUT_DIR.mkdir(parents=True, exist_ok=True)
_OUT.write_text("\n".join(sections) + "\n")
print(f"wrote {_OUT.relative_to(_OUT.parents[2])}")
# Also write per-role files so the prompt composer can include each
# role's verb table directly without parsing a multi-section document.
# Driver-based roles are skipped (see _DRIVER_BASED_ROLES) — they don't use
# this composition path and a role_config-only table would understate them.
for role in ROLE_CONFIGS:
if role in _DRIVER_BASED_ROLES:
continue
per_role = _OUT_DIR / f"{role}.md"
header = (
"<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->\n"
"<!-- Per-role verb signatures, derived from Pydantic schemas. -->\n\n"
"## Verbs available to you (autogenerated source of truth)\n\n"
)
body = _render_role_section(role)
# Strip the leading `## <role>` header — it's redundant once the
# composer injects this under a role prompt that already names the
# role in its identity section.
body_lines = body.splitlines()
if body_lines and body_lines[0].startswith("## "):
body_lines = body_lines[1:]
while body_lines and not body_lines[0].strip():
body_lines = body_lines[1:]
per_role.write_text(header + "\n".join(body_lines) + "\n")
print(f"wrote {per_role.relative_to(_OUT.parents[2])}")
if __name__ == "__main__":
main()