mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(tui): dispatch custom skill bundles as agent turns (#62859)
This commit is contained in:
parent
f8054601a8
commit
964ecef401
2 changed files with 204 additions and 5 deletions
|
|
@ -1266,6 +1266,58 @@ def test_slash_exec_rejects_skill_commands(server):
|
|||
assert "skill command" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_slash_exec_routes_custom_skill_bundle_away_from_worker(server):
|
||||
"""slash.exec expands any custom bundle through command.dispatch."""
|
||||
sid = "test-session"
|
||||
|
||||
class Worker:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def run(self, cmd):
|
||||
self.calls.append(cmd)
|
||||
return f"worker:{cmd}"
|
||||
|
||||
worker = Worker()
|
||||
server._sessions[sid] = {
|
||||
"session_key": sid,
|
||||
"agent": None,
|
||||
"slash_worker": worker,
|
||||
}
|
||||
fake_bundles = {
|
||||
"/analysis-pack": {
|
||||
"name": "analysis-pack",
|
||||
"skills": ["source-check", "claim-audit"],
|
||||
}
|
||||
}
|
||||
fake_msg = (
|
||||
'[IMPORTANT: The user has invoked the "analysis-pack" skill bundle.]\n\n'
|
||||
"User instruction: compare vector databases"
|
||||
)
|
||||
|
||||
with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \
|
||||
patch(
|
||||
"agent.skill_bundles.build_bundle_invocation_message",
|
||||
return_value=(fake_msg, ["source-check", "claim-audit"], []),
|
||||
):
|
||||
resp = server.handle_request({
|
||||
"id": "r-bundle-slash",
|
||||
"method": "slash.exec",
|
||||
"params": {
|
||||
"command": "analysis-pack compare vector databases",
|
||||
"session_id": sid,
|
||||
},
|
||||
})
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"] == {
|
||||
"type": "send",
|
||||
"message": fake_msg,
|
||||
"notice": "⚡ Loading bundle: analysis-pack (2 skills)",
|
||||
}
|
||||
assert worker.calls == []
|
||||
|
||||
|
||||
def test_slash_exec_handles_plugin_commands_in_live_gateway(server):
|
||||
"""Plugin slash commands return normal slash.exec output without using the worker."""
|
||||
sid = "test-session"
|
||||
|
|
@ -1418,6 +1470,37 @@ def test_command_dispatch_queue_sends_message(server):
|
|||
assert result["message"] == "tell me about quantum computing"
|
||||
|
||||
|
||||
def test_command_dispatch_builtin_queue_wins_over_colliding_bundle(server):
|
||||
"""A custom /queue bundle must not shadow the built-in /queue command."""
|
||||
sid = "test-session"
|
||||
server._sessions[sid] = {"session_key": sid}
|
||||
fake_bundles = {
|
||||
"/queue": {
|
||||
"name": "queue",
|
||||
"skills": ["source-check", "claim-audit"],
|
||||
}
|
||||
}
|
||||
|
||||
with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \
|
||||
patch("agent.skill_bundles.build_bundle_invocation_message") as build_bundle:
|
||||
resp = server.handle_request({
|
||||
"id": "r-queue-collision",
|
||||
"method": "command.dispatch",
|
||||
"params": {
|
||||
"name": "queue",
|
||||
"arg": "tell me about quantum computing",
|
||||
"session_id": sid,
|
||||
},
|
||||
})
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"] == {
|
||||
"type": "send",
|
||||
"message": "tell me about quantum computing",
|
||||
}
|
||||
build_bundle.assert_not_called()
|
||||
|
||||
|
||||
def test_command_dispatch_queue_requires_arg(server):
|
||||
"""command.dispatch /queue without an argument returns an error."""
|
||||
sid = "test-session"
|
||||
|
|
@ -1619,6 +1702,54 @@ def test_command_dispatch_returns_skill_payload(server):
|
|||
assert result["name"] == "hermes-agent-dev"
|
||||
|
||||
|
||||
def test_command_dispatch_returns_custom_bundle_payload(server):
|
||||
"""command.dispatch preserves bundle arguments in a sendable agent turn."""
|
||||
sid = "test-session"
|
||||
server._sessions[sid] = {"session_key": sid}
|
||||
fake_bundles = {
|
||||
"/review-suite": {
|
||||
"name": "review-suite",
|
||||
"skills": ["source-check", "claim-audit", "enough-research"],
|
||||
}
|
||||
}
|
||||
arg = "audit the migration plan"
|
||||
fake_msg = (
|
||||
'[IMPORTANT: The user has invoked the "review-suite" skill bundle.]\n\n'
|
||||
f"User instruction: {arg}"
|
||||
)
|
||||
|
||||
with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \
|
||||
patch(
|
||||
"agent.skill_bundles.build_bundle_invocation_message",
|
||||
return_value=(
|
||||
fake_msg,
|
||||
["source-check", "claim-audit", "enough-research"],
|
||||
[],
|
||||
),
|
||||
) as build_bundle, \
|
||||
patch("agent.skill_commands.build_skill_invocation_message") as build_skill, \
|
||||
patch.object(server, "_resolve_session_platform", return_value="tui"):
|
||||
resp = server.handle_request({
|
||||
"id": "r-bundle-dispatch",
|
||||
"method": "command.dispatch",
|
||||
"params": {"name": "review-suite", "arg": arg, "session_id": sid},
|
||||
})
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"] == {
|
||||
"type": "send",
|
||||
"message": fake_msg,
|
||||
"notice": "⚡ Loading bundle: review-suite (3 skills)",
|
||||
}
|
||||
build_bundle.assert_called_once_with(
|
||||
"/review-suite",
|
||||
arg,
|
||||
task_id=sid,
|
||||
platform="tui",
|
||||
)
|
||||
build_skill.assert_not_called()
|
||||
|
||||
|
||||
def test_command_dispatch_awaits_async_plugin_handler(server):
|
||||
async def _handler(arg):
|
||||
return f"async:{arg}"
|
||||
|
|
|
|||
|
|
@ -11866,6 +11866,52 @@ def _(rid, params: dict) -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from agent.skill_bundles import (
|
||||
build_bundle_invocation_message,
|
||||
get_skill_bundles,
|
||||
resolve_bundle_command_key,
|
||||
)
|
||||
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
bundle_key = (
|
||||
resolve_bundle_command_key(name)
|
||||
if resolve_command(name) is None
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
bundle_key = None
|
||||
|
||||
if bundle_key is not None:
|
||||
try:
|
||||
bundle_result = build_bundle_invocation_message(
|
||||
bundle_key,
|
||||
arg,
|
||||
task_id=session.get("session_key", "") if session else "",
|
||||
platform=_resolve_session_platform(),
|
||||
)
|
||||
except Exception as exc:
|
||||
return _err(rid, 4018, f"bundle dispatch failed: {exc}")
|
||||
|
||||
if not bundle_result:
|
||||
return _err(rid, 4018, f"failed to load bundle: {bundle_key}")
|
||||
|
||||
msg, loaded_names, missing = bundle_result
|
||||
bundle_info = get_skill_bundles().get(bundle_key, {})
|
||||
bundle_name = bundle_info.get("name", bundle_key.lstrip("/"))
|
||||
notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)"
|
||||
if missing:
|
||||
notice += f"\nSkipped missing skills: {', '.join(missing)}"
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"type": "send",
|
||||
"message": msg,
|
||||
"notice": notice,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.skill_commands import (
|
||||
scan_skill_commands,
|
||||
|
|
@ -12277,7 +12323,7 @@ def _(rid, params: dict) -> dict:
|
|||
except Exception as exc:
|
||||
return _err(rid, 5009, f"compress failed: {exc}")
|
||||
|
||||
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
|
||||
return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}")
|
||||
|
||||
|
||||
# ── Methods: paste ────────────────────────────────────────────────────
|
||||
|
|
@ -13069,10 +13115,11 @@ def _(rid, params: dict) -> dict:
|
|||
if not cmd:
|
||||
return _err(rid, 4004, "empty command")
|
||||
|
||||
# Skill slash commands and _pending_input commands must NOT go through the
|
||||
# slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin
|
||||
# commands must also avoid the worker, but unlike skills/pending-input they
|
||||
# still return normal slash.exec output so the TUI keeps the pager path.
|
||||
# Skill and bundle slash commands plus _pending_input commands must NOT go
|
||||
# through the slash worker — see _PENDING_INPUT_COMMANDS definition above.
|
||||
# Plugin commands must also avoid the worker, but unlike skills and
|
||||
# pending-input commands they still return normal slash.exec output so the
|
||||
# TUI keeps the pager path.
|
||||
_cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd
|
||||
_cmd_parts = _cmd_text.split(maxsplit=1)
|
||||
_cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower()
|
||||
|
|
@ -13100,6 +13147,27 @@ def _(rid, params: dict) -> dict:
|
|||
"snapshot restore mutates live config/state; use command.dispatch for /snapshot restore",
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.skill_bundles import resolve_bundle_command_key
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
_bundle_key = (
|
||||
resolve_bundle_command_key(_cmd_base)
|
||||
if resolve_command(_cmd_base) is None
|
||||
else None
|
||||
)
|
||||
if _bundle_key is not None:
|
||||
return _methods["command.dispatch"](
|
||||
rid,
|
||||
{
|
||||
"name": _bundle_key.lstrip("/"),
|
||||
"arg": _cmd_arg,
|
||||
"session_id": params.get("session_id", ""),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue