Files
hermes-agent/tools/kanban_tools.py

1370 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Kanban tools — structured tool-call surface for worker + orchestrator agents.
Registered into the model's schema only when running under the dispatcher
(``HERMES_KANBAN_TASK`` set) or when the active profile enables the ``kanban``
toolset; a plain ``hermes chat`` session sees zero kanban tools.
Why tools rather than shelling out to ``hermes kanban``: tools run in the
agent's Python process, so they reach ``~/.hermes/kanban.db`` even when the
terminal backend is a container/SSH host without ``hermes`` installed; they
avoid shlex/argparse quoting of JSON metadata; and failures come back as
structured JSON the model can reason about. Humans keep using the CLI,
dashboard, and ``/kanban`` slash command, which bypass the agent entirely.
"""
from __future__ import annotations
import functools
import json
import logging
import os
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Callable, Optional
from agent.redact import redact_sensitive_text
from hermes_cli.goals import judge_goal
from tools.registry import registry, tool_error
from hermes_cli.config import cfg_get, load_config
from tools.kanban_tools_schemas import ( # noqa: F401 - re-exported for callers/tests
_DESC_BOARD,
_DESC_TASK_ID_DEFAULT,
_board_schema_prop,
KANBAN_ATTACH_SCHEMA,
KANBAN_ATTACH_URL_SCHEMA,
KANBAN_ATTACHMENTS_SCHEMA,
KANBAN_BLOCK_SCHEMA,
KANBAN_COMMENT_SCHEMA,
KANBAN_COMPLETE_SCHEMA,
KANBAN_CREATE_SCHEMA,
KANBAN_HEARTBEAT_SCHEMA,
KANBAN_LINK_SCHEMA,
KANBAN_LIST_SCHEMA,
KANBAN_REQUEST_CHANGES_SCHEMA,
KANBAN_REQUEST_REVIEW_SCHEMA,
KANBAN_SHOW_SCHEMA,
KANBAN_UNBLOCK_SCHEMA,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Gating
# ---------------------------------------------------------------------------
KANBAN_LIST_DEFAULT_LIMIT = 50
KANBAN_LIST_MAX_LIMIT = 200
def _profile_has_kanban_toolset() -> bool:
# load_config() is mtime-cached and check_fn results are TTL-cached (~30s)
# by the registry, so this is cheap.
try:
from hermes_cli.config import load_config
cfg = load_config()
return "kanban" in cfg.get("toolsets", [])
except Exception:
return False
def _is_delegated_child_context() -> bool:
try:
from agent.delegation_context import is_delegated_child_context
return is_delegated_child_context()
except Exception:
return False
def _is_dispatcher_owned_worker() -> bool:
"""False for delegate_task children AND for cron jobs fired in-process from
a worker — i.e. whenever HERMES_KANBAN_* is present but not ours."""
try:
from agent.delegation_context import is_dispatcher_owned_worker_context
return is_dispatcher_owned_worker_context()
except Exception:
return True
def _is_env_worker() -> bool:
"""True only for a dispatcher-spawned worker scoped to HERMES_KANBAN_TASK."""
return bool(os.environ.get("HERMES_KANBAN_TASK")) and _is_dispatcher_owned_worker()
def _reject_delegated_child_mutation(tool_name: str) -> Optional[str]:
"""Deny Kanban mutations from delegate_task children.
A child runs in the same process as its parent, so inherited HERMES_KANBAN_*
env vars are not proof of dispatcher ownership. It may report findings to
the parent but must not mutate board state directly.
"""
if not _is_delegated_child_context():
return None
return tool_error(
f"{tool_name} refused: delegate_task child agents are not Kanban "
"run owners. Return findings to the parent agent; the dispatcher "
"worker or an explicitly configured Kanban orchestrator must perform "
"board mutations."
)
def _check_kanban_mode() -> bool:
"""Lifecycle tools: visible to dispatcher-spawned workers and to profiles
that enable the ``kanban`` toolset (orchestrators); never to delegate children."""
if _is_delegated_child_context():
return False
if _is_env_worker():
return True
return _profile_has_kanban_toolset()
def _check_kanban_orchestrator_mode() -> bool:
"""Board-routing tools (kanban_list, kanban_unblock): hidden from task workers.
Workers close their own task via complete/block/heartbeat; only profiles
that opt into the toolset and are NOT scoped to a single task route work.
"""
if _is_delegated_child_context():
return False
if _is_env_worker():
return False
return _profile_has_kanban_toolset()
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_TASK_ID_REQUIRED = "task_id is required (or set HERMES_KANBAN_TASK in the env)"
def _default_task_id(arg: Optional[str]) -> Optional[str]:
"""Resolve ``task_id`` arg or fall back to the env var the dispatcher set.
A delegate child or a cron job fired in-process from a worker must never
inherit the worker's task id as an implicit default.
"""
if arg:
return arg
if _is_delegated_child_context() or not _is_dispatcher_owned_worker():
return None
return os.environ.get("HERMES_KANBAN_TASK") or None
def _worker_run_id(task_id: str) -> Optional[int]:
"""Return this worker's dispatcher run id when it is scoped to task_id."""
if os.environ.get("HERMES_KANBAN_TASK") != task_id:
return None
raw = os.environ.get("HERMES_KANBAN_RUN_ID")
try:
return int(raw) if raw else None
except ValueError:
return None
def _stamp_worker_session_metadata(task_id: str, metadata: Optional[dict]) -> Optional[dict]:
"""Add trusted worker session id metadata for this worker's own task."""
session_id = os.environ.get("HERMES_SESSION_ID")
if os.environ.get("HERMES_KANBAN_TASK") != task_id or not session_id:
return metadata
return {**(metadata or {}), "worker_session_id": session_id}
def _enforce_worker_task_ownership(tid: str) -> Optional[str]:
"""Reject worker-driven destructive calls on foreign task IDs.
A dispatcher-spawned worker has ``HERMES_KANBAN_TASK`` set to its own task;
a buggy or prompt-injected explicit ``task_id`` must not corrupt sibling or
cross-tenant runs. Orchestrators (toolset enabled, no env task) are exempt:
routing legitimately closes or reopens child tasks.
"""
env_tid = os.environ.get("HERMES_KANBAN_TASK")
if env_tid and tid != env_tid:
return tool_error(
f"worker is scoped to task {env_tid}; refusing to mutate "
f"{tid}. Use kanban_comment to hand off information to other "
f"tasks, or kanban_create to spawn follow-up work."
)
return None
def _worker_guard(tool_name: str, args: dict) -> tuple[str, Optional[str]]:
"""Common preamble for worker mutation tools: ``(task_id, error)``.
Order matters: delegate-child rejection, then task id resolution, then
task-scope ownership. ``task_id`` is only meaningful when ``error`` is None.
"""
err = _reject_delegated_child_mutation(tool_name)
if err:
return "", err
tid = _default_task_id(args.get("task_id"))
if not tid:
return "", tool_error(_TASK_ID_REQUIRED)
return tid, _enforce_worker_task_ownership(tid)
def _connect(board: Optional[str] = None):
"""Import + connect lazily so the module imports cleanly in non-kanban contexts.
``board=None`` keeps the legacy resolution chain (``HERMES_KANBAN_DB`` →
``HERMES_KANBAN_BOARD`` → current symlink → ``default``); an explicit slug
lets e.g. a Telegram-side agent override the env-pinned board per call.
"""
from hermes_cli import kanban_db as kb
return kb, kb.connect(board=board)
@contextmanager
def _board(board: Optional[str]):
"""``with _board(slug) as (kb, conn)`` — connection closed on exit."""
kb, conn = _connect(board=board)
try:
yield kb, conn
finally:
conn.close()
def _close_quietly(conn) -> None:
try:
conn.close()
except Exception:
pass
def _kanban_handler(tool_name: str) -> Callable:
"""Wrap a handler so every failure is a structured tool error.
``ValueError`` (invalid board slug, DB validation such as cycle/self-link,
``AttachmentTooLarge``) is reported without a traceback; anything else is
logged with ``logger.exception``.
"""
def deco(fn):
@functools.wraps(fn)
def wrapper(args: dict, **kw) -> str:
try:
return fn(args, **kw)
except ValueError as e:
return tool_error(f"{tool_name}: {e}")
except Exception as e:
logger.exception(f"{tool_name} failed")
return tool_error(f"{tool_name}: {e}")
return wrapper
return deco
def _ok(**fields: Any) -> str:
return json.dumps({"ok": True, **fields})
def _redact(value: Any) -> str:
return redact_sensitive_text(str(value), force=True)
def _redact_metadata(metadata: dict) -> Optional[dict]:
"""Redact a metadata dict via a JSON round-trip; None if it can't be re-parsed."""
try:
return json.loads(redact_sensitive_text(json.dumps(metadata), force=True))
except json.JSONDecodeError:
return None
def _coerce_str_list(
value: Any, name: str, what: str, *, strip: bool = False
) -> tuple[Any, Optional[str]]:
"""Accept a single string (convenience) or a list/tuple; ``(value, error)``.
With ``strip`` the items are stringified, stripped, and empties dropped.
"""
if value is None:
return None, None
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
return None, tool_error(
f"{name} must be a list of {what}, got {type(value).__name__}"
)
if strip:
value = [str(x).strip() for x in value if str(x).strip()]
return value, None
def _metadata_type_error(metadata: Any) -> str:
return tool_error(f"metadata must be an object/dict, got {type(metadata).__name__}")
def _merge_artifacts(metadata: Any, artifacts: list[str]) -> tuple[Any, Optional[str]]:
"""Fold ``artifacts`` into ``metadata["artifacts"]``; ``(metadata, error)``.
Artifacts ride inside metadata so the completed-event payload needs no DB
schema change; the gateway notifier reads payload['artifacts'] and uploads
each path as a native attachment. Merged with (never overwriting) a
metadata.artifacts the worker passed manually.
"""
if metadata is None:
metadata = {}
elif not isinstance(metadata, dict):
return metadata, _metadata_type_error(metadata)
existing = metadata.get("artifacts")
if isinstance(existing, (list, tuple)):
merged = (str(item).strip() for item in [*existing, *artifacts])
metadata["artifacts"] = list(dict.fromkeys(s for s in merged if s))
else:
metadata["artifacts"] = artifacts
return metadata, None
def _require_text(args: dict, name: str, message: Optional[str] = None) -> tuple[Any, Optional[str]]:
"""``(raw_value, error)``: error when ``args[name]`` is missing or blank."""
value = args.get(name)
if not value or not str(value).strip():
return None, tool_error(message or f"{name} is required")
return value, None
def _parse_bool_arg(args: dict, name: str, *, default: bool = False):
value = args.get(name)
if value is None:
return default, None
if isinstance(value, bool):
return value, None
text = str(value).strip().lower()
if text in {"true", "1", "yes"}:
return True, None
if text in {"false", "0", "no"}:
return False, None
return default, f"{name} must be a boolean or 'true'/'false'"
def _require_orchestrator_tool(tool_name: str) -> Optional[str]:
"""Runtime guard for orchestrator-only handlers.
The check_fn already hides these from the worker schema; this catches a
stale registration or test harness routing a worker here anyway.
"""
if os.environ.get("HERMES_KANBAN_TASK"):
return tool_error(
f"{tool_name} is orchestrator-only; dispatcher-spawned workers "
"must use kanban_complete, kanban_block, kanban_heartbeat, or "
"kanban_comment for their assigned task."
)
return None
_TASK_FIELDS = (
"id", "title", "body", "assignee", "status", "tenant", "priority",
"workspace_kind", "workspace_path", "created_by", "created_at",
"started_at", "completed_at", "result", "current_run_id",
"model_override", "provider_override",
)
_TASK_SUMMARY_FIELDS = (
"id", "title", "assignee", "status", "priority", "tenant",
"workspace_kind", "workspace_path", "project_id", "created_by",
"created_at", "started_at", "completed_at", "current_run_id",
"model_override", "provider_override",
)
_RUN_FIELDS = (
"id", "profile", "status", "outcome", "summary", "error", "metadata",
"started_at", "ended_at",
)
_COMMENT_FIELDS = ("author", "body", "created_at")
_EVENT_FIELDS = ("kind", "payload", "created_at", "run_id")
_ATTACHMENT_FIELDS = (
"id", "filename", "content_type", "size", "uploaded_by", "stored_path",
"created_at",
)
def _fields(obj: Any, names: tuple[str, ...]) -> dict[str, Any]:
return {n: getattr(obj, n) for n in names}
def _task_summary_dict(kb, conn, task) -> dict[str, Any]:
"""Compact task shape for board-listing tools."""
parents = kb.parent_ids(conn, task.id)
children = kb.child_ids(conn, task.id)
return {
**_fields(task, _TASK_SUMMARY_FIELDS),
"parents": parents,
"children": children,
"parent_count": len(parents),
"child_count": len(children),
}
# ---------------------------------------------------------------------------
# Goal-mode judge gate
# ---------------------------------------------------------------------------
_GOAL_MODE_BLOCK_ALLOWED_KINDS = frozenset({"dependency", "needs_input"})
def _goal_judge_available() -> bool:
"""True when an auxiliary client is configured for the goal judge.
``judge_goal`` fails open: with no reachable auxiliary model it returns
``"continue"``, indistinguishable from a real "not done yet". Treating that
as a rejection would wedge every goal_mode worker, so the completion gate
is enforced only when a judge is actually reachable (same client lookup
``judge_goal`` performs internally).
"""
try:
from agent.auxiliary_client import get_text_auxiliary_client
client, model = get_text_auxiliary_client("goal_judge")
except Exception:
return False
return client is not None and bool(model)
def _goal_mode_handoff_rejection(task, evidence: str):
"""Return ``(verdict, reason_or_None)`` for a goal-mode terminal handoff.
``("done", None)`` allows the handoff. Otherwise the verdict picks the
guidance: ``continue`` = not done yet, ``blocked`` = judged unachievable.
A broken judge fails open (logged) so it cannot permanently wedge work.
"""
if not task or not task.goal_mode or not _goal_judge_available():
return ("done", None)
verdict = "done"
reason = ""
try:
verdict, reason, _, _, _ = judge_goal(
goal=f"{task.title}\n\n{task.body or ''}".strip(),
last_response=evidence.strip(),
)
except Exception as judge_exc:
logger.warning(
"goal judge check failed, allowing lifecycle handoff: %s",
judge_exc,
exc_info=True,
)
return (verdict, None if verdict == "done" else reason)
# Per-tool guidance for a judge rejection: verdict -> message. ``{reason}``/``{tid}`` are filled in.
_GOAL_GATE_MESSAGES = {
"kanban_complete": {
"blocked": (
"Goal completion rejected: judge ruled the goal "
"unachievable — {reason}. The task will NOT complete "
"silently. Either re-scope the task with kanban_edit, "
"or record the block with kanban_block and hand the "
"decision to a human / reviewer."
),
"continue": (
"Goal completion rejected by judge: {reason}. "
"To proceed, either: (1) provide explicit acceptance "
"evidence in your summary matching the task's criteria, "
"or (2) create continuation tasks with parents=[{tid}] "
"and keep this task alive."
),
},
"kanban_request_review": {
"blocked": (
"Goal review handoff rejected: judge ruled the goal "
"unachievable — {reason}. Record the block with "
"kanban_block instead of requesting review."
),
"continue": (
"Goal review handoff rejected by judge: {reason}. "
"Provide acceptance evidence matching the card before "
"requesting review."
),
},
}
def _goal_gate_error(tool_name: str, task, tid: str, evidence: str) -> Optional[str]:
"""Goal-mode pre-handoff judge gate; a tool error when the judge rejects, else None.
A worker must not bypass the auxiliary judge by completing / requesting
review before acceptance criteria are met. ``blocked`` gets its own
guidance; any other non-``done`` verdict gets the ``continue`` guidance.
"""
verdict, rejection = _goal_mode_handoff_rejection(task, evidence)
if rejection is None:
return None
key = "blocked" if verdict == "blocked" else "continue"
return tool_error(_GOAL_GATE_MESSAGES[tool_name][key].format(reason=rejection, tid=tid))
# ---------------------------------------------------------------------------
# Runtime-activity → board bridges (auto-heartbeat, live comment injection)
# ---------------------------------------------------------------------------
# The dispatcher watchdog reads ``tasks.last_heartbeat_at``, not the agent's
# in-process activity timestamp, so normal work (tool calls, stream chunks) is
# mirrored onto the board here; the explicit ``kanban_heartbeat`` tool stays
# for attaching a note or pre-extending a claim across a known-long op.
# Constraints: best-effort (never raise into the agent loop), rate-limited
# per process, no-op outside dispatcher-spawned worker context, no durable
# note on auto-heartbeats.
_AUTO_HEARTBEAT_MIN_INTERVAL_SECONDS = 60.0
_auto_heartbeat_last_attempt: float = 0.0
def heartbeat_current_worker_from_env() -> bool:
"""Best-effort: extend the claim + bump board heartbeat for the current worker.
Returns True if a write was attempted, False if skipped (not a worker,
rate-limited, or failed) — informational only. Identity from env:
``HERMES_KANBAN_TASK`` (required), ``HERMES_KANBAN_RUN_ID`` (pins the run
row so a reclaimed stale run is not heartbeated), ``HERMES_KANBAN_CLAIM_LOCK``
(falls back to the default claimer for locally-driven workers). The
monotonic rate limit is not strictly thread-safe; a race costs one extra
harmless DB write.
"""
global _auto_heartbeat_last_attempt
tid = os.environ.get("HERMES_KANBAN_TASK")
if not tid:
return False
import time as _time
now = _time.monotonic()
if (now - _auto_heartbeat_last_attempt) < _AUTO_HEARTBEAT_MIN_INTERVAL_SECONDS:
return False
_auto_heartbeat_last_attempt = now
try:
kb, conn = _connect()
try:
try:
kb.heartbeat_claim(conn, tid, claimer=os.environ.get("HERMES_KANBAN_CLAIM_LOCK"))
except Exception:
logger.debug("auto-heartbeat: heartbeat_claim failed", exc_info=True)
try:
kb.heartbeat_worker(conn, tid, note=None, expected_run_id=_worker_run_id(tid))
except Exception:
logger.debug("auto-heartbeat: heartbeat_worker failed", exc_info=True)
finally:
_close_quietly(conn)
return True
except Exception:
logger.debug("auto-heartbeat: bridge failed", exc_info=True)
return False
# Live operator-note injection: poll the worker's task for new comments and
# fold them in via the OUT-OF-BAND steer channel, so a user can talk to a
# running task without block → comment → unblock (or a restart). Polled
# tighter than the heartbeat so notes land within seconds; watermarked per task.
_COMMENT_POLL_MIN_INTERVAL_SECONDS = 6.0
_comment_poll_last_attempt: float = 0.0
# task_id -> highest comment id already seen (seeded on first poll so history
# already present in build_worker_context isn't re-injected).
_comment_watermark: dict[str, int] = {}
def inject_new_comments_from_env(agent: Any) -> bool:
"""Fold new operator comments on the current worker's task into ``agent``.
Self-gating no-op unless ``HERMES_KANBAN_TASK`` is set and ``agent`` exposes
``steer``; returns True iff a steer was injected; never raises. The first
poll only seeds the watermark (those comments are already in context), and
the worker's own comments (matched by ``HERMES_PROFILE``) are skipped.
"""
tid = os.environ.get("HERMES_KANBAN_TASK")
if not tid or agent is None or not hasattr(agent, "steer"):
return False
global _comment_poll_last_attempt
import time as _time
now = _time.monotonic()
if (now - _comment_poll_last_attempt) < _COMMENT_POLL_MIN_INTERVAL_SECONDS:
return False
_comment_poll_last_attempt = now
seen = _comment_watermark.get(tid)
try:
kb, conn = _connect()
try:
rows = kb.list_comments_after(conn, tid, after_id=seen or 0)
finally:
_close_quietly(conn)
except Exception:
logger.debug("comment-inject: bridge failed", exc_info=True)
return False
if seen is None:
_comment_watermark[tid] = max((c.id for c in rows), default=0)
return False
if not rows:
return False
# Advance past everything read (including our own notes) so nothing is re-injected.
_comment_watermark[tid] = max(c.id for c in rows)
own = (os.environ.get("HERMES_PROFILE") or "").strip()
fresh = [c for c in rows if (c.author or "").strip() != own and (c.body or "").strip()]
if not fresh:
return False
lines = [f"- {c.author or 'operator'}: {c.body.strip()}" for c in fresh]
note = (
"New note"
+ ("s" if len(fresh) > 1 else "")
+ " on your kanban task from the operator (delivered mid-run). "
+ "Take it into account for the work you're doing right now:\n"
+ "\n".join(lines)
)
try:
return bool(agent.steer(note))
except Exception:
logger.debug("comment-inject: steer failed", exc_info=True)
return False
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
@_kanban_handler("kanban_show")
def _handle_show(args: dict, **kw) -> str:
"""Read a task's full state: row, parents, children, comments, runs, last 50 events."""
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(_TASK_ID_REQUIRED)
with _board(args.get("board")) as (kb, conn):
task = kb.get_task(conn, tid)
if task is None:
return tool_error(f"task {tid} not found")
return json.dumps({
"task": _fields(task, _TASK_FIELDS),
"parents": kb.parent_ids(conn, tid),
"children": kb.child_ids(conn, tid),
"comments": [_fields(c, _COMMENT_FIELDS) for c in kb.list_comments(conn, tid)],
# Capped; full log via CLI.
"events": [_fields(e, _EVENT_FIELDS) for e in kb.list_events(conn, tid)[-50:]],
"runs": [_fields(r, _RUN_FIELDS) for r in kb.list_runs(conn, tid)],
# Same string build_worker_context hands the dispatcher at spawn time.
"worker_context": kb.build_worker_context(conn, tid),
})
@_kanban_handler("kanban_list")
def _handle_list(args: dict, **kw) -> str:
"""List task summaries with the same core filters as the CLI."""
guard = _require_orchestrator_tool("kanban_list")
if guard:
return guard
include_archived, bool_error = _parse_bool_arg(args, "include_archived")
if bool_error:
return tool_error(bool_error)
limit = args.get("limit")
if limit is None:
limit = KANBAN_LIST_DEFAULT_LIMIT
try:
limit = int(limit)
except (TypeError, ValueError):
return tool_error("limit must be an integer")
if limit < 1:
return tool_error("limit must be >= 1")
if limit > KANBAN_LIST_MAX_LIMIT:
return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}")
with _board(args.get("board")) as (kb, conn):
# Match CLI list: dependencies cleared since the last dispatcher tick
# should be visible to orchestrators immediately.
promoted = kb.recompute_ready(conn)
# One extra row lets the output report truncation without dumping the board.
rows = kb.list_tasks(
conn,
assignee=args.get("assignee"),
status=args.get("status"),
tenant=args.get("tenant"),
include_archived=include_archived,
limit=limit + 1,
)
truncated = len(rows) > limit
tasks = rows[:limit]
return json.dumps({
"tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
"count": len(tasks),
"limit": limit,
"truncated": truncated,
"next_limit": (
min(limit * 2, KANBAN_LIST_MAX_LIMIT)
if truncated and limit < KANBAN_LIST_MAX_LIMIT else None
),
"promoted": promoted,
})
@_kanban_handler("kanban_complete")
def _handle_complete(args: dict, **kw) -> str:
"""Mark the current task done with a structured handoff."""
tid, err = _worker_guard("kanban_complete", args)
if err:
return err
summary = args.get("summary")
metadata = args.get("metadata")
result = args.get("result")
if summary:
summary = _redact(summary)
if result:
result = _redact(result)
if isinstance(metadata, dict):
# Keep the unredacted dict if the redacted JSON cannot be re-parsed.
redacted = _redact_metadata(metadata)
if redacted is not None:
metadata = redacted
created_cards, err = _coerce_str_list(
args.get("created_cards"), "created_cards", "task ids", strip=True
)
if err:
return err
artifacts, err = _coerce_str_list(
args.get("artifacts"), "artifacts", "file paths", strip=True
)
if err:
return err
if artifacts:
metadata, err = _merge_artifacts(metadata, artifacts)
if err:
return err
if not (summary or result):
return tool_error("provide at least one of: summary (preferred), result")
if metadata is not None and not isinstance(metadata, dict):
return _metadata_type_error(metadata)
metadata = _stamp_worker_session_metadata(tid, metadata)
with _board(args.get("board")) as (kb, conn):
task = kb.get_task(conn, tid)
gate_err = _goal_gate_error("kanban_complete", task, tid, (summary or result or "").strip())
if gate_err:
return gate_err
try:
ok = kb.complete_task(
conn, tid,
result=result, summary=summary, metadata=metadata,
created_cards=created_cards,
expected_run_id=_worker_run_id(tid),
)
except kb.ArtifactPreservationError as artifact_err:
return tool_error(
f"kanban_complete could not preserve the declared artifacts: "
f"{artifact_err}. Your task is still in-flight and its "
f"scratch workspace was kept. Fix the artifact path or "
f"storage error, then retry kanban_complete with the same handoff."
)
except kb.HallucinatedCardsError as hall_err:
# The gate runs before the write txn, so the task was NOT mutated;
# say so explicitly or the model treats the error as terminal and
# blocks/crashes instead of retrying. Audit event already landed.
return tool_error(
f"kanban_complete blocked: the following created_cards "
f"do not exist or were not created by this worker: "
f"{', '.join(hall_err.phantom)}. "
f"Your task is still in-flight (no state change). "
f"Retry kanban_complete with the same summary/metadata "
f"and either drop these ids from created_cards, or pass "
f"created_cards=[] to skip the card-claim check entirely."
)
if not ok:
return tool_error(
f"could not complete {tid} (unknown id or already terminal)"
)
run = kb.latest_run(conn, tid)
return _ok(task_id=tid, run_id=run.id if run else None)
@_kanban_handler("kanban_block")
def _handle_block(args: dict, **kw) -> str:
"""Transition the task to blocked with a reason a human will read."""
tid, err = _worker_guard("kanban_block", args)
if err:
return err
reason, err = _require_text(args, "reason", "reason is required — explain what input you need")
if err:
return err
reason = _redact(reason)
kind = args.get("kind")
with _board(args.get("board")) as (kb, conn):
if kind is not None and kind not in kb.VALID_BLOCK_KINDS:
return tool_error(
f"kind must be one of {sorted(kb.VALID_BLOCK_KINDS)} (or omit it)"
)
# Goal-mode block gate: the goal loop treats ANY blocked status as
# terminal, so kanban_block would be an escape hatch around the
# completion judge. Restrict goal_mode tasks to kinds that are genuine
# external blockers; everything else routes back through kanban_complete.
task = kb.get_task(conn, tid)
if task and task.goal_mode and kind not in _GOAL_MODE_BLOCK_ALLOWED_KINDS:
return tool_error(
f"goal_mode tasks can only block with kind in "
f"{sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} (got {kind!r}). "
f"If the task is actually finished or cannot proceed for "
f"another reason, call kanban_complete instead — the "
f"completion judge will evaluate it."
)
ok = kb.block_task(
conn, tid, reason=reason, kind=kind, expected_run_id=_worker_run_id(tid),
)
if not ok:
return tool_error(
f"could not block {tid} (unknown id or not in running/ready)"
)
run = kb.latest_run(conn, tid)
# Report where the task actually landed; routing may not leave it in 'blocked'.
landed = kb.get_task(conn, tid)
return _ok(
task_id=tid,
run_id=run.id if run else None,
status=landed.status if landed else "blocked",
block_kind=kind,
)
@_kanban_handler("kanban_request_review")
def _handle_request_review(args: dict, **kw) -> str:
"""Move implementation into the first-class review phase."""
tid, err = _worker_guard("kanban_request_review", args)
if err:
return err
summary, err = _require_text(
args, "summary",
"summary is required — describe what was implemented and how it "
"was verified so the reviewer has context",
)
if err:
return err
summary = _redact(summary)
metadata = args.get("metadata")
if metadata is not None and not isinstance(metadata, dict):
return _metadata_type_error(metadata)
if metadata is not None:
metadata = _redact_metadata(metadata)
if metadata is None:
return tool_error("metadata could not be safely serialized")
metadata = _stamp_worker_session_metadata(tid, metadata)
reviewer = args.get("reviewer") or None
if reviewer:
# Model-supplied free text stored durably on the event payload.
reviewer = _redact(reviewer)
with _board(args.get("board")) as (kb, conn):
task = kb.get_task(conn, tid)
gate_err = _goal_gate_error("kanban_request_review", task, tid, summary)
if gate_err:
return gate_err
ok, fail_reason = kb.request_review(
conn, tid,
summary=summary,
metadata=metadata,
reviewer=reviewer,
expected_run_id=_worker_run_id(tid),
with_reason=True,
)
if not ok:
detail = fail_reason or "unknown id or not in running/ready"
return tool_error(f"could not request review for {tid}: {detail}")
run = kb.latest_run(conn, tid)
landed = kb.get_task(conn, tid)
return _ok(
task_id=tid,
run_id=run.id if run else None,
status=landed.status if landed else "review",
)
@_kanban_handler("kanban_request_changes")
def _handle_request_changes(args: dict, **kw) -> str:
"""Return a reviewer-owned running task to its implementer."""
tid, err = _worker_guard("kanban_request_changes", args)
if err:
return err
reason, err = _require_text(args, "reason", "reason is required — describe the changes needed")
if err:
return err
reason = _redact(reason)
with _board(args.get("board")) as (kb, conn):
ok, detail = kb.request_changes(
conn, tid, reason=reason, expected_run_id=_worker_run_id(tid),
)
if not ok:
return tool_error(
f"could not request changes for {tid}: {detail or 'invalid review state'}"
)
landed = kb.get_task(conn, tid)
run = kb.latest_run(conn, tid)
return _ok(
task_id=tid,
run_id=run.id if run else None,
status=landed.status if landed else "ready",
implementer=detail,
)
@_kanban_handler("kanban_heartbeat")
def _handle_heartbeat(args: dict, **kw) -> str:
"""Signal liveness during a long operation.
Extends the claim TTL (``heartbeat_claim``) AND records a heartbeat event
(``heartbeat_worker``). Without the claim half, a worker looping this tool
while one tool call blocks longer than the claim TTL still gets reclaimed
by ``release_stale_claims``.
"""
tid, err = _worker_guard("kanban_heartbeat", args)
if err:
return err
with _board(args.get("board")) as (kb, conn):
# The dispatcher pins HERMES_KANBAN_CLAIM_LOCK at spawn; the default
# claimer covers locally-driven workers that bypassed the dispatcher.
kb.heartbeat_claim(conn, tid, claimer=os.environ.get("HERMES_KANBAN_CLAIM_LOCK"))
ok = kb.heartbeat_worker(
conn, tid, note=args.get("note"), expected_run_id=_worker_run_id(tid),
)
if not ok:
return tool_error(f"could not heartbeat {tid} (unknown id or not running)")
return _ok(task_id=tid)
@_kanban_handler("kanban_comment")
def _handle_comment(args: dict, **kw) -> str:
"""Append a comment to a task's thread."""
delegated_err = _reject_delegated_child_mutation("kanban_comment")
if delegated_err:
return delegated_err
tid = args.get("task_id")
if not tid:
return tool_error(
"task_id is required (use the current task id if that's what "
"you mean — pulls from env but kept explicit here)"
)
body, err = _require_text(args, "body")
if err:
return err
body = _redact(body)
# Author comes from the worker's runtime identity, never caller args:
# comments are injected into future workers' system prompts as
# ``**{author}** (timestamp): {body}``, so an args["author"] override
# could forge a directive from an authoritative-looking name like
# ``hermes-system``. Cross-task commenting stays unrestricted — it is the
# deliberate handoff channel between tasks.
author = os.environ.get("HERMES_PROFILE") or "worker"
with _board(args.get("board")) as (kb, conn):
cid = kb.add_comment(conn, tid, author=author, body=str(body))
return _ok(task_id=tid, comment_id=cid)
def _store_attachment(board, tid, filename, data, content_type) -> str:
"""Store bytes via ``kanban_db.store_attachment_bytes`` (shared size cap, per-task
dir, metadata row) so agent, dashboard, and CLI surfaces stay in lockstep."""
with _board(board) as (kb, conn):
att_id = kb.store_attachment_bytes(
conn, tid, str(filename), data,
content_type=content_type, uploaded_by="agent", board=board,
)
return _ok(task_id=tid, attachment_id=att_id, size=len(data))
@_kanban_handler("kanban_attach")
def _handle_attach(args: dict, **kw) -> str:
"""Attach an inline (base64) file to a task."""
tid, err = _worker_guard("kanban_attach", args)
if err:
return err
filename, err = _require_text(args, "filename")
if err:
return err
content_b64, err = _require_text(args, "content_base64")
if err:
return err
import base64
import binascii
try:
data = base64.b64decode(str(content_b64), validate=True)
except (binascii.Error, ValueError) as e:
return tool_error(f"content_base64 is not valid base64: {e}")
return _store_attachment(args.get("board"), tid, filename, data, args.get("content_type"))
_MAX_ATTACH_URL_REDIRECTS = 5
def _download_url_with_cap(url: str, max_bytes: int) -> tuple[bytes, Optional[str]]:
"""Fetch ``url`` over http(s) with SSRF guarding, capped at ``max_bytes``.
Every hop (initial URL and each redirect target) is validated with
``tools.url_safety.is_safe_url`` before fetching, so a model-controlled URL
(or a public host 302ing to one) cannot reach loopback, private/CGNAT
ranges, or cloud metadata. Redirects are followed manually so each
Location is re-checked (mirrors ``tools.skills_hub._guarded_http_get``).
Returns ``(data, content_type)``; raises
``ValueError`` for a bad scheme, blocked target, too many redirects, or a
body over the cap (checked while streaming, so nothing oversize is buffered).
"""
from urllib.parse import urljoin, urlparse
import httpx
from tools.url_safety import is_safe_url
current_url = url
for _ in range(_MAX_ATTACH_URL_REDIRECTS + 1):
scheme = (urlparse(current_url).scheme or "").lower()
if scheme not in ("http", "https"):
raise ValueError(
f"unsupported URL scheme {scheme!r}; only http/https are allowed"
)
if not is_safe_url(current_url):
raise ValueError(
f"URL blocked by SSRF protection (private/internal address): {current_url}"
)
chunks: list[bytes] = []
total = 0
with httpx.stream(
"GET",
current_url,
headers={"User-Agent": "hermes-kanban/attach"},
timeout=30,
follow_redirects=False,
) as resp:
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise ValueError(f"redirect without Location header from {current_url}")
current_url = urljoin(current_url, location)
continue
resp.raise_for_status()
content_type = (resp.headers.get("content-type") or "").split(";")[0].strip() or None
for chunk in resp.iter_bytes(1024 * 1024):
total += len(chunk)
if total > max_bytes:
raise ValueError(
f"attachment exceeds {max_bytes // (1024 * 1024)} MB limit"
)
chunks.append(chunk)
return b"".join(chunks), content_type
raise ValueError(f"too many redirects fetching {url}")
@_kanban_handler("kanban_attach_url")
def _handle_attach_url(args: dict, **kw) -> str:
"""Attach a file fetched server-side from an http(s) URL (shared size cap)."""
from hermes_cli import kanban_db as kb
tid, err = _worker_guard("kanban_attach_url", args)
if err:
return err
url, err = _require_text(args, "url")
if err:
return err
url = str(url).strip()
filename = args.get("filename") or args.get("title")
if not filename or not str(filename).strip():
# Derive a name from the URL path's leaf component.
from urllib.parse import unquote, urlparse
leaf = unquote(urlparse(url).path.rsplit("/", 1)[-1]).strip()
filename = leaf or "download"
try:
data, fetched_ct = _download_url_with_cap(url, kb.KANBAN_ATTACHMENT_MAX_BYTES)
except ValueError as e:
return tool_error(f"kanban_attach_url: {e}")
except Exception as e:
logger.exception("kanban_attach_url download failed")
return tool_error(f"kanban_attach_url: failed to fetch {url}: {e}")
return _store_attachment(
args.get("board"), tid, filename, data, args.get("content_type") or fetched_ct
)
@_kanban_handler("kanban_attachments")
def _handle_attachments(args: dict, **kw) -> str:
"""List a task's attachments (read-only; no ownership restriction)."""
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(_TASK_ID_REQUIRED)
with _board(args.get("board")) as (kb, conn):
if kb.get_task(conn, tid) is None:
return tool_error(f"task {tid} not found")
return json.dumps({
"ok": True,
"task_id": tid,
"attachments": [_fields(a, _ATTACHMENT_FIELDS) for a in kb.list_attachments(conn, tid)],
})
def _opt_int(value: Any, default: Optional[int] = None) -> Optional[int]:
return int(value) if value is not None else default
@_kanban_handler("kanban_create")
def _handle_create(args: dict, **kw) -> str:
"""Create a (child) task; orchestrator workers use this to fan out."""
delegated_err = _reject_delegated_child_mutation("kanban_create")
if delegated_err:
return delegated_err
title, err = _require_text(args, "title")
if err:
return err
assignee = args.get("assignee")
if not assignee:
return tool_error(
"assignee is required — name the profile that should execute this "
"task (the dispatcher will only spawn tasks with an assignee)"
)
# Prefer the request-scoped api_server origin binding over HERMES_SESSION_ID:
# the env var is clobbered with a subagent's internal id whenever a child
# agent is constructed in-process, which would stamp — and later wake —
# the wrong session. NULL on CLI/dashboard paths that set neither.
from tools.async_delegation import _current_origin_session_id
session_id = (
args.get("session_id")
or _current_origin_session_id()
or os.environ.get("HERMES_SESSION_ID")
)
# Workspace sharing is always explicit: omitted fields mean a fresh scratch
# workspace even for a dispatcher-spawned creator — reusing the parent's
# literal path would let a child mutate review evidence or race its
# checkout. Project identity is the one safe thing to inherit implicitly
# (the DB turns it into a fresh per-task worktree).
workspace_kind = args.get("workspace_kind")
workspace_path = args.get("workspace_path")
project_id = args.get("project") or args.get("project_id")
project_source_task_id = None
inherit_project = workspace_kind is None and workspace_path is None
triage, bool_error = _parse_bool_arg(args, "triage")
if bool_error:
return tool_error(bool_error)
skills, err = _coerce_str_list(args.get("skills"), "skills", "skill names")
if err:
return err
goal_mode, bool_error = _parse_bool_arg(args, "goal_mode")
if bool_error:
return tool_error(bool_error)
model_override = args.get("model")
provider_override = args.get("provider")
if provider_override and not model_override:
return tool_error("'provider' requires 'model' to be set as well")
parents, err = _coerce_str_list(args.get("parents") or [], "parents", "task ids")
if err:
return err
with _board(args.get("board")) as (kb, conn):
if inherit_project and project_id is None:
self_tid = os.environ.get("HERMES_KANBAN_TASK")
self_task = kb.get_task(conn, self_tid) if self_tid else None
if self_task is not None and self_task.project_id:
project_id = self_task.project_id
project_source_task_id = self_task.id
new_tid = kb.create_task(
conn,
title=str(title).strip(),
body=args.get("body"),
assignee=str(assignee),
parents=tuple(parents),
tenant=args.get("tenant") or os.environ.get("HERMES_TENANT"),
priority=_opt_int(args.get("priority"), 0),
workspace_kind=str(workspace_kind if workspace_kind is not None else "scratch"),
workspace_path=workspace_path,
project_id=project_id,
project_source_task_id=project_source_task_id,
triage=triage,
idempotency_key=args.get("idempotency_key"),
max_runtime_seconds=_opt_int(args.get("max_runtime_seconds")),
skills=skills,
model_override=model_override,
provider_override=provider_override,
goal_mode=goal_mode,
goal_max_turns=_opt_int(args.get("goal_max_turns")),
initial_status=str(args.get("initial_status") or "running"),
created_by=os.environ.get("HERMES_PROFILE") or "worker",
session_id=session_id,
)
new_task = kb.get_task(conn, new_tid)
subscribed = _maybe_auto_subscribe(conn, new_tid)
return _ok(
task_id=new_tid,
status=new_task.status if new_task else None,
workspace_kind=new_task.workspace_kind if new_task else None,
workspace_path=new_task.workspace_path if new_task else None,
project_id=new_task.project_id if new_task else None,
subscribed=subscribed,
)
@dataclass
class _NotifyTarget:
"""Where kanban_create completion/block notifications for this session go."""
platform: str
chat_id: str
chat_type: Optional[str]
thread_id: Optional[str]
user_id: Optional[str]
user_id_alt: Optional[str]
notifier_profile: str
delivery_mode: Optional[str]
delivery_metadata: Optional[dict[str, Any]]
def _resolve_notify_target() -> Optional[_NotifyTarget]:
"""Delivery target for the calling session, or None when there is no channel.
- Gateway (telegram/discord/...): ``HERMES_SESSION_PLATFORM`` /
``HERMES_SESSION_CHAT_ID`` ContextVars set before dispatch.
- TUI/desktop: those ContextVars are cleared, but the subprocess inherits
``HERMES_SESSION_KEY``; subscribe as ``platform="tui"``, ``chat_id=<key>``
for the TUI notification poller. ``HERMES_SESSION_ID`` is deliberately
NOT a fallback — it is set for every CLI/ACP invocation for telemetry and
would auto-subscribe every CLI run.
- CLI / cron / tests: no persistent channel -> None.
"""
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
if not platform or not chat_id:
session_key = (
get_session_env("HERMES_SESSION_KEY", "")
or os.environ.get("HERMES_SESSION_KEY", "")
)
if not session_key:
return None
platform, chat_id = "tui", session_key
chat_type = get_session_env("HERMES_SESSION_CHAT_TYPE", "") or None
thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "") or None
message_id = get_session_env("HERMES_SESSION_MESSAGE_ID", "") or ""
notifier_profile = (
get_session_env("HERMES_SESSION_PROFILE", "")
or os.environ.get("HERMES_PROFILE")
)
if not notifier_profile:
try:
from hermes_cli.profiles import get_active_profile_name
notifier_profile = get_active_profile_name() or "default"
except Exception:
notifier_profile = "default"
delivery_metadata: dict[str, Any] = {}
if thread_id:
delivery_metadata["thread_id"] = thread_id
if chat_type:
delivery_metadata["chat_type"] = chat_type
if (
platform.lower() == "telegram"
and thread_id
and (chat_type or "").lower() in {"dm", "direct", "private"}
):
delivery_metadata["telegram_dm_topic_reply_fallback"] = True
if str(thread_id) not in {"", "1"}:
delivery_metadata["direct_messages_topic_id"] = str(thread_id)
if message_id:
delivery_metadata["telegram_reply_to_message_id"] = str(message_id)
return _NotifyTarget(
platform=platform,
chat_id=chat_id,
chat_type=chat_type,
thread_id=thread_id,
user_id=get_session_env("HERMES_SESSION_USER_ID", "") or None,
user_id_alt=get_session_env("HERMES_SESSION_USER_ID_ALT", "") or None,
notifier_profile=notifier_profile,
delivery_mode="notify+wake" if platform != "tui" else None,
delivery_metadata=delivery_metadata or None,
)
def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool:
"""Auto-subscribe the calling session to task completion / block events.
Returns True iff a subscription row was written; surfaced as ``subscribed``
on kanban_create so an orchestrator can fall back to an explicit
``kanban_notify-subscribe`` or polling. Gated by
``kanban.auto_subscribe_on_create`` (default True; unreadable config also
means True). Target resolution: see ``_resolve_notify_target``.
Any failure is logged at WARNING and swallowed: notification bookkeeping
must never fail the kanban_create the agent is mid-conversation about.
"""
try:
cfg = load_config()
if not cfg_get(cfg, "kanban", "auto_subscribe_on_create", default=True):
return False
except Exception:
pass # unreadable config keeps the user-friendly default (True)
target = None
try:
target = _resolve_notify_target()
if target is None:
return False # CLI / cron / test — no persistent channel
from hermes_cli import kanban_db as _kb
_kb.add_notify_sub(
conn, task_id=task_id,
platform=target.platform, chat_id=target.chat_id,
thread_id=target.thread_id, user_id=target.user_id, user_id_alt=target.user_id_alt,
chat_type=target.chat_type,
notifier_profile=target.notifier_profile,
delivery_mode=target.delivery_mode,
delivery_metadata=target.delivery_metadata,
)
return True
except Exception as _exc:
logger.warning(
"_maybe_auto_subscribe failed: %r (platform=%r key_set=%r)",
_exc, target.platform if target else "", bool(target and target.chat_id),
)
return False
@_kanban_handler("kanban_unblock")
def _handle_unblock(args: dict, **kw) -> str:
"""Transition a blocked task to ready, or todo while parents remain open."""
delegated_err = _reject_delegated_child_mutation("kanban_unblock")
if delegated_err:
return delegated_err
guard = _require_orchestrator_tool("kanban_unblock")
if guard:
return guard
tid = args.get("task_id")
if not tid:
return tool_error("task_id is required")
ownership_err = _enforce_worker_task_ownership(str(tid))
if ownership_err:
return ownership_err
with _board(args.get("board")) as (kb, conn):
ok = kb.unblock_task(conn, str(tid))
if not ok:
return tool_error(f"could not unblock {tid} (not blocked or unknown)")
task = kb.get_task(conn, str(tid))
return _ok(task_id=str(tid), status=task.status if task else None)
@_kanban_handler("kanban_link")
def _handle_link(args: dict, **kw) -> str:
"""Add a parent→child dependency edge after the fact (cycles/self-links → ValueError)."""
delegated_err = _reject_delegated_child_mutation("kanban_link")
if delegated_err:
return delegated_err
parent_id = args.get("parent_id")
child_id = args.get("child_id")
if not parent_id or not child_id:
return tool_error("both parent_id and child_id are required")
with _board(args.get("board")) as (kb, conn):
kb.link_tasks(conn, parent_id=parent_id, child_id=child_id)
return _ok(parent_id=parent_id, child_id=child_id)
# ---------------------------------------------------------------------------
# Registration (order preserved: it is the order tools appear in the schema)
# ---------------------------------------------------------------------------
_TOOLS = (
("kanban_show", KANBAN_SHOW_SCHEMA, _handle_show, _check_kanban_mode, "📋"),
("kanban_list", KANBAN_LIST_SCHEMA, _handle_list, _check_kanban_orchestrator_mode, "📋"),
("kanban_complete", KANBAN_COMPLETE_SCHEMA, _handle_complete, _check_kanban_mode, "✔"),
("kanban_block", KANBAN_BLOCK_SCHEMA, _handle_block, _check_kanban_mode, "⏸"),
("kanban_request_review", KANBAN_REQUEST_REVIEW_SCHEMA, _handle_request_review, _check_kanban_mode, "👀"),
("kanban_request_changes", KANBAN_REQUEST_CHANGES_SCHEMA, _handle_request_changes, _check_kanban_mode, "↩"),
("kanban_heartbeat", KANBAN_HEARTBEAT_SCHEMA, _handle_heartbeat, _check_kanban_mode, "💓"),
("kanban_comment", KANBAN_COMMENT_SCHEMA, _handle_comment, _check_kanban_mode, "💬"),
("kanban_attach", KANBAN_ATTACH_SCHEMA, _handle_attach, _check_kanban_mode, "📎"),
("kanban_attach_url", KANBAN_ATTACH_URL_SCHEMA, _handle_attach_url, _check_kanban_mode, "📎"),
("kanban_attachments", KANBAN_ATTACHMENTS_SCHEMA, _handle_attachments, _check_kanban_mode, "📎"),
("kanban_create", KANBAN_CREATE_SCHEMA, _handle_create, _check_kanban_mode, "➕"),
("kanban_unblock", KANBAN_UNBLOCK_SCHEMA, _handle_unblock, _check_kanban_orchestrator_mode, "▶"),
("kanban_link", KANBAN_LINK_SCHEMA, _handle_link, _check_kanban_mode, "🔗"),
)
for _name, _sch, _handler, _check_fn, _emoji in _TOOLS:
registry.register(
name=_name,
toolset="kanban",
schema=_sch,
handler=_handler,
check_fn=_check_fn,
emoji=_emoji,
)