Skip to content

Commit 4c789bb

Browse files
committed
Migrate built-in commands from hand-crafted per-backend strings to FormattedMessage
executor.py — New _extract_attachments() helper converts FormattedImage/FormattedAttachment from a FormattedMessage into base Attachment objects; _coerce_response() now populates Message.attachments when returning a FormattedMessage echo.py — Replaced manual mention_users() calls with FormattedMessage + UserMention nodes help.py — Replaced per-backend if/elif rendering (Symphony <expandable-card>, Slack mrkdwn, Discord markdown table) with FormattedMessage + Heading + Table.from_dict_list() that auto-renders for each backend status.py — Same migration: builds a Table.from_dict_list() of metrics instead of manual **bold** markdown lines schedule.py — Same migration for the schedule listing test_command_framework.py — 3 new tests for _coerce_response() covering images in content, file attachments, and mixed content+attachments Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent f320900 commit 4c789bb

11 files changed

Lines changed: 1154 additions & 94 deletions

File tree

csp_bot/bot.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ def connect(self, channels: GatewayChannels) -> None:
126126
log.info(f"Fetching bot info for {backend}...")
127127
self._fetch_bot_info(backend)
128128

129+
# Inject backends into AgentCommand subclasses
130+
self._inject_backends_into_agent_commands()
131+
129132
# Subscribe to messages from all adapters
130133
# chatom provides unified Message type across all backends
131134
messages_in = csp.null_ts(Message)
@@ -248,6 +251,53 @@ def _update_user_access_loop(self, backend: str) -> None:
248251
except Exception:
249252
log.exception(f"Error updating user access for {backend}")
250253

254+
def _inject_backends_into_agent_commands(self) -> None:
255+
"""Inject connected BackendBase instances into AgentCommand subclasses."""
256+
try:
257+
from csp_bot.commands.agent import AgentCommand
258+
except ImportError:
259+
# pydantic-ai / chatom[agent] not installed — skip silently
260+
return
261+
262+
backends = {}
263+
loops = {}
264+
for name in self._adapters:
265+
result = self._ensure_backend_connected(name)
266+
if result:
267+
connected_backend, loop = result
268+
backends[name] = connected_backend
269+
loops[name] = loop
270+
if backends:
271+
AgentCommand.set_backends(backends, loops=loops)
272+
log.info(f"Injected {len(backends)} connected backends into AgentCommand: {list(backends.keys())}")
273+
274+
def _track_agent_session_response(self, response: Message, command: BotCommand) -> None:
275+
"""Associate a sent response with an agent session for reply tracking.
276+
277+
Uses the original command's message ID as the key that future replies
278+
will reference (e.g., thread_ts in Slack, or message reference in Discord).
279+
"""
280+
metadata = response.metadata or {}
281+
session_key = metadata.get("agent_session_key")
282+
if not session_key:
283+
return
284+
285+
try:
286+
from csp_bot.commands.agent import AgentCommand
287+
except ImportError:
288+
return
289+
290+
# Use the original command's message ID — in Slack threads, replies
291+
# reference this as thread_ts; in Discord, as message_reference.
292+
orig_msg_id = command.message.id if command.message else None
293+
if orig_msg_id:
294+
AgentCommand._sessions.update_response_id(session_key, orig_msg_id)
295+
log.debug(f"Tracked agent session response: session={session_key}, msg_id={orig_msg_id}")
296+
297+
# Also track by the response message ID if it has one
298+
if response.id and response.id != orig_msg_id:
299+
AgentCommand._sessions.update_response_id(session_key, response.id)
300+
251301
def _ensure_backend_connected(self, backend: str) -> Optional[Tuple[Any, asyncio.AbstractEventLoop]]:
252302
"""Ensure a connected backend exists for the given platform.
253303
@@ -579,6 +629,8 @@ def _handle_commands(self, cmd: ts[BotCommand]) -> Outputs(messages=ts[[Message]
579629
if isinstance(item, Message):
580630
log.debug(f"Adding message to buffer: {item.content[:100] if item.content else 'empty'}...")
581631
s_buffer.append(item)
632+
# Track agent session responses for reply continuity
633+
self._track_agent_session_response(item, command)
582634
elif isinstance(item, BotCommand):
583635
next_cycle_commands.append(item)
584636
else:
@@ -821,6 +873,11 @@ def _extract_commands(
821873

822874
# Check for command syntax (supports both / and ! prefixes)
823875
if not content.startswith("/") and not content.startswith("!"):
876+
# Check if this is a reply to an active agent session
877+
session_cmd = self._check_agent_session_reply(msg, backend, channel_id)
878+
if session_cmd:
879+
return session_cmd
880+
824881
# If tagged but no command, show help
825882
log.info("No command prefix, showing help")
826883
return self._create_help_command(msg, backend, channel_id)
@@ -914,6 +971,79 @@ def _extract_commands(
914971
log.exception("Error extracting command")
915972
return None
916973

974+
def _check_agent_session_reply(self, msg: Message, backend: str, channel_id: str) -> Optional[BotCommand]:
975+
"""Check if the message is a reply to a bot response with an active agent session.
976+
977+
If so, constructs a BotCommand to continue the conversation.
978+
"""
979+
try:
980+
from csp_bot.commands.agent import AgentCommand
981+
except ImportError:
982+
return None
983+
984+
# Get the referenced message ID
985+
ref_id = None
986+
if msg.reference and msg.reference.message_id:
987+
ref_id = msg.reference.message_id
988+
elif msg.reply_to and msg.reply_to.id:
989+
ref_id = msg.reply_to.id
990+
# Check thread metadata (Slack thread_ts)
991+
if not ref_id and msg.thread and msg.thread.id:
992+
ref_id = msg.thread.id
993+
994+
if not ref_id:
995+
return None
996+
997+
# Look up session by the bot response ID
998+
session = AgentCommand._sessions.get_by_response_id(ref_id)
999+
if session is None:
1000+
return None
1001+
1002+
# Found an active session — route the reply to the same command
1003+
command_name = session.command_name
1004+
if command_name not in self._commands:
1005+
log.warning(f"Agent session references unknown command: {command_name}")
1006+
return None
1007+
1008+
command_runner = self._commands[command_name]
1009+
content = msg.content or ""
1010+
# Strip bot mention if present
1011+
bot_name = self._get_bot_name(backend)
1012+
bot_id = self._get_bot_id(backend)
1013+
if bot_id:
1014+
content = re.sub(rf"<@!?{re.escape(bot_id)}>", "", content).strip()
1015+
if bot_name and content.startswith(f"@{bot_name}"):
1016+
content = content[len(f"@{bot_name}") :].strip()
1017+
1018+
source = User(
1019+
id=msg.author.id if msg.author else msg.author_id or "",
1020+
name=msg.author.name if msg.author else "",
1021+
email=getattr(msg.author, "email", "") if msg.author else "",
1022+
handle=getattr(msg.author, "handle", "") if msg.author else "",
1023+
)
1024+
1025+
channel_name = ""
1026+
if msg.channel and hasattr(msg.channel, "name"):
1027+
channel_name = msg.channel.name or ""
1028+
1029+
bot_cmd = BotCommand(
1030+
command=command_name,
1031+
args=(content,),
1032+
source=source,
1033+
targets=(),
1034+
channel_id=channel_id,
1035+
channel_name=channel_name,
1036+
backend=backend,
1037+
variant=command_runner.kind() if isinstance(command_runner, BaseCommand) else CommandVariant.REPLY,
1038+
message=msg,
1039+
delay=None,
1040+
schedule="",
1041+
times_run=0,
1042+
)
1043+
1044+
log.info(f"Routing reply to active agent session: command={command_name}, user={source.id}")
1045+
return command_runner.preexecute(bot_cmd)
1046+
9171047
def _parse_command_args(
9181048
self,
9191049
tokens: List[str],

csp_bot/commands/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
from .schedule import ScheduleCommand, ScheduleCommandModel
3333
from .status import StatusCommand, StatusCommandModel
3434

35+
try:
36+
from .agent import AgentCommand
37+
except ImportError:
38+
pass
39+
3540
__all__ = (
3641
# New framework
3742
"Command",
@@ -45,6 +50,7 @@
4550
"get_registered_commands",
4651
"execute_command_func",
4752
# Legacy base classes
53+
"AgentCommand",
4854
"BaseCommand",
4955
"BaseCommandModel",
5056
"NoResponseCommand",

0 commit comments

Comments
 (0)