Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -600,6 +599,7 @@ def init_agent(
# Stateful scrubber for <memory-context> 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
Expand Down
40 changes: 33 additions & 7 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="dm",
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=[...])``
Expand Down
62 changes: 37 additions & 25 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines 549 to 552
"help",
"new",
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)",),
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
without risk of circular imports.
"""

from __future__ import annotations

import os
import shutil
import stat
Expand Down
16 changes: 14 additions & 2 deletions tools/kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"},
Expand Down
2 changes: 2 additions & 0 deletions toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down