csp-bot 2.0.0__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {csp_bot-2.0.0 → csp_bot-2.0.2}/PKG-INFO +9 -4
  2. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/__init__.py +1 -1
  3. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/bot.py +8 -2
  4. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/__init__.py +8 -6
  5. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/agent.py +80 -27
  6. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/base.py +4 -0
  7. csp_bot-2.0.2/csp_bot/commands/claude_agent.py +206 -0
  8. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/executor.py +2 -2
  9. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/framework.py +4 -0
  10. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/status.py +2 -2
  11. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/persistence.py +5 -5
  12. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/structs.py +3 -3
  13. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_agent_command.py +275 -35
  14. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_bot_integration.py +46 -0
  15. csp_bot-2.0.2/csp_bot/tests/test_claude_agent.py +272 -0
  16. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_command_framework.py +11 -1
  17. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_echo.py +7 -0
  18. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_persistence.py +5 -5
  19. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_schedule.py +4 -4
  20. {csp_bot-2.0.0 → csp_bot-2.0.2}/pyproject.toml +11 -5
  21. {csp_bot-2.0.0 → csp_bot-2.0.2}/.gitignore +0 -0
  22. {csp_bot-2.0.0 → csp_bot-2.0.2}/LICENSE +0 -0
  23. {csp_bot-2.0.0 → csp_bot-2.0.2}/README.md +0 -0
  24. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/__init__.py +0 -0
  25. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/discord.py +0 -0
  26. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/slack.py +0 -0
  27. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/symphony.py +0 -0
  28. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/telegram.py +0 -0
  29. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/bot_config.py +0 -0
  30. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/cli.py +0 -0
  31. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/context.py +0 -0
  32. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/echo.py +0 -0
  33. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/help.py +0 -0
  34. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/legacy.py +0 -0
  35. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/schedule.py +0 -0
  36. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/__init__.py +0 -0
  37. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/discord.yaml +0 -0
  38. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/slack.yaml +0 -0
  39. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/symphony.yaml +0 -0
  40. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/telegram.yaml +0 -0
  41. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/commands.yaml +0 -0
  42. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/conf.yaml +0 -0
  43. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/all.yaml +0 -0
  44. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/bot.yaml +0 -0
  45. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/discord.yaml +0 -0
  46. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/mixed.yaml +0 -0
  47. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/slack.yaml +0 -0
  48. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/symphony.yaml +0 -0
  49. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/telegram.yaml +0 -0
  50. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/hydra/job_logging/custom.yaml +0 -0
  51. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/modules.yaml +0 -0
  52. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/gateway/__init__.py +0 -0
  53. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/gateway/gateway.py +0 -0
  54. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/__init__.py +0 -0
  55. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/conftest.py +0 -0
  56. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_backends.py +0 -0
  57. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_bot.py +0 -0
  58. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_config.py +0 -0
  59. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_config_composition.py +0 -0
  60. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_help_command.py +0 -0
  61. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_slack_entity_regex.py +0 -0
  62. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_status.py +0 -0
  63. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_structs.py +0 -0
  64. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_utils.py +0 -0
  65. {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: csp-bot
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Reactive chat bot framework
5
5
  Project-URL: Repository, https://github.com/Point72/csp-bot
6
6
  Project-URL: Homepage, https://github.com/Point72/csp-bot
@@ -12,19 +12,18 @@ Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: License :: OSI Approved :: Apache Software License
13
13
  Classifier: Programming Language :: Python
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Programming Language :: Python :: 3.14
20
19
  Classifier: Programming Language :: Python :: Implementation :: CPython
21
20
  Classifier: Programming Language :: Python :: Implementation :: PyPy
22
- Requires-Python: >=3.10
21
+ Requires-Python: >=3.11
23
22
  Requires-Dist: beautifulsoup4
24
23
  Requires-Dist: ccflow>=0.5.2
25
24
  Requires-Dist: chatom>=0.2.0
26
25
  Requires-Dist: croniter
27
- Requires-Dist: csp-gateway<2.7,>=2.5
26
+ Requires-Dist: csp-gateway<4,>=2.5
28
27
  Requires-Dist: csp<1,>=0.9
29
28
  Requires-Dist: dateparser
30
29
  Requires-Dist: hydra-core
@@ -32,11 +31,17 @@ Requires-Dist: pandas
32
31
  Requires-Dist: pydantic>=2
33
32
  Provides-Extra: agent
34
33
  Requires-Dist: chatom[agent]>=0.2.0; extra == 'agent'
34
+ Requires-Dist: pydantic-ai>=2.42; extra == 'agent'
35
+ Provides-Extra: claude-agent
36
+ Requires-Dist: chatom[agent]>=0.2.0; extra == 'claude-agent'
37
+ Requires-Dist: claude-agent-sdk>=0.2.152; extra == 'claude-agent'
38
+ Requires-Dist: pydantic-ai>=2.42; extra == 'claude-agent'
35
39
  Provides-Extra: develop
36
40
  Requires-Dist: build; extra == 'develop'
37
41
  Requires-Dist: bump-my-version; extra == 'develop'
38
42
  Requires-Dist: chatom[agent]>=0.2.0; extra == 'develop'
39
43
  Requires-Dist: check-dist; extra == 'develop'
44
+ Requires-Dist: claude-agent-sdk>=0.2.152; extra == 'develop'
40
45
  Requires-Dist: codespell; extra == 'develop'
41
46
  Requires-Dist: csp-adapter-discord<0.3,>=0.2; extra == 'develop'
42
47
  Requires-Dist: csp-adapter-slack<0.5,>=0.4; extra == 'develop'
@@ -11,7 +11,7 @@ Key features:
11
11
  - Support for Slack, Symphony, and Discord
12
12
  """
13
13
 
14
- __version__ = "2.0.0"
14
+ __version__ = "2.0.2"
15
15
 
16
16
  # Re-export chatom types for convenience
17
17
  from chatom import Channel, Message, User
@@ -435,7 +435,7 @@ class Bot(GatewayModule):
435
435
  active_backends = self._active_backends()
436
436
  for model in command_models:
437
437
  try:
438
- command = model.command()
438
+ command = model.create_command()
439
439
  except TypeError:
440
440
  log.critical(f"Incomplete command type - implement all abstract methods: {model.command}")
441
441
  raise
@@ -1053,7 +1053,13 @@ class Bot(GatewayModule):
1053
1053
  return None
1054
1054
 
1055
1055
  # Look up session by the bot response ID
1056
- session = AgentCommand._sessions.get_by_response_id(ref_id)
1056
+ source_id = msg.author.id if msg.author else msg.author_id or ""
1057
+ session = AgentCommand._sessions.get_by_response_id(
1058
+ ref_id,
1059
+ user_id=source_id,
1060
+ channel_id=channel_id,
1061
+ backend=backend,
1062
+ )
1057
1063
  if session is None:
1058
1064
  return None
1059
1065
 
@@ -32,13 +32,7 @@ from .legacy import LegacyCommandAdapter
32
32
  from .schedule import ScheduleCommand, ScheduleCommandModel
33
33
  from .status import StatusCommand, StatusCommandModel
34
34
 
35
- try:
36
- from .agent import AgentCommand
37
- except ImportError:
38
- pass
39
-
40
35
  __all__ = (
41
- "AgentCommand",
42
36
  "BaseCommand",
43
37
  "BaseCommandModel",
44
38
  "BotInfo",
@@ -66,3 +60,11 @@ __all__ = (
66
60
  "get_registered_commands",
67
61
  "mention_user",
68
62
  )
63
+
64
+ try:
65
+ from .agent import AgentCommand, AgentCommandModel
66
+ from .claude_agent import ClaudeAgentCommand
67
+
68
+ __all__ += ("AgentCommand", "AgentCommandModel", "ClaudeAgentCommand")
69
+ except ImportError:
70
+ pass
@@ -21,34 +21,33 @@ from abc import abstractmethod
21
21
  from collections.abc import Sequence
22
22
  from concurrent.futures import Future, ThreadPoolExecutor
23
23
  from dataclasses import dataclass, field
24
- from datetime import datetime, timedelta, timezone
24
+ from datetime import UTC, datetime, timedelta
25
25
  from typing import Any, ClassVar
26
26
 
27
27
  from chatom import Channel, Message
28
28
  from chatom.backend import BackendBase
29
29
  from chatom.format import Format, convert_format
30
30
 
31
- from csp_bot.commands.base import BaseCommand, ReplyCommand
31
+ from csp_bot.commands.base import BaseCommand, BaseCommandModel, ReplyCommand
32
32
  from csp_bot.persistence import InMemoryStateStore, StateStore
33
33
  from csp_bot.structs import BotCommand
34
34
 
35
35
  try:
36
36
  from chatom.agent import BackendToolset
37
37
  from chatom.agent.toolset import AccessPolicy
38
- from pydantic_ai import Agent
39
- from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
38
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
40
39
  except ImportError as e:
41
40
  raise ImportError("AgentCommand requires the 'agent' extra. Install with: pip install csp-bot[agent]") from e
42
41
 
43
42
  log = logging.getLogger(__name__)
44
43
 
45
- __all__ = ("AgentCommand",)
44
+ __all__ = ("AgentCommand", "AgentCommandModel")
46
45
 
47
46
  _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="agent-cmd")
48
47
 
49
48
 
50
49
  def _utc_now() -> datetime:
51
- return datetime.now(timezone.utc)
50
+ return datetime.now(UTC)
52
51
 
53
52
 
54
53
  @dataclass
@@ -56,19 +55,20 @@ class AgentSession:
56
55
  """Tracks a multi-turn conversation between a user and an agent command."""
57
56
 
58
57
  #: Bumped whenever the serialized layout in :meth:`to_dict` changes.
59
- SCHEMA_VERSION: ClassVar[int] = 1
58
+ SCHEMA_VERSION: ClassVar[int] = 2
60
59
 
61
60
  user_id: str
62
61
  channel_id: str
62
+ backend: str
63
63
  command_name: str
64
- message_history: list[ModelMessage] = field(default_factory=list)
64
+ message_history: list[Any] = field(default_factory=list)
65
65
  last_active: datetime = field(default_factory=_utc_now)
66
66
  bot_response_id: str | None = None # ID of last bot message (for reply matching)
67
67
 
68
68
  @property
69
69
  def store_key(self) -> str:
70
- """Deterministic key for this session: command:user:channel."""
71
- return f"{self.command_name}:{self.user_id}:{self.channel_id}"
70
+ """Deterministic key for this session: command:backend:user:channel."""
71
+ return f"{self.command_name}:{self.backend}:{self.user_id}:{self.channel_id}"
72
72
 
73
73
  def touch(self) -> None:
74
74
  self.last_active = _utc_now()
@@ -82,12 +82,17 @@ class AgentSession:
82
82
  The pydantic-ai conversation history is serialized via
83
83
  :data:`ModelMessagesTypeAdapter` so it round-trips across processes.
84
84
  """
85
+ opaque_history = all(isinstance(item, dict) and item.get("type") == "claude-session" for item in self.message_history)
86
+ history_format = "opaque" if self.message_history and opaque_history else "pydantic-ai"
87
+ history = self.message_history if history_format == "opaque" else ModelMessagesTypeAdapter.dump_python(self.message_history, mode="json")
85
88
  return {
86
89
  "schema_version": self.SCHEMA_VERSION,
87
90
  "user_id": self.user_id,
88
91
  "channel_id": self.channel_id,
92
+ "backend": self.backend,
89
93
  "command_name": self.command_name,
90
- "message_history": ModelMessagesTypeAdapter.dump_python(self.message_history, mode="json"),
94
+ "history_format": history_format,
95
+ "message_history": history,
91
96
  "last_active": self.last_active.isoformat(),
92
97
  "bot_response_id": self.bot_response_id,
93
98
  }
@@ -96,14 +101,23 @@ class AgentSession:
96
101
  def from_dict(cls, data: dict[str, Any]) -> AgentSession:
97
102
  """Reconstruct a session from :meth:`to_dict` output."""
98
103
  version = data.get("schema_version")
99
- if version != cls.SCHEMA_VERSION:
100
- raise ValueError(f"Unsupported AgentSession schema version: {version!r} (expected {cls.SCHEMA_VERSION})")
104
+ if version not in (1, cls.SCHEMA_VERSION):
105
+ raise ValueError(f"Unsupported AgentSession schema version: {version!r} (expected 1 or {cls.SCHEMA_VERSION})")
101
106
  last_active = data.get("last_active")
107
+ history_format = data.get("history_format", "pydantic-ai")
108
+ raw_history = data.get("message_history") or []
109
+ if history_format == "opaque":
110
+ message_history = list(raw_history)
111
+ elif history_format == "pydantic-ai":
112
+ message_history = list(ModelMessagesTypeAdapter.validate_python(raw_history))
113
+ else:
114
+ raise ValueError(f"Unsupported agent history format: {history_format!r}")
102
115
  return cls(
103
116
  user_id=data["user_id"],
104
117
  channel_id=data["channel_id"],
118
+ backend=data.get("backend", ""),
105
119
  command_name=data["command_name"],
106
- message_history=list(ModelMessagesTypeAdapter.validate_python(data.get("message_history") or [])),
120
+ message_history=message_history,
107
121
  last_active=datetime.fromisoformat(last_active) if last_active else _utc_now(),
108
122
  bot_response_id=data.get("bot_response_id"),
109
123
  )
@@ -138,7 +152,14 @@ class SessionStore:
138
152
  return None
139
153
  return session
140
154
 
141
- def get_by_response_id(self, response_id: str) -> AgentSession | None:
155
+ def get_by_response_id(
156
+ self,
157
+ response_id: str,
158
+ *,
159
+ user_id: str,
160
+ channel_id: str,
161
+ backend: str,
162
+ ) -> AgentSession | None:
142
163
  """Look up a session by the bot's response message ID (for replies)."""
143
164
  with self._lock:
144
165
  key = self.store.get(self.response_namespace, response_id)
@@ -148,6 +169,8 @@ class SessionStore:
148
169
  if session and session.is_expired(self._ttl):
149
170
  self._remove_session(key, session)
150
171
  return None
172
+ if session and (session.user_id != user_id or session.channel_id != channel_id or session.backend != backend):
173
+ return None
151
174
  return session
152
175
 
153
176
  def put(self, key: str, session: AgentSession) -> None:
@@ -198,10 +221,10 @@ class SessionStore:
198
221
 
199
222
 
200
223
  def _run_agent(
201
- agent: Agent,
224
+ agent: Any,
202
225
  prompt: str | Sequence[Any],
203
226
  loop: asyncio.AbstractEventLoop | None = None,
204
- message_history: Sequence[ModelMessage] | None = None,
227
+ message_history: Sequence[Any] | None = None,
205
228
  ) -> Any:
206
229
  """Run an agent on an event loop (for use in thread pool).
207
230
 
@@ -243,6 +266,8 @@ class AgentCommand(ReplyCommand):
243
266
  Example::
244
267
 
245
268
  class AskCommand(AgentCommand):
269
+ model_name = "github-copilot:claude-haiku-4.5"
270
+
246
271
  def command(self): return "ask"
247
272
  def name(self): return "Ask"
248
273
  def help(self): return "/ask <question> — Ask the AI (reply to continue)"
@@ -250,7 +275,7 @@ class AgentCommand(ReplyCommand):
250
275
  def build_agent(self, command):
251
276
  toolset = self.build_toolset(command)
252
277
  return Agent(
253
- "anthropic:claude-sonnet-4-6",
278
+ self.get_model(),
254
279
  toolsets=[toolset] if toolset else [],
255
280
  instructions="You are a helpful assistant.",
256
281
  )
@@ -264,6 +289,7 @@ class AgentCommand(ReplyCommand):
264
289
  _futures: ClassVar[dict[str, Future]] = {}
265
290
  _sessions: ClassVar[SessionStore] = SessionStore(ttl_seconds=900.0)
266
291
 
292
+ model_name: str = "claude-sonnet-4-6"
267
293
  # Configurable delay between polling checks (seconds)
268
294
  poll_interval: int = 2
269
295
  # Maximum time to wait for agent completion (seconds)
@@ -301,7 +327,9 @@ class AgentCommand(ReplyCommand):
301
327
  ]
302
328
 
303
329
  def __init__(self, *args, **kwargs):
304
- pass
330
+ model_name = kwargs.pop("model_name", None)
331
+ if model_name is not None:
332
+ self.model_name = model_name
305
333
 
306
334
  @classmethod
307
335
  def set_backends(
@@ -329,8 +357,8 @@ class AgentCommand(ReplyCommand):
329
357
  cls._sessions = SessionStore(ttl_seconds=ttl, store=store)
330
358
 
331
359
  @abstractmethod
332
- def build_agent(self, command: BotCommand) -> Agent:
333
- """Return the pydantic-ai Agent to run for this command."""
360
+ def build_agent(self, command: BotCommand) -> Any:
361
+ """Return the agent or runner to use for this command."""
334
362
  ...
335
363
 
336
364
  @abstractmethod
@@ -392,12 +420,20 @@ class AgentCommand(ReplyCommand):
392
420
  block_dm_reads=True,
393
421
  )
394
422
 
395
- def get_model(self, model_name: str = "claude-sonnet-4-6") -> Any:
423
+ def get_model(self, model_name: str | None = None) -> Any:
396
424
  """Return a model instance configured from environment variables.
397
425
 
398
- Checks ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL
399
- to construct a properly-configured provider.
426
+ Fully qualified model names are resolved by PydanticAI. Bare model names
427
+ retain the Anthropic gateway configuration used by existing commands.
400
428
  """
429
+ if model_name is None:
430
+ model_name = self.model_name
431
+
432
+ if ":" in model_name:
433
+ from pydantic_ai.models import infer_model
434
+
435
+ return infer_model(model_name)
436
+
401
437
  from pydantic_ai.models.anthropic import AnthropicModel
402
438
  from pydantic_ai.providers.anthropic import AnthropicProvider
403
439
 
@@ -416,8 +452,8 @@ class AgentCommand(ReplyCommand):
416
452
  return messageml
417
453
 
418
454
  def _session_key(self, command: BotCommand) -> str:
419
- """Key for session lookup: command:user:channel."""
420
- return f"{self.command()}:{command.source.id}:{command.channel_id}"
455
+ """Key for session lookup: command:backend:user:channel."""
456
+ return f"{self.command()}:{command.backend}:{command.source.id}:{command.channel_id}"
421
457
 
422
458
  def _get_session(self, command: BotCommand) -> AgentSession | None:
423
459
  """Find an existing session — by reply reference or by user+channel."""
@@ -425,7 +461,12 @@ class AgentCommand(ReplyCommand):
425
461
  msg = command.message
426
462
  reply_to_id = getattr(msg, "reply_to_id", None) or (msg.reference.message_id if getattr(msg, "reference", None) else None)
427
463
  if reply_to_id:
428
- session = self._sessions.get_by_response_id(reply_to_id)
464
+ session = self._sessions.get_by_response_id(
465
+ reply_to_id,
466
+ user_id=command.source.id,
467
+ channel_id=command.channel_id,
468
+ backend=command.backend,
469
+ )
429
470
  if session:
430
471
  session.touch()
431
472
  self._sessions.put(session.store_key, session)
@@ -446,6 +487,7 @@ class AgentCommand(ReplyCommand):
446
487
  user_id=command.source.id,
447
488
  channel_id=command.channel_id,
448
489
  command_name=self.command(),
490
+ backend=command.backend,
449
491
  )
450
492
  self._sessions.put(self._session_key(command), session)
451
493
  return session
@@ -736,3 +778,14 @@ class AgentCommand(ReplyCommand):
736
778
  future replies to that message can be routed back to this session.
737
779
  """
738
780
  self._sessions.update_response_id(session_key, response_message_id)
781
+
782
+
783
+ class AgentCommandModel(BaseCommandModel):
784
+ """Model for registering an agent command with model configuration."""
785
+
786
+ command: type[AgentCommand]
787
+ model_name: str = "claude-sonnet-4-6"
788
+
789
+ def create_command(self) -> AgentCommand:
790
+ """Create the configured agent command."""
791
+ return self.command(model_name=self.model_name)
@@ -136,3 +136,7 @@ class BaseCommandModel(BaseModel):
136
136
  """Model for registering commands via configuration."""
137
137
 
138
138
  command: type[BaseCommand]
139
+
140
+ def create_command(self) -> BaseCommand:
141
+ """Create the configured command."""
142
+ return self.command()
@@ -0,0 +1,206 @@
1
+ import base64
2
+ import json
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Any, cast
6
+
7
+ from chatom.agent import BackendToolset
8
+
9
+ from csp_bot.commands.agent import AgentCommand
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ClaudeRunResult:
14
+ output: str
15
+ session_id: str
16
+
17
+ def all_messages(self) -> list[dict[str, str]]:
18
+ """Return session state in the form persisted by AgentCommand."""
19
+ return [{"type": "claude-session", "session_id": self.session_id}]
20
+
21
+
22
+ class ClaudeAgentRunner:
23
+ """Run Claude with Chatom tools and resumable sessions."""
24
+
25
+ def __init__(
26
+ self,
27
+ toolset: BackendToolset,
28
+ *,
29
+ model: str | None = None,
30
+ query_fn: Any = None,
31
+ ) -> None:
32
+ if query_fn is None:
33
+ from claude_agent_sdk import query
34
+
35
+ query_fn = query
36
+ self._toolset = toolset
37
+ self._model = model
38
+ self._query = query_fn
39
+
40
+ async def run(
41
+ self,
42
+ prompt: str | Sequence[Any],
43
+ *,
44
+ session_id: str | None = None,
45
+ message_history: Sequence[Any] | None = None,
46
+ ) -> ClaudeRunResult:
47
+ """Run one turn and return its output and resumable session ID."""
48
+ from claude_agent_sdk import ResultError, ResultMessage
49
+
50
+ if session_id is None and message_history:
51
+ state = message_history[-1]
52
+ if isinstance(state, dict) and state.get("type") == "claude-session":
53
+ session_id = state.get("session_id")
54
+
55
+ final_result = None
56
+ options = await build_claude_options(
57
+ self._toolset,
58
+ model=self._model,
59
+ resume=session_id,
60
+ )
61
+ claude_prompt = _claude_prompt(prompt)
62
+ try:
63
+ async for message in self._query(prompt=claude_prompt, options=options):
64
+ if isinstance(message, ResultMessage):
65
+ final_result = message
66
+ except ResultError as error:
67
+ if final_result is None:
68
+ raise RuntimeError("; ".join(error.errors) or str(error)) from error
69
+
70
+ if final_result is None:
71
+ raise RuntimeError("Claude query completed without a result")
72
+ if final_result.is_error or final_result.subtype != "success":
73
+ errors = final_result.errors or ([final_result.result] if final_result.result else [])
74
+ raise RuntimeError("; ".join(errors) or f"Claude query failed: {final_result.subtype}")
75
+
76
+ return ClaudeRunResult(
77
+ output=final_result.result or "",
78
+ session_id=final_result.session_id,
79
+ )
80
+
81
+
82
+ class ClaudeAgentCommand(AgentCommand):
83
+ """Agent command backed by the Claude Agent SDK."""
84
+
85
+ def build_agent(self, command: Any) -> ClaudeAgentRunner:
86
+ toolset = self.build_toolset(command)
87
+ if toolset is None:
88
+ raise RuntimeError("Claude agent command requires a connected backend")
89
+ return ClaudeAgentRunner(toolset, model=self.model_name)
90
+
91
+
92
+ def _claude_prompt(prompt: str | Sequence[Any]) -> Any:
93
+ if isinstance(prompt, str):
94
+ return prompt
95
+
96
+ from pydantic_ai import BinaryContent
97
+
98
+ content = []
99
+ for part in prompt:
100
+ if isinstance(part, str):
101
+ content.append({"type": "text", "text": part})
102
+ elif isinstance(part, BinaryContent) and part.media_type.startswith("image/"):
103
+ content.append(
104
+ {
105
+ "type": "image",
106
+ "source": {
107
+ "type": "base64",
108
+ "media_type": part.media_type,
109
+ "data": base64.b64encode(part.data).decode("ascii"),
110
+ },
111
+ }
112
+ )
113
+ else:
114
+ raise TypeError(f"Unsupported Claude prompt content: {type(part).__name__}")
115
+
116
+ async def message_stream():
117
+ yield {
118
+ "type": "user",
119
+ "message": {
120
+ "role": "user",
121
+ "content": content,
122
+ },
123
+ }
124
+
125
+ return message_stream()
126
+
127
+
128
+ def _handler(toolset: BackendToolset, name: str, toolset_tool: Any = None):
129
+ async def call(tool_args: dict[str, Any]) -> dict[str, Any]:
130
+ call_tool = getattr(toolset, "call", None)
131
+ if call_tool is not None:
132
+ result = await call_tool(name, tool_args)
133
+ else:
134
+ validated_args = toolset_tool.args_validator.validate_python(tool_args)
135
+ result = await toolset.call_tool(name, validated_args, cast(Any, None), toolset_tool)
136
+ return {
137
+ "content": [
138
+ {
139
+ "type": "text",
140
+ "text": json.dumps(result),
141
+ }
142
+ ]
143
+ }
144
+
145
+ return call
146
+
147
+
148
+ async def build_claude_tools(toolset: BackendToolset) -> list[Any]:
149
+ """Convert a Chatom backend toolset to Claude SDK MCP tools."""
150
+ from claude_agent_sdk import tool
151
+
152
+ tool_definitions = getattr(toolset, "tool_definitions", None)
153
+ if tool_definitions is not None:
154
+ definitions = [(definition, None) for definition in tool_definitions().values()]
155
+ else:
156
+ toolset_tools = await toolset.get_tools(cast(Any, None))
157
+ definitions = [(toolset_tool.tool_def, toolset_tool) for toolset_tool in toolset_tools.values()]
158
+
159
+ tools = []
160
+ for definition, toolset_tool in definitions:
161
+ tools.append(
162
+ tool(
163
+ definition.name,
164
+ definition.description or definition.name,
165
+ definition.parameters_json_schema,
166
+ )(_handler(toolset, definition.name, toolset_tool))
167
+ )
168
+ return tools
169
+
170
+
171
+ async def build_claude_mcp_server(toolset: BackendToolset) -> Any:
172
+ """Build an in-process Claude SDK MCP server for a Chatom backend."""
173
+ from claude_agent_sdk import create_sdk_mcp_server
174
+
175
+ return create_sdk_mcp_server(
176
+ name=toolset.id or "chatom",
177
+ tools=await build_claude_tools(toolset),
178
+ )
179
+
180
+
181
+ async def build_claude_options(
182
+ toolset: BackendToolset,
183
+ *,
184
+ model: str | None = None,
185
+ resume: str | None = None,
186
+ ) -> Any:
187
+ """Configure Claude to use only the supplied Chatom tools."""
188
+ from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server
189
+
190
+ server_name = toolset.id or "chatom"
191
+ tools = await build_claude_tools(toolset)
192
+ allowed_tools = [f"mcp__{server_name}__{tool.name}" for tool in tools]
193
+ return ClaudeAgentOptions(
194
+ tools=[],
195
+ allowed_tools=allowed_tools,
196
+ mcp_servers={
197
+ server_name: create_sdk_mcp_server(
198
+ name=server_name,
199
+ tools=tools,
200
+ )
201
+ },
202
+ strict_mcp_config=True,
203
+ setting_sources=[],
204
+ model=model,
205
+ resume=resume,
206
+ )
@@ -200,7 +200,7 @@ def _run_async_function(
200
200
  future = asyncio.run_coroutine_threadsafe(fn(ctx), loop)
201
201
  result = future.result(timeout=timeout)
202
202
  return [_coerce_response(result, backend)]
203
- except asyncio.TimeoutError:
203
+ except TimeoutError:
204
204
  log.error(f"Async command timed out after {timeout}s")
205
205
  raise
206
206
  except Exception:
@@ -258,7 +258,7 @@ def _run_async_generator(
258
258
  loop,
259
259
  )
260
260
  return future.result(timeout=timeout)
261
- except asyncio.TimeoutError:
261
+ except TimeoutError:
262
262
  log.error(f"Async generator command timed out after {timeout}s")
263
263
  raise
264
264
  except Exception:
@@ -158,3 +158,7 @@ class CommandModel(BaseModel):
158
158
  """
159
159
 
160
160
  command: type[Command] = Command
161
+
162
+ def create_command(self) -> Command:
163
+ """Create the configured command."""
164
+ return self.command()
@@ -3,7 +3,7 @@
3
3
  Displays system and bot status information using FormattedMessage.
4
4
  """
5
5
 
6
- from datetime import datetime, timezone
6
+ from datetime import UTC, datetime
7
7
  from getpass import getuser
8
8
  from logging import getLogger
9
9
  from socket import gethostname
@@ -52,7 +52,7 @@ class StatusCommand(ReplyCommand):
52
52
  proc = psutil.Process()
53
53
 
54
54
  rows = [
55
- {"Metric": "Now", "Value": str(datetime.now(timezone.utc))},
55
+ {"Metric": "Now", "Value": str(datetime.now(UTC))},
56
56
  {"Metric": "Backends", "Value": ", ".join(self._adapters)},
57
57
  {"Metric": "CPU", "Value": f"{psutil.cpu_percent()}%"},
58
58
  {"Metric": "Memory", "Value": f"{mem.percent}%"},
@@ -10,7 +10,7 @@ import threading
10
10
  import uuid
11
11
  from collections.abc import Iterable
12
12
  from dataclasses import dataclass
13
- from datetime import datetime, timedelta, timezone
13
+ from datetime import UTC, datetime, timedelta
14
14
  from pickle import HIGHEST_PROTOCOL, dumps, loads
15
15
  from typing import Any, Protocol
16
16
  from urllib.parse import quote, unquote
@@ -28,19 +28,19 @@ __all__ = (
28
28
 
29
29
 
30
30
  def _utc_now() -> datetime:
31
- return datetime.now(timezone.utc)
31
+ return datetime.now(UTC)
32
32
 
33
33
 
34
34
  def _to_utc(value: datetime | None) -> datetime | None:
35
35
  if value is None:
36
36
  return None
37
37
  if value.tzinfo is None:
38
- return value.replace(tzinfo=timezone.utc)
39
- return value.astimezone(timezone.utc)
38
+ return value.replace(tzinfo=UTC)
39
+ return value.astimezone(UTC)
40
40
 
41
41
 
42
42
  def _sort_datetime(value: datetime | None) -> datetime:
43
- return _to_utc(value) or datetime.max.replace(tzinfo=timezone.utc)
43
+ return _to_utc(value) or datetime.max.replace(tzinfo=UTC)
44
44
 
45
45
 
46
46
  @dataclass(frozen=True)