Skip to content

Commit 29bcb05

Browse files
authored
Merge pull request #15 from BukeLy/copilot/add-command-list-mechanism
Separate agent vs local commands with client-side handling and feedback
2 parents fd4719c + 2175062 commit 29bcb05

5 files changed

Lines changed: 284 additions & 1 deletion

File tree

agent-sdk-client/config.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,61 @@
11
"""Configuration for sdk-client Lambda."""
2+
import logging
23
import os
4+
import tomllib
35
from dataclasses import dataclass
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
logger = logging.getLogger(__name__)
10+
DEFAULT_CONFIG_PATH = Path(__file__).with_name("config.toml")
11+
12+
13+
def extract_command(text: Optional[str]) -> Optional[str]:
14+
"""Extract command (with leading slash) from text, ignoring arguments/bot names."""
15+
if not text:
16+
return None
17+
18+
trimmed = text.strip()
19+
if not trimmed.startswith('/'):
20+
return None
21+
22+
command = trimmed.split()[0]
23+
if '@' in command:
24+
command = command.split('@', 1)[0]
25+
command = command.strip()
26+
if not command or command == '/':
27+
return None
28+
return command
29+
30+
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."""
33+
if not config_path.exists():
34+
return [], {}
35+
36+
try:
37+
with config_path.open('rb') as f:
38+
data = tomllib.load(f)
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
56+
except (OSError, tomllib.TOMLDecodeError) as exc: # pragma: no cover - defensive logging
57+
logger.warning("Failed to load command configuration: %s", exc)
58+
return [], {}
459

560

661
@dataclass
@@ -11,13 +66,40 @@ class Config:
1166
agent_server_url: str
1267
auth_token: str
1368
queue_url: str
69+
agent_commands: list[str]
70+
local_commands: dict[str, str]
1471

1572
@classmethod
16-
def from_env(cls) -> 'Config':
73+
def from_env(cls, config_path: Optional[Path] = None) -> 'Config':
1774
"""Load configuration from environment variables."""
75+
agent_cmds, local_cmds = _load_config(config_path or DEFAULT_CONFIG_PATH)
1876
return cls(
1977
telegram_token=os.getenv('TELEGRAM_BOT_TOKEN', ''),
2078
agent_server_url=os.getenv('AGENT_SERVER_URL', ''),
2179
auth_token=os.getenv('SDK_CLIENT_AUTH_TOKEN', 'default-token'),
2280
queue_url=os.getenv('QUEUE_URL', ''),
81+
agent_commands=agent_cmds,
82+
local_commands=local_cmds,
2383
)
84+
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[agent_commands]
2+
# Commands forwarded to the Agent backend
3+
commands = [
4+
"/custom-skill",
5+
"/hello-world",
6+
]
7+
8+
[local_commands]
9+
# Local-only commands handled by the client
10+
help = "Hello World"

agent-sdk-client/consumer.py

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

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},
64+
)
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
95+
5896
# Send typing indicator
5997
await bot.send_chat_action(
6098
chat_id=message.chat_id,

agent-sdk-client/handler.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,33 @@ 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, cmd: str) -> bool:
124+
"""Handle local commands or unknown commands."""
125+
if config.is_local_command(cmd):
126+
text = config.local_response(cmd)
127+
else:
128+
text = config.unknown_command_message()
129+
130+
try:
131+
bot.send_message(
132+
chat_id=message.chat_id,
133+
text=text,
134+
message_thread_id=message.message_thread_id,
135+
reply_to_message_id=message.message_id,
136+
)
137+
except Exception:
138+
logger.warning("Failed to send local command response", exc_info=True)
139+
140+
logger.info(
141+
'Handled non-whitelisted command locally',
142+
extra={
143+
'chat_id': message.chat_id,
144+
'message_id': message.message_id,
145+
},
146+
)
147+
return True
148+
149+
123150
def lambda_handler(event: dict, context: Any) -> dict:
124151
"""Lambda entry point - Producer.
125152
@@ -147,6 +174,15 @@ def lambda_handler(event: dict, context: Any) -> dict:
147174
logger.debug('Ignoring webhook without text message')
148175
return {'statusCode': 200}
149176

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)
184+
return {'statusCode': 200}
185+
150186
# Write to SQS for async processing
151187
sqs = _get_sqs_client()
152188
message_body = {

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)