From 12c58645e4b286b2d4b17fa12688926b6c3de17d Mon Sep 17 00:00:00 2001 From: Khusnudhoni Hendra M <66800518+CarlitoDon@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:49:00 +0700 Subject: [PATCH 1/3] feat(kanban): add inject_as_turn notification option to trigger active agent turns --- agent/agent_init.py | 2 +- gateway/kanban_watchers.py | 40 +++++++++++++++++++----- hermes_cli/commands.py | 62 +++++++++++++++++++++++--------------- hermes_cli/kanban.py | 8 ++++- hermes_cli/kanban_db.py | 24 ++++++++++++--- hermes_constants.py | 2 ++ toolsets.py | 2 ++ 7 files changed, 102 insertions(+), 38 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 41f7cc11bbb1..74b83d954e9d 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -32,7 +32,6 @@ from agent.context_compressor import ContextCompressor from agent.iteration_budget import IterationBudget -from agent.memory_manager import StreamingContextScrubber from agent.model_metadata import ( MINIMUM_CONTEXT_LENGTH, fetch_model_metadata, @@ -600,6 +599,7 @@ def init_agent( # Stateful scrubber for spans split across stream # deltas (#5719). sanitize_context() alone can't survive chunk # boundaries because the block regex needs both tags in one string. + from agent.memory_manager import StreamingContextScrubber agent._stream_context_scrubber = StreamingContextScrubber() # Stateful scrubber for reasoning/thinking tags in streamed deltas # (#17924). Replaces the per-delta _strip_think_blocks regex that diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index 5bcf70c8d218..46b82b6c7462 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -384,13 +384,39 @@ def _collect(): sub["chat_id"], sub.get("thread_id") or "", ) try: - await adapter.send( - sub["chat_id"], msg, metadata=metadata, - ) - logger.debug( - "kanban notifier: delivered %s event for %s to %s/%s on board %s", - kind, sub["task_id"], platform_str, sub["chat_id"], board_slug, - ) + inject = bool(sub.get("inject_as_turn", 0)) + if inject: + # Inject as synthetic MessageEvent with internal=True so the + # message triggers an agent turn (rather than a silent push). + from gateway.session import SessionSource + from gateway.platforms.base import MessageEvent, MessageType + plat = _Platform(platform_str) + source = SessionSource( + platform=plat, + chat_id=sub["chat_id"], + chat_type="direct", + user_id=sub.get("user_id") or None, + thread_id=str(sub.get("thread_id") or "").strip() or None, + ) + synth_event = MessageEvent( + text=msg, + message_type=MessageType.TEXT, + source=source, + internal=True, + ) + await adapter.handle_message(synth_event) + logger.debug( + "kanban notifier: injected %s event for %s as turn in %s/%s on board %s", + kind, sub["task_id"], platform_str, sub["chat_id"], board_slug, + ) + else: + await adapter.send( + sub["chat_id"], msg, metadata=metadata, + ) + logger.debug( + "kanban notifier: delivered %s event for %s to %s/%s on board %s", + kind, sub["task_id"], platform_str, sub["chat_id"], board_slug, + ) # After delivering the text notification, surface # any artifact paths the worker referenced in # ``kanban_complete(summary=..., artifacts=[...])`` diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index bdba0af1cfa5..52c1181bf1c0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -547,6 +547,8 @@ def telegram_bot_commands() -> list[tuple[str, str]]: _TELEGRAM_PRIORITY_MODES = {"prepend", "append", "replace"} _TELEGRAM_MENU_PRIORITY = ( + # Custom pinned commands + "llm-wiki", # Most-typed everyday commands first. "help", "new", @@ -887,38 +889,48 @@ def _collect_gateway_skill_entries( # --------------------------------------------------------------------------- def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: - """Return Telegram menu commands capped to the Bot API limit. - - Priority order (higher priority = never bumped by overflow): - 1. Core CommandDef commands (always included) - 2. Plugin slash commands (take precedence over skills) - 3. Built-in skill commands (fill remaining slots, alphabetical) - - Skills are the only tier that gets trimmed when the cap is hit. - User-installed hub skills are excluded — accessible via /skills. - Skills disabled for the ``"telegram"`` platform (via ``hermes skills - config``) are excluded from the menu entirely. - - Returns: - (menu_commands, hidden_count) where hidden_count is the number of - commands omitted due to the cap. - """ + """Return Telegram menu commands capped to the Bot API limit.""" + # Build list of core commands core_commands = _prioritize_telegram_menu_commands(list(telegram_bot_commands())) + + # We want to pin llm-wiki, which is a skill command, to the top of the menu if it exists. + # To do this, we collect all skill entries first. reserved_names = {n for n, _ in core_commands} - all_commands = list(core_commands) - hidden_core_count = max(0, len(all_commands) - max_commands) - - remaining_slots = max(0, max_commands - len(all_commands)) + entries, hidden_count = _collect_gateway_skill_entries( platform="telegram", - max_slots=remaining_slots, - reserved_names=reserved_names, + max_slots=200, # Large buffer to search all skills + reserved_names=reserved_names.copy(), desc_limit=40, sanitize_name=_sanitize_telegram_name, ) - # Drop the cmd_key — Telegram only needs (name, desc) pairs. - all_commands.extend((n, d) for n, d, _k in entries) - return all_commands[:max_commands], hidden_count + hidden_core_count + + # Locate llm-wiki + pinned_skills = [] + other_skills = [] + for n, d, k in entries: + if n == "llm-wiki" or n == "llm_wiki": + pinned_skills.append((n, d)) + else: + other_skills.append((n, d)) + + # Combine lists: Pinned skills first, then core commands, then other skills + combined = pinned_skills + core_commands + other_skills + + # Now slice to max_commands and compute the actual hidden count + menu_commands = combined[:max_commands] + + # Calculate hidden totals + total_skills_count = len(entries) + skills_in_menu = len([c for c in menu_commands if c in [(n, d) for n, d, _ in entries]]) + hidden_skills = total_skills_count - skills_in_menu + + total_cores_count = len(core_commands) + cores_in_menu = len([c for c in menu_commands if c in core_commands]) + hidden_cores = total_cores_count - cores_in_menu + + return menu_commands, hidden_skills + hidden_cores + def discord_skill_commands( diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 7fc7bf948950..7512f4ce0016 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -705,6 +705,10 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "--notifier-profile", default=None, help="Profile gateway that owns/delivers this subscription (default: active profile)", ) + p_nsub.add_argument( + "--inject", action="store_true", + help="Inject notification as a user message (triggers agent turn instead of silent push)", + ) p_nlist = sub.add_parser( "notify-list", @@ -2441,8 +2445,10 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int: platform=args.platform, chat_id=args.chat_id, thread_id=args.thread_id, user_id=args.user_id, notifier_profile=args.notifier_profile or _profile_author(), + inject_as_turn=getattr(args, 'inject', False), ) - print(f"Subscribed {args.platform}:{args.chat_id}" + inject_hint = " (inject as turn)" if getattr(args, 'inject', False) else "" + print(f"Subscribed {args.platform}:{args.chat_id}{inject_hint}" + (f":{args.thread_id}" if args.thread_id else "") + f" to {args.task_id}") return 0 diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6150b141537b..a1c339704e84 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1258,6 +1258,7 @@ class Event: thread_id TEXT NOT NULL DEFAULT '', user_id TEXT, notifier_profile TEXT, + inject_as_turn INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_event_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (task_id, platform, chat_id, thread_id) @@ -2026,6 +2027,10 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: _add_column_if_missing( conn, "kanban_notify_subs", "notifier_profile", "notifier_profile TEXT" ) + if "inject_as_turn" not in notify_cols: + _add_column_if_missing( + conn, "kanban_notify_subs", "inject_as_turn", "inject_as_turn INTEGER NOT NULL DEFAULT 0" + ) # One-shot backfill: any task that is 'running' before runs existed # had its claim_lock / claim_expires / worker_pid on the task row. @@ -2149,7 +2154,8 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: "CREATE TABLE kanban_notify_subs (" " task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL," " thread_id TEXT NOT NULL DEFAULT '', user_id TEXT," - " notifier_profile TEXT, created_at INTEGER NOT NULL," + " notifier_profile TEXT, inject_as_turn INTEGER NOT NULL DEFAULT 0," + " created_at INTEGER NOT NULL," " last_event_id INTEGER NOT NULL DEFAULT 0," " PRIMARY KEY (task_id, platform, chat_id, thread_id))", ("CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id)",), @@ -8242,18 +8248,28 @@ def add_notify_sub( thread_id: Optional[str] = None, user_id: Optional[str] = None, notifier_profile: Optional[str] = None, + inject_as_turn: bool = False, ) -> None: """Register a gateway source that wants terminal-state notifications for ``task_id``. Idempotent on (task, platform, chat, thread).""" now = int(time.time()) + inject_val = 1 if inject_as_turn else 0 with write_txn(conn): conn.execute( """ INSERT OR IGNORE INTO kanban_notify_subs - (task_id, platform, chat_id, thread_id, user_id, notifier_profile, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + (task_id, platform, chat_id, thread_id, user_id, notifier_profile, inject_as_turn, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, platform, chat_id, thread_id or "", user_id, notifier_profile, inject_val, now), + ) + conn.execute( + """ + UPDATE kanban_notify_subs + SET inject_as_turn = ? + WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? """, - (task_id, platform, chat_id, thread_id or "", user_id, notifier_profile, now), + (inject_val, task_id, platform, chat_id, thread_id or ""), ) if notifier_profile: # Self-heal legacy rows that predate notifier ownership by diff --git a/hermes_constants.py b/hermes_constants.py index 526bb0ed473b..bf537e1c30c7 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -4,6 +4,8 @@ without risk of circular imports. """ +from __future__ import annotations + import os import shutil import stat diff --git a/toolsets.py b/toolsets.py index 1453c3505f83..4c1ba1a8ff62 100644 --- a/toolsets.py +++ b/toolsets.py @@ -77,6 +77,8 @@ "kanban_unblock", # Computer use (macOS, gated on cua-driver being installed via check_fn) "computer_use", + # Sequential Thinking MCP + "mcp_sequential_thinking_sequential_thinking", ] # Webhook events may originate from untrusted third-party content (for example, From f977a48ce3ed1bb2684714fb0c9cfdda2ed80dac Mon Sep 17 00:00:00 2001 From: Khusnudhoni Hendra M <66800518+CarlitoDon@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:00:33 +0700 Subject: [PATCH 2/3] feat(kanban_tools): add inject_notification param to kanban_create tool --- tools/kanban_tools.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index c78317f63f88..8e358fef8598 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -793,6 +793,7 @@ def _handle_create(args: dict, **kw) -> str: idempotency_key = args.get("idempotency_key") max_runtime_seconds = args.get("max_runtime_seconds") initial_status = args.get("initial_status") or "running" + inject_notification = bool(args.get("inject_notification", False)) skills = args.get("skills") if isinstance(skills, str): # Accept a single skill name as a string for convenience. @@ -855,7 +856,7 @@ def _handle_create(args: dict, **kw) -> str: session_id=session_id, ) new_task = kb.get_task(conn, new_tid) - subscribed = _maybe_auto_subscribe(conn, new_tid) + subscribed = _maybe_auto_subscribe(conn, new_tid, inject_notification=inject_notification) return _ok( task_id=new_tid, status=new_task.status if new_task else None, @@ -870,7 +871,7 @@ def _handle_create(args: dict, **kw) -> str: return tool_error(f"kanban_create: {e}") -def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: +def _maybe_auto_subscribe(conn: Any, task_id: str, inject_notification: bool = False) -> bool: """Auto-subscribe the calling session to task completion / block events. Returns True if a subscription row was written, False otherwise (no @@ -956,6 +957,7 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: platform=platform, chat_id=chat_id, thread_id=thread_id, user_id=user_id, notifier_profile=notifier_profile, + inject_as_turn=inject_notification, ) return True except Exception as _exc: @@ -1422,6 +1424,16 @@ def _board_schema_prop() -> dict[str, str]: "'running', which preserves the usual dispatch path." ), }, + "inject_notification": { + "type": "boolean", + "description": ( + "If true, notification pushes to subscribed chats are " + "injected as a synthetic inbound message (triggers an " + "agent turn) instead of a silent push. Useful when the " + "originating worker wants the orchestrator to discuss " + "the result or delegate follow-ups." + ), + }, "skills": { "type": "array", "items": {"type": "string"}, From 3a7a81e06c1860ea69e21491dfd24525f973ffd6 Mon Sep 17 00:00:00 2001 From: Khusnudhoni Hendra M <66800518+CarlitoDon@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:19:04 +0700 Subject: [PATCH 3/3] fix(kanban): route inject_as_turn to dm session instead of direct --- gateway/kanban_watchers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index 46b82b6c7462..2e6d43d47499 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -394,7 +394,7 @@ def _collect(): source = SessionSource( platform=plat, chat_id=sub["chat_id"], - chat_type="direct", + chat_type="dm", user_id=sub.get("user_id") or None, thread_id=str(sub.get("thread_id") or "").strip() or None, )