Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # cli.py # hermes_cli/main.py # run_agent.py # tests/hermes_cli/test_cmd_update.py # tools/mcp_tool.py # web/src/lib/gatewayClient.ts
This commit is contained in:
+115
-8
@@ -4763,11 +4763,106 @@ class GatewayRunner:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Auto-decompose: turn fresh triage tasks into ready workgraphs
|
||||
# before the dispatcher fans out workers. Gated by
|
||||
# ``kanban.auto_decompose`` (default True). Capped by
|
||||
# ``kanban.auto_decompose_per_tick`` (default 3) so a bulk-load
|
||||
# of triage tasks doesn't burst-spend the aux LLM in one tick;
|
||||
# remainder defers to subsequent ticks.
|
||||
auto_decompose_enabled = bool(kanban_cfg.get("auto_decompose", True))
|
||||
try:
|
||||
auto_decompose_per_tick = int(
|
||||
kanban_cfg.get("auto_decompose_per_tick", 3) or 3
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
auto_decompose_per_tick = 3
|
||||
if auto_decompose_per_tick < 1:
|
||||
auto_decompose_per_tick = 1
|
||||
|
||||
def _auto_decompose_tick() -> int:
|
||||
"""Run the auto-decomposer for up to N triage tasks across all
|
||||
boards. Returns the number of triage tasks that were
|
||||
successfully decomposed or specified this tick.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import kanban_decompose as _decomp
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning(
|
||||
"kanban auto-decompose: import failed (%s); skipping", exc,
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
boards = _kb.list_boards(include_archived=False)
|
||||
except Exception:
|
||||
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
|
||||
attempted = 0
|
||||
successes = 0
|
||||
for b in boards:
|
||||
slug = b.get("slug") or _kb.DEFAULT_BOARD
|
||||
if attempted >= auto_decompose_per_tick:
|
||||
break
|
||||
# Pin this board for the duration of the call — same
|
||||
# pattern as the dashboard specify endpoint. The
|
||||
# decomposer module connects with no board kwarg and
|
||||
# relies on the env var.
|
||||
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
try:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = slug
|
||||
try:
|
||||
triage_ids = _decomp.list_triage_ids()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"kanban auto-decompose: list_triage_ids failed on board %s (%s)",
|
||||
slug, exc,
|
||||
)
|
||||
triage_ids = []
|
||||
for tid in triage_ids:
|
||||
if attempted >= auto_decompose_per_tick:
|
||||
break
|
||||
attempted += 1
|
||||
try:
|
||||
outcome = _decomp.decompose_task(
|
||||
tid, author="auto-decomposer",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"kanban auto-decompose: decompose_task crashed on %s",
|
||||
tid,
|
||||
)
|
||||
continue
|
||||
if outcome.ok:
|
||||
successes += 1
|
||||
if outcome.fanout and outcome.child_ids:
|
||||
logger.info(
|
||||
"kanban auto-decompose [%s]: %s → %d children",
|
||||
slug, tid, len(outcome.child_ids),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"kanban auto-decompose [%s]: %s → single task (no fanout)",
|
||||
slug, tid,
|
||||
)
|
||||
else:
|
||||
# Common no-op reasons (no aux client configured) shouldn't
|
||||
# spam logs every tick. Log at debug.
|
||||
logger.debug(
|
||||
"kanban auto-decompose [%s]: %s skipped: %s",
|
||||
slug, tid, outcome.reason,
|
||||
)
|
||||
finally:
|
||||
if prev_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_env
|
||||
return successes
|
||||
|
||||
logger.info(
|
||||
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
|
||||
)
|
||||
while self._running:
|
||||
try:
|
||||
if auto_decompose_enabled:
|
||||
await asyncio.to_thread(_auto_decompose_tick)
|
||||
results = await asyncio.to_thread(_tick_once)
|
||||
any_spawned = False
|
||||
for slug, res in (results or []):
|
||||
@@ -8845,7 +8940,7 @@ class GatewayRunner:
|
||||
lines.append("Failed/paused: (none)")
|
||||
return "\n".join(lines)
|
||||
|
||||
if action in ("pause", "resume"):
|
||||
if action in {"pause", "resume"}:
|
||||
if not target:
|
||||
return f"Usage: /platform {action} <name>"
|
||||
platform = _resolve_platform(target)
|
||||
@@ -8953,13 +9048,15 @@ class GatewayRunner:
|
||||
logger.debug("Failed to write restart dedup marker: %s", e)
|
||||
|
||||
active_agents = self._running_agent_count()
|
||||
# When running under a service manager (systemd/launchd), use the
|
||||
# service restart path: exit with code 75 so the service manager
|
||||
# restarts us. The detached subprocess approach (setsid + bash)
|
||||
# doesn't work under systemd because KillMode=mixed kills all
|
||||
# processes in the cgroup, including the detached helper.
|
||||
# When running under a service manager (systemd/launchd) or inside a
|
||||
# Docker/Podman container, use the service restart path: exit with
|
||||
# code 75 so the service manager / container restart policy restarts
|
||||
# us. The detached subprocess approach (setsid + bash) doesn't work
|
||||
# under systemd (KillMode=mixed kills the cgroup) or Docker (tini
|
||||
# exits when the gateway dies, taking the detached helper with it).
|
||||
_under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this
|
||||
if _under_service:
|
||||
_in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
||||
if _under_service or _in_container:
|
||||
self.request_restart(detached=False, via_service=True)
|
||||
else:
|
||||
self.request_restart(detached=True, via_service=False)
|
||||
@@ -12528,6 +12625,12 @@ class GatewayRunner:
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
):
|
||||
metadata["telegram_dm_topic_reply_fallback"] = True
|
||||
# Telegram DM topic lanes need direct_messages_topic_id in metadata
|
||||
# so synthetic/queued messages (goal continuations, status notices)
|
||||
# route to the correct topic even when reply anchor is unavailable.
|
||||
tid = str(thread_id)
|
||||
if tid and tid not in {"", "1"}:
|
||||
metadata["direct_messages_topic_id"] = tid
|
||||
anchor = reply_to_message_id or getattr(source, "message_id", None)
|
||||
if anchor is not None:
|
||||
metadata["telegram_reply_to_message_id"] = str(anchor)
|
||||
@@ -12813,7 +12916,11 @@ class GatewayRunner:
|
||||
update_cmd = (
|
||||
f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway"
|
||||
f" > {shlex.quote(str(output_path))} 2>&1; "
|
||||
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
|
||||
# Avoid `status=$?`: `status` is a read-only special parameter
|
||||
# in zsh, and this command string is copied/reused in macOS/zsh
|
||||
# operator wrappers. Keep the template zsh-safe even though this
|
||||
# specific subprocess currently runs under bash.
|
||||
f"rc=$?; printf '%s' \"$rc\" > {shlex.quote(str(exit_code_path))}"
|
||||
)
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
|
||||
Reference in New Issue
Block a user