CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
task_board.py505 linesDownload Raw Back to tools
1"""task_board — a per-run task board for the coordinator (main agent)."""2 3from __future__ import annotations4 5import logging6from typing import Any7 8from frontier_agent.components.agent_bus import AgentBus9from frontier_agent.components.observers.task_board import TaskBoardObserver10from frontier_agent.components.task_board_types import (11    BOARD_TOOLS,12    RESOLUTION_MARKS,13    VALID_RESOLUTION,14    BoardCounts,15    count_resolutions,16)17from frontier_agent.core.execution_context import get_current_execution_scope18from frontier_agent.core.runtime import registry19from frontier_agent.core.tool import tool20from plugins.tools._bus_scope import resolve_bus_task_id21from plugins.tools._coerce import coerce_json_list, coerce_json_object22 23logger = logging.getLogger(__name__)24 25# task_id -> {"seq": int, "tasks": {id: {description, resolution, owners, group}}}26_BOARDS: dict[str, dict[str, Any]] = {}27 28# task_id -> list of pending board write-ops the stream observer has not yet29# drained. Each op is ``{"op": "add"|"update"|"finish_planning", "ids": [...],30# "phase": "planning"|"execution"}``. The board tools append here on every write;31# A task-board stream observer drains them and emits one32# ``response.swarm.task_board`` frame per op. Recording is unconditional + cheap (the33# eval / HTTP paths simply never drain), and ``clear_board`` empties it at run end34# so it can never leak across trials.35_PENDING_OPS: dict[str, list[dict[str, Any]]] = {}36 37 38def _record_op(task_id: str, op: str, ids: list[str] | None = None) -> None:39    """Append a board write-op for the stream observer to drain.40 41    The op stamps the phase AT WRITE TIME. In the agent-team two-loop profile the42    stream observer is not attached to the planning loop, so ops written there are43    drained late (once the execution loop runs) — by then the phase has flipped to44    ``execution``. Freezing the write-time phase keeps a planning-loop ``add_task``45    rendered as ``phase=planning`` rather than the phase current at drain time.46    """47    _PENDING_OPS.setdefault(task_id, []).append(48        {"op": op, "ids": list(ids or []), "phase": current_phase(task_id)}49    )50 51 52# ── State helpers (also used by the observer + finalize_answer gate) ────────53 54def _board(task_id: str) -> dict[str, Any]:55    return _BOARDS.setdefault(task_id, {"seq": 0, "tasks": {}})56 57 58def board_size(task_id: str) -> int:59    """Number of tasks on this task's board (0 if no board)."""60    b = _BOARDS.get(task_id)61    return len(b["tasks"]) if b else 062 63 64def build_task_board_observer(*, cooldown_turns: int = 5) -> TaskBoardObserver:65    """Bind the shared observer to this plugin's task-board state."""66    return TaskBoardObserver(67        board_size=board_size,68        render_board=lambda task_id, bus_task_id: render_board(69            task_id, bus_task_id=bus_task_id,70        ),71        resolve_bus_task_id=resolve_bus_task_id,72        cooldown_turns=cooldown_turns,73    )74 75 76def clear_board(task_id: str) -> None:77    """Drop a task's board + phase — called at the end of the main-agent run."""78    _BOARDS.pop(task_id, None)79    _PHASE.pop(task_id, None)80    _PENDING_OPS.pop(task_id, None)81 82 83# ── Task-board stream projection — pure read helpers ─────────────────────────84# Consumed by the protocol layer's task-board stream observer85# to build the ``task_board.*`` wire frames. Kept here (next to the state they86# read) and side-effect-free so the observer stays a thin emitter.87 88def current_phase(task_id: str) -> str:89    """The board's coordinator phase — ``planning`` or ``execution``.90 91    Defaults to ``execution`` when the run never entered Planning Mode92    (``planning_mode:false`` profiles — apodex production — never call93    ``start_planning``, so ``_PHASE`` has no entry and the board is live in94    execution from the first ``add_task``)."""95    return _PHASE.get(task_id, "execution")96 97 98def serialize_tasks(task_id: str, ids: list[str]) -> list[dict[str, Any]]:99    """Render the given task ids to the wire shape, reading CURRENT board state.100 101    Reading the live ``resolution`` (rather than assuming ``open``) matters: an102    ``add_task`` that de-dups onto an already-``resolved`` id must carry that103    real resolution, or the frontend's whole-row replace would roll the task104    back to ``open`` and regress progress. Ids no longer on the105    board (raced clear) are skipped rather than emitted as ghosts."""106    b = _BOARDS.get(task_id)107    if not b:108        return []109    out: list[dict[str, Any]] = []110    for tid in ids:111        t = b["tasks"].get(tid)112        if t is None:113            continue114        out.append({115            "id": tid,116            "description": t.get("description", ""),117            "resolution": t.get("resolution", "open"),118            "owners": list(t.get("owners") or []),119            "group": t.get("group", ""),120        })121    return out122 123 124def snapshot_tasks(task_id: str) -> list[dict[str, Any]]:125    """Return the complete board in display order for UI projections.126 127    Unlike :func:`render_board`, this keeps task data structured so a client128    can render a real board instead of scraping the human-readable tool result.129    """130    b = _BOARDS.get(task_id)131    if not b:132        return []133    return serialize_tasks(task_id, list(b["tasks"]))134 135 136def drain_board_ops(task_id: str) -> list[dict[str, Any]]:137    """Pop and return all pending board write-ops for this task (FIFO)."""138    return _PENDING_OPS.pop(task_id, [])139 140 141# ── Planning Mode (two-phase state machine) ─────────────────────────────────142# task_id -> "planning" | "execution". Default (no entry) = execution, so ONLY143# the agent-team main agent (which calls start_planning) is ever gated; every144# other caller / workflow is unaffected.145_PHASE: dict[str, str] = {}146# In Planning Mode the agent is a PLANNER, not a solver: it may use only147# READ-ONLY tools (to understand the problem / look up a basic term) plus the148# board tools. EVERYTHING ELSE — team building, dispatch, code/file writes,149# finalize — is blocked until it calls finish_planning. This is an ALLOWLIST150# (the complement is blocked) so a newly-added write tool is denied by default.151_PLANNING_ALLOWED = (152    # read-only inspection / understanding153    "grep_search", "glob_search", "read_file", "read_text", "view_image",154    "web_search", "web_fetch",155    # board tools156    "add_task", "update_task", "finish_planning",157)158 159 160def start_planning(task_id: str) -> None:161    _PHASE[task_id] = "planning"162 163 164def force_finish_planning(task_id: str) -> None:165    """Flip a task out of Planning Mode WITHOUT the empty-board guard the166    ``finish_planning`` tool enforces. Used by the planning-turn-cap path167    (auto-finish at ``planning_max_turns``) and by the two-loop driver after a168    planning loop that ended on max_turns rather than an explicit finish."""169    if task_id in _PHASE:170        _PHASE[task_id] = "execution"171 172 173def is_planning_allowed(tool_name: str) -> bool:174    """True if ``tool_name`` may run during Planning Mode (read-only / board)."""175    return tool_name in _PLANNING_ALLOWED176 177 178def in_planning(task_id: str) -> bool:179    return _PHASE.get(task_id) == "planning"180 181 182def planning_enabled(task_id: str) -> bool:183    """True if this run enabled Planning Mode (start_planning was called) —184    stays True after finish_planning, so the no-solo / verify finalize gates185    keep applying through execution. ``False`` for non-planning runs."""186    return task_id in _PHASE187 188 189def planning_block_message(task_id: str, tool_name: str) -> str | None:190    """Error string to return when ``tool_name`` is called during Planning Mode;191    ``None`` when the call is allowed. No-op unless start_planning() was called192    for this task (i.e. only the agent-team main agent)."""193    if in_planning(task_id) and not is_planning_allowed(tool_name):194        return (195            f"Blocked: `{tool_name}` is unavailable in PLANNING MODE. You are a "196            "PLANNER here — use only READ-ONLY tools (grep_search / glob_search "197            "/ web_search to understand the problem) and the board tools "198            "(add_task / update_task). List EVERY sub-question via add_task, "199            "then call finish_planning() to unlock the team."200        )201    return None202 203 204def unresolved_task_ids(task_id: str) -> list[str]:205    """Ids not yet finished — open or in_progress. ``[]`` if no board.206 207    Cancelled tasks are retracted work (dropped on purpose / created in error),208    so they do NOT count as unresolved and never hold back the finalize gate."""209    b = _BOARDS.get(task_id)210    if not b:211        return []212    return [213        tid for tid, t in b["tasks"].items()214        if t.get("resolution") not in ("resolved", "cancelled")215    ]216 217 218def _exec_status_by_owner(task_id: str) -> dict[str, str]:219    """Map owner sub-agent name -> coarse execution status, derived from the bus.220 221    This is the system-owned half of the board: the model never writes it, so222    the model's ``resolution`` verdict can never clobber the live run state.223    """224    bus = registry.get_optional(AgentBus)225    if bus is None:226        return {}227    out: dict[str, str] = {}228    try:229        sessions = bus.list_sessions_for_task(task_id)230    except Exception:231        return {}232    for s in sessions:233        if getattr(s, "current_job_id", None) is not None:234            st = "running"235        elif getattr(s, "pending_tasks", None):236            st = "queued"237        elif getattr(s, "total_task_count", 0) == 0:238            st = "created"239        else:240            st = "reported"241        out[s.name] = st242    return out243 244 245def render_board(task_id: str, *, bus_task_id: str | None = None) -> str:246    """Render the board, joining model fields with live bus exec-status."""247    b = _BOARDS.get(task_id)248    if not b or not b["tasks"]:249        return "[task board] empty — call add_task to register sub-questions."250    tasks = b["tasks"]251    c = count_resolutions(t["resolution"] for t in tasks.values())252    lines = [253        f"[task board] resolved {c.resolved}/{c.active} · "254        f"in-progress {c.in_progress} · "255        f"open {c.open} · cancelled {c.cancelled}"256    ]257    # The owners/exec column only makes sense when there IS a team: in a solo258    # run (e.g. react) no task is ever assigned, so showing "agents=[unassigned]"259    # on every row is pure noise. Drop the whole column unless at least one task260    # has an owner — then skip the bus query too (nothing to join against).261    show_owners = any(t.get("owners") for t in tasks.values())262    exec_by_owner = _exec_status_by_owner(bus_task_id or task_id) if show_owners else {}263    for tid, t in tasks.items():264        row = (265            f"  {RESOLUTION_MARKS.get(t['resolution'], '○')} {tid} "266            f"{t['resolution']:<11} "267        )268        if show_owners:269            owners = t.get("owners") or []270            # one "name:exec_status" per owning agent, so the coordinator sees271            # exactly who is on this task and how far each has got.272            agents = (273                " · ".join(f"{o}:{exec_by_owner.get(o, '?')}" for o in owners)274                if owners else "unassigned"275            )276            row += f"agents=[{agents}]  "277        lines.append(f"{row}{t['description'][:80]}")278    return "\n".join(lines)279 280 281# ── Tools ───────────────────────────────────────────────────────────────────282 283def _as_owner_list(val: Any) -> list[str]:284    """Normalise an ``owner`` field (a name, a comma-string, or a list of names)285    into a clean list of agent names. One task can have MANY owners — several286    agents attacking the SAME sub-question from different angles for287    corroboration are all owners of that one task (not separate tasks)."""288    if val is None:289        return []290    items = val if isinstance(val, list) else str(val).split(",")291    out: list[str] = []292    for x in items:293        name = str(x).strip()294        if name and name not in out:295            out.append(name)296    return out297 298 299@tool300async def add_task(tasks: list[Any]) -> str:301    """Register the work items / sub-questions for this run on the task board.302 303    This is your plan and external memory. Call it up front — before doing any304    real work (fetching, running code, gathering evidence) — once you've broken305    the question into the concrete steps you'll work through, and again whenever306    a new sub-question emerges. (Reasoning, and a few clarifying searches to307    understand the problem, may come first.) Duplicate descriptions are308    de-duplicated (you get the existing id).309 310    Args:311        tasks: a list, each item ``{"description": str}``:312            - description (required): ONE concrete, checkable work item, e.g.313              "Verify the Markov condition Δω ≫ system rate holds" — not a vague314              area like "look into the math".315            - owner (OPTIONAL, multi-agent runs only): if a teammate sub-agent is316              already assigned to this item, name it here (a name, comma-string,317              or list — a task may have many). In a solo run, just OMIT it.318 319    Returns:320        The assigned ids plus the rendered board.321    """322    items = coerce_json_list(tasks) or []323    scope = get_current_execution_scope()324    if scope is None:325        return "Error: add_task can only be called inside an active run."326    if not items:327        return "Error: add_task requires at least one {description} item."328    b = _board(scope.task_id)329    existing = {t["description"].strip(): tid for tid, t in b["tasks"].items()}330    new_ids: list[str] = []331    skipped = 0  # items that weren't usable {description} objects332    for raw in items:333        it = coerce_json_object(raw)334        if it is None:335            skipped += 1336            continue337        desc = str(it.get("description", "")).strip()338        if not desc:339            skipped += 1340            continue341        if desc in existing:  # dedup re-decomposition342            new_ids.append(existing[desc])343            continue344        b["seq"] += 1345        tid = f"t{b['seq']}"346        b["tasks"][tid] = {347            "description": desc,348            "resolution": "open",349            "owners": _as_owner_list(it.get("owner") or it.get("owners")),350            "group": str(it.get("group", "")).strip(),351        }352        existing[desc] = tid353        new_ids.append(tid)354    logger.info(355        "add_task(task=%s): +%d skipped=%d (ids=%s)",356        scope.task_id, len(new_ids), skipped, new_ids,357    )358    # Nothing landed but items were passed → the shape was wrong. Return a359    # corrective error (not a silent "Added []") so the model re-sends the right360    # shape instead of burning turns repeating the mistake.361    if not new_ids:362        return (363            'Error: add_task expects a list of objects like '364            '[{"description": "..."}]; none of the items were usable, so nothing '365            'was added. Re-call with each task as its own '366            '{"description": "<one concrete work item>"}.'367        )368    _record_op(scope.task_id, "add", new_ids)369    msg = f"Added {new_ids}.\n{render_board(scope.task_id, bus_task_id=resolve_bus_task_id(scope))}"370    if skipped:371        msg += (372            f'\nNote: {skipped} item(s) were skipped (not a {{"description": ...}} '373            "object). Re-add them with that shape if still needed."374        )375    return msg376 377 378@tool379async def update_task(updates: list[Any]) -> str:380    """Mark progress on the board — call this the MOMENT a task finishes.381 382    Update each task as soon as it is done, before starting the next one; don't383    let finished tasks pile up. The arg is a LIST only to cover the case where384    two tasks finished in the SAME turn (resolve both at once) — it is NOT a385    reason to batch resolutions across turns. Returns the full updated board.386 387    Args:388        updates: a list of ``{"id", "resolution"?}`` items:389            - id (required): task id from add_task, e.g. "t3".390            - resolution: "in_progress" (set this the MOMENT you start working a391              task — it marks the one you are on now) | "resolved" (the work item392              is answered AND corroborated — your judgment, not merely "I glanced393              at it") | "cancelled" (retract a task you no longer need or created394              in error — it stops counting toward unresolved work; the id stays395              on the board for the trail) | "open" (the default; not started).396            - owner (OPTIONAL, multi-agent runs only): teammate sub-agent397              name(s) now working it — ADDED to the task's owner list (a name,398              comma-string, or list). Set "replace_owners": true to overwrite399              instead of add. Omit entirely in a solo run.400 401    Returns:402        The full updated task board (+ any per-item errors).403    """404    items = coerce_json_list(updates) or []405    scope = get_current_execution_scope()406    if scope is None:407        return "Error: update_task can only be called inside an active run."408    b = _BOARDS.get(scope.task_id)409    if not b:410        return "Error: no task board yet — call add_task first."411    changed: list[str] = []412    errors: list[str] = []413    for raw in items:414        u = coerce_json_object(raw)415        if u is None:416            continue417        tid = str(u.get("id", ""))418        if tid not in b["tasks"]:419            errors.append(f"{tid or '?'}: no such task")420            continue421        res = str(u.get("resolution", "")).strip()422        if res and res not in VALID_RESOLUTION:423            errors.append(f"{tid}: bad resolution {res!r} (use {VALID_RESOLUTION})")424            continue425        t = b["tasks"][tid]426        t.setdefault("owners", [])427        if res:428            t["resolution"] = res429        # owners ACCUMULATE — assigning another agent to the same task adds it,430        # it does not replace the existing owner(s). ``replace_owners: true``431        # overwrites (e.g. to drop an agent that was reassigned elsewhere).432        new_owners = _as_owner_list(u.get("owner") or u.get("owners"))433        if new_owners:434            if u.get("replace_owners"):435                t["owners"] = new_owners436            else:437                for o in new_owners:438                    if o not in t["owners"]:439                        t["owners"].append(o)440        changed.append(tid)441    if not changed and not errors:442        return ("Error: update_task needs a list of "443                "{id, resolution?, owner?} items.")444    # Emit ONLY the actually-changed ids — rejected ids (bad id /445    # bad resolution) stay off the wire so the frontend never builds ghost rows.446    if changed:447        _record_op(scope.task_id, "update", changed)448    msg = f"Updated {changed}.\n{render_board(scope.task_id, bus_task_id=resolve_bus_task_id(scope))}"449    if errors:450        msg += "\nerrors: " + "; ".join(errors)451    return msg452 453 454@tool455async def finish_planning() -> str:456    """Leave Planning Mode and start building the team.457 458    While planning, only add_task / update_task are available. Call this once459    your task board lists EVERY sub-question; afterwards create_subagent /460    assign_task / collect_reports become available (you can still add_task /461    update_task to refine the plan as the investigation unfolds).462    """463    scope = get_current_execution_scope()464    if scope is None:465        return "Error: finish_planning can only be called inside an active run."466    if board_size(scope.task_id) == 0:467        return (468            "Error: the task board is empty — call add_task to list the "469            "sub-questions before finishing planning."470        )471    _PHASE[scope.task_id] = "execution"472    _record_op(scope.task_id, "finish_planning")473    return (474        "Planning complete — now in EXECUTION mode. You may create_subagent / "475        "assign_task to build and dispatch the team.\n"476        + render_board(scope.task_id, bus_task_id=resolve_bus_task_id(scope))477    )478 479 480__all__ = [481    "BOARD_TOOLS",482    "RESOLUTION_MARKS",483    "VALID_RESOLUTION",484    "BoardCounts",485    "add_task",486    "board_size",487    "build_task_board_observer",488    "clear_board",489    "count_resolutions",490    "current_phase",491    "drain_board_ops",492    "finish_planning",493    "force_finish_planning",494    "in_planning",495    "is_planning_allowed",496    "planning_block_message",497    "planning_enabled",498    "render_board",499    "serialize_tasks",500    "snapshot_tasks",501    "start_planning",502    "unresolved_task_ids",503    "update_task",504]505