hermes_cli/kanban.py 3,565 -> 2,912; kanban_diagnostics 1,216 -> 996; kanban_decompose 468 -> 393; kanban_transfer 478 -> 443; kanban_specify 264 -> 229; kanban_swarm 390 -> 378; active_sessions 871 -> 775. `hermes kanban [sub] --help` byte-identical for all 55 parsers. - kanban.py: _err / _print_json / _json_out / _fmt_counts / _bulk_apply / _obj_dict field tuples replace repeated print/JSON/exit-code blocks; action and board subcommand routing via dict dispatch; shared run-state and triage-sweep argparse blocks; argparse declarations re-packed (AST-identical). - specify/decompose: one _run_triage_sweep driver, shared _extract_json_blob / _truncate / _profile_author / _title_body / _resolve_profile_from_cfg. - diagnostics: rule helpers (_first_field / _latest_event_ts / _log_hint_action / _error_snippet), _rows_by_task fleet fetch; unreferenced DIAGNOSTIC_KINDS dropped. - swarm: graph nodes share one create_task kwarg set. - active_sessions: one _flock per platform, _pid_alive via _pid_liveness, shared _read_live_entries / _without_lease / _clean_metadata, table-driven strict registry validation. - Docstrings/comments hand-compacted (AST-identical), invariants kept.
394 lines
13 KiB
Python
394 lines
13 KiB
Python
"""Kanban decomposer — fan a triage task out into a graph of child tasks.
|
|
|
|
Invoked by ``hermes kanban decompose [task_id | --all]`` and the
|
|
auto-decompose path in the gateway dispatcher loop. Reads the user's
|
|
profile roster (with descriptions) and asks the auxiliary LLM to
|
|
return a task graph in JSON. Then atomically creates the children,
|
|
links them under the root, and flips the root ``triage -> todo``.
|
|
|
|
The root task stays alive and becomes the parent of every leaf child,
|
|
so when the whole graph completes the root wakes back up — its
|
|
assignee (the orchestrator profile) gets a chance to judge completion
|
|
and add more tasks if the work isn't done yet.
|
|
|
|
Design notes: mirrors ``kanban_specify`` (lazy aux import, lenient parse,
|
|
never raises on expected failures). The prompt sees the configured profile
|
|
roster; undescribed profiles are listed with a note so name-matching still
|
|
works. ``fanout=false`` collapses to the ``specify`` behaviour (tighten +
|
|
promote, no children), making ``decompose`` a strict superset. Unknown
|
|
assignees are rewritten to ``default_assignee`` — a child NEVER ends up with
|
|
``assignee=None``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
from hermes_cli import profiles as profiles_mod
|
|
from hermes_cli.kanban_specify import _extract_json_blob, _title_body, _truncate
|
|
from hermes_cli.kanban_specify import _profile_author as _specify_author
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_SYSTEM_PROMPT = """You are the Kanban decomposer for the Hermes Agent board.
|
|
|
|
A user dropped a rough idea into the Triage column. Your job is to break it
|
|
into a small graph of concrete child tasks and route each one to the best-
|
|
matching profile from the available roster.
|
|
|
|
You will be given:
|
|
- The original task title and body
|
|
- The list of available profiles (each with name + description)
|
|
- The fallback "default_assignee" used when no profile fits
|
|
|
|
Output a single JSON object with this exact shape:
|
|
|
|
{
|
|
"fanout": true,
|
|
"rationale": "<one sentence on why this decomposition>",
|
|
"tasks": [
|
|
{
|
|
"title": "<concrete task title, imperative voice, <= 80 chars>",
|
|
"body": "<detailed spec for the worker on this child task>",
|
|
"assignee": "<profile name from the roster, or null for default>",
|
|
"parents": [<int>, ...]
|
|
},
|
|
...
|
|
]
|
|
}
|
|
|
|
Rules:
|
|
- "parents" is a list of INDICES (0-based) into this same "tasks" list,
|
|
expressing actual data dependencies. Tasks with no parents run in
|
|
PARALLEL. Tasks with parents wait until every parent completes.
|
|
- Prefer parallelism. If two tasks can be done independently, give
|
|
them no parents so the dispatcher fans them out at once.
|
|
- Use 2-6 tasks for normal work. Don't create 20 tiny tasks. Don't
|
|
cram everything into 1 task.
|
|
- Pick assignees from the roster by matching the task to the profile's
|
|
DESCRIPTION (not just the name). When nothing matches well, use null
|
|
and the system will route to the default_assignee.
|
|
- Each child task body is what a fresh worker will read with no other
|
|
context — be specific about goal, approach, and acceptance criteria.
|
|
|
|
When the task is genuinely a single unit of work (no useful decomposition),
|
|
return:
|
|
|
|
{
|
|
"fanout": false,
|
|
"rationale": "<one sentence>",
|
|
"title": "<tightened title>",
|
|
"body": "<concrete spec for a single worker>",
|
|
"assignee": "<profile name from the roster, or null for default>"
|
|
}
|
|
|
|
In that case the task stays as one work item, just with a tightened spec and
|
|
a concrete assignee. If no profile fits, use null and the system will route to
|
|
the default_assignee.
|
|
|
|
No preamble, no closing remarks, no code fences. Output only the JSON object.
|
|
"""
|
|
|
|
|
|
_USER_TEMPLATE = """Task id: {task_id}
|
|
Title: {title}
|
|
Body:
|
|
{body}
|
|
|
|
Available profiles (assignees you may pick from):
|
|
{roster}
|
|
|
|
Default assignee (used when no profile fits a task): {default_assignee}
|
|
"""
|
|
|
|
|
|
_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
|
|
|
|
|
|
@dataclass
|
|
class DecomposeOutcome:
|
|
"""Result of decomposing a single triage task."""
|
|
|
|
task_id: str
|
|
ok: bool
|
|
reason: str = ""
|
|
fanout: bool = False
|
|
child_ids: list[str] | None = None
|
|
new_title: Optional[str] = None
|
|
|
|
|
|
def _profile_author() -> str:
|
|
"""Mirror of ``hermes_cli.kanban._profile_author``."""
|
|
return _specify_author("decomposer")
|
|
|
|
|
|
def _load_config() -> dict:
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
return load_config() or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _resolve_profile_from_cfg(cfg: dict, key: str) -> str:
|
|
"""``kanban.<key>`` if it names an existing profile, else the active
|
|
default profile — so a task is never stranded for lack of an owner.
|
|
|
|
``orchestrator_profile`` owns the root after fan-out; ``default_assignee``
|
|
catches children the decomposer can't route.
|
|
"""
|
|
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
|
|
explicit = (kanban_cfg.get(key) or "").strip()
|
|
if explicit:
|
|
try:
|
|
if profiles_mod.profile_exists(explicit):
|
|
return explicit
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return profiles_mod.get_active_profile_name() or "default"
|
|
except Exception:
|
|
return "default"
|
|
|
|
|
|
def _build_roster() -> tuple[list[dict], set[str]]:
|
|
"""``(roster_for_prompt, valid_assignee_names)``; entries are
|
|
``{name, description, has_description}``."""
|
|
roster: list[dict] = []
|
|
valid: set[str] = set()
|
|
try:
|
|
all_profiles = profiles_mod.list_profiles()
|
|
except Exception as exc:
|
|
logger.warning("decompose: failed to list profiles: %s", exc)
|
|
return roster, valid
|
|
for p in all_profiles:
|
|
desc = (p.description or "").strip()
|
|
roster.append({
|
|
"name": p.name,
|
|
"description": desc or f"(no description; profile named {p.name!r})",
|
|
"has_description": bool(desc),
|
|
})
|
|
valid.add(p.name)
|
|
return roster, valid
|
|
|
|
|
|
def _format_roster(roster: list[dict]) -> str:
|
|
if not roster:
|
|
return " (no profiles installed — decomposer cannot route work)"
|
|
lines = []
|
|
for entry in roster:
|
|
tag = "" if entry["has_description"] else " ⚠ undescribed"
|
|
lines.append(f" - {entry['name']}{tag}: {entry['description']}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _normalize_assignee_choice(
|
|
assignee: object,
|
|
*,
|
|
default_assignee: str,
|
|
valid_names: set[str],
|
|
) -> str:
|
|
"""A valid assignee, else ``default_assignee`` — promoted work is never
|
|
left unassigned."""
|
|
if not isinstance(assignee, str) or not assignee.strip():
|
|
return default_assignee
|
|
chosen = assignee.strip()
|
|
if chosen not in valid_names:
|
|
return default_assignee
|
|
return chosen
|
|
|
|
|
|
def decompose_task(
|
|
task_id: str,
|
|
*,
|
|
author: Optional[str] = None,
|
|
timeout: Optional[int] = None,
|
|
) -> DecomposeOutcome:
|
|
"""Decompose a triage task into a graph of child tasks. Expected failures
|
|
(not in triage, no aux client, API error, malformed/empty reply) surface
|
|
as ``ok=False``."""
|
|
with kb.connect_closing() as conn:
|
|
task = kb.get_task(conn, task_id)
|
|
if task is None:
|
|
return DecomposeOutcome(task_id, False, "unknown task id")
|
|
if task.status != "triage":
|
|
return DecomposeOutcome(
|
|
task_id, False, f"task is not in triage (status={task.status!r})"
|
|
)
|
|
|
|
cfg = _load_config()
|
|
orchestrator = _resolve_profile_from_cfg(cfg, "orchestrator_profile")
|
|
default_assignee = _resolve_profile_from_cfg(cfg, "default_assignee")
|
|
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
|
|
auto_promote = bool(kanban_cfg.get("auto_promote_children", True))
|
|
roster, valid_names = _build_roster()
|
|
|
|
try:
|
|
from agent.auxiliary_client import call_llm # type: ignore
|
|
except Exception as exc:
|
|
logger.debug("decompose: auxiliary client import failed: %s", exc)
|
|
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
|
|
|
|
user_msg = _USER_TEMPLATE.format(
|
|
task_id=task.id,
|
|
title=_truncate(task.title or "", 400),
|
|
body=_truncate(task.body or "(no body)", 4000),
|
|
roster=_format_roster(roster),
|
|
default_assignee=default_assignee,
|
|
)
|
|
|
|
try:
|
|
# call_llm applies all auxiliary.kanban_decomposer.* config
|
|
# (provider/model/base_url, extra_body, reasoning_effort, retries).
|
|
resp = call_llm(
|
|
task="kanban_decomposer",
|
|
messages=[
|
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_msg},
|
|
],
|
|
temperature=0.3,
|
|
max_tokens=4000,
|
|
timeout=timeout or 180,
|
|
)
|
|
except Exception as exc:
|
|
logger.info(
|
|
"decompose: API call failed for %s (%s)", task_id, exc,
|
|
)
|
|
return DecomposeOutcome(task_id, False, f"LLM error: {type(exc).__name__}")
|
|
|
|
try:
|
|
raw = resp.choices[0].message.content or ""
|
|
except Exception:
|
|
raw = ""
|
|
|
|
parsed = _extract_json_blob(raw, _FENCE_RE)
|
|
if parsed is None:
|
|
return DecomposeOutcome(task_id, False, "LLM returned malformed JSON")
|
|
|
|
fanout = bool(parsed.get("fanout"))
|
|
audit_author = author or _profile_author()
|
|
|
|
if not fanout:
|
|
# Fall back to single-task spec promotion (same effect as specify).
|
|
title_val, body_val = _title_body(parsed)
|
|
assignee_val = None
|
|
if not task.assignee:
|
|
assignee_val = _normalize_assignee_choice(
|
|
parsed.get("assignee"),
|
|
default_assignee=default_assignee,
|
|
valid_names=valid_names,
|
|
)
|
|
if title_val is None and body_val is None:
|
|
return DecomposeOutcome(
|
|
task_id, False, "decomposer returned fanout=false with no title/body",
|
|
)
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.specify_triage_task(
|
|
conn,
|
|
task_id,
|
|
title=title_val,
|
|
body=body_val,
|
|
assignee=assignee_val,
|
|
author=audit_author,
|
|
)
|
|
if not ok:
|
|
return DecomposeOutcome(
|
|
task_id, False, "task moved out of triage before promotion",
|
|
)
|
|
return DecomposeOutcome(
|
|
task_id, True, "single task (no fanout)",
|
|
fanout=False, new_title=title_val,
|
|
)
|
|
|
|
raw_tasks = parsed.get("tasks") or []
|
|
if not isinstance(raw_tasks, list) or not raw_tasks:
|
|
return DecomposeOutcome(
|
|
task_id, False, "decomposer returned fanout=true with empty tasks list",
|
|
)
|
|
|
|
# Unknown assignees route to the default; never assignee=None.
|
|
children: list[dict] = []
|
|
for idx, entry in enumerate(raw_tasks):
|
|
if not isinstance(entry, dict):
|
|
return DecomposeOutcome(
|
|
task_id, False, f"tasks[{idx}] is not an object",
|
|
)
|
|
title = entry.get("title")
|
|
if not isinstance(title, str) or not title.strip():
|
|
return DecomposeOutcome(
|
|
task_id, False, f"tasks[{idx}].title is missing or empty",
|
|
)
|
|
body = entry.get("body")
|
|
if not isinstance(body, str):
|
|
body = ""
|
|
assignee = entry.get("assignee")
|
|
chosen = _normalize_assignee_choice(
|
|
assignee,
|
|
default_assignee=default_assignee,
|
|
valid_names=valid_names,
|
|
)
|
|
if (
|
|
isinstance(assignee, str)
|
|
and assignee.strip()
|
|
and assignee.strip() not in valid_names
|
|
):
|
|
logger.info(
|
|
"decompose: task %s child %d picked unknown assignee %r — "
|
|
"routing to default_assignee %r",
|
|
task_id, idx, assignee, default_assignee,
|
|
)
|
|
parents = entry.get("parents") or []
|
|
if not isinstance(parents, list):
|
|
parents = []
|
|
# Clean parent indices: drop non-int and out-of-range.
|
|
clean_parents = [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx]
|
|
children.append({
|
|
"title": title.strip()[:200],
|
|
"body": body.strip(),
|
|
"assignee": chosen,
|
|
"parents": clean_parents,
|
|
})
|
|
|
|
try:
|
|
with kb.connect_closing() as conn:
|
|
child_ids = kb.decompose_triage_task(
|
|
conn,
|
|
task_id,
|
|
root_assignee=orchestrator,
|
|
children=children,
|
|
author=audit_author,
|
|
auto_promote=auto_promote,
|
|
)
|
|
except ValueError as exc:
|
|
return DecomposeOutcome(task_id, False, f"DB rejected graph: {exc}")
|
|
except Exception as exc:
|
|
logger.exception("decompose: DB error on task %s", task_id)
|
|
return DecomposeOutcome(task_id, False, f"DB error: {type(exc).__name__}")
|
|
|
|
if child_ids is None:
|
|
return DecomposeOutcome(
|
|
task_id, False, "task moved out of triage before decomposition",
|
|
)
|
|
|
|
return DecomposeOutcome(
|
|
task_id, True, f"decomposed into {len(child_ids)} children",
|
|
fanout=True, child_ids=child_ids,
|
|
)
|
|
|
|
|
|
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
|
"""Return task ids currently in the triage column."""
|
|
with kb.connect_closing() as conn:
|
|
rows = kb.list_tasks(
|
|
conn,
|
|
status="triage",
|
|
tenant=tenant,
|
|
limit=1000,
|
|
)
|
|
return [row.id for row in rows]
|