Compare commits
8 Commits
sid/discor
...
fix/web-en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aed1b2fe7 | ||
|
|
f87dbdf0a8 | ||
|
|
6407b3d5b3 | ||
|
|
0a59994030 | ||
|
|
0ed37c0ca4 | ||
|
|
1c8ce33d51 | ||
|
|
2182de55bb | ||
|
|
3cf13747b7 |
@@ -790,8 +790,11 @@ code_execution:
|
||||
# Supports single tasks and batch mode (default 3 parallel, configurable).
|
||||
delegation:
|
||||
max_iterations: 50 # Max tool-calling turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Max parallel child agents (default: 3)
|
||||
# max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers.
|
||||
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
|
||||
# WARNING: values above 10 multiply API cost linearly.
|
||||
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection.
|
||||
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
|
||||
|
||||
@@ -532,6 +532,20 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
await crypto_store.open()
|
||||
|
||||
# Bind the store to the runtime device_id before any
|
||||
# put_account() runs. PgCryptoStore defaults _device_id
|
||||
# to "" and its crypto_account UPSERT never updates the
|
||||
# device_id column on conflict — so once put_account
|
||||
# writes blank, it stays blank forever. That breaks
|
||||
# every downstream device-scoped olm operation: peer
|
||||
# to-device ciphertext can't find our identity key and
|
||||
# no megolm sessions ever land. Setting _device_id here
|
||||
# (in-memory; the on-disk row may not exist yet) makes
|
||||
# the first put_account write the correct value.
|
||||
# DeviceID is a NewType(str) so plain str works at runtime.
|
||||
if client.device_id:
|
||||
await crypto_store.put_device_id(client.device_id)
|
||||
|
||||
crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
|
||||
olm = OlmMachine(client, crypto_store, crypto_state)
|
||||
|
||||
|
||||
@@ -839,7 +839,7 @@ DEFAULT_CONFIG = {
|
||||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
|
||||
# discord / discord_admin tools: restrict which actions the agent may call.
|
||||
# discord_server tool: restrict which actions the agent may call.
|
||||
# Default (empty) = all actions allowed (subject to bot privileged intents).
|
||||
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
|
||||
# or YAML list. Unknown names are dropped with a warning at load time.
|
||||
|
||||
@@ -68,13 +68,12 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
|
||||
("homeassistant", "🏠 Home Assistant", "smart home device control"),
|
||||
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
|
||||
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
||||
]
|
||||
|
||||
# Toolsets that are OFF by default for new installs.
|
||||
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
|
||||
# but the setup checklist won't pre-select them for first-time users.
|
||||
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord_admin"}
|
||||
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify"}
|
||||
|
||||
|
||||
def _get_effective_configurable_toolsets():
|
||||
@@ -592,7 +591,7 @@ def _get_platform_tools(
|
||||
include_default_mcp_servers: bool = True,
|
||||
) -> Set[str]:
|
||||
"""Resolve which individual toolset names are enabled for a platform."""
|
||||
from toolsets import resolve_toolset, TOOLSETS
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
platform_toolsets = config.get("platform_toolsets") or {}
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
@@ -606,8 +605,6 @@ def _get_platform_tools(
|
||||
toolset_names = [str(ts) for ts in toolset_names]
|
||||
|
||||
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
|
||||
plugin_ts_keys = _get_plugin_toolset_keys()
|
||||
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
|
||||
|
||||
# If the saved list contains any configurable keys directly, the user
|
||||
# has explicitly configured this platform — use direct membership.
|
||||
@@ -630,42 +627,11 @@ def _get_platform_tools(
|
||||
ts_tools = set(resolve_toolset(ts_key))
|
||||
if ts_tools and ts_tools.issubset(all_tool_names):
|
||||
enabled_toolsets.add(ts_key)
|
||||
|
||||
default_off = set(_DEFAULT_OFF_TOOLSETS)
|
||||
if platform in default_off:
|
||||
default_off.remove(platform)
|
||||
enabled_toolsets -= default_off
|
||||
|
||||
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
|
||||
# feishu_drive). These are part of the platform's default composite but
|
||||
# absent from CONFIGURABLE_TOOLSETS, so they can't appear in the TUI
|
||||
# checklist or in a user-saved config. Must run in BOTH branches —
|
||||
# otherwise saving via `hermes tools` (which flips has_explicit_config
|
||||
# to True) silently drops them.
|
||||
platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"]))
|
||||
configurable_tool_universe = set()
|
||||
for ck in configurable_keys:
|
||||
configurable_tool_universe.update(resolve_toolset(ck))
|
||||
claimed = set()
|
||||
for ts_key in enabled_toolsets:
|
||||
claimed.update(resolve_toolset(ts_key))
|
||||
skip = configurable_keys | plugin_ts_keys | platform_default_keys
|
||||
skip |= {k for k in TOOLSETS if k.startswith("hermes-")}
|
||||
skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform}
|
||||
for ts_key, ts_def in TOOLSETS.items():
|
||||
if ts_key in skip:
|
||||
continue
|
||||
if ts_def.get("includes"):
|
||||
continue
|
||||
ts_tools = set(resolve_toolset(ts_key))
|
||||
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
|
||||
continue
|
||||
if ts_tools.issubset(configurable_tool_universe):
|
||||
continue
|
||||
if not ts_tools.issubset(claimed):
|
||||
enabled_toolsets.add(ts_key)
|
||||
claimed.update(ts_tools)
|
||||
|
||||
# Plugin toolsets: enabled by default unless explicitly disabled, or
|
||||
# unless the toolset is in _DEFAULT_OFF_TOOLSETS (e.g. spotify —
|
||||
# shipped as a bundled plugin but user must opt in via `hermes tools`
|
||||
@@ -673,6 +639,7 @@ def _get_platform_tools(
|
||||
# A plugin toolset is "known" for a platform once `hermes tools`
|
||||
# has been saved for that platform (tracked via known_plugin_toolsets).
|
||||
# Unknown plugins default to enabled; known-but-absent = disabled.
|
||||
plugin_ts_keys = _get_plugin_toolset_keys()
|
||||
if plugin_ts_keys:
|
||||
known_map = config.get("known_plugin_toolsets", {})
|
||||
known_for_platform = set(known_map.get(platform, []))
|
||||
@@ -690,6 +657,7 @@ def _get_platform_tools(
|
||||
|
||||
# Preserve any explicit non-configurable toolset entries (for example,
|
||||
# custom toolsets or MCP server names saved in platform_toolsets).
|
||||
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
|
||||
explicit_passthrough = {
|
||||
ts
|
||||
for ts in toolset_names
|
||||
|
||||
@@ -53,7 +53,7 @@ try:
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
@@ -425,6 +425,20 @@ class EnvVarUpdate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
@field_validator("key")
|
||||
@classmethod
|
||||
def key_must_be_nonempty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("key must not be empty")
|
||||
return v
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def value_must_be_nonempty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("value must not be empty; use DELETE /api/env to remove a key")
|
||||
return v
|
||||
|
||||
|
||||
class EnvVarDelete(BaseModel):
|
||||
key: str
|
||||
|
||||
@@ -288,34 +288,30 @@ def get_tool_definitions(
|
||||
filtered_tools[i] = {"type": "function", "function": dynamic_schema}
|
||||
break
|
||||
|
||||
# Rebuild discord / discord_admin schemas based on the bot's privileged
|
||||
# intents (detected from GET /applications/@me) and the user's action
|
||||
# allowlist in config. Hides actions the bot's intents don't support so
|
||||
# the model never attempts them, and annotates fetch_messages when the
|
||||
# Rebuild discord_server schema based on the bot's privileged intents
|
||||
# (detected from GET /applications/@me) and the user's action allowlist
|
||||
# in config. Hides actions the bot's intents don't support so the
|
||||
# model never attempts them, and annotates fetch_messages when the
|
||||
# MESSAGE_CONTENT intent is missing.
|
||||
_discord_schema_fns = {
|
||||
"discord": "get_dynamic_schema_core",
|
||||
"discord_admin": "get_dynamic_schema_admin",
|
||||
}
|
||||
for discord_tool_name in _discord_schema_fns:
|
||||
if discord_tool_name in available_tool_names:
|
||||
try:
|
||||
from tools import discord_tool as _dt
|
||||
schema_fn = getattr(_dt, _discord_schema_fns[discord_tool_name])
|
||||
dynamic = schema_fn()
|
||||
except Exception:
|
||||
dynamic = None
|
||||
if dynamic is None:
|
||||
filtered_tools = [
|
||||
t for t in filtered_tools
|
||||
if t.get("function", {}).get("name") != discord_tool_name
|
||||
]
|
||||
available_tool_names.discard(discord_tool_name)
|
||||
else:
|
||||
for i, td in enumerate(filtered_tools):
|
||||
if td.get("function", {}).get("name") == discord_tool_name:
|
||||
filtered_tools[i] = {"type": "function", "function": dynamic}
|
||||
break
|
||||
if "discord_server" in available_tool_names:
|
||||
try:
|
||||
from tools.discord_tool import get_dynamic_schema
|
||||
dynamic = get_dynamic_schema()
|
||||
except Exception: # pragma: no cover — defensive, fall back to static
|
||||
dynamic = None
|
||||
if dynamic is None:
|
||||
# Tool filtered out entirely (empty allowlist or detection disabled
|
||||
# the only remaining actions). Drop it from the schema list.
|
||||
filtered_tools = [
|
||||
t for t in filtered_tools
|
||||
if t.get("function", {}).get("name") != "discord_server"
|
||||
]
|
||||
available_tool_names.discard("discord_server")
|
||||
else:
|
||||
for i, td in enumerate(filtered_tools):
|
||||
if td.get("function", {}).get("name") == "discord_server":
|
||||
filtered_tools[i] = {"type": "function", "function": dynamic}
|
||||
break
|
||||
|
||||
# Strip web tool cross-references from browser_navigate description when
|
||||
# web_search / web_extract are not available. The static schema says
|
||||
|
||||
@@ -503,6 +503,7 @@ AUTHOR_MAP = {
|
||||
"codex@openai.invalid": "teknium1",
|
||||
"screenmachine@gmail.com": "teknium1",
|
||||
"chenzeshi@live.com": "chen1749144759",
|
||||
"mor.aleksandr@yahoo.com": "MorAlekss",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -197,10 +197,14 @@ def _make_fake_mautrix():
|
||||
self.account_id = account_id
|
||||
self.pickle_key = pickle_key
|
||||
self.db = db
|
||||
self._device_id = ""
|
||||
|
||||
async def open(self):
|
||||
pass
|
||||
|
||||
async def put_device_id(self, device_id):
|
||||
self._device_id = device_id
|
||||
|
||||
mautrix_crypto_store_asyncpg.PgCryptoStore = PgCryptoStore
|
||||
|
||||
# --- mautrix.util ---
|
||||
|
||||
@@ -601,72 +601,3 @@ class TestImagegenModelPicker:
|
||||
_configure_imagegen_model("fal", config)
|
||||
assert isinstance(config["image_gen"], dict)
|
||||
assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b"
|
||||
|
||||
|
||||
def test_get_platform_tools_recovers_non_configurable_toolsets_from_composite():
|
||||
"""Non-configurable toolsets whose tools are in the composite but not in
|
||||
CONFIGURABLE_TOOLSETS should still appear in the result.
|
||||
"""
|
||||
from toolsets import TOOLSETS
|
||||
from hermes_cli.tools_config import PLATFORMS
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
fake_toolsets = dict(TOOLSETS)
|
||||
fake_toolsets["_test_platform_tool"] = {
|
||||
"description": "test",
|
||||
"tools": ["_test_special_tool"],
|
||||
"includes": [],
|
||||
}
|
||||
fake_toolsets["hermes-_test_platform"] = {
|
||||
"description": "test composite",
|
||||
"tools": ["web_search", "web_extract", "terminal", "process", "_test_special_tool"],
|
||||
"includes": [],
|
||||
}
|
||||
|
||||
test_platforms = {
|
||||
"_test_platform": {"label": "Test", "default_toolset": "hermes-_test_platform"},
|
||||
}
|
||||
|
||||
with mock_patch("hermes_cli.tools_config.PLATFORMS", {**PLATFORMS, **test_platforms}):
|
||||
with mock_patch("toolsets.TOOLSETS", fake_toolsets):
|
||||
enabled = _get_platform_tools({}, "_test_platform")
|
||||
|
||||
assert "_test_platform_tool" in enabled
|
||||
assert "web" in enabled
|
||||
assert "terminal" in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets():
|
||||
"""Toolsets whose tools are fully covered by configurable keys should NOT
|
||||
be added by the second pass (prevents 'search', 'hermes-acp' noise).
|
||||
"""
|
||||
enabled = _get_platform_tools({}, "cli")
|
||||
|
||||
assert "search" not in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_discord_includes_discord_not_admin():
|
||||
enabled = _get_platform_tools({}, "discord")
|
||||
assert "discord" in enabled
|
||||
assert "discord_admin" not in enabled
|
||||
|
||||
|
||||
def test_discord_admin_in_configurable_toolsets():
|
||||
assert any(ts_key == "discord_admin" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)
|
||||
|
||||
|
||||
def test_discord_admin_in_default_off():
|
||||
assert "discord_admin" in _DEFAULT_OFF_TOOLSETS
|
||||
|
||||
|
||||
def test_get_platform_tools_feishu_includes_doc_and_drive():
|
||||
enabled = _get_platform_tools({}, "feishu")
|
||||
assert "feishu_doc" in enabled
|
||||
assert "feishu_drive" in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_feishu_tools_not_on_other_platforms():
|
||||
for plat in ["cli", "telegram", "discord"]:
|
||||
enabled = _get_platform_tools({}, plat)
|
||||
assert "feishu_doc" not in enabled, f"feishu_doc leaked onto {plat}"
|
||||
assert "feishu_drive" not in enabled, f"feishu_drive leaked onto {plat}"
|
||||
|
||||
@@ -1925,3 +1925,34 @@ class TestPtyWebSocket:
|
||||
):
|
||||
pass
|
||||
assert exc.value.code == 4400
|
||||
|
||||
|
||||
class TestEnvVarUpdateValidation:
|
||||
"""PUT /api/env must reject empty values to prevent .env key destruction."""
|
||||
|
||||
def test_rejects_empty_value(self):
|
||||
from hermes_cli.web_server import EnvVarUpdate
|
||||
import pydantic
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
EnvVarUpdate(key="SOME_KEY", value="")
|
||||
|
||||
def test_rejects_whitespace_only_value(self):
|
||||
from hermes_cli.web_server import EnvVarUpdate
|
||||
import pydantic
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
EnvVarUpdate(key="SOME_KEY", value=" ")
|
||||
|
||||
def test_accepts_nonempty_value(self):
|
||||
from hermes_cli.web_server import EnvVarUpdate
|
||||
|
||||
update = EnvVarUpdate(key="SOME_KEY", value="sk-abc123")
|
||||
assert update.value == "sk-abc123"
|
||||
|
||||
def test_rejects_empty_key(self):
|
||||
from hermes_cli.web_server import EnvVarUpdate
|
||||
import pydantic
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
EnvVarUpdate(key="", value="some-value")
|
||||
|
||||
@@ -200,8 +200,8 @@ class TestToolsetConsistency:
|
||||
def test_hermes_platforms_share_core_tools(self):
|
||||
"""All hermes-* platform toolsets share the same core tools.
|
||||
|
||||
Platform-specific additions (e.g. ``discord`` / ``discord_admin``
|
||||
on hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top —
|
||||
Platform-specific additions (e.g. ``discord_server`` on
|
||||
hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top —
|
||||
the invariant is that the core set is identical across platforms.
|
||||
"""
|
||||
platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"]
|
||||
|
||||
@@ -11,8 +11,6 @@ import pytest
|
||||
from tools.discord_tool import (
|
||||
DiscordAPIError,
|
||||
_ACTIONS,
|
||||
_ADMIN_ACTIONS,
|
||||
_CORE_ACTIONS,
|
||||
_available_actions,
|
||||
_build_schema,
|
||||
_channel_type_name,
|
||||
@@ -23,11 +21,8 @@ from tools.discord_tool import (
|
||||
_load_allowed_actions_config,
|
||||
_reset_capability_cache,
|
||||
check_discord_tool_requirements,
|
||||
discord_admin_handler,
|
||||
discord_core,
|
||||
discord_server,
|
||||
get_dynamic_schema,
|
||||
get_dynamic_schema_admin,
|
||||
get_dynamic_schema_core,
|
||||
)
|
||||
|
||||
|
||||
@@ -152,32 +147,32 @@ class TestDiscordRequest:
|
||||
class TestDiscordServerValidation:
|
||||
def test_no_token(self, monkeypatch):
|
||||
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
|
||||
result = json.loads(discord_admin_handler(action="list_guilds"))
|
||||
result = json.loads(discord_server(action="list_guilds"))
|
||||
assert "error" in result
|
||||
assert "DISCORD_BOT_TOKEN" in result["error"]
|
||||
|
||||
def test_unknown_action(self, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
result = json.loads(discord_core(action="bad_action"))
|
||||
result = json.loads(discord_server(action="bad_action"))
|
||||
assert "error" in result
|
||||
assert "Unknown action" in result["error"]
|
||||
assert "available_actions" in result
|
||||
|
||||
def test_missing_required_guild_id(self, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
result = json.loads(discord_admin_handler(action="list_channels"))
|
||||
result = json.loads(discord_server(action="list_channels"))
|
||||
assert "error" in result
|
||||
assert "guild_id" in result["error"]
|
||||
|
||||
def test_missing_required_channel_id(self, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
result = json.loads(discord_core(action="fetch_messages"))
|
||||
result = json.loads(discord_server(action="fetch_messages"))
|
||||
assert "error" in result
|
||||
assert "channel_id" in result["error"]
|
||||
|
||||
def test_missing_multiple_params(self, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
result = json.loads(discord_admin_handler(action="add_role"))
|
||||
result = json.loads(discord_server(action="add_role"))
|
||||
assert "error" in result
|
||||
assert "guild_id" in result["error"]
|
||||
assert "user_id" in result["error"]
|
||||
@@ -196,7 +191,7 @@ class TestListGuilds:
|
||||
{"id": "111", "name": "Test Server", "icon": "abc", "owner": True, "permissions": "123"},
|
||||
{"id": "222", "name": "Other Server", "icon": None, "owner": False, "permissions": "456"},
|
||||
]
|
||||
result = json.loads(discord_admin_handler(action="list_guilds"))
|
||||
result = json.loads(discord_server(action="list_guilds"))
|
||||
assert result["count"] == 2
|
||||
assert result["guilds"][0]["name"] == "Test Server"
|
||||
assert result["guilds"][1]["id"] == "222"
|
||||
@@ -224,7 +219,7 @@ class TestServerInfo:
|
||||
"premium_subscription_count": 5,
|
||||
"verification_level": 1,
|
||||
}
|
||||
result = json.loads(discord_admin_handler(action="server_info", guild_id="111"))
|
||||
result = json.loads(discord_server(action="server_info", guild_id="111"))
|
||||
assert result["name"] == "My Server"
|
||||
assert result["member_count"] == 42
|
||||
assert result["online_count"] == 10
|
||||
@@ -247,7 +242,7 @@ class TestListChannels:
|
||||
{"id": "12", "name": "voice", "type": 2, "position": 1, "parent_id": "10", "topic": None, "nsfw": False},
|
||||
{"id": "13", "name": "no-category", "type": 0, "position": 0, "parent_id": None, "topic": None, "nsfw": False},
|
||||
]
|
||||
result = json.loads(discord_admin_handler(action="list_channels", guild_id="111"))
|
||||
result = json.loads(discord_server(action="list_channels", guild_id="111"))
|
||||
assert result["total_channels"] == 3 # excludes the category itself
|
||||
groups = result["channel_groups"]
|
||||
# Uncategorized first
|
||||
@@ -262,7 +257,7 @@ class TestListChannels:
|
||||
def test_empty_guild(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = []
|
||||
result = json.loads(discord_admin_handler(action="list_channels", guild_id="111"))
|
||||
result = json.loads(discord_server(action="list_channels", guild_id="111"))
|
||||
assert result["total_channels"] == 0
|
||||
|
||||
|
||||
@@ -279,7 +274,7 @@ class TestChannelInfo:
|
||||
"topic": "Welcome!", "nsfw": False, "position": 0,
|
||||
"parent_id": "10", "rate_limit_per_user": 0, "last_message_id": "999",
|
||||
}
|
||||
result = json.loads(discord_admin_handler(action="channel_info", channel_id="11"))
|
||||
result = json.loads(discord_server(action="channel_info", channel_id="11"))
|
||||
assert result["name"] == "general"
|
||||
assert result["type"] == "text"
|
||||
assert result["guild_id"] == "111"
|
||||
@@ -298,7 +293,7 @@ class TestListRoles:
|
||||
{"id": "2", "name": "Admin", "position": 2, "color": 16711680, "mentionable": True, "managed": False, "hoist": True},
|
||||
{"id": "3", "name": "Mod", "position": 1, "color": 255, "mentionable": True, "managed": False, "hoist": True},
|
||||
]
|
||||
result = json.loads(discord_admin_handler(action="list_roles", guild_id="111"))
|
||||
result = json.loads(discord_server(action="list_roles", guild_id="111"))
|
||||
assert result["count"] == 3
|
||||
# Should be sorted by position descending
|
||||
assert result["roles"][0]["name"] == "Admin"
|
||||
@@ -322,7 +317,7 @@ class TestMemberInfo:
|
||||
"joined_at": "2024-01-01T00:00:00Z",
|
||||
"premium_since": None,
|
||||
}
|
||||
result = json.loads(discord_admin_handler(action="member_info", guild_id="111", user_id="42"))
|
||||
result = json.loads(discord_server(action="member_info", guild_id="111", user_id="42"))
|
||||
assert result["username"] == "testuser"
|
||||
assert result["nickname"] == "Testy"
|
||||
assert result["roles"] == ["2", "3"]
|
||||
@@ -339,7 +334,7 @@ class TestSearchMembers:
|
||||
mock_req.return_value = [
|
||||
{"user": {"id": "42", "username": "testuser", "global_name": "Test", "bot": False}, "nick": None, "roles": []},
|
||||
]
|
||||
result = json.loads(discord_core(action="search_members", guild_id="111", query="test"))
|
||||
result = json.loads(discord_server(action="search_members", guild_id="111", query="test"))
|
||||
assert result["count"] == 1
|
||||
assert result["members"][0]["username"] == "testuser"
|
||||
mock_req.assert_called_once_with(
|
||||
@@ -351,7 +346,7 @@ class TestSearchMembers:
|
||||
def test_search_members_limit_capped(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = []
|
||||
discord_core(action="search_members", guild_id="111", query="x", limit=200)
|
||||
discord_server(action="search_members", guild_id="111", query="x", limit=200)
|
||||
call_params = mock_req.call_args[1]["params"]
|
||||
assert call_params["limit"] == "100" # Capped at 100
|
||||
|
||||
@@ -375,7 +370,7 @@ class TestFetchMessages:
|
||||
"pinned": False,
|
||||
},
|
||||
]
|
||||
result = json.loads(discord_core(action="fetch_messages", channel_id="11"))
|
||||
result = json.loads(discord_server(action="fetch_messages", channel_id="11"))
|
||||
assert result["count"] == 1
|
||||
assert result["messages"][0]["content"] == "Hello world"
|
||||
assert result["messages"][0]["author"]["username"] == "user1"
|
||||
@@ -384,7 +379,7 @@ class TestFetchMessages:
|
||||
def test_fetch_messages_with_pagination(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = []
|
||||
discord_core(action="fetch_messages", channel_id="11", before="999", limit=10)
|
||||
discord_server(action="fetch_messages", channel_id="11", before="999", limit=10)
|
||||
call_params = mock_req.call_args[1]["params"]
|
||||
assert call_params["before"] == "999"
|
||||
assert call_params["limit"] == "10"
|
||||
@@ -401,7 +396,7 @@ class TestListPins:
|
||||
mock_req.return_value = [
|
||||
{"id": "500", "content": "Important announcement", "author": {"username": "admin"}, "timestamp": "2024-01-01T00:00:00Z"},
|
||||
]
|
||||
result = json.loads(discord_admin_handler(action="list_pins", channel_id="11"))
|
||||
result = json.loads(discord_server(action="list_pins", channel_id="11"))
|
||||
assert result["count"] == 1
|
||||
assert result["pinned_messages"][0]["content"] == "Important announcement"
|
||||
|
||||
@@ -415,7 +410,7 @@ class TestPinUnpin:
|
||||
def test_pin_message(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = None # 204
|
||||
result = json.loads(discord_admin_handler(action="pin_message", channel_id="11", message_id="500"))
|
||||
result = json.loads(discord_server(action="pin_message", channel_id="11", message_id="500"))
|
||||
assert result["success"] is True
|
||||
mock_req.assert_called_once_with("PUT", "/channels/11/pins/500", "test-token")
|
||||
|
||||
@@ -423,7 +418,7 @@ class TestPinUnpin:
|
||||
def test_unpin_message(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = None
|
||||
result = json.loads(discord_admin_handler(action="unpin_message", channel_id="11", message_id="500"))
|
||||
result = json.loads(discord_server(action="unpin_message", channel_id="11", message_id="500"))
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@@ -436,7 +431,7 @@ class TestCreateThread:
|
||||
def test_create_standalone_thread(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = {"id": "800", "name": "New Thread"}
|
||||
result = json.loads(discord_core(action="create_thread", channel_id="11", name="New Thread"))
|
||||
result = json.loads(discord_server(action="create_thread", channel_id="11", name="New Thread"))
|
||||
assert result["success"] is True
|
||||
assert result["thread_id"] == "800"
|
||||
# Verify the API call
|
||||
@@ -449,7 +444,7 @@ class TestCreateThread:
|
||||
def test_create_thread_from_message(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = {"id": "801", "name": "Discussion"}
|
||||
result = json.loads(discord_core(
|
||||
result = json.loads(discord_server(
|
||||
action="create_thread", channel_id="11", name="Discussion", message_id="1001",
|
||||
))
|
||||
assert result["success"] is True
|
||||
@@ -468,7 +463,7 @@ class TestRoleManagement:
|
||||
def test_add_role(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = None
|
||||
result = json.loads(discord_admin_handler(
|
||||
result = json.loads(discord_server(
|
||||
action="add_role", guild_id="111", user_id="42", role_id="2",
|
||||
))
|
||||
assert result["success"] is True
|
||||
@@ -480,7 +475,7 @@ class TestRoleManagement:
|
||||
def test_remove_role(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.return_value = None
|
||||
result = json.loads(discord_admin_handler(
|
||||
result = json.loads(discord_server(
|
||||
action="remove_role", guild_id="111", user_id="42", role_id="2",
|
||||
))
|
||||
assert result["success"] is True
|
||||
@@ -495,23 +490,15 @@ class TestErrorHandling:
|
||||
def test_api_error_handled(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.side_effect = DiscordAPIError(403, '{"message": "Missing Access"}')
|
||||
result = json.loads(discord_admin_handler(action="list_guilds"))
|
||||
result = json.loads(discord_server(action="list_guilds"))
|
||||
assert "error" in result
|
||||
assert "403" in result["error"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_unexpected_error_handled_admin(self, mock_req, monkeypatch):
|
||||
def test_unexpected_error_handled(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.side_effect = RuntimeError("something broke")
|
||||
result = json.loads(discord_admin_handler(action="list_guilds"))
|
||||
assert "error" in result
|
||||
assert "something broke" in result["error"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_unexpected_error_handled_core(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
|
||||
mock_req.side_effect = RuntimeError("something broke")
|
||||
result = json.loads(discord_core(action="fetch_messages", channel_id="11"))
|
||||
result = json.loads(discord_server(action="list_guilds"))
|
||||
assert "error" in result
|
||||
assert "something broke" in result["error"]
|
||||
|
||||
@@ -521,109 +508,79 @@ class TestErrorHandling:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegistration:
|
||||
def test_core_tool_registered(self):
|
||||
def test_tool_registered(self):
|
||||
from tools.registry import registry
|
||||
entry = registry._tools.get("discord")
|
||||
entry = registry._tools.get("discord_server")
|
||||
assert entry is not None
|
||||
assert entry.schema["name"] == "discord"
|
||||
assert entry.schema["name"] == "discord_server"
|
||||
assert entry.toolset == "discord"
|
||||
assert entry.check_fn is not None
|
||||
assert entry.requires_env == ["DISCORD_BOT_TOKEN"]
|
||||
|
||||
def test_admin_tool_registered(self):
|
||||
def test_schema_actions(self):
|
||||
"""Static schema should list all actions (the model_tools post-processing
|
||||
narrows this per-session; static registration is the superset)."""
|
||||
from tools.registry import registry
|
||||
entry = registry._tools.get("discord_admin")
|
||||
assert entry is not None
|
||||
assert entry.schema["name"] == "discord_admin"
|
||||
assert entry.toolset == "discord_admin"
|
||||
assert entry.check_fn is not None
|
||||
assert entry.requires_env == ["DISCORD_BOT_TOKEN"]
|
||||
|
||||
def test_core_schema_actions(self):
|
||||
"""Core static schema should list only core actions."""
|
||||
from tools.registry import registry
|
||||
entry = registry._tools["discord"]
|
||||
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
|
||||
assert actions == {"fetch_messages", "search_members", "create_thread"}
|
||||
|
||||
def test_admin_schema_actions(self):
|
||||
"""Admin static schema should list only admin actions."""
|
||||
from tools.registry import registry
|
||||
entry = registry._tools["discord_admin"]
|
||||
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
|
||||
expected_admin = set(_ACTIONS.keys()) - {"fetch_messages", "search_members", "create_thread"}
|
||||
assert actions == expected_admin
|
||||
|
||||
def test_all_actions_covered(self):
|
||||
"""Core + admin actions should cover all known actions."""
|
||||
assert set(_CORE_ACTIONS.keys()) | set(_ADMIN_ACTIONS.keys()) == set(_ACTIONS.keys())
|
||||
assert set(_CORE_ACTIONS.keys()) & set(_ADMIN_ACTIONS.keys()) == set()
|
||||
entry = registry._tools["discord_server"]
|
||||
actions = entry.schema["parameters"]["properties"]["action"]["enum"]
|
||||
expected = [
|
||||
"list_guilds", "server_info", "list_channels", "channel_info",
|
||||
"list_roles", "member_info", "search_members", "fetch_messages",
|
||||
"list_pins", "pin_message", "unpin_message", "create_thread",
|
||||
"add_role", "remove_role",
|
||||
]
|
||||
assert set(actions) == set(expected)
|
||||
assert set(_ACTIONS.keys()) == set(expected)
|
||||
|
||||
def test_schema_parameter_bounds(self):
|
||||
from tools.registry import registry
|
||||
entry = registry._tools["discord"]
|
||||
entry = registry._tools["discord_server"]
|
||||
props = entry.schema["parameters"]["properties"]
|
||||
assert props["limit"]["minimum"] == 1
|
||||
assert props["limit"]["maximum"] == 100
|
||||
assert props["auto_archive_duration"]["enum"] == [60, 1440, 4320, 10080]
|
||||
|
||||
def test_core_schema_description(self):
|
||||
"""Core schema description should mention core actions."""
|
||||
def test_schema_description_is_action_manifest(self):
|
||||
"""The top-level description should include the action manifest
|
||||
(one-line signatures per action) so the model can find required
|
||||
params without re-reading every parameter description."""
|
||||
from tools.registry import registry
|
||||
entry = registry._tools["discord"]
|
||||
desc = entry.schema["description"]
|
||||
assert "fetch_messages(channel_id)" in desc
|
||||
assert "search_members(guild_id, query)" in desc
|
||||
assert "create_thread(channel_id, name)" in desc
|
||||
# Admin actions should NOT be in core description
|
||||
assert "list_guilds()" not in desc
|
||||
assert "add_role(" not in desc
|
||||
|
||||
def test_admin_schema_description(self):
|
||||
"""Admin schema description should mention admin actions."""
|
||||
from tools.registry import registry
|
||||
entry = registry._tools["discord_admin"]
|
||||
entry = registry._tools["discord_server"]
|
||||
desc = entry.schema["description"]
|
||||
# Spot-check a few entries
|
||||
assert "list_guilds()" in desc
|
||||
assert "fetch_messages(channel_id)" in desc
|
||||
assert "add_role(guild_id, user_id, role_id)" in desc
|
||||
# Core actions should NOT be in admin description
|
||||
assert "fetch_messages(" not in desc
|
||||
assert "create_thread(" not in desc
|
||||
|
||||
def test_handler_callable(self):
|
||||
from tools.registry import registry
|
||||
entry = registry._tools["discord"]
|
||||
entry = registry._tools["discord_server"]
|
||||
assert callable(entry.handler)
|
||||
entry_admin = registry._tools["discord_admin"]
|
||||
assert callable(entry_admin.handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Toolset: discord / discord_admin only in hermes-discord
|
||||
# Toolset: discord_server only in hermes-discord
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestToolsetInclusion:
|
||||
def test_discord_tools_in_hermes_discord_toolset(self):
|
||||
def test_discord_server_in_hermes_discord_toolset(self):
|
||||
from toolsets import TOOLSETS
|
||||
assert "discord" in TOOLSETS["hermes-discord"]["tools"]
|
||||
assert "discord_admin" in TOOLSETS["hermes-discord"]["tools"]
|
||||
assert "discord_server" in TOOLSETS["hermes-discord"]["tools"]
|
||||
|
||||
def test_discord_tools_not_in_core_tools(self):
|
||||
def test_discord_server_not_in_core_tools(self):
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
assert "discord" not in _HERMES_CORE_TOOLS
|
||||
assert "discord_admin" not in _HERMES_CORE_TOOLS
|
||||
assert "discord_server" not in _HERMES_CORE_TOOLS
|
||||
|
||||
def test_discord_tools_not_in_other_toolsets(self):
|
||||
def test_discord_server_not_in_other_toolsets(self):
|
||||
from toolsets import TOOLSETS
|
||||
for name, ts in TOOLSETS.items():
|
||||
if name in ("hermes-discord", "hermes-gateway", "discord", "discord_admin"):
|
||||
if name == "hermes-discord":
|
||||
continue
|
||||
tools = ts.get("tools", [])
|
||||
assert "discord" not in tools or name == "discord", (
|
||||
f"discord tool should not be in toolset '{name}'"
|
||||
)
|
||||
assert "discord_admin" not in tools or name == "discord_admin", (
|
||||
f"discord_admin tool should not be in toolset '{name}'"
|
||||
# The gateway toolset might include it if it unions all platform tools
|
||||
if name == "hermes-gateway":
|
||||
continue
|
||||
assert "discord_server" not in ts.get("tools", []), (
|
||||
f"discord_server should not be in toolset '{name}'"
|
||||
)
|
||||
|
||||
|
||||
@@ -841,69 +798,40 @@ class TestDynamicSchema:
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_no_token_returns_none(self, mock_req, monkeypatch):
|
||||
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
|
||||
assert get_dynamic_schema_core() is None
|
||||
assert get_dynamic_schema_admin() is None
|
||||
assert get_dynamic_schema() is None
|
||||
mock_req.assert_not_called()
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_full_intents_core_schema(self, mock_req, monkeypatch):
|
||||
def test_full_intents_full_schema(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
|
||||
schema = get_dynamic_schema_core()
|
||||
actions = set(schema["parameters"]["properties"]["action"]["enum"])
|
||||
assert actions == set(_CORE_ACTIONS.keys())
|
||||
assert schema["name"] == "discord"
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_full_intents_admin_schema(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
|
||||
schema = get_dynamic_schema_admin()
|
||||
actions = set(schema["parameters"]["properties"]["action"]["enum"])
|
||||
assert actions == set(_ADMIN_ACTIONS.keys())
|
||||
assert schema["name"] == "discord_admin"
|
||||
# No content warning when MESSAGE_CONTENT is enabled
|
||||
schema = get_dynamic_schema()
|
||||
actions = schema["parameters"]["properties"]["action"]["enum"]
|
||||
assert set(actions) == set(_ACTIONS.keys())
|
||||
# No content warning
|
||||
assert "MESSAGE_CONTENT" not in schema["description"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_no_members_intent_removes_member_actions_from_admin_schema(
|
||||
def test_no_members_intent_removes_member_actions_from_schema(
|
||||
self, mock_req, monkeypatch,
|
||||
):
|
||||
"""member_info is an admin action; it should be hidden when
|
||||
GUILD_MEMBERS intent is missing."""
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.return_value = {"flags": 1 << 18} # only MESSAGE_CONTENT
|
||||
schema = get_dynamic_schema_admin()
|
||||
actions = schema["parameters"]["properties"]["action"]["enum"]
|
||||
assert "member_info" not in actions
|
||||
assert "member_info" not in schema["description"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_no_members_intent_hides_search_members_from_core(
|
||||
self, mock_req, monkeypatch,
|
||||
):
|
||||
"""search_members is a core action gated by GUILD_MEMBERS intent."""
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.return_value = {"flags": 1 << 18} # only MESSAGE_CONTENT
|
||||
schema = get_dynamic_schema_core()
|
||||
schema = get_dynamic_schema()
|
||||
actions = schema["parameters"]["properties"]["action"]["enum"]
|
||||
assert "search_members" not in actions
|
||||
assert "member_info" not in actions
|
||||
# Manifest description should also not advertise them
|
||||
assert "search_members" not in schema["description"]
|
||||
assert "member_info" not in schema["description"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_no_message_content_adds_warning_note(self, mock_req, monkeypatch):
|
||||
@@ -913,53 +841,41 @@ class TestDynamicSchema:
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.return_value = {"flags": 1 << 14} # only GUILD_MEMBERS
|
||||
schema = get_dynamic_schema_core()
|
||||
schema = get_dynamic_schema()
|
||||
assert "MESSAGE_CONTENT" in schema["description"]
|
||||
# But fetch_messages is still available
|
||||
actions = schema["parameters"]["properties"]["action"]["enum"]
|
||||
assert "fetch_messages" in actions
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_config_allowlist_narrows_admin_schema(self, mock_req, monkeypatch):
|
||||
def test_config_allowlist_narrows_schema(self, mock_req, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": "list_guilds,list_channels"}},
|
||||
)
|
||||
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
|
||||
schema = get_dynamic_schema_admin()
|
||||
schema = get_dynamic_schema()
|
||||
actions = schema["parameters"]["properties"]["action"]["enum"]
|
||||
assert actions == ["list_guilds", "list_channels"]
|
||||
# Manifest description should only show allowed ones (check for
|
||||
# the signature marker, which is specific to manifest lines)
|
||||
assert "list_guilds()" in schema["description"]
|
||||
assert "add_role(" not in schema["description"]
|
||||
assert "create_thread(" not in schema["description"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_empty_allowlist_with_valid_values_hides_tools(self, mock_req, monkeypatch):
|
||||
def test_empty_allowlist_with_valid_values_hides_tool(self, mock_req, monkeypatch):
|
||||
"""If the allowlist resolves to zero valid actions (e.g. all names
|
||||
were typos), get_dynamic_schema returns None so the tool is dropped."""
|
||||
were typos), get_dynamic_schema returns None so the tool is dropped
|
||||
entirely rather than showing an empty enum."""
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": "typo_one,typo_two"}},
|
||||
)
|
||||
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
|
||||
assert get_dynamic_schema_core() is None
|
||||
assert get_dynamic_schema_admin() is None
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_backward_compat_wrapper(self, mock_req, monkeypatch):
|
||||
"""get_dynamic_schema() should delegate to get_dynamic_schema_core()."""
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
|
||||
schema = get_dynamic_schema()
|
||||
assert schema is not None
|
||||
assert schema["name"] == "discord"
|
||||
actions = set(schema["parameters"]["properties"]["action"]["enum"])
|
||||
assert actions == set(_CORE_ACTIONS.keys())
|
||||
assert get_dynamic_schema() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -974,7 +890,7 @@ class TestRuntimeAllowlistEnforcement:
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"discord": {"server_actions": "list_guilds"}},
|
||||
)
|
||||
result = json.loads(discord_admin_handler(action="add_role", guild_id="1", user_id="2", role_id="3"))
|
||||
result = json.loads(discord_server(action="add_role", guild_id="1", user_id="2", role_id="3"))
|
||||
assert "error" in result
|
||||
assert "disabled by config" in result["error"]
|
||||
mock_req.assert_not_called()
|
||||
@@ -987,7 +903,7 @@ class TestRuntimeAllowlistEnforcement:
|
||||
lambda: {"discord": {"server_actions": "list_guilds"}},
|
||||
)
|
||||
mock_req.return_value = []
|
||||
result = json.loads(discord_admin_handler(action="list_guilds"))
|
||||
result = json.loads(discord_server(action="list_guilds"))
|
||||
assert "guilds" in result
|
||||
|
||||
|
||||
@@ -1014,7 +930,7 @@ class Test403Enrichment:
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.side_effect = DiscordAPIError(403, '{"message":"Missing Permissions"}')
|
||||
result = json.loads(discord_admin_handler(
|
||||
result = json.loads(discord_server(
|
||||
action="add_role", guild_id="1", user_id="2", role_id="3",
|
||||
))
|
||||
assert "error" in result
|
||||
@@ -1028,7 +944,7 @@ class Test403Enrichment:
|
||||
lambda: {"discord": {"server_actions": ""}},
|
||||
)
|
||||
mock_req.side_effect = DiscordAPIError(500, "server error")
|
||||
result = json.loads(discord_admin_handler(action="list_guilds"))
|
||||
result = json.loads(discord_server(action="list_guilds"))
|
||||
assert "500" in result["error"]
|
||||
assert "MANAGE_ROLES" not in result["error"]
|
||||
|
||||
@@ -1045,10 +961,10 @@ class TestModelToolsIntegration:
|
||||
_reset_capability_cache()
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_discord_admin_schema_rebuilt_by_get_tool_definitions(
|
||||
def test_discord_server_schema_rebuilt_by_get_tool_definitions(
|
||||
self, mock_req, monkeypatch,
|
||||
):
|
||||
"""When model_tools.get_tool_definitions runs with discord_admin
|
||||
"""When model_tools.get_tool_definitions runs with discord_server
|
||||
available, it should replace the static schema with the dynamic one."""
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
monkeypatch.setattr(
|
||||
@@ -1060,16 +976,16 @@ class TestModelToolsIntegration:
|
||||
|
||||
from model_tools import get_tool_definitions
|
||||
tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True)
|
||||
discord_admin_tool = next(
|
||||
(t for t in tools if t.get("function", {}).get("name") == "discord_admin"),
|
||||
discord_tool = next(
|
||||
(t for t in tools if t.get("function", {}).get("name") == "discord_server"),
|
||||
None,
|
||||
)
|
||||
assert discord_admin_tool is not None, "discord_admin should be in the schema"
|
||||
actions = discord_admin_tool["function"]["parameters"]["properties"]["action"]["enum"]
|
||||
assert discord_tool is not None, "discord_server should be in the schema"
|
||||
actions = discord_tool["function"]["parameters"]["properties"]["action"]["enum"]
|
||||
assert actions == ["list_guilds", "server_info"]
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_discord_tools_dropped_when_allowlist_empties_them(
|
||||
def test_discord_server_dropped_when_allowlist_empties_it(
|
||||
self, mock_req, monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
|
||||
@@ -1082,6 +998,4 @@ class TestModelToolsIntegration:
|
||||
from model_tools import get_tool_definitions
|
||||
tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True)
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "discord" not in names
|
||||
assert "discord_admin" not in names
|
||||
assert "discord_server" not in names
|
||||
|
||||
@@ -276,7 +276,14 @@ def _get_max_concurrent_children() -> int:
|
||||
val = cfg.get("max_concurrent_children")
|
||||
if val is not None:
|
||||
try:
|
||||
return max(1, int(val))
|
||||
result = max(1, int(val))
|
||||
if result > 10:
|
||||
logger.warning(
|
||||
"delegation.max_concurrent_children=%d: each child consumes API tokens "
|
||||
"independently. High values multiply cost linearly.",
|
||||
result,
|
||||
)
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"delegation.max_concurrent_children=%r is not a valid integer; "
|
||||
@@ -2229,8 +2236,8 @@ DELEGATE_TASK_SCHEMA = {
|
||||
"never enter your context window.\n\n"
|
||||
"TWO MODES (one of 'goal' or 'tasks' is required):\n"
|
||||
"1. Single task: provide 'goal' (+ optional context, toolsets)\n"
|
||||
"2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3). "
|
||||
"All run concurrently and results are returned together.\n\n"
|
||||
"2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3, configurable via config.yaml, no hard ceiling). "
|
||||
"All run concurrently and results are returned together. Nested delegation requires role='orchestrator' and delegation.max_spawn_depth >= 2.\n\n"
|
||||
"WHEN TO USE delegate_task:\n"
|
||||
"- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n"
|
||||
"- Tasks that would flood your context with intermediate data\n"
|
||||
|
||||
@@ -473,12 +473,6 @@ _ACTIONS = {
|
||||
"remove_role": _remove_role,
|
||||
}
|
||||
|
||||
_CORE_ACTION_NAMES = frozenset({"fetch_messages", "search_members", "create_thread"})
|
||||
_ADMIN_ACTION_NAMES = frozenset(_ACTIONS.keys()) - _CORE_ACTION_NAMES
|
||||
|
||||
_CORE_ACTIONS = {k: v for k, v in _ACTIONS.items() if k in _CORE_ACTION_NAMES}
|
||||
_ADMIN_ACTIONS = {k: v for k, v in _ACTIONS.items() if k in _ADMIN_ACTION_NAMES}
|
||||
|
||||
# Single-source-of-truth manifest: action → (signature, one-line description).
|
||||
# Consumed by :func:`_build_schema` so the schema's top-level description
|
||||
# always matches the registered action set.
|
||||
@@ -537,7 +531,7 @@ def _load_allowed_actions_config() -> Optional[List[str]]:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception as exc:
|
||||
logger.debug("discord: could not load config (%s); allowing all actions.", exc)
|
||||
logger.debug("discord_server: could not load config (%s); allowing all actions.", exc)
|
||||
return None
|
||||
|
||||
raw = (cfg.get("discord") or {}).get("server_actions")
|
||||
@@ -592,16 +586,12 @@ def _available_actions(
|
||||
def _build_schema(
|
||||
actions: List[str],
|
||||
caps: Optional[Dict[str, Any]] = None,
|
||||
tool_name: str = "discord",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Build the tool schema for the given filtered action list.
|
||||
|
||||
Returns ``None`` when *actions* is empty — callers should drop the
|
||||
tool from registration in that case.
|
||||
"""
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the tool schema for the given filtered action list."""
|
||||
caps = caps or {}
|
||||
if not actions:
|
||||
return None
|
||||
# Tool shouldn't be registered when empty, but guard anyway.
|
||||
actions = list(_ACTIONS.keys())
|
||||
|
||||
# Action manifest lines (action-first, parameter-scoped).
|
||||
manifest_lines = [
|
||||
@@ -612,36 +602,24 @@ def _build_schema(
|
||||
manifest_block = "\n".join(manifest_lines)
|
||||
|
||||
content_note = ""
|
||||
affected_actions = {"fetch_messages", "list_pins"} & set(actions)
|
||||
if affected_actions and caps.get("detected") and caps.get("has_message_content") is False:
|
||||
names = " and ".join(sorted(affected_actions))
|
||||
if caps.get("detected") and caps.get("has_message_content") is False:
|
||||
content_note = (
|
||||
f"\n\nNOTE: Bot does NOT have the MESSAGE_CONTENT privileged intent. "
|
||||
f"{names} will return message metadata (author, "
|
||||
"\n\nNOTE: Bot does NOT have the MESSAGE_CONTENT privileged intent. "
|
||||
"fetch_messages and list_pins will return message metadata (author, "
|
||||
"timestamps, attachments, reactions, pin state) but `content` will be "
|
||||
"empty for messages not sent as a direct mention to the bot or in DMs. "
|
||||
"Enable the intent in the Discord Developer Portal to see all content."
|
||||
)
|
||||
|
||||
if tool_name == "discord_admin":
|
||||
description = (
|
||||
"Manage a Discord server via the REST API.\n\n"
|
||||
"Available actions:\n"
|
||||
f"{manifest_block}\n\n"
|
||||
"Call list_guilds first to discover guild_ids, then list_channels for "
|
||||
"channel_ids. Runtime errors will tell you if the bot lacks a specific "
|
||||
"per-guild permission (e.g. MANAGE_ROLES for add_role)."
|
||||
f"{content_note}"
|
||||
)
|
||||
else:
|
||||
description = (
|
||||
"Read and participate in a Discord server.\n\n"
|
||||
"Available actions:\n"
|
||||
f"{manifest_block}\n\n"
|
||||
"Use the channel_id from the current conversation context. "
|
||||
"Use search_members to look up user IDs by name prefix."
|
||||
f"{content_note}"
|
||||
)
|
||||
description = (
|
||||
"Query and manage a Discord server via the REST API.\n\n"
|
||||
"Available actions:\n"
|
||||
f"{manifest_block}\n\n"
|
||||
"Call list_guilds first to discover guild_ids, then list_channels for "
|
||||
"channel_ids. Runtime errors will tell you if the bot lacks a specific "
|
||||
"per-guild permission (e.g. MANAGE_ROLES for add_role)."
|
||||
f"{content_note}"
|
||||
)
|
||||
|
||||
properties: Dict[str, Any] = {
|
||||
"action": {
|
||||
@@ -698,7 +676,7 @@ def _build_schema(
|
||||
}
|
||||
|
||||
return {
|
||||
"name": tool_name,
|
||||
"name": "discord_server",
|
||||
"description": description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -708,33 +686,28 @@ def _build_schema(
|
||||
}
|
||||
|
||||
|
||||
def _get_dynamic_schema(
|
||||
action_subset: Dict[str, Any],
|
||||
tool_name: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Build a dynamic schema for *action_subset* filtered by intents + config."""
|
||||
def get_dynamic_schema() -> Optional[Dict[str, Any]]:
|
||||
"""Return a schema filtered by current intents + config allowlist.
|
||||
|
||||
Called by ``model_tools.get_tool_definitions`` as a post-processing
|
||||
step so the schema the model sees always reflects reality. Returns
|
||||
``None`` when no actions are available (tool should be removed from
|
||||
the schema list entirely).
|
||||
"""
|
||||
token = _get_bot_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
caps = _detect_capabilities(token)
|
||||
allowlist = _load_allowed_actions_config()
|
||||
actions = [a for a in _available_actions(caps, allowlist) if a in action_subset]
|
||||
actions = _available_actions(caps, allowlist)
|
||||
if not actions:
|
||||
logger.warning(
|
||||
"discord_server: config allowlist/intents left zero available actions; "
|
||||
"hiding tool from this session."
|
||||
)
|
||||
return None
|
||||
return _build_schema(actions, caps, tool_name=tool_name)
|
||||
|
||||
|
||||
def get_dynamic_schema_core() -> Optional[Dict[str, Any]]:
|
||||
return _get_dynamic_schema(_CORE_ACTIONS, "discord")
|
||||
|
||||
|
||||
def get_dynamic_schema_admin() -> Optional[Dict[str, Any]]:
|
||||
return _get_dynamic_schema(_ADMIN_ACTIONS, "discord_admin")
|
||||
|
||||
|
||||
def get_dynamic_schema() -> Optional[Dict[str, Any]]:
|
||||
"""Backward-compat wrapper — returns core schema."""
|
||||
return get_dynamic_schema_core()
|
||||
return _build_schema(actions, caps)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -801,13 +774,11 @@ def check_discord_tool_requirements() -> bool:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# Main handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_discord_action(
|
||||
def discord_server(
|
||||
action: str,
|
||||
valid_actions: Dict[str, Any],
|
||||
tool_label: str,
|
||||
guild_id: str = "",
|
||||
channel_id: str = "",
|
||||
user_id: str = "",
|
||||
@@ -819,17 +790,18 @@ def _run_discord_action(
|
||||
before: str = "",
|
||||
after: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
task_id: str = None,
|
||||
) -> str:
|
||||
"""Shared handler logic for both discord tools."""
|
||||
"""Execute a Discord server action."""
|
||||
token = _get_bot_token()
|
||||
if not token:
|
||||
return json.dumps({"error": "DISCORD_BOT_TOKEN not configured."})
|
||||
|
||||
action_fn = valid_actions.get(action)
|
||||
action_fn = _ACTIONS.get(action)
|
||||
if not action_fn:
|
||||
return json.dumps({
|
||||
"error": f"Unknown action: {action}",
|
||||
"available_actions": list(valid_actions.keys()),
|
||||
"available_actions": list(_ACTIONS.keys()),
|
||||
})
|
||||
|
||||
# Config-level allowlist gate (defense in depth — schema already filtered,
|
||||
@@ -876,64 +848,44 @@ def _run_discord_action(
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
)
|
||||
except DiscordAPIError as e:
|
||||
logger.warning("Discord API error in %s action '%s': %s", tool_label, action, e)
|
||||
logger.warning("Discord API error in action '%s': %s", action, e)
|
||||
if e.status == 403:
|
||||
return json.dumps({"error": _enrich_403(action, e.body)})
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in %s action '%s'", tool_label, action)
|
||||
logger.exception("Unexpected error in discord_server action '%s'", action)
|
||||
return json.dumps({"error": f"Unexpected error: {e}"})
|
||||
|
||||
|
||||
def discord_core(action: str, **kwargs) -> str:
|
||||
"""Execute a core Discord action (fetch_messages, search_members, create_thread)."""
|
||||
return _run_discord_action(action, _CORE_ACTIONS, "discord", **kwargs)
|
||||
|
||||
|
||||
def discord_admin_handler(action: str, **kwargs) -> str:
|
||||
"""Execute a Discord admin action (server management)."""
|
||||
return _run_discord_action(action, _ADMIN_ACTIONS, "discord_admin", **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HANDLER_DEFAULTS = {
|
||||
"action": "", "guild_id": "", "channel_id": "", "user_id": "",
|
||||
"role_id": "", "message_id": "", "query": "", "name": "",
|
||||
"limit": 50, "before": "", "after": "", "auto_archive_duration": 1440,
|
||||
}
|
||||
|
||||
|
||||
def _make_handler(handler_fn):
|
||||
"""Create a registry-compatible handler lambda for a discord handler."""
|
||||
return lambda args, **kw: handler_fn(
|
||||
**{k: args.get(k, v) for k, v in _HANDLER_DEFAULTS.items()},
|
||||
)
|
||||
|
||||
|
||||
_STATIC_CORE_SCHEMA = _build_schema(
|
||||
list(_CORE_ACTIONS.keys()), caps={"detected": False}, tool_name="discord",
|
||||
)
|
||||
_STATIC_ADMIN_SCHEMA = _build_schema(
|
||||
list(_ADMIN_ACTIONS.keys()), caps={"detected": False}, tool_name="discord_admin",
|
||||
)
|
||||
# Register with the full unfiltered schema. ``model_tools.get_tool_definitions``
|
||||
# rebuilds this per-session via ``get_dynamic_schema`` so the model only ever
|
||||
# sees intent-available, config-allowed actions. The static registration is a
|
||||
# safe baseline for tools that inspect the registry directly.
|
||||
_STATIC_SCHEMA = _build_schema(list(_ACTIONS.keys()), caps={"detected": False})
|
||||
|
||||
registry.register(
|
||||
name="discord",
|
||||
name="discord_server",
|
||||
toolset="discord",
|
||||
schema=_STATIC_CORE_SCHEMA,
|
||||
handler=_make_handler(discord_core),
|
||||
check_fn=check_discord_tool_requirements,
|
||||
requires_env=["DISCORD_BOT_TOKEN"],
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="discord_admin",
|
||||
toolset="discord_admin",
|
||||
schema=_STATIC_ADMIN_SCHEMA,
|
||||
handler=_make_handler(discord_admin_handler),
|
||||
schema=_STATIC_SCHEMA,
|
||||
handler=lambda args, **kw: discord_server(
|
||||
action=args.get("action", ""),
|
||||
guild_id=args.get("guild_id", ""),
|
||||
channel_id=args.get("channel_id", ""),
|
||||
user_id=args.get("user_id", ""),
|
||||
role_id=args.get("role_id", ""),
|
||||
message_id=args.get("message_id", ""),
|
||||
query=args.get("query", ""),
|
||||
name=args.get("name", ""),
|
||||
limit=args.get("limit", 50),
|
||||
before=args.get("before", ""),
|
||||
after=args.get("after", ""),
|
||||
auto_archive_duration=args.get("auto_archive_duration", 1440),
|
||||
task_id=kw.get("task_id"),
|
||||
),
|
||||
check_fn=check_discord_tool_requirements,
|
||||
requires_env=["DISCORD_BOT_TOKEN"],
|
||||
)
|
||||
|
||||
24
toolsets.py
24
toolsets.py
@@ -202,18 +202,6 @@ TOOLSETS = {
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"discord": {
|
||||
"description": "Discord read and participate tools (fetch messages, search members, create threads)",
|
||||
"tools": ["discord"],
|
||||
"includes": [],
|
||||
},
|
||||
|
||||
"discord_admin": {
|
||||
"description": "Discord server management (list channels/roles, pin messages, assign roles)",
|
||||
"tools": ["discord_admin"],
|
||||
"includes": [],
|
||||
},
|
||||
|
||||
"feishu_doc": {
|
||||
"description": "Read Feishu/Lark document content",
|
||||
"tools": ["feishu_doc_read"],
|
||||
@@ -338,8 +326,8 @@ TOOLSETS = {
|
||||
"hermes-discord": {
|
||||
"description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)",
|
||||
"tools": _HERMES_CORE_TOOLS + [
|
||||
"discord",
|
||||
"discord_admin",
|
||||
# Discord server introspection & management (gated on DISCORD_BOT_TOKEN via check_fn)
|
||||
"discord_server",
|
||||
],
|
||||
"includes": []
|
||||
},
|
||||
@@ -400,13 +388,7 @@ TOOLSETS = {
|
||||
|
||||
"hermes-feishu": {
|
||||
"description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS + [
|
||||
"feishu_doc_read",
|
||||
"feishu_drive_list_comments",
|
||||
"feishu_drive_list_comment_replies",
|
||||
"feishu_drive_reply_comment",
|
||||
"feishu_drive_add_comment",
|
||||
],
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
|
||||
@@ -2789,6 +2789,23 @@ def _(rid, params: dict) -> dict:
|
||||
_write_config_key("display.tui_statusbar", nv)
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key == "mouse":
|
||||
raw = str(value or "").strip().lower()
|
||||
display = _load_cfg().get("display") if isinstance(_load_cfg().get("display"), dict) else {}
|
||||
current = bool(display.get("tui_mouse", True))
|
||||
|
||||
if raw in ("", "toggle"):
|
||||
nv = not current
|
||||
elif raw == "on":
|
||||
nv = True
|
||||
elif raw == "off":
|
||||
nv = False
|
||||
else:
|
||||
return _err(rid, 4002, f"unknown mouse value: {value}")
|
||||
|
||||
_write_config_key("display.tui_mouse", nv)
|
||||
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
|
||||
|
||||
if key in ("prompt", "personality", "skin"):
|
||||
try:
|
||||
cfg = _load_cfg()
|
||||
@@ -2917,6 +2934,10 @@ def _(rid, params: dict) -> dict:
|
||||
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
|
||||
)
|
||||
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||
if key == "mouse":
|
||||
display = _load_cfg().get("display")
|
||||
on = display.get("tui_mouse", True) if isinstance(display, dict) else True
|
||||
return _ok(rid, {"value": "on" if on else "off"})
|
||||
if key == "mtime":
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
|
||||
@@ -53,7 +53,7 @@ export function AlternateScreen(t0: Props) {
|
||||
}
|
||||
|
||||
writeRaw(
|
||||
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
|
||||
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
|
||||
)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
|
||||
|
||||
@@ -1121,6 +1121,23 @@ export default class Ink {
|
||||
this.repaint()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mouse tracking at runtime while the alt screen is active.
|
||||
* Writes the appropriate DEC reset/set sequences so the terminal
|
||||
* (and ConPTY on Windows WSL2) reflects the change immediately.
|
||||
*/
|
||||
setAltScreenMouseTracking(enabled: boolean): void {
|
||||
if (this.altScreenMouseTracking === enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.altScreenMouseTracking = enabled
|
||||
|
||||
if (this.altScreenActive) {
|
||||
this.options.stdout.write(enabled ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
|
||||
}
|
||||
}
|
||||
get isAltScreenActive(): boolean {
|
||||
return this.altScreenActive
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { GatewayProvider } from './app/gatewayContext.js'
|
||||
import { useMainApp } from './app/useMainApp.js'
|
||||
import { $uiState } from './app/uiStore.js'
|
||||
import { AppLayout } from './components/appLayout.js'
|
||||
import { MOUSE_TRACKING } from './config/env.js'
|
||||
import type { GatewayClient } from './gatewayClient.js'
|
||||
|
||||
export function App({ gw }: { gw: GatewayClient }) {
|
||||
const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw)
|
||||
const { mouseTracking } = useStore($uiState)
|
||||
|
||||
return (
|
||||
<GatewayProvider value={gateway}>
|
||||
<AppLayout
|
||||
actions={appActions}
|
||||
composer={appComposer}
|
||||
mouseTracking={MOUSE_TRACKING}
|
||||
mouseTracking={mouseTracking}
|
||||
progress={appProgress}
|
||||
status={appStatus}
|
||||
transcript={appTranscript}
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface UiState {
|
||||
detailsMode: DetailsMode
|
||||
info: null | SessionInfo
|
||||
inlineDiffs: boolean
|
||||
mouseTracking: boolean
|
||||
sections: SectionVisibility
|
||||
showCost: boolean
|
||||
showReasoning: boolean
|
||||
|
||||
@@ -84,6 +84,27 @@ export const coreCommands: SlashCommand[] = [
|
||||
run: (_arg, ctx) => ctx.session.die()
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['scroll'],
|
||||
help: 'toggle mouse/wheel tracking [on|off|toggle]',
|
||||
name: 'mouse',
|
||||
run: (arg, ctx) => {
|
||||
const current = ctx.ui.mouseTracking
|
||||
const next = flagFromArg(arg, current)
|
||||
|
||||
if (next === null) {
|
||||
return ctx.transcript.sys('usage: /mouse [on|off|toggle]')
|
||||
}
|
||||
|
||||
patchUiState({ mouseTracking: next })
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' })
|
||||
.catch(() => {})
|
||||
|
||||
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`))
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['new'],
|
||||
help: 'start a new session',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { atom } from 'nanostores'
|
||||
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
import { MOUSE_TRACKING } from '../config/env.js'
|
||||
|
||||
import type { UiState } from './interfaces.js'
|
||||
|
||||
@@ -12,6 +13,7 @@ const buildUiState = (): UiState => ({
|
||||
detailsMode: 'collapsed',
|
||||
info: null,
|
||||
inlineDiffs: true,
|
||||
mouseTracking: MOUSE_TRACKING,
|
||||
sections: {},
|
||||
showCost: false,
|
||||
showReasoning: false,
|
||||
|
||||
@@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
mouseTracking: d.tui_mouse !== false,
|
||||
sections: resolveSections(d.sections),
|
||||
showCost: !!d.show_cost,
|
||||
showReasoning: !!d.show_reasoning,
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface ConfigDisplayConfig {
|
||||
streaming?: boolean
|
||||
thinking_mode?: string
|
||||
tui_compact?: boolean
|
||||
tui_mouse?: boolean
|
||||
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -216,8 +216,24 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Default 3 parallel tasks** — batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml — no hard ceiling, only a floor of 1)
|
||||
- **Nested delegation is opt-in** — leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
|
||||
- **Default 3 parallel tasks**: batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml, no hard ceiling, only a floor of 1)
|
||||
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
|
||||
|
||||
### Tuning Concurrency and Depth
|
||||
|
||||
| Config | Default | Range | Effect |
|
||||
|--------|---------|-------|--------|
|
||||
| `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call |
|
||||
| `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further |
|
||||
|
||||
Example: running 30 parallel workers with nested subagents:
|
||||
|
||||
```yaml
|
||||
delegation:
|
||||
max_concurrent_children: 30
|
||||
max_spawn_depth: 2
|
||||
```
|
||||
|
||||
- **Separate terminals** — each subagent gets its own terminal session with separate working directory and state
|
||||
- **No conversation history** — subagents see only the `goal` and `context` the parent agent passes when calling `delegate_task`
|
||||
- **Default 50 iterations** — set `max_iterations` lower for simple tasks to save cost
|
||||
|
||||
Reference in New Issue
Block a user