Skip to content

Commit 2175062

Browse files
CopilotBukeLy
andcommitted
feat: separate agent vs local commands
Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com>
1 parent 85f409f commit 2175062

6 files changed

Lines changed: 220 additions & 167 deletions

File tree

agent-sdk-client/config.py

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,34 @@ def extract_command(text: Optional[str]) -> Optional[str]:
2828
return command
2929

3030

31-
def load_command_whitelist(config_path: Path = DEFAULT_CONFIG_PATH) -> list[str]:
32-
"""Load command whitelist from TOML config file."""
31+
def _load_config(config_path: Path = DEFAULT_CONFIG_PATH) -> tuple[list[str], dict[str, str]]:
32+
"""Load agent/local commands from TOML config file."""
3333
if not config_path.exists():
34-
return []
34+
return [], {}
3535

3636
try:
3737
with config_path.open('rb') as f:
3838
data = tomllib.load(f)
39-
whitelist = data.get('white_list_commands', {}).get('whitelist', [])
40-
if not isinstance(whitelist, list):
41-
logger.warning("Command whitelist is not a list; ignoring configuration")
42-
return []
43-
44-
commands = [cmd for cmd in whitelist if isinstance(cmd, str)]
45-
if len(commands) != len(whitelist):
46-
logger.warning("Ignoring non-string entries in command whitelist")
47-
return commands
39+
agent_commands = data.get('agent_commands', {}).get('commands', [])
40+
if not isinstance(agent_commands, list):
41+
logger.warning("Agent commands config is not a list; ignoring configuration")
42+
agent_commands = []
43+
agent_commands = [cmd for cmd in agent_commands if isinstance(cmd, str)]
44+
45+
local_commands_raw = data.get('local_commands', {})
46+
if not isinstance(local_commands_raw, dict):
47+
logger.warning("Local commands config is not a table; ignoring configuration")
48+
local_commands_raw = {}
49+
local_commands = {
50+
f"/{name.lstrip('/')}" if not name.startswith('/') else name: str(value)
51+
for name, value in local_commands_raw.items()
52+
if isinstance(name, str) and isinstance(value, str)
53+
}
54+
55+
return agent_commands, local_commands
4856
except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging
49-
logger.warning("Failed to load command whitelist: %s", exc)
50-
return []
57+
logger.warning("Failed to load command configuration: %s", exc)
58+
return [], {}
5159

5260

5361
@dataclass
@@ -58,22 +66,40 @@ class Config:
5866
agent_server_url: str
5967
auth_token: str
6068
queue_url: str
61-
command_whitelist: list[str]
69+
agent_commands: list[str]
70+
local_commands: dict[str, str]
6271

6372
@classmethod
6473
def from_env(cls, config_path: Optional[Path] = None) -> 'Config':
6574
"""Load configuration from environment variables."""
75+
agent_cmds, local_cmds = _load_config(config_path or DEFAULT_CONFIG_PATH)
6676
return cls(
6777
telegram_token=os.getenv('TELEGRAM_BOT_TOKEN', ''),
6878
agent_server_url=os.getenv('AGENT_SERVER_URL', ''),
6979
auth_token=os.getenv('SDK_CLIENT_AUTH_TOKEN', 'default-token'),
7080
queue_url=os.getenv('QUEUE_URL', ''),
71-
command_whitelist=load_command_whitelist(config_path or DEFAULT_CONFIG_PATH),
81+
agent_commands=agent_cmds,
82+
local_commands=local_cmds,
7283
)
7384

74-
def is_command_allowed(self, text: Optional[str]) -> bool:
75-
"""Check whether text should be forwarded to Agent backend."""
76-
command = extract_command(text)
77-
if command is None:
78-
return True
79-
return command in self.command_whitelist
85+
def get_command(self, text: Optional[str]) -> Optional[str]:
86+
return extract_command(text)
87+
88+
def is_agent_command(self, cmd: Optional[str]) -> bool:
89+
return bool(cmd) and cmd in self.agent_commands
90+
91+
def is_local_command(self, cmd: Optional[str]) -> bool:
92+
return bool(cmd) and cmd in self.local_commands
93+
94+
def local_response(self, cmd: str) -> str:
95+
return self.local_commands.get(cmd, "Unsupported command.")
96+
97+
def unknown_command_message(self) -> str:
98+
parts = []
99+
if self.agent_commands:
100+
parts.append("Agent commands:\n" + "\n".join(self.agent_commands))
101+
if self.local_commands:
102+
parts.append("Local commands:\n" + "\n".join(self.local_commands.keys()))
103+
if not parts:
104+
return "Unsupported command."
105+
return "Unsupported command.\n\n" + "\n\n".join(parts)

agent-sdk-client/config.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
[white_list_commands]
2-
# Only commands in this whitelist will be forwarded to the Agent backend
3-
whitelist = [
1+
[agent_commands]
2+
# Commands forwarded to the Agent backend
3+
commands = [
44
"/custom-skill",
55
"/hello-world",
66
]
7+
8+
[local_commands]
9+
# Local-only commands handled by the client
10+
help = "Hello World"

agent-sdk-client/consumer.py

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,31 +55,43 @@ async def process_message(message_data: dict) -> None:
5555
logger.warning("Received update with no message or edited_message")
5656
return
5757

58-
if not config.is_command_allowed(message.text):
59-
# Defensive guard: producer should already block non-whitelisted commands.
60-
logger.info(
61-
"Skipping non-whitelisted command (consumer fallback)",
62-
extra={
63-
'chat_id': message.chat_id,
64-
'message_id': message.message_id,
65-
},
66-
)
67-
allowed = config.command_whitelist
68-
if allowed:
69-
allowed_list = "\n".join(allowed)
70-
text = f"Unsupported command. Allowed commands:\n{allowed_list}"
71-
else:
72-
text = "Unsupported command."
73-
try:
74-
await bot.send_message(
75-
chat_id=message.chat_id,
76-
text=text,
77-
message_thread_id=message.message_thread_id,
78-
reply_to_message_id=message.message_id,
58+
cmd = config.get_command(message.text)
59+
if cmd:
60+
if config.is_local_command(cmd):
61+
logger.info(
62+
"Handling local command in consumer (fallback path)",
63+
extra={'chat_id': message.chat_id, 'message_id': message.message_id},
7964
)
80-
except Exception:
81-
logger.warning("Failed to send local command response", exc_info=True)
82-
return
65+
try:
66+
await bot.send_message(
67+
chat_id=message.chat_id,
68+
text=config.local_response(cmd),
69+
message_thread_id=message.message_thread_id,
70+
reply_to_message_id=message.message_id,
71+
)
72+
except Exception:
73+
logger.warning("Failed to send local command response", exc_info=True)
74+
return
75+
76+
if not config.is_agent_command(cmd):
77+
# Defensive guard: producer should already block non-agent commands.
78+
logger.info(
79+
"Skipping non-agent command (consumer fallback)",
80+
extra={
81+
'chat_id': message.chat_id,
82+
'message_id': message.message_id,
83+
},
84+
)
85+
try:
86+
await bot.send_message(
87+
chat_id=message.chat_id,
88+
text=config.unknown_command_message(),
89+
message_thread_id=message.message_thread_id,
90+
reply_to_message_id=message.message_id,
91+
)
92+
except Exception:
93+
logger.warning("Failed to send local command response", exc_info=True)
94+
return
8395

8496
# Send typing indicator
8597
await bot.send_chat_action(

agent-sdk-client/handler.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,12 @@ def _send_to_sqs_safe(sqs, queue_url: str, message_body: dict) -> bool:
120120
return False
121121

122122

123-
def _handle_local_command(bot: Bot, message, config: Config) -> bool:
124-
"""Handle non-whitelisted commands locally to give user feedback."""
125-
if config.is_command_allowed(message.text):
126-
return False
127-
128-
allowed = config.command_whitelist
129-
if allowed:
130-
allowed_list = "\n".join(allowed)
131-
text = f"Unsupported command. Allowed commands:\n{allowed_list}"
123+
def _handle_local_command(bot: Bot, message, config: Config, cmd: str) -> bool:
124+
"""Handle local commands or unknown commands."""
125+
if config.is_local_command(cmd):
126+
text = config.local_response(cmd)
132127
else:
133-
text = "Unsupported command."
128+
text = config.unknown_command_message()
134129

135130
try:
136131
bot.send_message(
@@ -179,7 +174,13 @@ def lambda_handler(event: dict, context: Any) -> dict:
179174
logger.debug('Ignoring webhook without text message')
180175
return {'statusCode': 200}
181176

182-
if _handle_local_command(bot, message, config):
177+
cmd = config.get_command(message.text)
178+
if cmd and config.is_local_command(cmd):
179+
_handle_local_command(bot, message, config, cmd)
180+
return {'statusCode': 200}
181+
182+
if cmd and not config.is_agent_command(cmd):
183+
_handle_local_command(bot, message, config, cmd)
183184
return {'statusCode': 200}
184185

185186
# Write to SQS for async processing

tests/test_command_config.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import importlib.util
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
CLIENT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "agent-sdk-client" / "config.py"
7+
spec = importlib.util.spec_from_file_location("agent_sdk_client_config", CLIENT_CONFIG_PATH)
8+
config_module = importlib.util.module_from_spec(spec)
9+
assert spec.loader is not None
10+
spec.loader.exec_module(config_module)
11+
Config = config_module.Config
12+
extract_command = config_module.extract_command
13+
14+
15+
def load_config_from_text(text: str, tmp_path: Path) -> Config:
16+
config_path = tmp_path / "config.toml"
17+
config_path.write_text(text)
18+
return Config.from_env(config_path=config_path)
19+
20+
21+
def test_load_agent_and_local_commands(tmp_path):
22+
cfg = load_config_from_text(
23+
"""[agent_commands]
24+
commands = ["/a", "/b"]
25+
26+
[local_commands]
27+
help = "Hello"
28+
""",
29+
tmp_path,
30+
)
31+
assert cfg.agent_commands == ["/a", "/b"]
32+
assert cfg.local_commands == {"/help": "Hello"}
33+
34+
35+
@pytest.mark.parametrize(
36+
"text,cmd",
37+
[
38+
("hello world", None),
39+
("/allowed", "/allowed"),
40+
("/allowed extra args", "/allowed"),
41+
("/allowed@bot", "/allowed"),
42+
("/@bot", None),
43+
("/", None),
44+
(None, None),
45+
],
46+
)
47+
def test_extract_command(text, cmd):
48+
assert extract_command(text) == cmd
49+
50+
51+
def test_command_classification(tmp_path):
52+
cfg = load_config_from_text(
53+
"""[agent_commands]
54+
commands = ["/agent"]
55+
56+
[local_commands]
57+
help = "Hello World"
58+
""",
59+
tmp_path,
60+
)
61+
assert cfg.is_agent_command("/agent")
62+
assert not cfg.is_agent_command("/other")
63+
assert cfg.is_local_command("/help")
64+
assert not cfg.is_local_command("/agent")
65+
66+
67+
def test_unknown_command_message_lists_known():
68+
cfg = Config(
69+
telegram_token="",
70+
agent_server_url="",
71+
auth_token="",
72+
queue_url="",
73+
agent_commands=["/agent1"],
74+
local_commands={"/help": "hi"},
75+
)
76+
msg = cfg.unknown_command_message()
77+
assert "Agent commands" in msg and "/agent1" in msg
78+
assert "Local commands" in msg and "/help" in msg
79+
80+
81+
def test_invalid_agent_commands_type(tmp_path, caplog):
82+
with caplog.at_level("WARNING"):
83+
cfg = load_config_from_text(
84+
"""[agent_commands]
85+
commands = "not-a-list"
86+
""",
87+
tmp_path,
88+
)
89+
assert cfg.agent_commands == []
90+
assert any("Agent commands config is not a list" in rec.message for rec in caplog.records)
91+
92+
93+
def test_invalid_local_commands_type(tmp_path, caplog):
94+
cfg = load_config_from_text(
95+
"""[local_commands]
96+
value = 1
97+
""",
98+
tmp_path,
99+
)
100+
assert cfg.local_commands == {}
101+
102+
103+
def test_missing_config_file(tmp_path):
104+
missing = tmp_path / "missing.toml"
105+
cfg = Config.from_env(config_path=missing)
106+
assert cfg.agent_commands == []
107+
assert cfg.local_commands == {}
108+
109+
110+
def test_malformed_toml_returns_empty(tmp_path, caplog):
111+
path = tmp_path / "bad.toml"
112+
path.write_text("not = [ [")
113+
with caplog.at_level("WARNING"):
114+
cfg = Config.from_env(config_path=path)
115+
assert cfg.agent_commands == []
116+
assert cfg.local_commands == {}
117+
assert any("Failed to load command configuration" in rec.message for rec in caplog.records)

0 commit comments

Comments
 (0)