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.
- {csp_bot-2.0.0 → csp_bot-2.0.2}/PKG-INFO +9 -4
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/__init__.py +1 -1
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/bot.py +8 -2
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/__init__.py +8 -6
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/agent.py +80 -27
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/base.py +4 -0
- csp_bot-2.0.2/csp_bot/commands/claude_agent.py +206 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/executor.py +2 -2
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/framework.py +4 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/status.py +2 -2
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/persistence.py +5 -5
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/structs.py +3 -3
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_agent_command.py +275 -35
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_bot_integration.py +46 -0
- csp_bot-2.0.2/csp_bot/tests/test_claude_agent.py +272 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_command_framework.py +11 -1
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_echo.py +7 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_persistence.py +5 -5
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_schedule.py +4 -4
- {csp_bot-2.0.0 → csp_bot-2.0.2}/pyproject.toml +11 -5
- {csp_bot-2.0.0 → csp_bot-2.0.2}/.gitignore +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/LICENSE +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/README.md +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/__init__.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/discord.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/slack.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/symphony.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/backends/telegram.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/bot_config.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/cli.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/context.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/echo.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/help.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/legacy.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/commands/schedule.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/__init__.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/discord.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/slack.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/symphony.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/backend/telegram.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/commands.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/conf.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/all.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/bot.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/discord.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/mixed.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/slack.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/symphony.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/gateway/telegram.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/hydra/job_logging/custom.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/config/modules.yaml +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/gateway/__init__.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/gateway/gateway.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/__init__.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/conftest.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_backends.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_bot.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_config.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_config_composition.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_help_command.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_slack_entity_regex.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_status.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_structs.py +0 -0
- {csp_bot-2.0.0 → csp_bot-2.0.2}/csp_bot/tests/test_utils.py +0 -0
- {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.
|
|
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.
|
|
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<
|
|
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'
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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] =
|
|
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[
|
|
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
|
-
"
|
|
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
|
|
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=
|
|
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(
|
|
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:
|
|
224
|
+
agent: Any,
|
|
202
225
|
prompt: str | Sequence[Any],
|
|
203
226
|
loop: asyncio.AbstractEventLoop | None = None,
|
|
204
|
-
message_history: Sequence[
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
333
|
-
"""Return the
|
|
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 =
|
|
423
|
+
def get_model(self, model_name: str | None = None) -> Any:
|
|
396
424
|
"""Return a model instance configured from environment variables.
|
|
397
425
|
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
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)
|
|
@@ -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
|
|
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
|
|
261
|
+
except TimeoutError:
|
|
262
262
|
log.error(f"Async generator command timed out after {timeout}s")
|
|
263
263
|
raise
|
|
264
264
|
except Exception:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Displays system and bot status information using FormattedMessage.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from datetime import
|
|
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(
|
|
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
|
|
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(
|
|
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=
|
|
39
|
-
return value.astimezone(
|
|
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=
|
|
43
|
+
return _to_utc(value) or datetime.max.replace(tzinfo=UTC)
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
@dataclass(frozen=True)
|