1370 lines
54 KiB
Python
1370 lines
54 KiB
Python
"""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,
|
||
)
|