mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(kanban): orchestrator-driven auto-decomposition on triage (#27572)
* feat(kanban): orchestrator-driven auto-decomposition on triage
Closes the core gap in the kanban system: dropping a one-liner into Triage
now decomposes it into a graph of child tasks routed to specialist
profiles by description, matching teknium's original vision ("main
orchestrator splits/creates actual tasks, doles them out to each agent").
The build
---------
- hermes_cli/profiles.py: new `description` + `description_auto` fields
on ProfileInfo, persisted in <profile_dir>/profile.yaml. Helpers
read_profile_meta / write_profile_meta. `create_profile` accepts
optional description.
- hermes_cli/profile_describer.py: new module — auto-generate a 1-2
sentence description from a profile's skills + model + name via the
auxiliary LLM (`auxiliary.profile_describer`).
- hermes_cli/main.py: new `hermes profile create --description ...`
flag; new `hermes profile describe [name] [--text ... | --auto |
--all --auto]` subcommand.
- hermes_cli/kanban_db.py: new `decompose_triage_task` atomic helper —
creates N child tasks, links the root as a child of every leaf
(root waits for the whole graph), flips root `triage -> todo` with
orchestrator assignee, records an audit comment + `decomposed` event
in a single write_txn.
- hermes_cli/kanban_decompose.py: new module — calls the auxiliary LLM
(`auxiliary.kanban_decomposer`) with the profile roster + descriptions
to produce a JSON task graph, then invokes the DB helper. Rewrites
unknown assignees to the configured `kanban.default_assignee` (or
the active default profile) so a task NEVER lands with assignee=None.
Falls back to specify-style single-task promotion when the LLM
returns `fanout: false`.
- hermes_cli/kanban.py: new `hermes kanban decompose [task_id | --all]`
CLI verb.
- hermes_cli/config.py: new DEFAULT_CONFIG keys —
kanban.orchestrator_profile, kanban.default_assignee,
kanban.auto_decompose (default True), kanban.auto_decompose_per_tick
(default 3), auxiliary.kanban_decomposer, auxiliary.profile_describer.
- gateway/run.py: kanban dispatcher watcher now runs auto-decompose
before each `_tick_once`, capped by `auto_decompose_per_tick` so a
bulk-load of triage tasks doesn't burst-spend the aux LLM.
- plugins/kanban/dashboard/plugin_api.py: new endpoints —
GET /profiles (list roster + descriptions),
PATCH /profiles/<name> (set description, user-authored),
POST /profiles/<name>/describe-auto (LLM-generate),
POST /tasks/<id>/decompose (run decomposer),
GET/PUT /orchestration (orchestrator/default-assignee/auto-decompose
pickers, with resolved fallbacks echoed back).
- plugins/kanban/dashboard/dist/index.js: new OrchestrationPanel
collapsible — dropdowns for orchestrator profile and default
assignee, auto-decompose toggle, per-profile description editor with
Save and Auto-generate buttons. New ⚗ Decompose button next to
✨ Specify on triage-column task drawers.
Behavior
--------
- A task in Triage gets fanned out into a small DAG of child tasks.
Children with no internal parents flip to `ready` immediately
(parallel dispatch). Children with sibling parents wait. The root
stays alive as a parent of every child — when the whole graph
finishes, it promotes to `ready` and the orchestrator profile wakes
back up to judge completion (the "adds more tasks until done" part
of the original vision).
- `kanban.orchestrator_profile` unset -> falls back to the default
profile (whichever `hermes` launches with no -p flag).
- `kanban.default_assignee` unset -> same fallback. Tasks NEVER end
up unassigned.
- `kanban.auto_decompose=true` (default) runs the decomposer
automatically on dispatcher ticks; manual `hermes kanban decompose`
is always available.
Tests
-----
- tests/hermes_cli/test_kanban_decompose_db.py — 7 tests for the
atomic DB helper (status transitions, dep graph, audit trail,
validation errors).
- tests/hermes_cli/test_kanban_decompose.py — 6 tests for the
decomposer module (fanout, no-fanout fallback, unknown-assignee
rewrite, malformed-JSON resilience, no-aux-client path).
- tests/hermes_cli/test_profile_describer.py — 10 tests for
profile.yaml r/w + the LLM auto-describer (yaml corrupt tolerance,
user-vs-auto description protection, --overwrite, fallback parsing).
E2E
---
- CLI end-to-end: created profiles with descriptions, dropped a triage
task, mocked the aux LLM with a 3-task graph -> verified all three
children were created with the right assignees, the dependency
edges matched the LLM's graph, root flipped to todo gated by every
child, audit comment + `decomposed` event recorded.
- Dashboard end-to-end: started the dashboard against an isolated
HERMES_HOME, verified all four new endpoints via curl (profile
listing, PATCH for description, PUT for orchestration settings,
POST for decompose). Opened the UI in the browser, confirmed the
OrchestrationPanel renders with all three pickers + the per-profile
description editor, typed a description, clicked Save, verified
~/.hermes/profile.yaml was written. Clicked Decompose on the triage
card and confirmed the inline error message surfaced as designed
("no auxiliary client configured").
* feat(kanban): surface decompose mode (Auto/Manual) as a one-click pill
The auto/manual toggle already existed as kanban.auto_decompose (default
true), but it was buried inside the collapsed Orchestration settings
panel — users couldn't tell at a glance which mode they were in. This
hoists it to a pill at the top of the kanban page so the state is always
visible and one click flips it.
UX
- New "⚗ Decompose: AUTO|MANUAL" pill in the kanban header. Emerald
styling when Auto is on (the default), muted/gray when Manual.
- Pill is visible both in the collapsed AND expanded Orchestration
settings views so context is preserved when the user opens the panel.
- Tooltip explains both states + what clicking does.
- Renamed the in-panel "Auto-decompose on triage / Enabled" checkbox
to "Decompose mode / Auto (default) | Manual" for language parity
with the pill.
Behavior preserved
- Default remains Auto (kanban.auto_decompose=true).
- Manual mode restores pre-PR behavior: triage tasks stay in triage
until the user clicks ⚗ Decompose on each card (or runs
`hermes kanban decompose <id>`).
Implementation
- plugins/kanban/dashboard/dist/index.js: load /orchestration on mount
(not just on expand) so the collapsed pill reflects real state.
Render mode pill in both collapsed and expanded headers. Reuses the
existing PUT /api/plugins/kanban/orchestration endpoint — no new
backend, no new tests required.
E2E verified
- Pill renders as "⚗ Decompose: AUTO" on page load (default).
- One click flips to "⚗ Decompose: MANUAL" with muted styling.
- config.yaml on disk shows auto_decompose: false after the flip.
- Second click round-trips back to Auto; config.yaml flips to true.
* feat(kanban): rename mode pill to "Orchestration: Auto/Manual"
Per Teknium feedback — "Decompose" was too implementation-specific.
"Orchestration" is the user-facing concept (the whole pitch is the
orchestrator profile routing work), and the pill is the front door to it.
- Pill text: "Orchestration: Auto" / "Orchestration: Manual" (title case,
no ⚗ prefix, no SHOUTY-CAPS for the mode value)
- In-panel checkbox label: "Orchestration mode" (was "Decompose mode")
- Tooltips updated to match
- No behavior change
* docs(kanban): document decompose, profile descriptions, orchestration mode
Brings the docs site up to parity with the PR. English build verified
locally (npx docusaurus build --locale en) — clean, no new broken links
or anchors. Pre-existing broken-link warnings (rl-training, llms.txt,
step-by-step-checklist, fallback-model) untouched.
- website/docs/reference/cli-commands.md
+ `hermes kanban decompose` action row in the action table, with
pointer to the Auto vs Manual orchestration section.
- website/docs/reference/profile-commands.md
+ `--description "<text>"` flag on `hermes profile create`.
+ Full `hermes profile describe` section: read, --text, --auto,
--overwrite, --all flags with examples.
- website/docs/user-guide/features/kanban.md (the big one)
+ Triage column intro rewritten around the Auto-decompose default
behavior, with pointer to the new Auto vs Manual section.
+ Status action row updated to mention both ⚗ Decompose and
✨ Specify on triage cards.
+ New "Auto vs Manual orchestration" section explaining the two
modes, how to flip them (pill, config), how routing-by-description
works, the no-None-assignee guarantee, plus a config knob table
(auto_decompose, auto_decompose_per_tick, orchestrator_profile,
default_assignee) and the two new auxiliary slots
(kanban_decomposer, profile_describer).
+ REST surface table gains 6 new endpoint rows: /tasks/:id/decompose,
/profiles (GET), /profiles/:name (PATCH), /profiles/:name/describe-auto,
/orchestration (GET + PUT).
- website/docs/user-guide/features/kanban-tutorial.md
+ Triage column blurb updated for Auto by default + Manual via the
pill, with cross-link to the Auto vs Manual orchestration section.
- website/docs/user-guide/profiles.md
+ Blank-profile flow now mentions --description and points to the
kanban routing model for context.
- website/docs/user-guide/configuration.md
+ `kanban_decomposer` and `profile_describer` added to the
`hermes model -> Configure auxiliary models` menu listing.
This commit is contained in:
parent
04b4f765cc
commit
1345dda0cf
19 changed files with 2698 additions and 3 deletions
|
|
@ -4763,11 +4763,106 @@ class GatewayRunner:
|
|||
pass
|
||||
return False
|
||||
|
||||
# Auto-decompose: turn fresh triage tasks into ready workgraphs
|
||||
# before the dispatcher fans out workers. Gated by
|
||||
# ``kanban.auto_decompose`` (default True). Capped by
|
||||
# ``kanban.auto_decompose_per_tick`` (default 3) so a bulk-load
|
||||
# of triage tasks doesn't burst-spend the aux LLM in one tick;
|
||||
# remainder defers to subsequent ticks.
|
||||
auto_decompose_enabled = bool(kanban_cfg.get("auto_decompose", True))
|
||||
try:
|
||||
auto_decompose_per_tick = int(
|
||||
kanban_cfg.get("auto_decompose_per_tick", 3) or 3
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
auto_decompose_per_tick = 3
|
||||
if auto_decompose_per_tick < 1:
|
||||
auto_decompose_per_tick = 1
|
||||
|
||||
def _auto_decompose_tick() -> int:
|
||||
"""Run the auto-decomposer for up to N triage tasks across all
|
||||
boards. Returns the number of triage tasks that were
|
||||
successfully decomposed or specified this tick.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import kanban_decompose as _decomp
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning(
|
||||
"kanban auto-decompose: import failed (%s); skipping", exc,
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
boards = _kb.list_boards(include_archived=False)
|
||||
except Exception:
|
||||
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
|
||||
attempted = 0
|
||||
successes = 0
|
||||
for b in boards:
|
||||
slug = b.get("slug") or _kb.DEFAULT_BOARD
|
||||
if attempted >= auto_decompose_per_tick:
|
||||
break
|
||||
# Pin this board for the duration of the call — same
|
||||
# pattern as the dashboard specify endpoint. The
|
||||
# decomposer module connects with no board kwarg and
|
||||
# relies on the env var.
|
||||
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
try:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = slug
|
||||
try:
|
||||
triage_ids = _decomp.list_triage_ids()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"kanban auto-decompose: list_triage_ids failed on board %s (%s)",
|
||||
slug, exc,
|
||||
)
|
||||
triage_ids = []
|
||||
for tid in triage_ids:
|
||||
if attempted >= auto_decompose_per_tick:
|
||||
break
|
||||
attempted += 1
|
||||
try:
|
||||
outcome = _decomp.decompose_task(
|
||||
tid, author="auto-decomposer",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"kanban auto-decompose: decompose_task crashed on %s",
|
||||
tid,
|
||||
)
|
||||
continue
|
||||
if outcome.ok:
|
||||
successes += 1
|
||||
if outcome.fanout and outcome.child_ids:
|
||||
logger.info(
|
||||
"kanban auto-decompose [%s]: %s → %d children",
|
||||
slug, tid, len(outcome.child_ids),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"kanban auto-decompose [%s]: %s → single task (no fanout)",
|
||||
slug, tid,
|
||||
)
|
||||
else:
|
||||
# Common no-op reasons (no aux client configured) shouldn't
|
||||
# spam logs every tick. Log at debug.
|
||||
logger.debug(
|
||||
"kanban auto-decompose [%s]: %s skipped: %s",
|
||||
slug, tid, outcome.reason,
|
||||
)
|
||||
finally:
|
||||
if prev_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_env
|
||||
return successes
|
||||
|
||||
logger.info(
|
||||
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
|
||||
)
|
||||
while self._running:
|
||||
try:
|
||||
if auto_decompose_enabled:
|
||||
await asyncio.to_thread(_auto_decompose_tick)
|
||||
results = await asyncio.to_thread(_tick_once)
|
||||
any_spawned = False
|
||||
for slug, res in (results or []):
|
||||
|
|
|
|||
|
|
@ -925,6 +925,31 @@ DEFAULT_CONFIG = {
|
|||
"timeout": 120,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Kanban decomposer — decomposes a triage task into a graph of
|
||||
# child tasks routed to specialist profiles by description.
|
||||
# Invoked by ``hermes kanban decompose`` and the kanban
|
||||
# auto-decompose dispatcher tick. Returns a JSON task graph;
|
||||
# uses more tokens than the specifier so allow more headroom.
|
||||
"kanban_decomposer": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 180,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Profile describer — auto-generates a 1-2 sentence description
|
||||
# of what a profile is good at. Invoked by
|
||||
# ``hermes profile describe <name> --auto`` and the dashboard's
|
||||
# auto-generate button. Short, cheap call.
|
||||
"profile_describer": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 60,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Curator — skill-usage review fork. Timeout is generous because the
|
||||
# review pass can take several minutes on reasoning models (umbrella
|
||||
# building over hundreds of candidate skills). "auto" = use main chat
|
||||
|
|
@ -1466,6 +1491,25 @@ DEFAULT_CONFIG = {
|
|||
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
|
||||
# resets the streak for the new profile.
|
||||
"failure_limit": 2,
|
||||
# Profile that decomposes tasks in the Triage column. When unset,
|
||||
# falls back to the default profile (the one `hermes` launches with
|
||||
# no -p flag). Set this to a dedicated 'orchestrator' profile if you
|
||||
# want decomposition to use a different model/skills from your main
|
||||
# working profile.
|
||||
"orchestrator_profile": "",
|
||||
# Where a child task lands if the orchestrator can't match an
|
||||
# assignee to any installed profile. When unset, falls back to the
|
||||
# default profile. A task never ends up with assignee=None.
|
||||
"default_assignee": "",
|
||||
# When true, the kanban dispatcher auto-runs the decomposer on
|
||||
# tasks that land in Triage (every dispatcher tick). When false,
|
||||
# decomposition is manual via `hermes kanban decompose <id>` or
|
||||
# the dashboard's Decompose button.
|
||||
"auto_decompose": True,
|
||||
# Max triage tasks to decompose per dispatcher tick. Prevents a
|
||||
# large bulk-load of triage tasks from spending a burst of aux
|
||||
# LLM calls in one tick. Excess tasks defer to the next tick.
|
||||
"auto_decompose_per_tick": 3,
|
||||
},
|
||||
|
||||
# execute_code settings — controls the tool used for programmatic tool calls.
|
||||
|
|
|
|||
|
|
@ -610,6 +610,43 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
help="Emit one JSON object per task on stdout",
|
||||
)
|
||||
|
||||
# --- decompose --- (triage → fan-out via auxiliary LLM + orchestrator)
|
||||
p_decompose = sub.add_parser(
|
||||
"decompose",
|
||||
help="Decompose a triage-column task into a graph of child tasks "
|
||||
"routed to specialist profiles by description. Falls back to "
|
||||
"specify-style single-task promotion when the task doesn't "
|
||||
"benefit from fan-out. Uses auxiliary.kanban_decomposer.",
|
||||
)
|
||||
p_decompose.add_argument(
|
||||
"task_id",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Task id to decompose (required unless --all is given)",
|
||||
)
|
||||
p_decompose.add_argument(
|
||||
"--all",
|
||||
dest="all_triage",
|
||||
action="store_true",
|
||||
help="Decompose every task currently in the triage column",
|
||||
)
|
||||
p_decompose.add_argument(
|
||||
"--tenant",
|
||||
default=None,
|
||||
help="When used with --all, restrict the sweep to this tenant",
|
||||
)
|
||||
p_decompose.add_argument(
|
||||
"--author",
|
||||
default=None,
|
||||
help="Author name recorded on the audit comment "
|
||||
"(default: $HERMES_PROFILE or 'decomposer')",
|
||||
)
|
||||
p_decompose.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit one JSON object per task on stdout",
|
||||
)
|
||||
|
||||
# --- gc ---
|
||||
p_gc = sub.add_parser(
|
||||
"gc", help="Garbage-collect archived-task workspaces, old events, and old logs",
|
||||
|
|
@ -740,6 +777,7 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
"notify-unsubscribe": _cmd_notify_unsubscribe,
|
||||
"context": _cmd_context,
|
||||
"specify": _cmd_specify,
|
||||
"decompose": _cmd_decompose,
|
||||
"gc": _cmd_gc,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
|
|
@ -2115,6 +2153,87 @@ def _cmd_specify(args: argparse.Namespace) -> int:
|
|||
return 0 if (ok_count > 0 or not ids) else 1
|
||||
|
||||
|
||||
def _cmd_decompose(args: argparse.Namespace) -> int:
|
||||
"""Fan a triage task (or all of them) out into a graph of child
|
||||
tasks via the auxiliary LLM, routed to specialist profiles by
|
||||
description. Thin wrapper over ``kanban_decompose``."""
|
||||
from hermes_cli import kanban_decompose as decomp
|
||||
|
||||
all_flag = bool(getattr(args, "all_triage", False))
|
||||
tenant = getattr(args, "tenant", None)
|
||||
author = getattr(args, "author", None) or _profile_author()
|
||||
want_json = bool(getattr(args, "json", False))
|
||||
|
||||
if args.task_id and all_flag:
|
||||
print(
|
||||
"kanban: pass either a task id OR --all, not both",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if all_flag:
|
||||
ids = decomp.list_triage_ids(tenant=tenant)
|
||||
if not ids:
|
||||
msg = (
|
||||
"No triage tasks"
|
||||
+ (f" for tenant {tenant!r}" if tenant else "")
|
||||
+ "."
|
||||
)
|
||||
if want_json:
|
||||
print(json.dumps({"decomposed": 0, "total": 0}))
|
||||
else:
|
||||
print(msg)
|
||||
return 0
|
||||
elif args.task_id:
|
||||
ids = [args.task_id]
|
||||
else:
|
||||
print(
|
||||
"kanban: decompose requires a task id or --all",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
ok_count = 0
|
||||
for tid in ids:
|
||||
outcome = decomp.decompose_task(tid, author=author)
|
||||
if outcome.ok:
|
||||
ok_count += 1
|
||||
if want_json:
|
||||
print(json.dumps({
|
||||
"task_id": outcome.task_id,
|
||||
"ok": outcome.ok,
|
||||
"reason": outcome.reason,
|
||||
"fanout": outcome.fanout,
|
||||
"child_ids": outcome.child_ids,
|
||||
"new_title": outcome.new_title,
|
||||
}))
|
||||
elif outcome.ok:
|
||||
if outcome.fanout and outcome.child_ids:
|
||||
child_summary = ", ".join(outcome.child_ids)
|
||||
print(
|
||||
f"Decomposed {outcome.task_id} → {len(outcome.child_ids)} "
|
||||
f"children ({child_summary}); root promoted to todo"
|
||||
)
|
||||
else:
|
||||
title_suffix = (
|
||||
f" — retitled: {outcome.new_title!r}"
|
||||
if outcome.new_title
|
||||
else ""
|
||||
)
|
||||
print(
|
||||
f"Specified {outcome.task_id} → todo "
|
||||
f"(no fanout){title_suffix}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"kanban: decompose {outcome.task_id}: {outcome.reason}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not all_flag:
|
||||
return 0 if ok_count == 1 else 1
|
||||
return 0 if (ok_count > 0 or not ids) else 1
|
||||
|
||||
|
||||
def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
"""Remove scratch workspaces of archived tasks, prune old events, and
|
||||
delete old worker logs."""
|
||||
|
|
|
|||
|
|
@ -2777,6 +2777,180 @@ def specify_triage_task(
|
|||
return True
|
||||
|
||||
|
||||
def decompose_triage_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
root_assignee: Optional[str],
|
||||
children: list[dict],
|
||||
author: Optional[str] = None,
|
||||
) -> Optional[list[str]]:
|
||||
"""Fan a triage task out into child tasks and promote the root to ``todo``.
|
||||
|
||||
The root task stays alive and becomes the parent of every child —
|
||||
when all children reach ``done``, the root promotes to ``ready`` and
|
||||
its assignee (typically the orchestrator profile) wakes back up to
|
||||
judge completion or spawn more work.
|
||||
|
||||
``children`` is a list of dicts, each shaped like::
|
||||
|
||||
{
|
||||
"title": "...",
|
||||
"body": "...", # optional
|
||||
"assignee": "profile-name", # optional, None -> default fallback
|
||||
"parents": [0, 2], # indices into this same children list
|
||||
}
|
||||
|
||||
Returns the list of created child task ids (in input order) on
|
||||
success. Returns ``None`` when:
|
||||
- The root task does not exist
|
||||
- The root task is not in ``triage``
|
||||
- A cycle would result (caller built a bad graph)
|
||||
|
||||
Validation of titles/assignees happens inside the same write_txn as
|
||||
the inserts so a malformed entry aborts the whole decomposition
|
||||
cleanly (no orphan children).
|
||||
"""
|
||||
if not children:
|
||||
return None
|
||||
if root_assignee is not None:
|
||||
root_assignee = _canonical_assignee(root_assignee)
|
||||
|
||||
# Pre-validate the children list shape outside the txn. Cheap checks
|
||||
# that don't need DB access. Bad input aborts before we touch the DB.
|
||||
for idx, child in enumerate(children):
|
||||
if not isinstance(child, dict):
|
||||
raise ValueError(f"child[{idx}] is not a dict")
|
||||
title = child.get("title")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
raise ValueError(f"child[{idx}].title is required")
|
||||
parents_idx = child.get("parents") or []
|
||||
if not isinstance(parents_idx, list):
|
||||
raise ValueError(f"child[{idx}].parents must be a list")
|
||||
for p in parents_idx:
|
||||
if not isinstance(p, int) or p < 0 or p >= len(children):
|
||||
raise ValueError(
|
||||
f"child[{idx}].parents[{p}] is not a valid index into children"
|
||||
)
|
||||
if p == idx:
|
||||
raise ValueError(f"child[{idx}] cannot list itself as a parent")
|
||||
|
||||
# We do the full decomposition in a SINGLE write_txn so it's
|
||||
# atomic: either every child is created AND the root flips to
|
||||
# ``todo``, or nothing changes. We deliberately do NOT call any
|
||||
# kb helper that opens its own write_txn (create_task, link_tasks,
|
||||
# add_comment) from inside this block — see architecture.md
|
||||
# write_txn pitfalls. Instead we inline the INSERTs and
|
||||
# _append_event calls.
|
||||
now = int(time.time())
|
||||
child_ids: list[str] = []
|
||||
with write_txn(conn):
|
||||
root_row = conn.execute(
|
||||
"SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
if root_row is None:
|
||||
return None
|
||||
if root_row["status"] != "triage":
|
||||
return None
|
||||
tenant = root_row["tenant"]
|
||||
|
||||
# Create children. Status is 'todo' regardless of parents — we
|
||||
# link them under the root AFTER creation so the dispatcher
|
||||
# sees a coherent state, and recompute_ready() at the end
|
||||
# promotes parent-free children to 'ready'.
|
||||
for idx, child in enumerate(children):
|
||||
new_id = _new_task_id()
|
||||
title = child["title"].strip()
|
||||
body = child.get("body")
|
||||
assignee = _canonical_assignee(child.get("assignee"))
|
||||
conn.execute(
|
||||
"INSERT INTO tasks "
|
||||
"(id, title, body, assignee, status, workspace_kind, "
|
||||
" tenant, created_at, created_by) "
|
||||
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
|
||||
(
|
||||
new_id,
|
||||
title,
|
||||
body if isinstance(body, str) else None,
|
||||
assignee,
|
||||
tenant,
|
||||
now,
|
||||
(author or "decomposer"),
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn, new_id, "created",
|
||||
{"by": author or "decomposer", "from_decompose_of": task_id},
|
||||
)
|
||||
child_ids.append(new_id)
|
||||
|
||||
# Link children to their sibling parents (within the decomposed graph).
|
||||
for idx, child in enumerate(children):
|
||||
for p_idx in child.get("parents") or []:
|
||||
parent_id = child_ids[p_idx]
|
||||
child_id = child_ids[idx]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO task_links (parent_id, child_id) "
|
||||
"VALUES (?, ?)",
|
||||
(parent_id, child_id),
|
||||
)
|
||||
_append_event(
|
||||
conn, child_id, "linked",
|
||||
{"parent": parent_id, "child": child_id},
|
||||
)
|
||||
|
||||
# Link the ROOT task as a child of every leaf child — i.e. the
|
||||
# root waits for the whole graph. Simpler than computing leaves:
|
||||
# link root under every child. Cycle-free because the root is
|
||||
# only ever a child here, never a parent of children.
|
||||
for cid in child_ids:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO task_links (parent_id, child_id) "
|
||||
"VALUES (?, ?)",
|
||||
(cid, task_id),
|
||||
)
|
||||
|
||||
# Flip the root: triage -> todo, set assignee to the orchestrator.
|
||||
sets = ["status = 'todo'"]
|
||||
params: list[Any] = []
|
||||
if root_assignee is not None:
|
||||
sets.append("assignee = ?")
|
||||
params.append(root_assignee)
|
||||
params.append(task_id)
|
||||
conn.execute(
|
||||
f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
# Audit comment + event on the root so the timeline shows the fan-out.
|
||||
if author and author.strip():
|
||||
conn.execute(
|
||||
"INSERT INTO task_comments (task_id, author, body, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
author.strip(),
|
||||
"Decomposed into "
|
||||
+ ", ".join(child_ids)
|
||||
+ ". Root will wake when all children complete.",
|
||||
now,
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn, task_id, "decomposed",
|
||||
{
|
||||
"child_ids": child_ids,
|
||||
"root_assignee": root_assignee,
|
||||
},
|
||||
)
|
||||
|
||||
# Outside the write_txn: promote parent-free children to 'ready'
|
||||
# so the dispatcher picks them up on its next tick. Same pattern
|
||||
# specify_triage_task uses.
|
||||
recompute_ready(conn)
|
||||
return child_ids
|
||||
|
||||
|
||||
def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
|
|
|
|||
440
hermes_cli/kanban_decompose.py
Normal file
440
hermes_cli/kanban_decompose.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"""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 the shape of ``hermes_cli/kanban_specify.py``: lazy aux
|
||||
client import inside the function, lenient response parse, never
|
||||
raises on expected failure modes.
|
||||
|
||||
* The system prompt sees the *configured* profile roster — names plus
|
||||
descriptions plus the default fallback. Profiles without a
|
||||
description are still listed (with a note) so the orchestrator can
|
||||
match on name as a fallback, but the user has an obvious incentive
|
||||
to describe them.
|
||||
|
||||
* ``fanout=false`` collapses to the same effect as ``kanban specify``:
|
||||
we tighten the body and flip ``triage -> todo`` as a single task,
|
||||
no children created. This makes ``decompose`` a strict superset of
|
||||
``specify`` from the user's perspective.
|
||||
|
||||
* If the LLM picks an assignee that doesn't exist as a profile, we
|
||||
rewrite it to the configured ``default_assignee`` (or the default
|
||||
profile if unset). A child task NEVER ends up with ``assignee=None``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
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
|
||||
|
||||
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>"
|
||||
}
|
||||
|
||||
In that case the task stays as one work item, just with a tightened spec.
|
||||
|
||||
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 _truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def _extract_json_blob(raw: str) -> Optional[dict]:
|
||||
if not raw:
|
||||
return None
|
||||
stripped = _FENCE_RE.sub("", raw.strip())
|
||||
first = stripped.find("{")
|
||||
last = stripped.rfind("}")
|
||||
if first == -1 or last == -1 or last <= first:
|
||||
return None
|
||||
candidate = stripped[first : last + 1]
|
||||
try:
|
||||
val = json.loads(candidate)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(val, dict):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def _profile_author() -> str:
|
||||
"""Mirror of ``hermes_cli.kanban._profile_author``."""
|
||||
return (
|
||||
os.environ.get("HERMES_PROFILE")
|
||||
or os.environ.get("USER")
|
||||
or "decomposer"
|
||||
)
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
return load_config() or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_orchestrator_profile(cfg: dict) -> str:
|
||||
"""Resolve which profile owns decomposition.
|
||||
|
||||
Falls back to the active default profile when ``kanban.orchestrator_profile``
|
||||
is unset, so a task is never stranded for lack of an orchestrator.
|
||||
"""
|
||||
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
|
||||
explicit = (kanban_cfg.get("orchestrator_profile") or "").strip()
|
||||
if explicit:
|
||||
try:
|
||||
if profiles_mod.profile_exists(explicit):
|
||||
return explicit
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to the active default profile.
|
||||
try:
|
||||
return profiles_mod.get_active_profile_name() or "default"
|
||||
except Exception:
|
||||
return "default"
|
||||
|
||||
|
||||
def _resolve_default_assignee(cfg: dict) -> str:
|
||||
"""Resolve which profile catches child tasks the orchestrator can't route."""
|
||||
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
|
||||
explicit = (kanban_cfg.get("default_assignee") 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]]:
|
||||
"""Return (roster_for_prompt, valid_assignee_names).
|
||||
|
||||
Each roster entry is ``{name, description, has_description}``. The
|
||||
valid-set is used after the LLM responds to rewrite invalid
|
||||
assignees to the default fallback.
|
||||
"""
|
||||
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 decompose_task(
|
||||
task_id: str,
|
||||
*,
|
||||
author: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> DecomposeOutcome:
|
||||
"""Decompose a triage task into a graph of child tasks.
|
||||
|
||||
Returns an outcome describing what happened. Never raises for
|
||||
expected failure modes (task not in triage, no aux client
|
||||
configured, API error, malformed response, decomposer returned
|
||||
fanout=true with empty task list) — those surface via ``ok=False``.
|
||||
"""
|
||||
with kb.connect() 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_orchestrator_profile(cfg)
|
||||
default_assignee = _resolve_default_assignee(cfg)
|
||||
roster, valid_names = _build_roster()
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import ( # type: ignore
|
||||
get_auxiliary_extra_body,
|
||||
get_text_auxiliary_client,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("decompose: auxiliary client import failed: %s", exc)
|
||||
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
|
||||
|
||||
try:
|
||||
client, model = get_text_auxiliary_client("kanban_decomposer")
|
||||
except Exception as exc:
|
||||
logger.debug("decompose: get_text_auxiliary_client failed: %s", exc)
|
||||
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
|
||||
|
||||
if client is None or not model:
|
||||
return DecomposeOutcome(task_id, False, "no auxiliary client configured")
|
||||
|
||||
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:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=4000,
|
||||
timeout=timeout or 180,
|
||||
extra_body=get_auxiliary_extra_body() or None,
|
||||
)
|
||||
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)
|
||||
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).
|
||||
new_title = parsed.get("title")
|
||||
new_body = parsed.get("body")
|
||||
title_val = new_title.strip() if isinstance(new_title, str) and new_title.strip() else None
|
||||
body_val = new_body if isinstance(new_body, str) and new_body.strip() else None
|
||||
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() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
title=title_val,
|
||||
body=body_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",
|
||||
)
|
||||
|
||||
# Rewrite invalid assignees to the default fallback. Never leave a
|
||||
# task with assignee=None — the user explicitly does not want that.
|
||||
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")
|
||||
if not isinstance(assignee, str) or not assignee.strip():
|
||||
chosen = default_assignee
|
||||
elif assignee 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,
|
||||
)
|
||||
chosen = default_assignee
|
||||
else:
|
||||
chosen = 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() as conn:
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
root_assignee=orchestrator,
|
||||
children=children,
|
||||
author=audit_author,
|
||||
)
|
||||
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() as conn:
|
||||
rows = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
tenant=tenant,
|
||||
limit=1000,
|
||||
)
|
||||
return [row.id for row in rows]
|
||||
|
|
@ -9043,6 +9043,7 @@ def cmd_profile(args):
|
|||
clone_config=clone,
|
||||
no_alias=no_alias,
|
||||
no_skills=no_skills,
|
||||
description=getattr(args, "description", None),
|
||||
)
|
||||
print(f"\nProfile '{name}' created at {profile_dir}")
|
||||
|
||||
|
|
@ -9142,6 +9143,107 @@ def cmd_profile(args):
|
|||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif action == "describe":
|
||||
# Read or write a profile's description. The description is
|
||||
# consumed by the kanban decomposer to route tasks based on
|
||||
# role instead of name alone.
|
||||
from hermes_cli import profiles as _profiles_mod
|
||||
|
||||
all_flag = bool(getattr(args, "all_missing", False))
|
||||
auto_flag = bool(getattr(args, "auto", False))
|
||||
overwrite_flag = bool(getattr(args, "overwrite", False))
|
||||
text_value = getattr(args, "text", None)
|
||||
name = getattr(args, "profile_name", None)
|
||||
|
||||
if all_flag and not auto_flag:
|
||||
print("profile describe: --all requires --auto", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if all_flag and (text_value or name):
|
||||
print(
|
||||
"profile describe: --all is mutually exclusive with a profile name / --text",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if not all_flag and not name:
|
||||
print("profile describe: profile name is required (or --all --auto)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if text_value and auto_flag:
|
||||
print(
|
||||
"profile describe: --text is mutually exclusive with --auto",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Show current description if no operation requested.
|
||||
if name and not text_value and not auto_flag:
|
||||
try:
|
||||
if _profiles_mod.normalize_profile_name(name) == "default":
|
||||
from hermes_constants import get_hermes_home as _hh
|
||||
profile_dir = Path(_hh())
|
||||
else:
|
||||
profile_dir = _profiles_mod.get_profile_dir(name)
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not profile_dir.is_dir():
|
||||
print(f"Error: profile '{name}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
meta = _profiles_mod.read_profile_meta(profile_dir)
|
||||
desc = meta.get("description") or ""
|
||||
if not desc:
|
||||
print(f"(no description set for '{name}')")
|
||||
else:
|
||||
tag = "[auto] " if meta.get("description_auto") else ""
|
||||
print(f"{tag}{desc}")
|
||||
sys.exit(0)
|
||||
|
||||
# --text path: just write the user-authored description.
|
||||
if text_value:
|
||||
try:
|
||||
if _profiles_mod.normalize_profile_name(name) == "default":
|
||||
from hermes_constants import get_hermes_home as _hh
|
||||
profile_dir = Path(_hh())
|
||||
else:
|
||||
profile_dir = _profiles_mod.get_profile_dir(name)
|
||||
_profiles_mod.write_profile_meta(
|
||||
profile_dir,
|
||||
description=text_value,
|
||||
description_auto=False,
|
||||
)
|
||||
print(f"Description updated for '{name}'.")
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
# --auto path: invoke the LLM describer.
|
||||
from hermes_cli import profile_describer as _pd
|
||||
|
||||
if all_flag:
|
||||
targets = _pd.list_describable_profiles(missing_only=True)
|
||||
if not targets:
|
||||
print("All profiles already have descriptions.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
targets = [name]
|
||||
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
for tgt in targets:
|
||||
outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag)
|
||||
if outcome.ok:
|
||||
ok_count += 1
|
||||
print(f"Described '{outcome.profile_name}': {outcome.description}")
|
||||
else:
|
||||
fail_count += 1
|
||||
print(
|
||||
f"profile describe {outcome.profile_name}: {outcome.reason}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not all_flag:
|
||||
sys.exit(0 if ok_count == 1 else 1)
|
||||
sys.exit(0 if ok_count > 0 else 1)
|
||||
|
||||
elif action == "show":
|
||||
name = args.profile_name
|
||||
from hermes_cli.profiles import (
|
||||
|
|
@ -12023,6 +12125,13 @@ Examples:
|
|||
action="store_true",
|
||||
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--description",
|
||||
default=None,
|
||||
help="One- or two-sentence description of what this profile is good at. "
|
||||
"Used by the kanban decomposer to route tasks based on role instead "
|
||||
"of profile name alone. Skip and add later via `hermes profile describe`.",
|
||||
)
|
||||
|
||||
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
|
||||
profile_delete.add_argument("profile_name", help="Profile to delete")
|
||||
|
|
@ -12030,6 +12139,40 @@ Examples:
|
|||
"-y", "--yes", action="store_true", help="Skip confirmation prompt"
|
||||
)
|
||||
|
||||
profile_describe = profile_subparsers.add_parser(
|
||||
"describe",
|
||||
help="Read or set a profile's description (used by the kanban orchestrator)",
|
||||
)
|
||||
profile_describe.add_argument(
|
||||
"profile_name",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Profile to describe (omit + use --all --auto to sweep)",
|
||||
)
|
||||
profile_describe.add_argument(
|
||||
"--text",
|
||||
default=None,
|
||||
help="Set description to this exact text (overwrites any existing description)",
|
||||
)
|
||||
profile_describe.add_argument(
|
||||
"--auto",
|
||||
action="store_true",
|
||||
help="Auto-generate description via the auxiliary LLM "
|
||||
"(uses auxiliary.profile_describer)",
|
||||
)
|
||||
profile_describe.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="With --auto, replace user-authored descriptions too (default: only "
|
||||
"fill in missing or previously-auto descriptions)",
|
||||
)
|
||||
profile_describe.add_argument(
|
||||
"--all",
|
||||
dest="all_missing",
|
||||
action="store_true",
|
||||
help="With --auto, run on every profile missing a description",
|
||||
)
|
||||
|
||||
profile_show = profile_subparsers.add_parser("show", help="Show profile details")
|
||||
profile_show.add_argument("profile_name", help="Profile to show")
|
||||
|
||||
|
|
|
|||
299
hermes_cli/profile_describer.py
Normal file
299
hermes_cli/profile_describer.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""Profile describer — auto-generate ``description`` for a profile.
|
||||
|
||||
Used by ``hermes profile describe <name> --auto`` and the dashboard's
|
||||
"auto-generate description" button. Reads the profile's installed
|
||||
skills, model+provider, name, and optionally a small slice of memory,
|
||||
then asks the auxiliary LLM to produce a 1-2 sentence description of
|
||||
what the profile is good at.
|
||||
|
||||
Result is written to ``<profile_dir>/profile.yaml`` with
|
||||
``description_auto: true`` so the dashboard can surface a "review"
|
||||
badge. User can edit afterward to confirm.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux
|
||||
client import inside the function, lenient response parse, never
|
||||
raises on expected failure modes.
|
||||
- Reads at most ``MAX_SKILLS_FOR_PROMPT`` skill names to keep the
|
||||
prompt bounded. No skill body — names + categories are enough
|
||||
signal and avoid blowing context on profiles with 100+ skills.
|
||||
- Memory is intentionally NOT read here. Memories are personal and
|
||||
the orchestrator routes work to a *role* not a *biography*. If we
|
||||
find later that memory adds signal we can wire it; for now,
|
||||
skills + name + model is plenty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cap on how many skill names we feed the LLM. Profiles with 200+
|
||||
# skills (uncommon but possible) would blow context otherwise. The cap
|
||||
# is per-category — see _collect_skills.
|
||||
MAX_SKILLS_FOR_PROMPT = 60
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You are a profile-describer for the Hermes Agent kanban board.
|
||||
|
||||
A user runs multiple "profiles" — distinct agent identities, each with their
|
||||
own skills, model, and configuration. The kanban board's orchestrator routes
|
||||
work to whichever profile best fits each task. To do that well, every
|
||||
profile needs a short, concrete description of what it's good at.
|
||||
|
||||
You are given a profile's:
|
||||
- Name
|
||||
- Model / provider
|
||||
- List of installed skill names (a strong signal of role / domain)
|
||||
|
||||
Produce a single JSON object with exactly one key:
|
||||
|
||||
{
|
||||
"description": "<1-2 sentence description, plain prose, no preamble>"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- The description is what an orchestrator will read to decide whether to
|
||||
route a task here. Lead with the profile's strongest capability.
|
||||
- Stay concrete. Bad: "an AI agent that helps users."
|
||||
Good: "Reads and modifies Python codebases — runs tests,
|
||||
refactors functions, opens GitHub PRs."
|
||||
- 1-2 sentences, <= 280 characters total.
|
||||
- Never invent capabilities the skills don't suggest.
|
||||
- Never write "Hermes Agent profile" or other meta-narration.
|
||||
- No code fences, no preamble, no closing remarks. Output only JSON.
|
||||
"""
|
||||
|
||||
|
||||
_USER_TEMPLATE = """Profile name: {name}
|
||||
Default model: {model}
|
||||
Provider: {provider}
|
||||
Installed skill count: {skill_count}
|
||||
Notable skills (up to {skill_cap}):
|
||||
{skill_list}
|
||||
"""
|
||||
|
||||
|
||||
_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DescribeOutcome:
|
||||
"""Result of describing a single profile."""
|
||||
|
||||
profile_name: str
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
def _collect_skills(profile_dir: Path) -> list[str]:
|
||||
"""Return a stable, capped list of skill names for the prompt.
|
||||
|
||||
Format: ``category/skill_name`` where category is the immediate
|
||||
subdir under ``skills/`` (e.g. ``devops``, ``research``). Skills
|
||||
that live directly under ``skills/`` show as bare ``skill_name``.
|
||||
"""
|
||||
skills_dir = profile_dir / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
for md in skills_dir.rglob("SKILL.md"):
|
||||
path_str = str(md)
|
||||
if "/.hub/" in path_str or "/.git/" in path_str:
|
||||
continue
|
||||
try:
|
||||
rel = md.relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
parts = rel.parts[:-1] # drop SKILL.md filename
|
||||
if not parts:
|
||||
continue
|
||||
# parts[-1] is the skill dir name; parts[:-1] is the category path
|
||||
if len(parts) == 1:
|
||||
names.append(parts[0])
|
||||
else:
|
||||
names.append(f"{parts[0]}/{parts[-1]}")
|
||||
names.sort()
|
||||
# Keep within prompt budget. Skills earlier in alphabet aren't more
|
||||
# important — we'll let the LLM see a sample. Pick evenly-spaced
|
||||
# entries instead of just the head so a profile with skills A..Z
|
||||
# doesn't get described as "starts with A".
|
||||
if len(names) <= MAX_SKILLS_FOR_PROMPT:
|
||||
return names
|
||||
step = len(names) / MAX_SKILLS_FOR_PROMPT
|
||||
sampled = [names[int(i * step)] for i in range(MAX_SKILLS_FOR_PROMPT)]
|
||||
return sampled
|
||||
|
||||
|
||||
def _extract_json_blob(raw: str) -> Optional[dict]:
|
||||
if not raw:
|
||||
return None
|
||||
stripped = _FENCE_RE.sub("", raw.strip())
|
||||
first = stripped.find("{")
|
||||
last = stripped.rfind("}")
|
||||
if first == -1 or last == -1 or last <= first:
|
||||
return None
|
||||
candidate = stripped[first : last + 1]
|
||||
try:
|
||||
val = json.loads(candidate)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(val, dict):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def describe_profile(
|
||||
profile_name: str,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
timeout: Optional[int] = None,
|
||||
) -> DescribeOutcome:
|
||||
"""Auto-generate a description for one profile.
|
||||
|
||||
Returns an outcome describing what happened. Never raises for
|
||||
expected failure modes (profile missing, no aux client configured,
|
||||
API error, malformed response) — those surface via ``ok=False`` so
|
||||
a sweep can continue past individual failures.
|
||||
|
||||
``overwrite`` controls whether an existing user-authored description
|
||||
is replaced. By default we refuse to overwrite a description with
|
||||
``description_auto: false`` to protect curated text. Auto-generated
|
||||
descriptions (``description_auto: true``) are always replaceable.
|
||||
"""
|
||||
canon = profiles_mod.normalize_profile_name(profile_name)
|
||||
if not profiles_mod.profile_exists(canon):
|
||||
# Special case: "default" exists as a virtual profile name
|
||||
# mapped to the default home dir. profile_exists() handles it.
|
||||
return DescribeOutcome(canon, False, "profile not found")
|
||||
|
||||
try:
|
||||
if canon == "default":
|
||||
from hermes_constants import get_hermes_home # type: ignore
|
||||
profile_dir = Path(get_hermes_home())
|
||||
else:
|
||||
profile_dir = profiles_mod.get_profile_dir(canon)
|
||||
except Exception as exc:
|
||||
return DescribeOutcome(canon, False, f"cannot resolve profile dir: {exc}")
|
||||
|
||||
# Honor curated descriptions unless --overwrite.
|
||||
existing = profiles_mod.read_profile_meta(profile_dir)
|
||||
if existing.get("description") and not existing.get("description_auto") and not overwrite:
|
||||
return DescribeOutcome(
|
||||
canon,
|
||||
False,
|
||||
"profile already has a user-authored description "
|
||||
"(use --overwrite to replace)",
|
||||
)
|
||||
|
||||
skill_names = _collect_skills(profile_dir)
|
||||
skill_list = "\n".join(f" - {n}" for n in skill_names) or " (no skills installed)"
|
||||
skill_count = sum(
|
||||
1 for _ in (profile_dir / "skills").rglob("SKILL.md")
|
||||
if "/.hub/" not in str(_) and "/.git/" not in str(_)
|
||||
) if (profile_dir / "skills").is_dir() else 0
|
||||
|
||||
# Read model + provider from the profile's config.
|
||||
try:
|
||||
model, provider = profiles_mod._read_config_model(profile_dir)
|
||||
except Exception:
|
||||
model, provider = None, None
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import ( # type: ignore
|
||||
get_auxiliary_extra_body,
|
||||
get_text_auxiliary_client,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("describe: auxiliary client import failed: %s", exc)
|
||||
return DescribeOutcome(canon, False, "auxiliary client unavailable")
|
||||
|
||||
try:
|
||||
client, aux_model = get_text_auxiliary_client("profile_describer")
|
||||
except Exception as exc:
|
||||
logger.debug("describe: get_text_auxiliary_client failed: %s", exc)
|
||||
return DescribeOutcome(canon, False, "auxiliary client unavailable")
|
||||
|
||||
if client is None or not aux_model:
|
||||
return DescribeOutcome(canon, False, "no auxiliary client configured")
|
||||
|
||||
user_msg = _USER_TEMPLATE.format(
|
||||
name=canon,
|
||||
model=(model or "(unset)"),
|
||||
provider=(provider or "(unset)"),
|
||||
skill_count=skill_count,
|
||||
skill_cap=MAX_SKILLS_FOR_PROMPT,
|
||||
skill_list=skill_list,
|
||||
)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=aux_model,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=400,
|
||||
timeout=timeout or 60,
|
||||
extra_body=get_auxiliary_extra_body() or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("describe: API call failed for %s (%s)", canon, exc)
|
||||
return DescribeOutcome(canon, False, f"LLM error: {type(exc).__name__}")
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
parsed = _extract_json_blob(raw)
|
||||
if parsed is None:
|
||||
# Fall back: take the raw text trimmed to one paragraph.
|
||||
text = raw.strip().split("\n\n", 1)[0]
|
||||
if not text:
|
||||
return DescribeOutcome(canon, False, "LLM returned an empty response")
|
||||
description = text[:280]
|
||||
else:
|
||||
val = parsed.get("description")
|
||||
if not isinstance(val, str) or not val.strip():
|
||||
return DescribeOutcome(
|
||||
canon, False, "LLM response missing 'description' field"
|
||||
)
|
||||
description = val.strip()[:280]
|
||||
|
||||
try:
|
||||
profiles_mod.write_profile_meta(
|
||||
profile_dir,
|
||||
description=description,
|
||||
description_auto=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return DescribeOutcome(canon, False, f"failed to write profile.yaml: {exc}")
|
||||
|
||||
return DescribeOutcome(canon, True, "described", description=description)
|
||||
|
||||
|
||||
def list_describable_profiles(*, missing_only: bool = True) -> list[str]:
|
||||
"""Return profile names that can be described.
|
||||
|
||||
``missing_only=True`` (default) returns only profiles without a
|
||||
description. ``missing_only=False`` returns every profile.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for p in profiles_mod.list_profiles():
|
||||
if missing_only and (p.description or "").strip() and not p.description_auto:
|
||||
continue
|
||||
out.append(p.name)
|
||||
return out
|
||||
|
|
@ -412,6 +412,17 @@ class ProfileInfo:
|
|||
distribution_name: Optional[str] = None
|
||||
distribution_version: Optional[str] = None
|
||||
distribution_source: Optional[str] = None
|
||||
# Free-form description (1-2 sentences) of what this profile is good
|
||||
# at. Persisted in ``<profile_dir>/profile.yaml``. Empty when the
|
||||
# user has not described the profile (legacy profiles, fresh
|
||||
# installs). Surfaced to the kanban decomposer so it can route work
|
||||
# to the right profile based on role rather than name alone.
|
||||
description: str = ""
|
||||
# When True, ``description`` was auto-generated by the LLM
|
||||
# describer and has not been confirmed by the user. The dashboard
|
||||
# surfaces a "review" badge in this case so the user can edit or
|
||||
# accept.
|
||||
description_auto: bool = False
|
||||
|
||||
|
||||
def _read_distribution_meta(profile_dir: Path) -> tuple:
|
||||
|
|
@ -479,6 +490,82 @@ def _count_skills(profile_dir: Path) -> int:
|
|||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# profile.yaml — per-profile metadata (description, role, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# We keep this file deliberately tiny and separate from the profile's
|
||||
# ``config.yaml``. ``config.yaml`` is the user-facing Hermes config
|
||||
# (~5000 lines of defaults); ``profile.yaml`` is metadata ABOUT the
|
||||
# profile itself (its role, who described it). Mixing them makes both
|
||||
# harder to read.
|
||||
#
|
||||
# Missing file -> empty defaults; never an error. The kanban decomposer
|
||||
# tolerates empty descriptions and just falls back to the profile name.
|
||||
|
||||
|
||||
def _profile_yaml_path(profile_dir: Path) -> Path:
|
||||
return profile_dir / "profile.yaml"
|
||||
|
||||
|
||||
def read_profile_meta(profile_dir: Path) -> dict:
|
||||
"""Read ``<profile_dir>/profile.yaml`` and return a dict.
|
||||
|
||||
Returns ``{"description": "", "description_auto": False}`` when the
|
||||
file is missing or unreadable. Never raises — a corrupt
|
||||
profile.yaml on an unrelated profile must not break
|
||||
``hermes profile list``.
|
||||
"""
|
||||
path = _profile_yaml_path(profile_dir)
|
||||
if not path.is_file():
|
||||
return {"description": "", "description_auto": False}
|
||||
try:
|
||||
import yaml
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
return {"description": "", "description_auto": False}
|
||||
if not isinstance(data, dict):
|
||||
return {"description": "", "description_auto": False}
|
||||
return {
|
||||
"description": str(data.get("description") or "").strip(),
|
||||
"description_auto": bool(data.get("description_auto", False)),
|
||||
}
|
||||
|
||||
|
||||
def write_profile_meta(
|
||||
profile_dir: Path,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
description_auto: Optional[bool] = None,
|
||||
) -> None:
|
||||
"""Update ``<profile_dir>/profile.yaml`` in place.
|
||||
|
||||
Only the explicitly passed fields are overwritten; unspecified
|
||||
fields preserve existing values. Creates the file if missing.
|
||||
Profile directory itself must exist.
|
||||
"""
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"profile directory does not exist: {profile_dir}")
|
||||
import yaml
|
||||
path = _profile_yaml_path(profile_dir)
|
||||
existing: dict = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = yaml.safe_load(f) or {}
|
||||
if isinstance(loaded, dict):
|
||||
existing = loaded
|
||||
except Exception:
|
||||
existing = {}
|
||||
if description is not None:
|
||||
existing["description"] = description.strip()
|
||||
if description_auto is not None:
|
||||
existing["description_auto"] = bool(description_auto)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(existing, f, sort_keys=False, default_flow_style=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -493,6 +580,7 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
if default_home.is_dir():
|
||||
model, provider = _read_config_model(default_home)
|
||||
dist_name, dist_version, dist_source = _read_distribution_meta(default_home)
|
||||
meta = read_profile_meta(default_home)
|
||||
profiles.append(ProfileInfo(
|
||||
name="default",
|
||||
path=default_home,
|
||||
|
|
@ -505,6 +593,8 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
distribution_name=dist_name,
|
||||
distribution_version=dist_version,
|
||||
distribution_source=dist_source,
|
||||
description=meta.get("description", ""),
|
||||
description_auto=meta.get("description_auto", False),
|
||||
))
|
||||
|
||||
# Named profiles
|
||||
|
|
@ -519,6 +609,7 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
model, provider = _read_config_model(entry)
|
||||
alias_path = wrapper_dir / name
|
||||
dist_name, dist_version, dist_source = _read_distribution_meta(entry)
|
||||
meta = read_profile_meta(entry)
|
||||
profiles.append(ProfileInfo(
|
||||
name=name,
|
||||
path=entry,
|
||||
|
|
@ -532,6 +623,8 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
distribution_name=dist_name,
|
||||
distribution_version=dist_version,
|
||||
distribution_source=dist_source,
|
||||
description=meta.get("description", ""),
|
||||
description_auto=meta.get("description_auto", False),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
|
@ -544,6 +637,7 @@ def create_profile(
|
|||
clone_config: bool = False,
|
||||
no_alias: bool = False,
|
||||
no_skills: bool = False,
|
||||
description: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Create a new profile directory.
|
||||
|
||||
|
|
@ -667,6 +761,19 @@ def create_profile(
|
|||
except OSError:
|
||||
pass # best-effort — the feature still works via the empty skills/ dir
|
||||
|
||||
# Persist description if the caller provided one. Done last so a
|
||||
# partial-create failure doesn't strand a description file in an
|
||||
# incomplete profile.
|
||||
if description and description.strip():
|
||||
try:
|
||||
write_profile_meta(
|
||||
profile_dir,
|
||||
description=description.strip(),
|
||||
description_auto=False,
|
||||
)
|
||||
except Exception:
|
||||
pass # non-fatal — user can describe later with `hermes profile describe`
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
|
|
|
|||
359
plugins/kanban/dashboard/dist/index.js
vendored
359
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -908,6 +908,7 @@
|
|||
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
|
||||
},
|
||||
}) : null,
|
||||
h(OrchestrationPanel, null),
|
||||
h(AttentionStrip, {
|
||||
boardData,
|
||||
onOpen: setSelectedTaskId,
|
||||
|
|
@ -1386,6 +1387,288 @@
|
|||
}, "?");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OrchestrationPanel — collapsible settings panel for the kanban
|
||||
// orchestrator (orchestrator profile picker, default assignee picker,
|
||||
// auto-decompose toggle, plus per-profile description editing with
|
||||
// auto-generate). Backed by /orchestration + /profiles endpoints.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function OrchestrationPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [busy, setBusy] = useState({});
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
const loadAll = useCallback(function () {
|
||||
Promise.all([
|
||||
SDK.fetchJSON(`${API}/orchestration`),
|
||||
SDK.fetchJSON(`${API}/profiles`),
|
||||
]).then(function (results) {
|
||||
setSettings(results[0] || null);
|
||||
setProfiles((results[1] && results[1].profiles) || []);
|
||||
setMsg(null);
|
||||
}).catch(function (err) {
|
||||
setMsg({ ok: false, text: "Failed to load: " + (err.message || String(err)) });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
// Load on mount so the collapsed pill shows the real mode without
|
||||
// requiring the user to expand the panel first.
|
||||
if (settings === null) loadAll();
|
||||
}, [settings, loadAll]);
|
||||
|
||||
const saveSettings = function (patch) {
|
||||
setMsg(null);
|
||||
return SDK.fetchJSON(`${API}/orchestration`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
}).then(function (res) {
|
||||
setSettings(res);
|
||||
setMsg({ ok: true, text: "Settings saved." });
|
||||
return res;
|
||||
}).catch(function (err) {
|
||||
setMsg({ ok: false, text: "Save failed: " + (err.message || String(err)) });
|
||||
});
|
||||
};
|
||||
|
||||
const saveProfileDescription = function (name, description) {
|
||||
setBusy(function (b) { return Object.assign({}, b, { [name]: "save" }); });
|
||||
return SDK.fetchJSON(`${API}/profiles/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description: description }),
|
||||
}).then(function () {
|
||||
loadAll();
|
||||
setMsg({ ok: true, text: `Description saved for ${name}.` });
|
||||
}).catch(function (err) {
|
||||
setMsg({ ok: false, text: "Save failed: " + (err.message || String(err)) });
|
||||
}).then(function () {
|
||||
setBusy(function (b) {
|
||||
const next = Object.assign({}, b); delete next[name]; return next;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const autoGenerateDescription = function (name, overwrite) {
|
||||
setBusy(function (b) { return Object.assign({}, b, { [name]: "auto" }); });
|
||||
return SDK.fetchJSON(`${API}/profiles/${encodeURIComponent(name)}/describe-auto`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overwrite: !!overwrite }),
|
||||
}).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
loadAll();
|
||||
setMsg({ ok: true, text: `Auto-generated description for ${name}.` });
|
||||
} else {
|
||||
setMsg({
|
||||
ok: false,
|
||||
text: "Auto-generate failed: " + ((res && res.reason) || "unknown error"),
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
setMsg({ ok: false, text: "Auto-generate failed: " + (err.message || String(err)) });
|
||||
}).then(function () {
|
||||
setBusy(function (b) {
|
||||
const next = Object.assign({}, b); delete next[name]; return next;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const headerLabel = expanded
|
||||
? "▾ Orchestration settings"
|
||||
: "▸ Orchestration settings";
|
||||
|
||||
// Mode pill — always visible (collapsed or expanded). One click flips
|
||||
// between Auto and Manual. Auto = dispatcher decomposes new triage tasks
|
||||
// every tick. Manual = pre-PR behavior, the user clicks ⚗ Decompose on
|
||||
// each triage card (or runs `hermes kanban decompose <id>`) and tasks
|
||||
// stay in triage until then.
|
||||
const autoOn = !!(settings && settings.auto_decompose);
|
||||
const modePillTitle = settings === null
|
||||
? "Loading mode…"
|
||||
: (autoOn
|
||||
? "Orchestration: Auto — the dispatcher decomposes new triage tasks automatically every tick. Click to switch to Manual (pre-PR behavior)."
|
||||
: "Orchestration: Manual — triage tasks stay in triage until you click ⚗ Decompose on each card. Click to switch to Auto.");
|
||||
const modePill = h("button", {
|
||||
type: "button",
|
||||
onClick: function () {
|
||||
if (settings === null) return; // not loaded yet
|
||||
saveSettings({ auto_decompose: !autoOn });
|
||||
},
|
||||
disabled: settings === null,
|
||||
title: modePillTitle,
|
||||
className: "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 "
|
||||
+ "text-xs font-medium "
|
||||
+ (autoOn
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-muted-foreground/30 bg-muted/30 text-muted-foreground"),
|
||||
},
|
||||
"Orchestration: ",
|
||||
h("span", { className: "ml-1 font-semibold" },
|
||||
settings === null ? "…" : (autoOn ? "Auto" : "Manual"))
|
||||
);
|
||||
|
||||
if (!expanded) {
|
||||
return h("div", { className: "flex items-center gap-3 text-xs" },
|
||||
modePill,
|
||||
h("button", {
|
||||
type: "button",
|
||||
onClick: function () { setExpanded(true); },
|
||||
className: "underline text-muted-foreground hover:text-foreground",
|
||||
title: "Configure the kanban orchestrator (profile picker, default assignee, auto-decompose, profile descriptions)",
|
||||
}, headerLabel),
|
||||
);
|
||||
}
|
||||
|
||||
const profileOptions = profiles.map(function (p) {
|
||||
const tag = p.is_default ? " (default)" : "";
|
||||
return h(SelectOption, { key: p.name, value: p.name }, p.name + tag);
|
||||
});
|
||||
|
||||
return h(Card, { className: "p-3" },
|
||||
h(CardContent, { className: "p-2 flex flex-col gap-3" },
|
||||
h("div", { className: "flex items-center justify-between" },
|
||||
h("button", {
|
||||
type: "button",
|
||||
onClick: function () { setExpanded(false); },
|
||||
className: "text-sm font-medium underline-offset-2 hover:underline",
|
||||
}, headerLabel),
|
||||
modePill,
|
||||
h(Button, { onClick: loadAll, size: "sm" }, "Reload"),
|
||||
),
|
||||
msg ? h("div", {
|
||||
className: msg.ok ? "hermes-kanban-msg-ok" : "hermes-kanban-msg-err",
|
||||
}, msg.text) : null,
|
||||
|
||||
settings ? h("div", { className: "grid gap-3 sm:grid-cols-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" },
|
||||
"Orchestrator profile"),
|
||||
h(Select, {
|
||||
value: settings.orchestrator_profile || "",
|
||||
className: "h-8",
|
||||
onChange: function (e) {
|
||||
const v = (e && e.target ? e.target.value : e) || "";
|
||||
saveSettings({ orchestrator_profile: v });
|
||||
},
|
||||
},
|
||||
h(SelectOption, { value: "" },
|
||||
"(default: " + (settings.active_profile || "default") + ")"),
|
||||
profileOptions,
|
||||
),
|
||||
h("div", { className: "text-[10px] text-muted-foreground" },
|
||||
"Resolved: " + (settings.resolved_orchestrator_profile || "default")),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" },
|
||||
"Default assignee"),
|
||||
h(Select, {
|
||||
value: settings.default_assignee || "",
|
||||
className: "h-8",
|
||||
onChange: function (e) {
|
||||
const v = (e && e.target ? e.target.value : e) || "";
|
||||
saveSettings({ default_assignee: v });
|
||||
},
|
||||
},
|
||||
h(SelectOption, { value: "" },
|
||||
"(default: " + (settings.active_profile || "default") + ")"),
|
||||
profileOptions,
|
||||
),
|
||||
h("div", { className: "text-[10px] text-muted-foreground" },
|
||||
"Resolved: " + (settings.resolved_default_assignee || "default")),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" },
|
||||
"Orchestration mode"),
|
||||
h("label", { className: "flex items-center gap-2 text-xs h-8" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: !!settings.auto_decompose,
|
||||
onChange: function (e) {
|
||||
saveSettings({ auto_decompose: !!e.target.checked });
|
||||
},
|
||||
}),
|
||||
settings.auto_decompose ? "Auto (default)" : "Manual",
|
||||
),
|
||||
h("div", { className: "text-[10px] text-muted-foreground" },
|
||||
"When on, the dispatcher decomposes new triage tasks automatically."),
|
||||
),
|
||||
) : h("div", { className: "text-xs text-muted-foreground" },
|
||||
"Loading…"),
|
||||
|
||||
h("div", { className: "border-t pt-3" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" },
|
||||
"Profile descriptions"),
|
||||
h("div", { className: "text-[10px] text-muted-foreground pb-2" },
|
||||
"Descriptions guide the orchestrator's routing. Click ⚗ to auto-generate, or edit and save."),
|
||||
profiles.length === 0
|
||||
? h("div", { className: "text-xs text-muted-foreground" }, "No profiles installed.")
|
||||
: h("div", { className: "flex flex-col gap-2" },
|
||||
profiles.map(function (p) {
|
||||
return h(ProfileDescriptionRow, {
|
||||
key: p.name,
|
||||
profile: p,
|
||||
busy: busy[p.name] || null,
|
||||
onSave: saveProfileDescription,
|
||||
onAuto: autoGenerateDescription,
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDescriptionRow(props) {
|
||||
const p = props.profile;
|
||||
const [draft, setDraft] = useState(p.description || "");
|
||||
const busy = props.busy;
|
||||
// Re-sync the local draft if the server-side description changes (e.g.
|
||||
// after auto-generate). Cheap because re-runs only happen on prop change.
|
||||
useEffect(function () {
|
||||
setDraft(p.description || "");
|
||||
}, [p.description]);
|
||||
|
||||
const tag = p.description_auto && p.description ? " [auto, review]" : "";
|
||||
return h("div", { className: "flex flex-col gap-1 border-l-2 pl-2",
|
||||
style: { borderColor: p.description ? "#888" : "#cc6" } },
|
||||
h("div", { className: "flex items-center gap-2 text-xs" },
|
||||
h("span", { className: "font-medium" }, p.name),
|
||||
p.is_default ? h("span", { className: "text-[10px] text-muted-foreground" }, "(default)") : null,
|
||||
p.description_auto && p.description
|
||||
? h("span", { className: "text-[10px] text-yellow-600" }, "auto — review")
|
||||
: null,
|
||||
!p.description
|
||||
? h("span", { className: "text-[10px] text-yellow-600" }, "⚠ no description")
|
||||
: null,
|
||||
),
|
||||
h("div", { className: "flex items-center gap-2" },
|
||||
h(Input, {
|
||||
value: draft,
|
||||
onChange: function (e) { setDraft(e.target.value); },
|
||||
placeholder: "What is this profile good at?",
|
||||
className: "h-7 text-xs flex-1",
|
||||
}),
|
||||
h(Button, {
|
||||
onClick: function () { props.onSave(p.name, draft); },
|
||||
size: "sm",
|
||||
disabled: !!busy || draft === (p.description || ""),
|
||||
title: "Save the description above as user-authored",
|
||||
}, busy === "save" ? "Saving…" : "Save"),
|
||||
h(Button, {
|
||||
onClick: function () { props.onAuto(p.name, true); },
|
||||
size: "sm",
|
||||
disabled: !!busy,
|
||||
title: "Auto-generate a description from this profile's skills and model",
|
||||
}, busy === "auto" ? "Generating…" : "⚗ Auto"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function BoardSwitcher(props) {
|
||||
const { t } = useI18n();
|
||||
const list = props.boardList || [];
|
||||
|
|
@ -2395,6 +2678,25 @@
|
|||
});
|
||||
};
|
||||
|
||||
// POST /tasks/:id/decompose — fan a triage task out into a graph
|
||||
// of child tasks routed to specialist profiles by description.
|
||||
// Refreshes both the drawer (so the user sees the root flip to
|
||||
// todo) and the board (so the new children appear in the columns).
|
||||
const doDecompose = function () {
|
||||
return SDK.fetchJSON(
|
||||
withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/decompose`, boardSlug),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
).then(function (res) {
|
||||
load();
|
||||
props.onRefresh();
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = function (parentId) {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
|
|
@ -2486,6 +2788,7 @@
|
|||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onSpecify: doSpecify,
|
||||
onDecompose: doDecompose,
|
||||
onAddParent: addLink,
|
||||
onRemoveParent: removeLink,
|
||||
onAddChild: addChild,
|
||||
|
|
@ -2559,6 +2862,7 @@
|
|||
task: t,
|
||||
onPatch: props.onPatch,
|
||||
onSpecify: props.onSpecify,
|
||||
onDecompose: props.onDecompose,
|
||||
}),
|
||||
h(DiagnosticsSection, {
|
||||
task: t,
|
||||
|
|
@ -3023,6 +3327,8 @@
|
|||
const task = props.task;
|
||||
const [specifyBusy, setSpecifyBusy] = useState(false);
|
||||
const [specifyMsg, setSpecifyMsg] = useState(null);
|
||||
const [decomposeBusy, setDecomposeBusy] = useState(false);
|
||||
const [decomposeMsg, setDecomposeMsg] = useState(null);
|
||||
const b = function (label, patch, enabled, confirmMsg) {
|
||||
return h(Button, {
|
||||
onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); },
|
||||
|
|
@ -3067,9 +3373,57 @@
|
|||
}, specifyBusy ? "Specifying…" : "✨ Specify")
|
||||
: null;
|
||||
|
||||
// "Decompose" is the orchestrator-driven fan-out. Like Specify, only
|
||||
// makes sense on triage-column tasks — elsewhere the backend short-
|
||||
// circuits with ok:false. When the orchestrator returns fanout:false
|
||||
// we render the same single-task message as Specify; when it fans
|
||||
// out we report the child count for quick at-a-glance verification.
|
||||
const decomposeButton = (task.status === "triage" && props.onDecompose)
|
||||
? h(Button, {
|
||||
onClick: function () {
|
||||
if (decomposeBusy) return;
|
||||
setDecomposeBusy(true);
|
||||
setDecomposeMsg(null);
|
||||
props.onDecompose().then(function (res) {
|
||||
if (res && res.ok) {
|
||||
if (res.fanout && res.child_ids && res.child_ids.length) {
|
||||
setDecomposeMsg({
|
||||
ok: true,
|
||||
text: `Decomposed into ${res.child_ids.length} children: ${res.child_ids.join(", ")}`,
|
||||
});
|
||||
} else {
|
||||
const suffix = res.new_title
|
||||
? ` — retitled: ${res.new_title}`
|
||||
: "";
|
||||
setDecomposeMsg({
|
||||
ok: true,
|
||||
text: `Single task (no fanout)${suffix}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setDecomposeMsg({
|
||||
ok: false,
|
||||
text: "Decompose failed: " + ((res && res.reason) || "unknown error"),
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
setDecomposeMsg({
|
||||
ok: false,
|
||||
text: "Decompose failed: " + (err.message || String(err)),
|
||||
});
|
||||
}).then(function () {
|
||||
setDecomposeBusy(false);
|
||||
});
|
||||
},
|
||||
disabled: decomposeBusy,
|
||||
size: "sm",
|
||||
}, decomposeBusy ? "Decomposing…" : "⚗ Decompose")
|
||||
: null;
|
||||
|
||||
return h("div", null,
|
||||
h("div", { className: "hermes-kanban-actions" },
|
||||
specifyButton,
|
||||
decomposeButton,
|
||||
b("→ triage", { status: "triage" }, task.status !== "triage"),
|
||||
b("→ ready", { status: "ready" }, task.status !== "ready"),
|
||||
// No direct → running button: /tasks/:id PATCH rejects status=running
|
||||
|
|
@ -3091,6 +3445,11 @@
|
|||
? "hermes-kanban-msg-ok"
|
||||
: "hermes-kanban-msg-err",
|
||||
}, specifyMsg.text) : null,
|
||||
decomposeMsg ? h("div", {
|
||||
className: decomposeMsg.ok
|
||||
? "hermes-kanban-msg-ok"
|
||||
: "hermes-kanban-msg-err",
|
||||
}, decomposeMsg.text) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1535,6 +1535,279 @@ def switch_board(slug: str):
|
|||
_EVENT_POLL_SECONDS = 0.3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile metadata & description editing (consumed by the kanban orchestrator)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DescribeBody(BaseModel):
|
||||
description: Optional[str] = None # explicit user-authored text
|
||||
|
||||
|
||||
class DescribeAutoBody(BaseModel):
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
@router.get("/profiles")
|
||||
def list_profile_roster():
|
||||
"""Return every installed profile with its description.
|
||||
|
||||
Consumed by the dashboard's settings panel (orchestrator picker)
|
||||
and the profile-description editing UI. Profiles without a
|
||||
description still appear here — they're routable on name alone,
|
||||
just less precisely.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
profiles = profiles_mod.list_profiles()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to list profiles: {exc}")
|
||||
return {
|
||||
"profiles": [
|
||||
{
|
||||
"name": p.name,
|
||||
"is_default": bool(p.is_default),
|
||||
"model": p.model or "",
|
||||
"provider": p.provider or "",
|
||||
"description": p.description or "",
|
||||
"description_auto": bool(p.description_auto),
|
||||
"skill_count": int(p.skill_count or 0),
|
||||
}
|
||||
for p in profiles
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/profiles/{profile_name}")
|
||||
def update_profile_description(profile_name: str, payload: DescribeBody):
|
||||
"""Set or clear the description of a profile.
|
||||
|
||||
Empty string clears the description; non-empty stores it as a
|
||||
user-authored description (``description_auto: false``) so the
|
||||
auto-describer won't overwrite it on a sweep without
|
||||
``--overwrite``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
canon = profiles_mod.normalize_profile_name(profile_name)
|
||||
if canon == "default":
|
||||
from hermes_constants import get_hermes_home # type: ignore
|
||||
from pathlib import Path as _Path
|
||||
profile_dir = _Path(get_hermes_home())
|
||||
else:
|
||||
profile_dir = profiles_mod.get_profile_dir(canon)
|
||||
if not profile_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"profile '{profile_name}' not found")
|
||||
text = (payload.description or "").strip()
|
||||
profiles_mod.write_profile_meta(
|
||||
profile_dir,
|
||||
description=text,
|
||||
description_auto=False,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to update profile: {exc}")
|
||||
return {"ok": True, "profile": canon, "description": text}
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_name}/describe-auto")
|
||||
def auto_describe_profile(profile_name: str, payload: DescribeAutoBody):
|
||||
"""Generate a description for the named profile via the auxiliary
|
||||
LLM (``auxiliary.profile_describer``). Persists with
|
||||
``description_auto: true`` so the dashboard can surface a "review"
|
||||
badge.
|
||||
|
||||
Maps 1:1 to ``hermes profile describe <name> --auto``. Non-OK
|
||||
outcomes are NOT HTTP errors — the UI renders the reason inline
|
||||
(e.g. "no auxiliary client configured") so the operator can fix
|
||||
config and retry without a page reload.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import profile_describer # noqa: WPS433 (intentional)
|
||||
outcome = profile_describer.describe_profile(
|
||||
profile_name,
|
||||
overwrite=bool(payload.overwrite),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"describer crashed: {exc}")
|
||||
return {
|
||||
"ok": bool(outcome.ok),
|
||||
"profile": outcome.profile_name,
|
||||
"reason": outcome.reason,
|
||||
"description": outcome.description,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decompose endpoint (orchestrator-driven fan-out)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DecomposeBody(BaseModel):
|
||||
author: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/decompose")
|
||||
def decompose_task_endpoint(
|
||||
task_id: str,
|
||||
payload: DecomposeBody,
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Fan a triage-column task out into a graph of child tasks via the
|
||||
auxiliary LLM, routed to specialist profiles by description. Maps
|
||||
1:1 to ``hermes kanban decompose <task_id>``.
|
||||
|
||||
Returns the outcome shape used by the CLI: ``{ok, task_id, reason,
|
||||
fanout, child_ids, new_title}``. A non-OK outcome is NOT an HTTP
|
||||
error — the UI renders the reason inline.
|
||||
|
||||
Runs in FastAPI's threadpool (sync ``def``) because the LLM call
|
||||
can take minutes on reasoning models.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
try:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = board or kanban_db.DEFAULT_BOARD
|
||||
from hermes_cli import kanban_decompose # noqa: WPS433 (intentional)
|
||||
outcome = kanban_decompose.decompose_task(
|
||||
task_id,
|
||||
author=(payload.author or None),
|
||||
)
|
||||
finally:
|
||||
if prev_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_env
|
||||
|
||||
return {
|
||||
"ok": bool(outcome.ok),
|
||||
"task_id": outcome.task_id,
|
||||
"reason": outcome.reason,
|
||||
"fanout": bool(outcome.fanout),
|
||||
"child_ids": outcome.child_ids or [],
|
||||
"new_title": outcome.new_title,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration settings (kanban.orchestrator_profile / default_assignee /
|
||||
# auto_decompose) — surfaced to the dashboard's settings panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrchestrationSettingsBody(BaseModel):
|
||||
orchestrator_profile: Optional[str] = None
|
||||
default_assignee: Optional[str] = None
|
||||
auto_decompose: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/orchestration")
|
||||
def get_orchestration_settings():
|
||||
"""Return the current kanban orchestration knobs from config.yaml
|
||||
plus the resolved effective values (filling in fallbacks)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config() or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
kanban_cfg = (cfg.get("kanban") or {}) if isinstance(cfg, dict) else {}
|
||||
explicit_orch = (kanban_cfg.get("orchestrator_profile") or "").strip()
|
||||
explicit_default = (kanban_cfg.get("default_assignee") or "").strip()
|
||||
auto_decompose = bool(kanban_cfg.get("auto_decompose", True))
|
||||
|
||||
# Resolve fallbacks the same way the decomposer does.
|
||||
resolved_orch = explicit_orch
|
||||
resolved_default = explicit_default
|
||||
try:
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
active_default = profiles_mod.get_active_profile_name() or "default"
|
||||
if not resolved_orch or not profiles_mod.profile_exists(resolved_orch):
|
||||
resolved_orch = active_default
|
||||
if not resolved_default or not profiles_mod.profile_exists(resolved_default):
|
||||
resolved_default = active_default
|
||||
except Exception:
|
||||
active_default = "default"
|
||||
if not resolved_orch:
|
||||
resolved_orch = active_default
|
||||
if not resolved_default:
|
||||
resolved_default = active_default
|
||||
|
||||
return {
|
||||
"orchestrator_profile": explicit_orch,
|
||||
"default_assignee": explicit_default,
|
||||
"auto_decompose": auto_decompose,
|
||||
"resolved_orchestrator_profile": resolved_orch,
|
||||
"resolved_default_assignee": resolved_default,
|
||||
"active_profile": active_default,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/orchestration")
|
||||
def set_orchestration_settings(payload: OrchestrationSettingsBody):
|
||||
"""Update the kanban orchestration knobs in ~/.hermes/config.yaml.
|
||||
|
||||
Each field is optional — only fields explicitly passed are
|
||||
written. ``orchestrator_profile`` / ``default_assignee`` accept
|
||||
empty strings to clear the override and fall back to the default
|
||||
profile.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config() or {}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to load config: {exc}")
|
||||
|
||||
kanban_section = cfg.setdefault("kanban", {})
|
||||
if not isinstance(kanban_section, dict):
|
||||
kanban_section = {}
|
||||
cfg["kanban"] = kanban_section
|
||||
|
||||
# Validate any non-empty profile names exist before saving.
|
||||
try:
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
except Exception:
|
||||
profiles_mod = None # type: ignore
|
||||
|
||||
if payload.orchestrator_profile is not None:
|
||||
name = (payload.orchestrator_profile or "").strip()
|
||||
if name and profiles_mod is not None:
|
||||
try:
|
||||
if not profiles_mod.profile_exists(name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"profile '{name}' does not exist",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass # fail open if the lookup itself errors
|
||||
kanban_section["orchestrator_profile"] = name
|
||||
|
||||
if payload.default_assignee is not None:
|
||||
name = (payload.default_assignee or "").strip()
|
||||
if name and profiles_mod is not None:
|
||||
try:
|
||||
if not profiles_mod.profile_exists(name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"profile '{name}' does not exist",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
kanban_section["default_assignee"] = name
|
||||
|
||||
if payload.auto_decompose is not None:
|
||||
kanban_section["auto_decompose"] = bool(payload.auto_decompose)
|
||||
|
||||
try:
|
||||
save_config(cfg)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to save config: {exc}")
|
||||
|
||||
# Echo back the resolved state (callers usually re-render from it).
|
||||
return get_orchestration_settings()
|
||||
|
||||
|
||||
@router.websocket("/events")
|
||||
async def stream_events(ws: WebSocket):
|
||||
# Enforce the dashboard session token as a query param — browsers can't
|
||||
|
|
|
|||
242
tests/hermes_cli/test_kanban_decompose.py
Normal file
242
tests/hermes_cli/test_kanban_decompose.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""Tests for the decomposer module + `hermes kanban decompose` CLI surface.
|
||||
|
||||
The auxiliary LLM client is mocked — no network calls. Tests exercise the
|
||||
prompt plumbing, response parsing, DB writes (via the real DB helper),
|
||||
and the assignee-fallback logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json as jsonlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import kanban as kanban_cli
|
||||
from hermes_cli import kanban_db as kb
|
||||
from hermes_cli import kanban_decompose as decomp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
def _fake_aux_response(content: str):
|
||||
resp = MagicMock()
|
||||
resp.choices = [MagicMock()]
|
||||
resp.choices[0].message.content = content
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_client_returning(content: str):
|
||||
client = MagicMock()
|
||||
client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))
|
||||
return client
|
||||
|
||||
|
||||
def _patch_aux_client(content: str, *, model: str = "test-model"):
|
||||
client = _mock_client_returning(content)
|
||||
return patch(
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(client, model),
|
||||
)
|
||||
|
||||
|
||||
def _patch_extra_body():
|
||||
return patch(
|
||||
"agent.auxiliary_client.get_auxiliary_extra_body",
|
||||
return_value={},
|
||||
)
|
||||
|
||||
|
||||
def _patch_list_profiles(names: list[str]):
|
||||
"""Pretend the named profiles exist. The decomposer uses
|
||||
profiles_mod.list_profiles() to build the roster + valid-set, and
|
||||
profiles_mod.profile_exists() to resolve orchestrator/default."""
|
||||
from types import SimpleNamespace
|
||||
fake_profiles = [
|
||||
SimpleNamespace(
|
||||
name=n, is_default=(i == 0), description=f"desc for {n}",
|
||||
description_auto=False, model="m", provider="p", skill_count=1,
|
||||
)
|
||||
for i, n in enumerate(names)
|
||||
]
|
||||
return [
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=fake_profiles),
|
||||
patch("hermes_cli.profiles.profile_exists", side_effect=lambda x: x in names),
|
||||
patch("hermes_cli.profiles.get_active_profile_name", return_value=names[0] if names else "default"),
|
||||
]
|
||||
|
||||
|
||||
def test_decompose_with_fanout_creates_children(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="ship a feature", triage=True)
|
||||
|
||||
llm_payload = jsonlib.dumps({
|
||||
"fanout": True,
|
||||
"rationale": "test split",
|
||||
"tasks": [
|
||||
{"title": "research", "body": "look it up", "assignee": "researcher", "parents": []},
|
||||
{"title": "build", "body": "code it", "assignee": "engineer", "parents": [0]},
|
||||
],
|
||||
})
|
||||
|
||||
patches = _patch_list_profiles(["orchestrator", "researcher", "engineer"])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
with _patch_aux_client(llm_payload), _patch_extra_body():
|
||||
outcome = decomp.decompose_task(tid, author="me")
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
assert outcome.ok, outcome.reason
|
||||
assert outcome.fanout is True
|
||||
assert outcome.child_ids and len(outcome.child_ids) == 2
|
||||
|
||||
with kb.connect() as conn:
|
||||
root = kb.get_task(conn, tid)
|
||||
c0 = kb.get_task(conn, outcome.child_ids[0])
|
||||
c1 = kb.get_task(conn, outcome.child_ids[1])
|
||||
assert root.status == "todo"
|
||||
assert c0.status == "ready"
|
||||
assert c1.status == "todo"
|
||||
assert c0.assignee == "researcher"
|
||||
assert c1.assignee == "engineer"
|
||||
|
||||
|
||||
def test_decompose_fanout_false_falls_back_to_specify(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="just one thing", triage=True)
|
||||
|
||||
llm_payload = jsonlib.dumps({
|
||||
"fanout": False,
|
||||
"rationale": "single unit",
|
||||
"title": "Tightened title",
|
||||
"body": "**Goal**\nDo the thing.",
|
||||
})
|
||||
|
||||
patches = _patch_list_profiles(["orchestrator"])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
with _patch_aux_client(llm_payload), _patch_extra_body():
|
||||
outcome = decomp.decompose_task(tid, author="me")
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
assert outcome.ok, outcome.reason
|
||||
assert outcome.fanout is False
|
||||
assert outcome.new_title == "Tightened title"
|
||||
with kb.connect() as conn:
|
||||
task = kb.get_task(conn, tid)
|
||||
# specify path with no parents -> recompute_ready flips to 'ready'
|
||||
assert task.status == "ready"
|
||||
assert task.title == "Tightened title"
|
||||
|
||||
|
||||
def test_decompose_unknown_assignee_falls_back_to_default(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="x", triage=True)
|
||||
|
||||
# Roster only has 'orchestrator' and 'fallback'; LLM picks 'made_up'.
|
||||
llm_payload = jsonlib.dumps({
|
||||
"fanout": True,
|
||||
"rationale": "test",
|
||||
"tasks": [
|
||||
{"title": "do X", "body": "", "assignee": "made_up", "parents": []},
|
||||
],
|
||||
})
|
||||
|
||||
patches = _patch_list_profiles(["orchestrator", "fallback"])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
with patch.dict(
|
||||
"os.environ", {}, clear=False,
|
||||
), _patch_aux_client(llm_payload), _patch_extra_body(), \
|
||||
patch(
|
||||
"hermes_cli.kanban_decompose._load_config",
|
||||
return_value={
|
||||
"kanban": {
|
||||
"orchestrator_profile": "orchestrator",
|
||||
"default_assignee": "fallback",
|
||||
}
|
||||
},
|
||||
):
|
||||
outcome = decomp.decompose_task(tid, author="me")
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
assert outcome.ok, outcome.reason
|
||||
assert outcome.child_ids and len(outcome.child_ids) == 1
|
||||
with kb.connect() as conn:
|
||||
child = kb.get_task(conn, outcome.child_ids[0])
|
||||
# 'made_up' wasn't in roster, so assignee rewritten to 'fallback'
|
||||
assert child.assignee == "fallback"
|
||||
|
||||
|
||||
def test_decompose_handles_malformed_llm_json(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="x", triage=True)
|
||||
|
||||
patches = _patch_list_profiles(["orchestrator"])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
with _patch_aux_client("not json at all, sorry"), _patch_extra_body():
|
||||
outcome = decomp.decompose_task(tid, author="me")
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
assert outcome.ok is False
|
||||
assert "malformed JSON" in outcome.reason
|
||||
|
||||
|
||||
def test_decompose_returns_false_when_task_not_triage(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="x") # ready, not triage
|
||||
|
||||
patches = _patch_list_profiles(["orchestrator"])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
outcome = decomp.decompose_task(tid, author="me")
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
assert outcome.ok is False
|
||||
assert "not in triage" in outcome.reason
|
||||
|
||||
|
||||
def test_decompose_no_aux_client_configured(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="x", triage=True)
|
||||
|
||||
patches = _patch_list_profiles(["orchestrator"])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
with patch(
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(None, ""),
|
||||
):
|
||||
outcome = decomp.decompose_task(tid, author="me")
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
assert outcome.ok is False
|
||||
assert "no auxiliary client" in outcome.reason
|
||||
152
tests/hermes_cli/test_kanban_decompose_db.py
Normal file
152
tests/hermes_cli/test_kanban_decompose_db.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Tests for kb.decompose_triage_task — the DB-layer atomic fan-out
|
||||
from the triage column. LLM-free by design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
def _create_triage(conn, title="rough idea", body=None, assignee=None, tenant=None):
|
||||
return kb.create_task(
|
||||
conn,
|
||||
title=title,
|
||||
body=body,
|
||||
assignee=assignee,
|
||||
tenant=tenant,
|
||||
triage=True,
|
||||
)
|
||||
|
||||
|
||||
def test_decompose_creates_children_and_promotes_root(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = _create_triage(conn, title="ship a feature")
|
||||
assert kb.get_task(conn, tid).status == "triage"
|
||||
|
||||
children = [
|
||||
{"title": "research", "body": "look at prior art", "assignee": "researcher", "parents": []},
|
||||
{"title": "build it", "body": "write code", "assignee": "engineer", "parents": [0]},
|
||||
]
|
||||
with kb.connect() as conn:
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn,
|
||||
tid,
|
||||
root_assignee="orchestrator",
|
||||
children=children,
|
||||
author="decomposer",
|
||||
)
|
||||
assert child_ids is not None
|
||||
assert len(child_ids) == 2
|
||||
|
||||
with kb.connect() as conn:
|
||||
root = kb.get_task(conn, tid)
|
||||
c0 = kb.get_task(conn, child_ids[0])
|
||||
c1 = kb.get_task(conn, child_ids[1])
|
||||
|
||||
# Root flipped to todo with orchestrator assignee, gated by children.
|
||||
assert root.status == "todo"
|
||||
assert root.assignee == "orchestrator"
|
||||
# First child has no internal parents → ready on recompute_ready.
|
||||
assert c0.status == "ready"
|
||||
assert c0.assignee == "researcher"
|
||||
# Second child has parents=[0] → stays in todo until c0 completes.
|
||||
assert c1.status == "todo"
|
||||
assert c1.assignee == "engineer"
|
||||
|
||||
|
||||
def test_decompose_returns_none_when_task_missing(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
result = kb.decompose_triage_task(
|
||||
conn,
|
||||
"nonexistent",
|
||||
root_assignee="orch",
|
||||
children=[{"title": "x"}],
|
||||
author="me",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decompose_returns_none_when_task_not_in_triage(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="already a real task") # not triage
|
||||
result = kb.decompose_triage_task(
|
||||
conn,
|
||||
tid,
|
||||
root_assignee="orch",
|
||||
children=[{"title": "x"}],
|
||||
author="me",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decompose_empty_children_returns_none(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = _create_triage(conn)
|
||||
result = kb.decompose_triage_task(
|
||||
conn,
|
||||
tid,
|
||||
root_assignee="orch",
|
||||
children=[],
|
||||
author="me",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decompose_rejects_self_parent(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = _create_triage(conn)
|
||||
with pytest.raises(ValueError, match="cannot list itself"):
|
||||
kb.decompose_triage_task(
|
||||
conn,
|
||||
tid,
|
||||
root_assignee="orch",
|
||||
children=[{"title": "x", "parents": [0]}],
|
||||
author="me",
|
||||
)
|
||||
|
||||
|
||||
def test_decompose_rejects_out_of_range_parent(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = _create_triage(conn)
|
||||
with pytest.raises(ValueError, match="not a valid index"):
|
||||
kb.decompose_triage_task(
|
||||
conn,
|
||||
tid,
|
||||
root_assignee="orch",
|
||||
children=[{"title": "x", "parents": [5]}],
|
||||
author="me",
|
||||
)
|
||||
|
||||
|
||||
def test_decompose_records_audit_comment_and_event(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = _create_triage(conn)
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn,
|
||||
tid,
|
||||
root_assignee="orch",
|
||||
children=[{"title": "task A", "assignee": "researcher"}],
|
||||
author="alice",
|
||||
)
|
||||
assert child_ids is not None
|
||||
|
||||
with kb.connect() as conn:
|
||||
comments = kb.list_comments(conn, tid)
|
||||
events = kb.list_events(conn, tid)
|
||||
|
||||
assert any("Decomposed into" in (c.body or "") for c in comments)
|
||||
assert any(ev.kind == "decomposed" for ev in events)
|
||||
168
tests/hermes_cli/test_profile_describer.py
Normal file
168
tests/hermes_cli/test_profile_describer.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Tests for the profile.yaml metadata layer (description + description_auto)
|
||||
and the profile_describer LLM module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as jsonlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
from hermes_cli import profile_describer as describer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
"""Set up an isolated HERMES_HOME with a default profile dir."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
return home
|
||||
|
||||
|
||||
def test_read_profile_meta_empty_when_missing(profile_env):
|
||||
meta = profiles_mod.read_profile_meta(profile_env)
|
||||
assert meta == {"description": "", "description_auto": False}
|
||||
|
||||
|
||||
def test_write_and_read_profile_meta(profile_env):
|
||||
profiles_mod.write_profile_meta(
|
||||
profile_env,
|
||||
description="a useful researcher",
|
||||
description_auto=False,
|
||||
)
|
||||
meta = profiles_mod.read_profile_meta(profile_env)
|
||||
assert meta["description"] == "a useful researcher"
|
||||
assert meta["description_auto"] is False
|
||||
|
||||
|
||||
def test_write_profile_meta_preserves_other_fields(profile_env):
|
||||
# First write sets description_auto=True; second write only updates
|
||||
# description and leaves description_auto unchanged.
|
||||
profiles_mod.write_profile_meta(
|
||||
profile_env,
|
||||
description="auto-gen",
|
||||
description_auto=True,
|
||||
)
|
||||
profiles_mod.write_profile_meta(profile_env, description="edited by hand")
|
||||
meta = profiles_mod.read_profile_meta(profile_env)
|
||||
assert meta["description"] == "edited by hand"
|
||||
assert meta["description_auto"] is True
|
||||
|
||||
|
||||
def test_write_profile_meta_rejects_missing_dir(tmp_path):
|
||||
bogus = tmp_path / "does_not_exist"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
profiles_mod.write_profile_meta(bogus, description="x")
|
||||
|
||||
|
||||
def test_read_profile_meta_tolerates_corrupt_yaml(profile_env):
|
||||
(profile_env / "profile.yaml").write_text("not: valid: yaml: [unclosed")
|
||||
meta = profiles_mod.read_profile_meta(profile_env)
|
||||
assert meta == {"description": "", "description_auto": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# profile_describer module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_aux_response(content: str):
|
||||
resp = MagicMock()
|
||||
resp.choices = [MagicMock()]
|
||||
resp.choices[0].message.content = content
|
||||
return resp
|
||||
|
||||
|
||||
def _patch_aux_client(content: str):
|
||||
client = MagicMock()
|
||||
client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))
|
||||
return patch(
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(client, "test-model"),
|
||||
)
|
||||
|
||||
|
||||
def test_describer_writes_description_with_auto_true(profile_env, monkeypatch):
|
||||
# Pretend "myprof" is a registered profile pointing at profile_env.
|
||||
monkeypatch.setattr(
|
||||
profiles_mod, "profile_exists", lambda n: n == "myprof",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
profiles_mod, "normalize_profile_name", lambda n: n,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
profiles_mod, "get_profile_dir", lambda n: profile_env,
|
||||
)
|
||||
|
||||
payload = jsonlib.dumps({"description": "writes Python codebases"})
|
||||
with _patch_aux_client(payload), patch(
|
||||
"agent.auxiliary_client.get_auxiliary_extra_body", return_value={}
|
||||
):
|
||||
outcome = describer.describe_profile("myprof")
|
||||
|
||||
assert outcome.ok, outcome.reason
|
||||
assert outcome.description == "writes Python codebases"
|
||||
meta = profiles_mod.read_profile_meta(profile_env)
|
||||
assert meta["description"] == "writes Python codebases"
|
||||
assert meta["description_auto"] is True
|
||||
|
||||
|
||||
def test_describer_refuses_to_overwrite_user_authored(profile_env, monkeypatch):
|
||||
profiles_mod.write_profile_meta(
|
||||
profile_env, description="curated", description_auto=False,
|
||||
)
|
||||
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof")
|
||||
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
|
||||
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env)
|
||||
|
||||
outcome = describer.describe_profile("myprof")
|
||||
assert outcome.ok is False
|
||||
assert "already has a user-authored description" in outcome.reason
|
||||
# Description unchanged
|
||||
assert profiles_mod.read_profile_meta(profile_env)["description"] == "curated"
|
||||
|
||||
|
||||
def test_describer_overwrite_flag_replaces_user_authored(profile_env, monkeypatch):
|
||||
profiles_mod.write_profile_meta(
|
||||
profile_env, description="curated", description_auto=False,
|
||||
)
|
||||
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof")
|
||||
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
|
||||
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env)
|
||||
|
||||
payload = jsonlib.dumps({"description": "new auto-gen"})
|
||||
with _patch_aux_client(payload), patch(
|
||||
"agent.auxiliary_client.get_auxiliary_extra_body", return_value={}
|
||||
):
|
||||
outcome = describer.describe_profile("myprof", overwrite=True)
|
||||
assert outcome.ok, outcome.reason
|
||||
meta = profiles_mod.read_profile_meta(profile_env)
|
||||
assert meta["description"] == "new auto-gen"
|
||||
assert meta["description_auto"] is True
|
||||
|
||||
|
||||
def test_describer_handles_malformed_llm_response(profile_env, monkeypatch):
|
||||
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof")
|
||||
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
|
||||
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env)
|
||||
|
||||
# Non-JSON: describer falls back to taking the first paragraph as the description.
|
||||
with _patch_aux_client("Plain text description that sneaks in"), patch(
|
||||
"agent.auxiliary_client.get_auxiliary_extra_body", return_value={}
|
||||
):
|
||||
outcome = describer.describe_profile("myprof")
|
||||
assert outcome.ok
|
||||
assert "Plain text description" in (outcome.description or "")
|
||||
|
||||
|
||||
def test_describer_returns_false_when_profile_missing(profile_env, monkeypatch):
|
||||
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: False)
|
||||
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
|
||||
outcome = describer.describe_profile("ghost")
|
||||
assert outcome.ok is False
|
||||
assert "not found" in outcome.reason
|
||||
|
|
@ -411,6 +411,7 @@ Multi-profile, multi-project collaboration board. Each install can host many boa
|
|||
| `dispatch` | One dispatcher pass on the active board. Flags: `--dry-run`, `--max N`, `--json`. |
|
||||
| `context <id>` | Print the full context a worker would see (title + body + parent results + comments). |
|
||||
| `specify <id>` / `specify --all` | Flesh out a triage-column task into a concrete spec (title + body with goal, approach, acceptance criteria) via the auxiliary LLM, then promote it to `todo`. Flags: `--tenant` (scope `--all` to one tenant), `--author`, `--json`. Configure the model under `auxiliary.triage_specifier` in `config.yaml`. |
|
||||
| `decompose <id>` / `decompose --all` | Fan a triage-column task out into a graph of child tasks routed to specialist profiles by description (the orchestrator-driven path). Falls back to specify-style single-task promotion when the LLM decides the task doesn't benefit from fan-out. Same flags as `specify`. Configure the model under `auxiliary.kanban_decomposer` in `config.yaml`. Also runs automatically every dispatcher tick when `kanban.auto_decompose: true` (the default). See [Auto vs Manual orchestration](/docs/user-guide/features/kanban#auto-vs-manual-orchestration). |
|
||||
| `gc` | Remove scratch workspaces for archived tasks. |
|
||||
|
||||
Examples:
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ Creates a new profile.
|
|||
| `--clone-all` | Copy everything (config, memories, skills, sessions, state) from the current profile. |
|
||||
| `--clone-from <profile>` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. |
|
||||
| `--no-alias` | Skip wrapper script creation. |
|
||||
| `--description "<text>"` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `<profile_dir>/profile.yaml`. |
|
||||
|
||||
Creating a profile does **not** make that profile directory the default project/workspace directory for terminal commands. If you want a profile to start in a specific project, set `terminal.cwd` in that profile's `config.yaml`.
|
||||
|
||||
|
|
@ -102,6 +103,40 @@ hermes profile create backup --clone-all
|
|||
hermes profile create work2 --clone --clone-from work
|
||||
```
|
||||
|
||||
## `hermes profile describe`
|
||||
|
||||
```bash
|
||||
hermes profile describe [<name>] [options]
|
||||
```
|
||||
|
||||
Read or set a profile's description. The description is consumed by the kanban orchestrator to route tasks based on what each profile is good at, rather than guessing from the profile name alone. Persisted in `<profile_dir>/profile.yaml` so it survives reboots and is shared with the gateway.
|
||||
|
||||
With no flags, prints the current description (or `(no description set for '<name>')` if empty).
|
||||
|
||||
| Argument / Option | Description |
|
||||
|-------------------|-------------|
|
||||
| `<name>` | Profile to describe. Required unless `--all --auto` is used. |
|
||||
| `--text "<text>"` | Set the description to this exact text (user-authored). Overwrites any existing description. |
|
||||
| `--auto` | Auto-generate a 1-2 sentence description via the auxiliary LLM, based on the profile's installed skills, configured model, and name. Configure the model under `auxiliary.profile_describer` in `config.yaml`. Auto-generated descriptions are marked `description_auto: true` so the dashboard can flag them for review. |
|
||||
| `--overwrite` | With `--auto`, replace user-authored descriptions too (default: skip profiles whose description was set explicitly). |
|
||||
| `--all` | With `--auto`, sweep every profile missing a description. |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Read the current description
|
||||
hermes profile describe researcher
|
||||
|
||||
# Set it explicitly
|
||||
hermes profile describe researcher --text "Reads source code and writes findings."
|
||||
|
||||
# Let the LLM generate one
|
||||
hermes profile describe researcher --auto
|
||||
|
||||
# Fill in descriptions for every profile that doesn't have one
|
||||
hermes profile describe --all --auto
|
||||
```
|
||||
|
||||
## `hermes profile delete`
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -785,6 +785,8 @@ $ hermes model
|
|||
[ ] compression currently: auto / main model
|
||||
[ ] approval currently: auto / main model
|
||||
[ ] triage_specifier currently: auto / main model
|
||||
[ ] kanban_decomposer currently: auto / main model
|
||||
[ ] profile_describer currently: auto / main model
|
||||
```
|
||||
|
||||
Select a task, pick a provider (OAuth flows open a browser; API-key providers prompt), pick a model. The change persists to `auxiliary.<task>.*` in `config.yaml`. Same machinery as the main-model picker — no extra syntax to learn.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Throughout the tutorial, **code blocks labelled `bash` are commands *you* run.**
|
|||
|
||||
Six columns, left to right:
|
||||
|
||||
- **Triage** — raw ideas, a specifier will flesh out the spec before anyone works on them. Click the **✨ Specify** button on any triage card (or run `hermes kanban specify <id>` / `/kanban specify <id>` from a chat) to have the auxiliary LLM turn a one-liner into a full spec (goal, approach, acceptance criteria) and promote it to `todo` in one shot. Configure which model runs it under `auxiliary.triage_specifier` in `config.yaml`.
|
||||
- **Triage** — raw ideas. By default the dispatcher auto-runs the **decomposer** (orchestrator-driven fan-out) on tasks here: it reads your profile roster + descriptions and produces a graph of child tasks routed to the best-fit specialists, with the original task held alive as the parent so the orchestrator wakes back up to judge completion when everything finishes. Flip the **Orchestration: Auto/Manual** pill at the top of the kanban page to switch modes. In Manual mode (or for setups without an orchestrator profile) click **⚗ Decompose** on a card, or run `hermes kanban decompose <id>` / `/kanban decompose <id>`. For single tasks that don't need fan-out, **✨ Specify** does a one-shot spec rewrite (goal, approach, acceptance criteria) and promotes to `todo`. Configure the models under `auxiliary.kanban_decomposer` and `auxiliary.triage_specifier` in `config.yaml`. See [Auto vs Manual orchestration](./kanban#auto-vs-manual-orchestration) in the main Kanban guide.
|
||||
- **Todo** — created but waiting on dependencies, or not yet assigned.
|
||||
- **Ready** — assigned and waiting for the dispatcher to claim.
|
||||
- **In progress** — a worker is actively running the task. With "Lanes by profile" on (the default), this column sub-groups by assignee so you can see at a glance what each worker is doing.
|
||||
|
|
|
|||
|
|
@ -444,7 +444,7 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills"
|
|||
### What the plugin gives you
|
||||
|
||||
- A **Kanban** tab showing one column per status: `triage`, `todo`, `ready`, `running`, `blocked`, `done` (plus `archived` when the toggle is on).
|
||||
- `triage` is the parking column for rough ideas a specifier is expected to flesh out. Tasks created with `hermes kanban create --triage` (or via the Triage column's inline create) land here and the dispatcher leaves them alone until a human or specifier promotes them to `todo` / `ready`. Run `hermes kanban specify <id>` to have the auxiliary LLM expand a triage task into a concrete spec (title + body with goal, approach, acceptance criteria) and promote it to `todo` in one shot; `--all` sweeps every triage task at once. Configure which model runs the specifier under `auxiliary.triage_specifier` in `config.yaml`.
|
||||
- `triage` is the parking column for rough ideas. By default (`kanban.auto_decompose: true`), the dispatcher auto-runs the **decomposer** on tasks that land here — the orchestrator profile reads the rough idea, looks at your profile roster (with descriptions), and fans the task out into a small graph of child tasks routed to the best-fit specialists. The original task stays alive as the parent of every child so the orchestrator wakes back up to judge completion when everything finishes. Flip the **Orchestration: Auto/Manual** pill at the top of the page (or set `kanban.auto_decompose: false`) to switch to manual mode, where triage tasks stay put until you click **⚗ Decompose** on a card or run `hermes kanban decompose <id>`. For tasks that don't need fan-out (or for setups without an orchestrator profile), the **✨ Specify** button does a single-task spec rewrite (title + body with goal, approach, acceptance criteria) via the same LLM machinery. See [Auto vs Manual orchestration](#auto-vs-manual-orchestration) below.
|
||||
- Cards show the task id, title, priority badge, tenant tag, assigned profile, comment/link counts, a **progress pill** (`N/M` children done when the task has dependents), and "created N ago". A per-card checkbox enables multi-select.
|
||||
- **Per-profile lanes inside Running** — toolbar checkbox toggles sub-grouping of the Running column by assignee.
|
||||
- **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. Reloads are debounced so a burst of events triggers a single refetch.
|
||||
|
|
@ -456,12 +456,40 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills"
|
|||
- **Editable assignee / priority** — click the meta row to rewrite.
|
||||
- **Editable description** — markdown-rendered by default (headings, bold, italic, inline code, fenced code, `http(s)` / `mailto:` links, bullet lists), with an "edit" button that swaps in a textarea. Markdown rendering is a tiny, XSS-safe renderer — every substitution runs on HTML-escaped input, only `http(s)` / `mailto:` links pass through, and `target="_blank"` + `rel="noopener noreferrer"` are always set.
|
||||
- **Dependency editor** — chip list of parents and children, each with an `×` to unlink, plus dropdowns over every other task to add a new parent or child. Cycle attempts are rejected server-side with a clear message.
|
||||
- **Status action row** (→ triage / → ready / → running / block / unblock / complete / archive) with confirm prompts for destructive transitions. For cards in the **Triage** column the row also exposes a **✨ Specify** button that calls the auxiliary LLM (`auxiliary.triage_specifier` in `config.yaml`) to expand the one-liner into a concrete spec (title + body with goal, approach, acceptance criteria) and promote the task to `todo`. The same behaviour is reachable from the CLI (`hermes kanban specify <id>` / `--all`), from any gateway platform (`/kanban specify <id>`), and programmatically via `POST /api/plugins/kanban/tasks/:id/specify`.
|
||||
- **Status action row** (→ triage / → ready / → running / block / unblock / complete / archive) with confirm prompts for destructive transitions. For cards in the **Triage** column the row also exposes two LLM-driven actions: **⚗ Decompose** fans the task out into a graph of child tasks routed to specialist profiles by description (the orchestrator-driven path), and **✨ Specify** does a single-task spec rewrite. Decompose falls back to specify-style promotion when the LLM decides the task doesn't benefit from fan-out, so it's a strict superset. Both are reachable from the CLI (`hermes kanban decompose <id>` / `specify <id>` / `--all`), from any gateway platform (`/kanban decompose <id>`), and programmatically via `POST /api/plugins/kanban/tasks/:id/decompose` and `…/specify`. Configure the models under `auxiliary.kanban_decomposer` and `auxiliary.triage_specifier` in `config.yaml`.
|
||||
- Result section (also markdown-rendered), comment thread with Enter-to-submit, the last 20 events.
|
||||
- **Toolbar filters** — free-text search, tenant dropdown (defaults to `dashboard.kanban.default_tenant` from `config.yaml`), assignee dropdown, "show archived" toggle, "lanes by profile" toggle, and a **Nudge dispatcher** button so you don't have to wait for the next 60 s tick.
|
||||
|
||||
Visually the target is the familiar Linear / Fusion layout: dark theme, column headers with counts, coloured status dots, pill chips for priority and tenant. The plugin reads only theme CSS vars (`--color-*`, `--radius`, `--font-mono`, ...), so it reskins automatically with whichever dashboard theme is active.
|
||||
|
||||
### Auto vs Manual orchestration
|
||||
|
||||
The kanban board has two ways to handle a task you drop into the Triage column:
|
||||
|
||||
**Auto (default)** — `kanban.auto_decompose: true`. The gateway-embedded dispatcher runs the **decomposer** on each tick, capped by `kanban.auto_decompose_per_tick` (default 3 tasks per tick) so a bulk-load of triage tasks doesn't burst-spend the auxiliary LLM. The decomposer reads the rough idea, looks at your installed profiles + their descriptions, and asks the LLM to produce a JSON task graph: which tasks to spawn, who they go to, and which depend on which. The original triage task becomes the parent of every leaf in the graph, so it stays alive until the whole graph completes — and then promotes back to `ready` so its assignee (the orchestrator profile) can judge completion and add more tasks if the work isn't done. This is the "drop a one-liner, walk away" flow.
|
||||
|
||||
**Manual** — `kanban.auto_decompose: false`. Triage tasks stay in triage until you act. Click the **⚗ Decompose** button on a card, run `hermes kanban decompose <id>` (or `--all`), or use `/kanban decompose <id>` from a chat. This matches the pre-decomposer behavior of the board, useful when you want full control over what runs when.
|
||||
|
||||
Flip between the two modes from the **Orchestration: Auto/Manual** pill at the top of the kanban page (emerald = Auto, muted gray = Manual), or by editing `config.yaml` directly. Both modes coexist with `hermes kanban specify` — that's still available as a single-task spec rewrite when you don't want fan-out.
|
||||
|
||||
The decomposer's routing decisions depend on profile descriptions, which is a per-profile labeling primitive you set with `hermes profile create --description "..."`, `hermes profile describe <name> --text "..."`, `hermes profile describe <name> --auto` (LLM-generates from the profile's installed skills + model), or the dashboard's per-profile editor in the expanded **Orchestration settings** panel. Profiles without a description still appear in the roster — they're routable by name, just less precisely. The decomposer NEVER lands a child task with `assignee=None`: when the LLM picks an unknown profile, the child gets routed to `kanban.default_assignee` (or the active default profile if that's unset).
|
||||
|
||||
Config knobs (all under `kanban:` in `~/.hermes/config.yaml`):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `auto_decompose` | `true` | Dispatcher auto-runs the decomposer every tick. |
|
||||
| `auto_decompose_per_tick` | `3` | Cap on decompositions per dispatcher tick. Excess defers to the next tick. |
|
||||
| `orchestrator_profile` | `""` | Profile that owns decomposition. Empty = fall back to active default profile. |
|
||||
| `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. |
|
||||
|
||||
And the two auxiliary LLM slots:
|
||||
|
||||
| Key | Purpose |
|
||||
|---|---|
|
||||
| `auxiliary.kanban_decomposer` | Model that produces the task graph (called by Decompose). Set `provider`/`model` to override the main chat model. |
|
||||
| `auxiliary.profile_describer` | Model that auto-generates profile descriptions (called by `hermes profile describe --auto`). |
|
||||
|
||||
### Architecture
|
||||
|
||||
The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer with no domain logic of its own:
|
||||
|
|
@ -499,6 +527,12 @@ All routes are mounted under `/api/plugins/kanban/` and protected by the dashboa
|
|||
| `POST` | `/tasks/bulk` | Apply the same patch (status / archive / assignee / priority) to every id in `ids`. Per-id failures reported without aborting siblings |
|
||||
| `POST` | `/tasks/:id/comments` | Append a comment |
|
||||
| `POST` | `/tasks/:id/specify` | Run the triage specifier — auxiliary LLM fleshes out the task body and promotes it from `triage` to `todo`. Returns `{ok, task_id, reason, new_title}`; `ok=false` with a human-readable reason on "not in triage" / no aux client / LLM error is a 200, not a 4xx |
|
||||
| `POST` | `/tasks/:id/decompose` | Run the kanban decomposer — auxiliary LLM produces a task graph and the helper atomically creates the children + links the root + flips `triage → todo`. Returns `{ok, task_id, reason, fanout, child_ids, new_title}`. Same 200-on-LLM-error convention as `/specify`. |
|
||||
| `GET` | `/profiles` | List installed profiles with their descriptions (consumed by the dashboard's profile-description editor and the orchestrator picker). |
|
||||
| `PATCH` | `/profiles/:name` | Set or clear a profile's description (user-authored — `description_auto: false`). Returns `{ok, profile, description}`. |
|
||||
| `POST` | `/profiles/:name/describe-auto` | Generate a description for a profile via `auxiliary.profile_describer`. Persists with `description_auto: true` so the dashboard can surface a "review" badge. |
|
||||
| `GET` | `/orchestration` | Read the kanban orchestration settings (`orchestrator_profile`, `default_assignee`, `auto_decompose`) plus the *resolved* effective values after fallbacks. |
|
||||
| `PUT` | `/orchestration` | Update one or more of the three orchestration keys in `config.yaml`. Validates that non-empty profile names actually exist. |
|
||||
| `POST` | `/links` | Add a dependency (`parent_id` → `child_id`) |
|
||||
| `DELETE` | `/links?parent_id=…&child_id=…` | Remove a dependency |
|
||||
| `POST` | `/dispatch?max=…&dry_run=…` | Nudge the dispatcher — skip the 60 s wait |
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ hermes profile create mybot
|
|||
|
||||
Creates a fresh profile with bundled skills seeded. Run `mybot setup` to configure API keys, model, and gateway tokens.
|
||||
|
||||
If you plan to use this profile as a kanban worker (or want the kanban orchestrator to route work to it), pass `--description "<role>"` at create time so the orchestrator knows what it's good at:
|
||||
|
||||
```bash
|
||||
hermes profile create researcher --description "Reads source code and external docs, writes findings."
|
||||
```
|
||||
|
||||
You can also set or auto-generate the description later with `hermes profile describe` — see the [Kanban guide](./features/kanban#auto-vs-manual-orchestration) for the full routing model.
|
||||
|
||||
### Clone config only (`--clone`)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue