Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
11 changed files with 3211 additions and 0 deletions
@@ -0,0 +1 @@
"""The AI-workforce engine: the tag protocol and the worker-facing helpers."""
+576
View File
@@ -0,0 +1,576 @@
"""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",
]
+114
View File
@@ -0,0 +1,114 @@
"""Moving a task between stages — the only place the stage column changes.
Two rules live here rather than in the scheduler:
1. **Optimistic moves.** ``advance`` refuses to guess: it takes the stage the
caller believes the task is at, and the repository's compare-and-set either
applies the move or raises :class:`~app.core.errors.ConflictError` because
another worker got there first. A lost race is a normal outcome, not a
crash — the caller re-reads and continues.
2. **Dependents never get stuck.** When a task fails or is cancelled, every
task that declared it in ``depends_on`` is parked in
``pending_user_decision`` instead of waiting for a dependency that will
never finish (agybot bug M1: a dead task deadlocked its children).
The coarse ``tasks.status`` is written by the caller in the same transaction
(see ``engine/scheduler.py`` in a later phase): the stage is the engine's
truth, the status column is the summary existing APIs keep reading.
"""
from __future__ import annotations
from app.core.errors import ConflictError, NotFoundError
from app.domain.workforce.pipeline import (
SIDE_STAGES,
TERMINAL_STAGES,
Stage,
TaskPipelineState,
can_transition_stage,
)
from app.infrastructure.repositories.engine import (
InvalidTransitionError,
PipelineStateRepository,
StaleStageError,
UnknownTaskError,
)
async def advance(
states: PipelineStateRepository,
*,
task_id: str,
expected_from: Stage,
to: Stage,
) -> TaskPipelineState:
"""Move one task from ``expected_from`` to ``to``, or fail cleanly.
Raises ``ConflictError`` when the transition is illegal or when another
worker moved the task first, and ``NotFoundError`` when the task has no
engine state row at all.
"""
if not can_transition_stage(expected_from, to):
raise ConflictError(
f"گذار نابجا از {expected_from.value} به {to.value}."
)
try:
return await states.advance(task_id=task_id, expected_from=expected_from, to=to)
except UnknownTaskError as exc:
raise NotFoundError("این کار وضعیت موتور ندارد.") from exc
except InvalidTransitionError as exc:
raise ConflictError(str(exc)) from exc
except StaleStageError as exc:
raise ConflictError(
"این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید."
) from exc
async def park_dependents(
states: PipelineStateRepository,
*,
task_id: str,
) -> list[TaskPipelineState]:
"""Park every dependent of a failed or cancelled task with a human.
A dependency that failed leaves its dependents unable to ever finish, so
they move to ``pending_user_decision`` instead of waiting for work that
will never happen (agybot bug M1: a dead task deadlocked its children).
Dependents that already sit in another side state are left alone — a task
a human already paused must not be re-parked.
The guard is deliberate: a task that merely *exists* is not a reason to
park anything. Only a dependency that is actually ``failed`` or
``cancelled`` strands its children, so the function refuses to act on a
healthy task however often a tick calls it.
"""
state = await states.get(task_id)
if state is None or state.stage not in (Stage.FAILED, Stage.CANCELLED):
return []
parked: list[TaskPipelineState] = []
for dependent in await states.list_dependents(task_id):
# A dependent that already waits on a human (paused, another decision)
# is left alone: it is already parked, just elsewhere.
if dependent.stage in SIDE_STAGES:
continue
if not can_transition_stage(dependent.stage, Stage.PENDING_USER_DECISION):
continue
try:
parked.append(
await advance(
states,
task_id=dependent.task_id,
expected_from=dependent.stage,
to=Stage.PENDING_USER_DECISION,
)
)
except ConflictError:
# A worker is already moving it; the next tick will see the truth.
continue
return parked
async def is_terminal_state(states: PipelineStateRepository, task_id: str) -> bool:
"""True when the task's stage can no longer move (done/cancelled/failed)."""
state = await states.get(task_id)
return state is None or state.stage in TERMINAL_STAGES