Skip to content

fix: dispatch unknown slash commands to the agent bridge (/plan, /spike, /goal, …)#983

Open
paulocavallari wants to merge 2 commits into
EKKOLearnAI:mainfrom
paulocavallari:feature/skill-commands-fix
Open

fix: dispatch unknown slash commands to the agent bridge (/plan, /spike, /goal, …)#983
paulocavallari wants to merge 2 commits into
EKKOLearnAI:mainfrom
paulocavallari:feature/skill-commands-fix

Conversation

@paulocavallari
Copy link
Copy Markdown
Contributor

Summary

The Web UI was eating any /something input that wasn't one of the nine hard-coded "session commands" (/usage, /clear, /title, /abort, /queue, /compress, /steer, /destroy, /status). Skill commands like /plan, /spike, /tdd, plus persistent commands like /goal, never reached the Python bridge — they were rewritten to {name: 'status', rawName: 'plan'} and the gateway answered with Unknown bridge command: /plan.

The CLI (cli.py::process_command) and gateway adapters (gateway/run.py::_handle_message) resolve these the right way, using the canonical helpers in hermes-agent. The Web UI just never grew the same hook. This PR adds the missing dispatch.

Repro (before)

In Web UI:

/plan write a function that adds two numbers

→ Server logs: Unknown bridge command: /plan. Skill never loaded.

Fix (two layers)

1. session-command.ts::parseSessionCommand returns null for unknown aliases instead of fabricating {name: 'status', rawName: 'plan'}. Unknown slash inputs now fall through to handleBridgeRun like any normal user message.

   const rawName = match[1].toLowerCase()
   const name = COMMAND_ALIASES[rawName]
-  if (!name) return { name: 'status', rawName, args: match[2]?.trim() || '' }
+  // Only return a parsed command for KNOWN bridge-level aliases. Unknown
+  // slash inputs (e.g. /plan, /spike, /goal, /tdd) must fall through to
+  // handleBridgeRun so the Python bridge can resolve them as skill
+  // commands or surface them to the agent as regular user input.
+  if (!name) return null
   return { name, rawName, args: match[2]?.trim() || '' }

2. hermes_bridge.py::_run_chat resolves /skill-name (and /bundle-name) inputs after prepersisting the literal slash command to the session DB and before calling AIAgent.run_conversation(). Resolution uses upstream helpers (agent.skill_commands.build_skill_invocation_message, agent.skill_bundles.build_bundle_invocation_message) so the Web UI shares the canonical injection logic with the CLI and gateway — no duplicated SKILL.md parsing, no drift if those helpers change.

The Python hook is pure-additive and wrapped in defensive try/except so import failures or missing skills silently fall back to the original message — never breaks a run.

Behavior after

Input Before After
/plan criar plano de aula Unknown bridge command: /plan DB sees /plan ...; model receives full SKILL.md content + instruction (matches CLI/Telegram/Discord)
/spike / /tdd / /goal / any of the 88+ skill commands Same error Resolved as the corresponding skill
/foobar (unknown, not a skill) Same error Flows through as a plain user message (matches CLI behavior)
/usage, /clear, /abort, /title, /compress, /steer, /queue, /destroy Worked Worked (untouched)

Verification

Tested live on a self-hosted Hermes Web UI v0.6.0 deployment with the kiro-go profile (88 skill commands, including /plan):

  • /plan verifique se a correção dos comandos está funcionando → SKILL.md was injected as user message with the standard [IMPORTANT: The user has invoked the "plan" skill...] activation header, plus the trailing user instruction. The model executed plan-mode behavior identical to CLI.
  • Bridge logs confirmed: Resolved /plan slash command for session <sid> (2519 chars).
  • Existing bridge commands (/usage, /clear) keep working unchanged.

Risk

Low. The TS change tightens behavior (less false-positive command parsing) without removing any working code path. The Python change is additive — a try/except wrapper around two helper calls — and falls back to the original message on any failure.

Notes for reviewers

  • The Python helpers (agent.skill_commands, agent.skill_bundles) are part of hermes-agent proper and are already imported elsewhere in hermes_bridge.py's runtime path. They're stable upstream APIs (used by cli.py and gateway/run.py).
  • The _run_chat hook runs after _prepersist_user_message so the user's literal /plan text is what gets stored in session history (matching CLI behavior where the typed command is preserved in transcripts), while the resolved skill content is what the model sees.
  • No new dependencies. No config changes. No schema changes.

The Web UI was eating any `/something` input that wasn't one of the nine
hard-coded "session commands" (`/usage`, `/clear`, `/title`, etc.).
Skill commands like `/plan`, `/spike`, `/tdd`, plus persistent commands
like `/goal`, never reached the Python bridge — they were rewritten to
`{name: 'status', rawName: 'plan'}` and the gateway answered with
"Unknown bridge command: /plan". The CLI and gateway adapters resolve
these the right way; the Web UI just never grew the same hook.

This PR adds the missing dispatch on both layers:

1. `session-command.ts::parseSessionCommand` returns `null` for unknown
   aliases instead of fabricating a fake command. Unknown slash inputs
   now fall through to `handleBridgeRun` like any normal user message.

2. `hermes_bridge.py::_run_chat` resolves `/skill-name` (and
   `/bundle-name`) inputs **after** prepersisting the literal slash
   command to the session DB and **before** calling
   `AIAgent.run_conversation()`. Resolution uses the upstream helpers
   (`agent.skill_commands.build_skill_invocation_message`,
   `agent.skill_bundles.build_bundle_invocation_message`) so the Web
   UI shares the canonical injection logic with the CLI and gateway —
   no duplicated SKILL.md parsing, no drift if those helpers change.

Behavior:
- `/plan criar plano de aula` → DB sees the literal `/plan ...` text;
  the model receives the SKILL.md content + user instruction, exactly
  like in CLI / Telegram / Discord.
- Unknown `/foobar` input → flows through as a plain user message
  instead of erroring out, matching CLI behavior.
- Existing bridge commands (`/usage`, `/clear`, `/abort`, `/title`,
  `/compress`, `/steer`, `/queue`, `/destroy`) keep working unchanged.

The Python hook is pure-additive and wrapped in defensive try/except so
import failures or missing skills silently fall back to the original
message — never breaks a run.
Copilot AI review requested due to automatic review settings May 24, 2026 13:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Routes unknown slash commands (e.g., /plan, /spike, /goal, /tdd) from the TS bridge layer down to the Python bridge, where they are resolved against skill bundles and individual skill commands before being sent to the agent.

Changes:

  • parseSessionCommand no longer coerces unknown slash inputs into a status command; it returns null so they fall through to handleBridgeRun.
  • hermes_bridge.py adds post-prepersist slash resolution that consults agent.skill_bundles then agent.skill_commands, replacing message with the resolved invocation text on success and silently falling back on failure.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
packages/server/src/services/hermes/run-chat/session-command.ts Return null for unknown slash commands so they pass through to the bridge.
packages/server/src/services/hermes/agent-bridge/hermes_bridge.py Adds skill-bundle/skill-command resolution for /name [args] messages with defensive try/excepts and logging.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1322 to +1355
if _slash_text.startswith("/") and len(_slash_text) > 1:
_slash_match = re.match(r"^/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$", _slash_text)
if _slash_match:
_cmd_raw = _slash_match.group(1)
_cmd_args = (_slash_match.group(2) or "").strip()
_resolved_msg: Optional[str] = None

# 1) Skill bundles first (mirrors gateway/run.py order)
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
_bundle_key = resolve_bundle_command_key(_cmd_raw)
if _bundle_key is not None:
_bundle_result = build_bundle_invocation_message(
_bundle_key, _cmd_args, task_id=session.session_id
)
if _bundle_result:
_bundle_msg, _loaded_bundle, _missing_bundle = _bundle_result
if _bundle_msg:
_resolved_msg = _bundle_msg
if _missing_bundle:
logger.info(
"Bundle /%s skipped missing skills: %s",
_cmd_raw, ", ".join(_missing_bundle),
)
except Exception as _bundle_exc:
logger.debug(
"Skill bundle dispatch failed (non-fatal) for /%s: %s",
_cmd_raw, _bundle_exc,
)

# 2) Individual skill command
Comment on lines +1330 to +1334
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
Comment on lines +1349 to +1353
except Exception as _bundle_exc:
logger.debug(
"Skill bundle dispatch failed (non-fatal) for /%s: %s",
_cmd_raw, _bundle_exc,
)
Comment on lines +1319 to +1381
try:
if isinstance(message, str) and message.startswith("/"):
_slash_text = message.strip()
if _slash_text.startswith("/") and len(_slash_text) > 1:
_slash_match = re.match(r"^/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$", _slash_text)
if _slash_match:
_cmd_raw = _slash_match.group(1)
_cmd_args = (_slash_match.group(2) or "").strip()
_resolved_msg: Optional[str] = None

# 1) Skill bundles first (mirrors gateway/run.py order)
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
_bundle_key = resolve_bundle_command_key(_cmd_raw)
if _bundle_key is not None:
_bundle_result = build_bundle_invocation_message(
_bundle_key, _cmd_args, task_id=session.session_id
)
if _bundle_result:
_bundle_msg, _loaded_bundle, _missing_bundle = _bundle_result
if _bundle_msg:
_resolved_msg = _bundle_msg
if _missing_bundle:
logger.info(
"Bundle /%s skipped missing skills: %s",
_cmd_raw, ", ".join(_missing_bundle),
)
except Exception as _bundle_exc:
logger.debug(
"Skill bundle dispatch failed (non-fatal) for /%s: %s",
_cmd_raw, _bundle_exc,
)

# 2) Individual skill command
if _resolved_msg is None:
try:
from agent.skill_commands import (
build_skill_invocation_message,
resolve_skill_command_key,
)
_cmd_key = resolve_skill_command_key(_cmd_raw)
if _cmd_key is not None:
_skill_msg = build_skill_invocation_message(
_cmd_key, _cmd_args, task_id=session.session_id
)
if _skill_msg:
_resolved_msg = _skill_msg
except Exception as _skill_exc:
logger.debug(
"Skill command dispatch failed (non-fatal) for /%s: %s",
_cmd_raw, _skill_exc,
)

if _resolved_msg:
logger.info(
"Resolved /%s slash command for session %s (%d chars)",
_cmd_raw, session.session_id, len(_resolved_msg),
)
message = _resolved_msg
except Exception as _slash_exc:
Comment on lines +1319 to +1385
try:
if isinstance(message, str) and message.startswith("/"):
_slash_text = message.strip()
if _slash_text.startswith("/") and len(_slash_text) > 1:
_slash_match = re.match(r"^/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$", _slash_text)
if _slash_match:
_cmd_raw = _slash_match.group(1)
_cmd_args = (_slash_match.group(2) or "").strip()
_resolved_msg: Optional[str] = None

# 1) Skill bundles first (mirrors gateway/run.py order)
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
_bundle_key = resolve_bundle_command_key(_cmd_raw)
if _bundle_key is not None:
_bundle_result = build_bundle_invocation_message(
_bundle_key, _cmd_args, task_id=session.session_id
)
if _bundle_result:
_bundle_msg, _loaded_bundle, _missing_bundle = _bundle_result
if _bundle_msg:
_resolved_msg = _bundle_msg
if _missing_bundle:
logger.info(
"Bundle /%s skipped missing skills: %s",
_cmd_raw, ", ".join(_missing_bundle),
)
except Exception as _bundle_exc:
logger.debug(
"Skill bundle dispatch failed (non-fatal) for /%s: %s",
_cmd_raw, _bundle_exc,
)

# 2) Individual skill command
if _resolved_msg is None:
try:
from agent.skill_commands import (
build_skill_invocation_message,
resolve_skill_command_key,
)
_cmd_key = resolve_skill_command_key(_cmd_raw)
if _cmd_key is not None:
_skill_msg = build_skill_invocation_message(
_cmd_key, _cmd_args, task_id=session.session_id
)
if _skill_msg:
_resolved_msg = _skill_msg
except Exception as _skill_exc:
logger.debug(
"Skill command dispatch failed (non-fatal) for /%s: %s",
_cmd_raw, _skill_exc,
)

if _resolved_msg:
logger.info(
"Resolved /%s slash command for session %s (%d chars)",
_cmd_raw, session.session_id, len(_resolved_msg),
)
message = _resolved_msg
except Exception as _slash_exc:
# Defensive: any unexpected failure must not break the run.
logger.warning(
"Slash command resolution failed (non-fatal): %s", _slash_exc,
)
Apply the five suggestions from the automated Copilot review on PR EKKOLearnAI#983:

1. **Hoist skill / bundle imports to module scope.** Both
   `agent.skill_commands` and `agent.skill_bundles` are now imported once
   at module load, with a single try/except guarding availability.
   ImportError now surfaces as a startup WARNING ("Skill command/bundle
   support unavailable; /<name> commands will fall back to plain user
   input: ...") instead of being silently swallowed at DEBUG on every
   request. Per-request lookups reuse the resolved callables — no more
   import cost on the chat hot path.

2. **Compile the slash regex once.** New `_SLASH_COMMAND_RE` constant at
   module scope replaces the per-request `re.match(...)` call.

3. **Collapse the redundant guard.** The previous nested
   `message.startswith("/")` + `_slash_text.startswith("/") and len > 1`
   pair is gone — the regex handles both checks in one shot.

4. **Narrow the per-request excepts.** Inner blocks now catch
   `(AttributeError, KeyError, TypeError, ValueError)` (the realistic
   failure modes from the helper return shapes) with `exc_info=True` so
   tracebacks land in DEBUG logs when something does go wrong. Future
   refactors that introduce a real bug will surface it through the bridge
   logger instead of being silently downgraded.

5. **Remove the outer catch-all.** With the inner excepts narrowed and
   the imports hoisted, the outer `try/except Exception` was redundant —
   it would only fire on the regex / isinstance / logger calls, which
   shouldn't realistically raise. Removed.

The contract is unchanged: `/plan`, `/spike`, `/goal`, `/tdd` and all
other skill commands still get resolved before `run_conversation()`,
the literal `/...` text still gets persisted to session history first,
and resolution failures still fall back gracefully to the original
message. Verified end-to-end on a live deployment after rebuild.
@paulocavallari
Copy link
Copy Markdown
Contributor Author

Thanks for the review! Pushed d36d6ff addressing all five points:

  1. Hoisted importsagent.skill_bundles and agent.skill_commands resolved once at module load. ImportError now surfaces as a startup WARNING instead of silent per-request DEBUG noise. Helpers reused via module-level callables.
  2. _SLASH_COMMAND_RE compiled once at module scope.
  3. Single guard — the redundant startswith("/") + length check pair is gone; regex match is the single source of truth.
  4. Narrowed per-request excepts to (AttributeError, KeyError, TypeError, ValueError) with exc_info=True so future bugs surface in DEBUG tracebacks rather than being silently downgraded.
  5. Outer catch-all removed.

Verified end-to-end on a live deployment after rebuild — /plan still resolves cleanly, no startup WARNINGs about missing skill modules, and the contract (literal /... persisted to history, resolved SKILL.md sent to model, graceful fallback on failure) is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants