Files
smart-org/apps/api/app/application/engine/protocol.py
T
mjalmoudy94 df7da8c75c feat(engine): workforce engine core — tag protocol, pipelines, optimistic state
Phase 1 of TODO-agent-2: pure logic plus the tables it writes to.

- domain/workforce/pipeline.py: Stage/PipelineKey/Outcome, the four pipelines,
  TRANSITIONS, next_stage, STAGE_ROLE (role+mode), resolve_pipeline with the
  no-reviewer fallback, task-kind defaults, stage -> coarse tasks.status, and
  the TaskPipelineState record (with the optimistic version).
- application/engine/protocol.py: the [[TAG attr="v"]]body[[/TAG]] parser and
  its self-closing form, the per-role/mode allow-list, literal bodies (a
  bracket inside a body is not a tag), typed readers (ASSIGN/CREATE_TASK/
  team changes/KNOWLEDGE/EDIT_FILE blocks) and the per-reply limits
  (3 tasks, 3 team changes, 3 knowledge items).
- application/engine/state.py: advance() with the optimistic compare-and-set
  and park_dependents() so a failed dependency never deadlocks its children.
- db/models_engine.py + migration a2b3c4d5e6f2: task_pipeline_state,
  employee_profiles, engine_turns, gate_results as side tables (tasks and
  employees stay locked). Linear head; alembic check reports no drift.
- repositories/engine.py + 58 new tests (test_engine_protocol,
  test_engine_pipeline) covering every pipeline's happy path, illegal
  transitions, exactly-one-winner concurrent advance, dependency parking and
  all parser edge cases.

Suite: 594 passed, ruff clean, mypy clean.
2026-10-14 15:35:31 +03:30

577 lines
19 KiB
Python

"""The tag protocol: ``[[TAG attr="v" ...]]body[[/TAG]]`` and ``[[TAG attr="v"/]``.
One parser, one matrix. The rules that make an agent protocol safe all live
here so no call site can re-decide them:
1. **An allow-list per role and mode.** The manager triages, gives the final
verdict, handles errors and chats, and each mode may emit a different set
of tags; a planner plans, a developer implements. A tag outside the matrix
is ignored and reported back to the model as an error line — the model
learns the rule, and nothing dangerous executes by accident.
2. **The toolkit is the only way to touch files.** ``READ_FILE``,
``WRITE_FILE``, ``EDIT_FILE``, ``LIST_DIR``, ``SEARCH`` and ``RUN`` run
inside the sandbox through ``SandboxProviderPort``; the platform still
does git (``RepoWorkspacePort``), and ``RUN`` refuses git push/remote/config
outright (see ``engine/tools.py``).
3. **Bounded turns.** A reply may create at most three tasks, change the team
at most three times and record at most three knowledge items. A model that
ignores the limit gets an error line and the extra tags are dropped, so the
effect of one runaway reply stays finite.
Bodies may contain brackets (a source file does), so the parser never
re-scans a body it already consumed: an outer tag wins and its content is
literal.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from enum import StrEnum
from app.domain.workforce.models import RoleKey
from app.domain.workforce.pipeline import Mode, PipelineKey
class TagName(StrEnum):
"""Every tag the protocol knows, whether or not a role may use it."""
# manager / triage
ASSIGN = "ASSIGN"
REJECT = "REJECT"
ASK_USER = "ASK_USER"
KNOWLEDGE = "KNOWLEDGE"
# manager / final
COMPLETE = "COMPLETE"
REVISE = "REVISE"
# manager / error
RESOLVE_ERROR = "RESOLVE_ERROR"
CANCEL = "CANCEL"
# manager / chat
CREATE_TASK = "CREATE_TASK"
PAUSE_TASK = "PAUSE_TASK"
RESUME_TASK = "RESUME_TASK"
CANCEL_TASK = "CANCEL_TASK"
HIRE = "HIRE"
UPDATE_EMPLOYEE = "UPDATE_EMPLOYEE"
FIRE = "FIRE"
# planner
PLAN_SPEC = "PLAN_SPEC"
SPLIT = "SPLIT"
# developer
WORK_REPORT = "WORK_REPORT"
# reviewer
REVIEW_PASS = "REVIEW_PASS"
REVIEW_CHANGES = "REVIEW_CHANGES"
# tester
TEST_PASS = "TEST_PASS"
TEST_FAIL = "TEST_FAIL"
# toolkit (every working role)
READ_FILE = "READ_FILE"
WRITE_FILE = "WRITE_FILE"
EDIT_FILE = "EDIT_FILE"
LIST_DIR = "LIST_DIR"
SEARCH = "SEARCH"
RUN = "RUN"
#: The toolkit. Available to every working role, never to the manager: the
#: manager decides, it does not edit the repository.
TOOL_TAGS: frozenset[TagName] = frozenset(
{
TagName.READ_FILE,
TagName.WRITE_FILE,
TagName.EDIT_FILE,
TagName.LIST_DIR,
TagName.SEARCH,
TagName.RUN,
}
)
#: Team-changing tags (all manager/chat). Counted together, one limit per turn.
TEAM_CHANGE_TAGS: frozenset[TagName] = frozenset(
{TagName.HIRE, TagName.UPDATE_EMPLOYEE, TagName.FIRE}
)
#: Tags that carry a body and therefore *must* be closed with ``[[/TAG]]``.
#: Every other tag is a single token (an order, a tool read, a decision), so
#: a forgotten trailing ``/`` is tolerated as self-closing instead of failing
#: the whole reply: ``[[RUN cmd="pytest"]]`` means what ``[[RUN cmd="pytest"/]]``
#: means.
BODY_TAGS: frozenset[TagName] = frozenset(
{
TagName.WRITE_FILE,
TagName.EDIT_FILE,
TagName.KNOWLEDGE,
TagName.ASK_USER,
TagName.CREATE_TASK,
TagName.SPLIT,
TagName.PLAN_SPEC,
TagName.WORK_REPORT,
TagName.REVIEW_PASS,
TagName.REVIEW_CHANGES,
TagName.TEST_PASS,
TagName.TEST_FAIL,
}
)
#: The per-role/mode matrix. Anything not listed is refused with an error line.
ALLOWED_TAGS: dict[tuple[RoleKey, Mode], frozenset[TagName]] = {
(RoleKey.MANAGER, Mode.TRIAGE): frozenset(
{TagName.ASSIGN, TagName.REJECT, TagName.ASK_USER, TagName.KNOWLEDGE}
),
(RoleKey.MANAGER, Mode.FINAL): frozenset(
{TagName.COMPLETE, TagName.REVISE, TagName.KNOWLEDGE}
),
(RoleKey.MANAGER, Mode.ERROR): frozenset(
{TagName.RESOLVE_ERROR, TagName.CANCEL, TagName.KNOWLEDGE}
),
(RoleKey.MANAGER, Mode.CHAT): frozenset(
{
TagName.CREATE_TASK,
TagName.PAUSE_TASK,
TagName.RESUME_TASK,
TagName.CANCEL_TASK,
TagName.HIRE,
TagName.UPDATE_EMPLOYEE,
TagName.FIRE,
TagName.KNOWLEDGE,
}
),
(RoleKey.PLANNER, Mode.WORK): frozenset({TagName.PLAN_SPEC, TagName.SPLIT, TagName.ASK_USER})
| TOOL_TAGS,
(RoleKey.DEVELOPER, Mode.WORK): frozenset({TagName.WORK_REPORT, TagName.ASK_USER}) | TOOL_TAGS,
(RoleKey.REVIEWER, Mode.WORK): frozenset(
{TagName.REVIEW_PASS, TagName.REVIEW_CHANGES}
)
| TOOL_TAGS,
(RoleKey.TESTER, Mode.WORK): frozenset({TagName.TEST_PASS, TagName.TEST_FAIL}) | TOOL_TAGS,
}
#: Per-reply limits. Extras are dropped and reported, never executed.
MAX_CREATE_TASKS_PER_REPLY = 3
MAX_TEAM_CHANGES_PER_TURN = 3
MAX_KNOWLEDGE_PER_TURN = 3
#: Which pipeline names ``ASSIGN pipeline=`` may pick. Kept here so the
#: manager cannot invent a pipeline that does not exist.
ASSIGNABLE_PIPELINES: frozenset[str] = frozenset(p.value for p in PipelineKey)
class ProtocolError(ValueError):
"""A tag was parsed but its attributes are not usable."""
# ---------------------------------------------------------------------------
# Tag and reply
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class Tag:
"""One parsed tag: its name, attributes and body (``""`` when self-closing)."""
name: TagName
attrs: dict[str, str] = field(default_factory=dict)
body: str = ""
#: The raw slice, so an error line can quote exactly what the model wrote.
raw: str = ""
def attr(self, key: str, default: str = "") -> str:
return self.attrs.get(key, default)
def require(self, key: str) -> str:
value = self.attrs.get(key, "").strip()
if not value:
raise ProtocolError(f"[[{self.name}]] requires {key}=...")
return value
@dataclass(frozen=True, slots=True)
class ParsedReply:
"""Everything one model reply contained, in order.
``errors`` are lines fed back to the model on its next turn: the protocol
is learned from feedback, not from a crash. ``prose`` is the text outside
any tag — for a manager chat reply that is the message the user reads.
"""
tags: tuple[Tag, ...] = ()
errors: tuple[str, ...] = ()
prose: str = ""
def names(self) -> tuple[TagName, ...]:
return tuple(tag.name for tag in self.tags)
def of(self, name: TagName | str) -> tuple[Tag, ...]:
wanted = TagName(name)
return tuple(tag for tag in self.tags if tag.name is wanted)
def first(self, name: TagName | str) -> Tag | None:
found = self.of(name)
return found[0] if found else None
def has(self, name: TagName | str) -> bool:
return bool(self.of(name))
def error_text(self) -> str:
return "\n".join(self.errors)
# ---------------------------------------------------------------------------
# Typed views over tags
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class AssignTag:
"""``ASSIGN employee="name" pipeline="plan_code_test"``."""
employee: str
pipeline: str = ""
task_id: str = ""
@dataclass(frozen=True, slots=True)
class TaskDraft:
"""``CREATE_TASK``: the manager asks for a new task in chat."""
title: str
kind: str = "feature"
priority: str = "normal"
depends_on: tuple[str, ...] = ()
pipeline: str = ""
description: str = ""
@dataclass(frozen=True, slots=True)
class TeamChange:
"""``HIRE`` / ``UPDATE_EMPLOYEE`` / ``FIRE``."""
action: TagName
name: str = ""
role: str = ""
tier: str = ""
task_id: str = ""
employee_id: str = ""
@dataclass(frozen=True, slots=True)
class KnowledgeItem:
"""``KNOWLEDGE kind="rule|fact" key="..."`` with the fact as the body."""
kind: str = "rule"
key: str = ""
content: str = ""
@dataclass(frozen=True, slots=True)
class EditBlock:
"""One ``<<<OLD … === … NEW>>>`` search/replace block inside an EDIT_FILE."""
old: str
new: str
_EDIT_BLOCK_RE = re.compile(
r"<<<OLD\s*\n(?P<old>.*?)\n===\s*\n(?P<new>.*?)\n\s*NEW>>>",
re.DOTALL,
)
def assign_of(tag: Tag) -> AssignTag:
"""Read an ``ASSIGN`` tag. ``employee=""`` means "pick for me"."""
employee = tag.attr("employee", "").strip()
if not employee and not tag.attr("task_id", "").strip():
raise ProtocolError("[[ASSIGN]] needs employee=... or task_id=...")
pipeline = tag.attr("pipeline", "").strip()
if pipeline and pipeline not in ASSIGNABLE_PIPELINES:
raise ProtocolError(f"unknown pipeline: {pipeline}")
return AssignTag(
employee=employee, pipeline=pipeline, task_id=tag.attr("task_id", "").strip()
)
def task_draft_of(tag: Tag) -> TaskDraft:
"""Read a ``CREATE_TASK`` tag."""
title = tag.attr("title", "").strip()
if not title:
raise ProtocolError("[[CREATE_TASK]] needs title=...")
raw_deps = tag.attr("depends_on", "")
depends = tuple(d.strip() for d in raw_deps.split(",") if d.strip())
return TaskDraft(
title=title,
kind=tag.attr("kind", "feature").strip() or "feature",
priority=tag.attr("priority", "normal").strip() or "normal",
depends_on=depends,
pipeline=tag.attr("pipeline", "").strip(),
description=tag.body.strip()[:4000],
)
def team_change_of(tag: Tag) -> TeamChange:
"""Read one of the team tags. Users choose role and tier, never a model."""
return TeamChange(
action=tag.name,
name=tag.attr("name", "").strip(),
role=tag.attr("role", "").strip(),
tier=tag.attr("tier", "").strip(),
task_id=tag.attr("task_id", "").strip(),
employee_id=tag.attr("employee_id", "").strip(),
)
def knowledge_of(tag: Tag) -> KnowledgeItem:
"""Read a ``KNOWLEDGE`` tag; the body is the content."""
return KnowledgeItem(
kind=tag.attr("kind", "rule").strip() or "rule",
key=tag.attr("key", "").strip(),
content=tag.body.strip(),
)
def edit_blocks_of(tag: Tag) -> list[EditBlock]:
"""Read the search/replace blocks of an ``EDIT_FILE`` body.
Blocks are ``<<<OLD`` … ``===`` … ``NEW>>>``. A malformed body raises
rather than guessing: a half-applied edit is worse than a failed one.
"""
blocks = [
EditBlock(old=m.group("old"), new=m.group("new"))
for m in _EDIT_BLOCK_RE.finditer(tag.body)
]
if not blocks:
raise ProtocolError("[[EDIT_FILE]] needs one or more <<<OLD ... === ... NEW>>> blocks")
for block in blocks:
if not block.old.strip():
raise ProtocolError("[[EDIT_FILE]] OLD block is empty")
return blocks
def task_drafts(reply: ParsedReply) -> list[TaskDraft]:
return [task_draft_of(tag) for tag in reply.of(TagName.CREATE_TASK)]
def team_changes(reply: ParsedReply) -> list[TeamChange]:
"""Every team tag, in the order the model wrote them."""
return [team_change_of(tag) for tag in reply.tags if tag.name in TEAM_CHANGE_TAGS]
def knowledge_items(reply: ParsedReply) -> list[KnowledgeItem]:
return [knowledge_of(tag) for tag in reply.of(TagName.KNOWLEDGE)]
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
#: ``[[ ... ]]`` whose content has no brackets inside: an opening token, a
#: closing token, or a self-closing one. Attribute values with a ``]`` are
#: refused by the attribute parser below instead of silently truncating.
_TOKEN_RE = re.compile(r"\[\[([^\[\]]*)\]\]")
_ATTR_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:\"([^\"]*)\"|'([^']*)')")
def _closing_re(name: str) -> re.Pattern[str]:
return re.compile(r"\[\[/\s*" + re.escape(name) + r"\s*\]\]", re.IGNORECASE)
def _split_token(inner: str) -> tuple[str, str, bool]:
"""``'TAG a="1"'`` -> ``("TAG", ' a="1"', False)``; trailing ``/`` self-closes."""
text = inner.strip()
self_closing = text.endswith("/")
if self_closing:
text = text[:-1].strip()
match = re.match(r"([A-Za-z_][A-Za-z0-9_]*)", text)
if match is None: # pragma: no cover - the token regex requires a name
return "", "", self_closing
name = match.group(1)
return name.upper(), text[match.end() :], self_closing
def _parse_attrs(rest: str) -> tuple[dict[str, str], list[str]]:
attrs: dict[str, str] = {}
errors: list[str] = []
consumed_end = 0
for match in _ATTR_RE.finditer(rest):
attrs[match.group(1)] = match.group(2) if match.group(2) is not None else match.group(3)
consumed_end = match.end()
leftover = rest[consumed_end:].strip()
if leftover:
# One line for the whole fragment: "employee سارا" reads better than two.
errors.append(
f"ERROR: malformed attribute fragment '{leftover}' (expected key=\"value\")"
)
return attrs, errors
def _consume(name: str, text: str, start: int, self_closing: bool) -> int:
"""Where the tag ends: its ``[[/NAME]]`` close, or just the token.
Consuming the whole pair is what keeps a body literal: the scanner moves
past the close, so brackets inside the body are never parsed as tags.
"""
if self_closing:
return start
close = _closing_re(name).search(text, start)
return close.end() if close else start
def parse_reply(text: str, *, role: RoleKey | str, mode: Mode | str) -> ParsedReply:
"""Parse one model reply under the role/mode matrix.
Unknown tags, disallowed tags and unclosed tags are dropped (their body is
consumed so it cannot re-appear as stray tags) and reported in
``ParsedReply.errors`` — one line per problem, in order, for the model to
read on its next turn.
"""
role_key = RoleKey(role)
role_mode = Mode(mode)
allowed = ALLOWED_TAGS.get((role_key, role_mode), frozenset())
tags: list[Tag] = []
errors: list[str] = []
prose_parts: list[str] = []
pos = 0
while pos < len(text):
match = _TOKEN_RE.search(text, pos)
if match is None:
prose_parts.append(text[pos:])
break
prose_parts.append(text[pos : match.start()])
inner = match.group(1)
name, rest, self_closing = _split_token(inner)
if inner.lstrip().startswith("/"):
# A close with no open: the model echoed a tag it never opened.
errors.append(f"ERROR: stray closing tag [[{inner.strip()}]] ignored")
pos = match.end()
continue
token_end = match.end()
try:
tag_name = TagName(name)
except ValueError:
# Unknown tag: consume the pair so its body stays literal text.
end = _consume(name, text, token_end, self_closing)
errors.append(f"ERROR: unknown tag [[{name}]] ignored")
pos = end
continue
if tag_name not in allowed:
end = _consume(name, text, token_end, self_closing)
errors.append(
f"ERROR: tag [[{tag_name}]] is not allowed for "
f"role={role_key.value} mode={role_mode.value}"
)
pos = end
continue
attrs, attr_errors = _parse_attrs(rest)
errors.extend(attr_errors)
body = ""
if not self_closing:
if tag_name not in BODY_TAGS:
# A bodyless tag written without its "/": read it as-is.
tags.append(
Tag(
name=tag_name,
attrs=attrs,
body="",
raw=text[match.start() : token_end],
)
)
pos = token_end
continue
close = _closing_re(name).search(text, token_end)
if close is None:
# Unclosed: report it and ignore the tag, body included.
errors.append(f"ERROR: unclosed tag [[{name}]] ignored")
pos = token_end
continue
body = text[token_end : close.start()]
token_end = close.end()
tags.append(Tag(name=tag_name, attrs=attrs, body=body, raw=text[match.start() : token_end]))
pos = token_end
limited, limit_errors = _apply_limits(tags)
errors.extend(limit_errors)
return ParsedReply(
tags=tuple(limited), errors=tuple(errors), prose="".join(prose_parts).strip()
)
def _apply_limits(tags: list[Tag]) -> tuple[list[Tag], list[str]]:
"""Drop tags past their per-reply limit, keeping the first ones written.
Team changes share one budget across all three tags: "max 3 changes per
turn" means three hires, or two hires and a firing — not three of each.
"""
errors: list[str] = []
kept: list[Tag] = []
created = 0
team_used = 0
knowledge_used = 0
for tag in tags:
if tag.name is TagName.CREATE_TASK:
created += 1
if created > MAX_CREATE_TASKS_PER_REPLY:
errors.append(_limit_error(tag, MAX_CREATE_TASKS_PER_REPLY))
continue
elif tag.name in TEAM_CHANGE_TAGS:
team_used += 1
if team_used > MAX_TEAM_CHANGES_PER_TURN:
errors.append(_limit_error(tag, MAX_TEAM_CHANGES_PER_TURN))
continue
elif tag.name is TagName.KNOWLEDGE:
knowledge_used += 1
if knowledge_used > MAX_KNOWLEDGE_PER_TURN:
errors.append(_limit_error(tag, MAX_KNOWLEDGE_PER_TURN))
continue
kept.append(tag)
return kept, errors
def _limit_error(tag: Tag, limit: int) -> str:
return (
f"ERROR: too many [[{tag.name}]] in one reply (max {limit}); the extra was ignored"
)
def allowed_tags(*, role: RoleKey | str, mode: Mode | str) -> frozenset[TagName]:
return ALLOWED_TAGS.get((RoleKey(role), Mode(mode)), frozenset())
__all__ = [
"ALLOWED_TAGS",
"ASSIGNABLE_PIPELINES",
"BODY_TAGS",
"MAX_CREATE_TASKS_PER_REPLY",
"MAX_KNOWLEDGE_PER_TURN",
"MAX_TEAM_CHANGES_PER_TURN",
"TEAM_CHANGE_TAGS",
"TOOL_TAGS",
"AssignTag",
"EditBlock",
"KnowledgeItem",
"ParsedReply",
"ProtocolError",
"Tag",
"TagName",
"TaskDraft",
"TeamChange",
"allowed_tags",
"assign_of",
"edit_blocks_of",
"knowledge_items",
"knowledge_of",
"parse_reply",
"task_drafts",
"task_draft_of",
"team_change_of",
"team_changes",
]