From 98082a979b0a1979d302f3875796492c4636bf58 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:59:51 -0400 Subject: [PATCH 1/2] AgentCommand: channel context, configurable root prompt, image download fix - Inject a channel-context note into every agent prompt so commands like /ask know their invoking channel ("this channel"/"the current room") and read the right history instead of guessing. - Make prompt assembly configurable: overridable root_prompt / build_root_prompt and an inject_channel toggle (default on). - Fix incoming image downloads on Symphony: drive the backend's own (idle) event loop with run_until_complete instead of a fresh loop, so the backend's aiohttp session stays on its loop and no longer raises "Timeout context manager should be used inside a task". Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- csp_bot/commands/agent.py | 232 +++++++++++++++++++++---- csp_bot/tests/test_agent_command.py | 251 +++++++++++++++++++++++++++- 2 files changed, 446 insertions(+), 37 deletions(-) diff --git a/csp_bot/commands/agent.py b/csp_bot/commands/agent.py index ce8ca74..f687ee8 100644 --- a/csp_bot/commands/agent.py +++ b/csp_bot/commands/agent.py @@ -28,13 +28,14 @@ from chatom.format import Format, convert_format from csp_bot.commands.base import BaseCommand, ReplyCommand +from csp_bot.persistence import InMemoryStateStore, StateStore from csp_bot.structs import BotCommand try: from chatom.agent import BackendToolset from chatom.agent.toolset import AccessPolicy from pydantic_ai import Agent - from pydantic_ai.messages import ModelMessage + from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter except ImportError as e: raise ImportError("AgentCommand requires the 'agent' extra. Install with: pip install csp-bot[agent]") from e @@ -53,6 +54,9 @@ def _utc_now() -> datetime: class AgentSession: """Tracks a multi-turn conversation between a user and an agent command.""" + #: Bumped whenever the serialized layout in :meth:`to_dict` changes. + SCHEMA_VERSION: ClassVar[int] = 1 + user_id: str channel_id: str command_name: str @@ -60,72 +64,136 @@ class AgentSession: last_active: datetime = field(default_factory=_utc_now) bot_response_id: Optional[str] = None # ID of last bot message (for reply matching) + @property + def store_key(self) -> str: + """Deterministic key for this session: command:user:channel.""" + return f"{self.command_name}:{self.user_id}:{self.channel_id}" + def touch(self) -> None: self.last_active = _utc_now() def is_expired(self, ttl_seconds: float) -> bool: return (_utc_now() - self.last_active).total_seconds() > ttl_seconds + def to_dict(self) -> Dict[str, Any]: + """Serialize to a JSON-safe dict for durable storage. + + The pydantic-ai conversation history is serialized via + :data:`ModelMessagesTypeAdapter` so it round-trips across processes. + """ + return { + "schema_version": self.SCHEMA_VERSION, + "user_id": self.user_id, + "channel_id": self.channel_id, + "command_name": self.command_name, + "message_history": ModelMessagesTypeAdapter.dump_python(self.message_history, mode="json"), + "last_active": self.last_active.isoformat(), + "bot_response_id": self.bot_response_id, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AgentSession": + """Reconstruct a session from :meth:`to_dict` output.""" + version = data.get("schema_version") + if version != cls.SCHEMA_VERSION: + raise ValueError(f"Unsupported AgentSession schema version: {version!r} (expected {cls.SCHEMA_VERSION})") + last_active = data.get("last_active") + return cls( + user_id=data["user_id"], + channel_id=data["channel_id"], + command_name=data["command_name"], + message_history=list(ModelMessagesTypeAdapter.validate_python(data.get("message_history") or [])), + last_active=datetime.fromisoformat(last_active) if last_active else _utc_now(), + bot_response_id=data.get("bot_response_id"), + ) + class SessionStore: - """Thread-safe store for agent sessions with automatic expiry.""" + """Store for agent sessions, backed by a :class:`StateStore`. + + Sessions and the bot-response→session reply index live in two namespaces + of the same :class:`StateStore`. The default :class:`InMemoryStateStore` + keeps behavior simple (and preserves object identity for callers that + mutate a returned session in place), while a durable backend can be + injected to survive restarts without changing command implementations. + + Expiry is driven by each session's ``last_active`` timestamp and the + configured TTL, independent of any TTL the underlying store applies. + """ - def __init__(self, ttl_seconds: float = 900.0): - self._sessions: Dict[str, AgentSession] = {} - self._response_index: Dict[str, str] = {} # bot_response_id -> session_key + namespace = "csp_bot.agent_sessions" + response_namespace = "csp_bot.agent_sessions.responses" + + def __init__(self, ttl_seconds: float = 900.0, store: Optional[StateStore] = None): self._ttl = ttl_seconds + self.store: StateStore = store if store is not None else InMemoryStateStore() self._lock = threading.Lock() def get(self, key: str) -> Optional[AgentSession]: with self._lock: - session = self._sessions.get(key) + session = self._load(key) if session and session.is_expired(self._ttl): - self._remove_session(key) + self._remove_session(key, session) return None return session def get_by_response_id(self, response_id: str) -> Optional[AgentSession]: """Look up a session by the bot's response message ID (for replies).""" with self._lock: - key = self._response_index.get(response_id) + key = self.store.get(self.response_namespace, response_id) if key is None: return None - session = self._sessions.get(key) + session = self._load(key) if session and session.is_expired(self._ttl): - self._remove_session(key) + self._remove_session(key, session) return None return session def put(self, key: str, session: AgentSession) -> None: with self._lock: - self._sessions[key] = session + self.store.put(self.namespace, key, session) if session.bot_response_id: - self._response_index[session.bot_response_id] = key + self.store.put(self.response_namespace, session.bot_response_id, key) def update_response_id(self, key: str, response_id: str) -> None: """Associate a bot response message ID with a session.""" with self._lock: - session = self._sessions.get(key) - if session: - # Remove old mapping - if session.bot_response_id and session.bot_response_id in self._response_index: - del self._response_index[session.bot_response_id] - session.bot_response_id = response_id - self._response_index[response_id] = key - - def _remove_session(self, key: str) -> None: - """Remove a session (caller must hold lock).""" - session = self._sessions.pop(key, None) + session = self._load(key) + if session is None: + return + if session.bot_response_id: + self.store.delete(self.response_namespace, session.bot_response_id) + session.bot_response_id = response_id + self.store.put(self.namespace, key, session) + self.store.put(self.response_namespace, response_id, key) + + def _load(self, key: str) -> Optional[AgentSession]: + """Load a session, accepting both live objects and serialized dicts.""" + value = self.store.get(self.namespace, key) + if value is None or isinstance(value, AgentSession): + return value + if isinstance(value, dict): + return AgentSession.from_dict(value) + raise TypeError(f"Unexpected session value for {key!r}: {type(value)!r}") + + def _remove_session(self, key: str, session: Optional[AgentSession] = None) -> None: + """Remove a session and its reply-index entry (caller holds lock).""" + if session is None: + session = self._load(key) + self.store.delete(self.namespace, key) if session and session.bot_response_id: - self._response_index.pop(session.bot_response_id, None) + self.store.delete(self.response_namespace, session.bot_response_id) def cleanup_expired(self) -> int: """Remove all expired sessions. Returns count removed.""" with self._lock: - expired = [k for k, s in self._sessions.items() if s.is_expired(self._ttl)] - for key in expired: - self._remove_session(key) - return len(expired) + removed = 0 + for record in list(self.store.records(self.namespace)): + session = record.value if isinstance(record.value, AgentSession) else AgentSession.from_dict(record.value) + if session.is_expired(self._ttl): + self._remove_session(record.key, session) + removed += 1 + return removed def _run_agent( @@ -199,6 +267,12 @@ def build_prompt(self, command): poll_interval: int = 2 # Maximum time to wait for agent completion (seconds) timeout: int = 120 + # Maximum total tool calls the agent may make in a single run. Guards + # against runaway tool loops. 0 disables the cap. + max_tool_calls: int = 25 + # Optional per-tool call caps for a single run (e.g. limit expensive + # history reads / searches). None applies no per-tool limit. + per_tool_limits: ClassVar[Optional[Dict[str, int]]] = None # Session time-to-live (seconds). 0 disables sessions. session_ttl_seconds: float = 900.0 # Send a status message every N poll cycles (0 disables) @@ -209,6 +283,13 @@ def build_prompt(self, command): include_incoming_images: bool = True # Maximum size (bytes) of an incoming image to download for the model. max_incoming_image_bytes: int = 5_000_000 + # Base preamble prepended ahead of every prompt for this command. + # Override per subclass (class attribute) or dynamically via + # build_root_prompt(). Empty by default. + root_prompt: str = "" + # When True, prepend a note describing the invoking channel so the agent + # can resolve references like "this channel" / "the current room". + inject_channel: bool = True # Status messages shown to the user while processing status_messages: ClassVar[List[str]] = [ "Thinking...", @@ -233,8 +314,18 @@ def set_backends( @classmethod def set_session_ttl(cls, ttl_seconds: float) -> None: - """Reconfigure the session TTL.""" - cls._sessions = SessionStore(ttl_seconds=ttl_seconds) + """Reconfigure the session TTL, preserving the backing store.""" + cls._sessions = SessionStore(ttl_seconds=ttl_seconds, store=cls._sessions.store) + + @classmethod + def set_session_store(cls, store: StateStore, ttl_seconds: Optional[float] = None) -> None: + """Back agent sessions with a (possibly durable) StateStore. + + Injecting an ``FsspecStateStore`` (or other durable backend) lets + sessions survive bot restarts without changing command code. + """ + ttl = ttl_seconds if ttl_seconds is not None else cls.session_ttl_seconds + cls._sessions = SessionStore(ttl_seconds=ttl, store=store) @abstractmethod def build_agent(self, command: BotCommand) -> Agent: @@ -246,6 +337,16 @@ def build_prompt(self, command: BotCommand) -> str: """Return the user prompt string.""" ... + def build_root_prompt(self, command: BotCommand) -> str: + """Return a base preamble prepended ahead of :meth:`build_prompt`. + + Defaults to the :attr:`root_prompt` class attribute. Override for a + dynamic, per-invocation preamble. The final prompt is assembled as + ``root_prompt`` + channel-context note (when :attr:`inject_channel`) + + the command's own prompt. + """ + return self.root_prompt + def build_toolset(self, command: BotCommand) -> Optional[BackendToolset]: """Return a BackendToolset for the command's backend, or None. @@ -255,13 +356,22 @@ def build_toolset(self, command: BotCommand) -> Optional[BackendToolset]: - DM reads are blocked by default - Message count is capped per request + The toolset also enforces a per-run tool call budget + (:attr:`max_tool_calls`) and optional per-tool caps + (:attr:`per_tool_limits`) to guard against runaway tool loops. + Subclasses can override :meth:`build_access_policy` to customize. """ backend = self._backends.get(command.backend) if backend is None: return None policy = self.build_access_policy(command) - return BackendToolset(backend=backend, access_policy=policy) + return BackendToolset( + backend=backend, + access_policy=policy, + max_tool_calls=self.max_tool_calls, + per_tool_limits=self.per_tool_limits, + ) def build_access_policy(self, command: BotCommand) -> "AccessPolicy": """Build the access policy for this command invocation. @@ -317,12 +427,14 @@ def _get_session(self, command: BotCommand) -> Optional[AgentSession]: session = self._sessions.get_by_response_id(reply_to_id) if session: session.touch() + self._sessions.put(session.store_key, session) return session # Second: check by user+channel (same command re-invoked) session = self._sessions.get(self._session_key(command)) if session: session.touch() + self._sessions.put(session.store_key, session) return session return None @@ -369,6 +481,44 @@ def _incoming_image_attachments(self, command: BotCommand) -> List[Any]: images.append(att) return images + def _prompt_prefix(self, command: BotCommand) -> str: + """Assemble the root prompt and channel-context note that precede the + command's own prompt. Returns "" when neither applies.""" + parts: List[str] = [] + root = self.build_root_prompt(command) + if root: + parts.append(root) + if self.inject_channel: + note = self._channel_context_note(command) + if note: + parts.append(note) + return "\n\n".join(parts) + + def _channel_context_note(self, command: BotCommand) -> str: + """Describe the invoking channel so the agent can resolve references. + + Without this, a command like ``/ask`` has no idea which channel it is + running in, so phrases like "this channel" or "the current room" are + unresolvable and history tools get called with a guessed channel. + Uses the origin channel (where the user typed the command), not any + ``/room`` redirect target. + """ + channel_id = command.message.channel_id if command.message and command.message.channel_id else command.channel_id + if not channel_id: + return "" + channel_name = "" + if command.message and command.message.channel: + channel_name = command.message.channel.name or "" + if not channel_name: + channel_name = command.channel_name or "" + name_part = f' (name: "{channel_name}")' if channel_name else "" + return ( + f'[Context: this conversation is taking place in the channel with ' + f'id="{channel_id}"{name_part}. When the user refers to "this channel", ' + '"this room", "here", or "the current channel/room", they mean this ' + 'channel; pass this id to tools such as read_channel_history.]' + ) + def _build_model_prompt(self, command: BotCommand, prompt: str) -> Union[str, List[Any]]: """Assemble the prompt for the model, attaching incoming images. @@ -416,10 +566,20 @@ def _download_on_loop( message: Optional[Message], loop: Optional[asyncio.AbstractEventLoop], ) -> bytes: - """Download an attachment, reusing the backend's event loop if running.""" + """Download an attachment on the backend's own event loop. + + The backend's HTTP client (e.g. Symphony's aiohttp session) is bound to + the loop it was connected on, so the download must run there. That loop + is usually idle rather than running, so drive it with + ``run_until_complete``; only a running loop needs a threadsafe submit. + Running the coroutine on a fresh loop raises aiohttp's "Timeout context + manager should be used inside a task". + """ coro = backend.download_attachment(attachment, message=message) - if loop is not None and loop.is_running(): - return asyncio.run_coroutine_threadsafe(coro, loop).result() + if loop is not None: + if loop.is_running(): + return asyncio.run_coroutine_threadsafe(coro, loop).result() + return loop.run_until_complete(coro) new_loop = asyncio.new_event_loop() try: return new_loop.run_until_complete(coro) @@ -437,6 +597,9 @@ def preexecute(self, command: BotCommand) -> BotCommand: try: agent = self.build_agent(command) prompt_text = self.build_prompt(command) + prefix = self._prompt_prefix(command) + if prefix: + prompt_text = f"{prefix}\n\n{prompt_text}" prompt = self._build_model_prompt(command, prompt_text) except Exception: log.exception("Error building agent/prompt for %s", self.command()) @@ -535,6 +698,7 @@ def execute(self, command: BotCommand) -> Optional[Union[Message, List[Union[Mes if hasattr(result, "all_messages"): session.message_history = list(result.all_messages()) session.touch() + self._sessions.put(session.store_key, session) except Exception: log.exception("AgentCommand[%s] agent execution failed", self.command()) diff --git a/csp_bot/tests/test_agent_command.py b/csp_bot/tests/test_agent_command.py index 8dbf5c1..aa452bd 100644 --- a/csp_bot/tests/test_agent_command.py +++ b/csp_bot/tests/test_agent_command.py @@ -131,8 +131,8 @@ def test_cleans_up_expired_sessions(self, cmd, bot_command): mock_executor.submit.return_value = MagicMock(spec=Future) cmd.preexecute(bot_command) - assert "old-key" not in AgentCommand._sessions._sessions - assert "old-response" not in AgentCommand._sessions._response_index + assert AgentCommand._sessions.get("old-key") is None + assert AgentCommand._sessions.get_by_response_id("old-response") is None class TestExecute: @@ -303,10 +303,155 @@ def test_cleanup_expired_removes_response_index(self): removed = store.cleanup_expired() assert removed == 1 - assert "old-response" not in store._response_index + assert store.get_by_response_id("old-response") is None assert store.get_by_response_id("new-response") is active +def _sample_history(): + """Build a small but real pydantic-ai message history.""" + from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart + + return [ + ModelRequest(parts=[UserPromptPart(content="What is 2+2?")]), + ModelResponse(parts=[TextPart(content="4")]), + ] + + +class TestAgentSessionSerialization: + """Round-trip tests for the AgentSession serialization schema.""" + + def test_to_dict_is_json_safe(self): + import json + + session = AgentSession( + user_id="U1", + channel_id="C1", + command_name="ask", + message_history=_sample_history(), + bot_response_id="resp1", + ) + data = session.to_dict() + # Must be serializable to JSON without custom encoders. + json.dumps(data) + assert data["schema_version"] == AgentSession.SCHEMA_VERSION + assert data["user_id"] == "U1" + assert data["bot_response_id"] == "resp1" + + def test_round_trip_preserves_fields(self): + original = AgentSession( + user_id="U1", + channel_id="C1", + command_name="ask", + message_history=_sample_history(), + bot_response_id="resp1", + ) + restored = AgentSession.from_dict(original.to_dict()) + assert restored.user_id == original.user_id + assert restored.channel_id == original.channel_id + assert restored.command_name == original.command_name + assert restored.bot_response_id == original.bot_response_id + assert len(restored.message_history) == len(original.message_history) + # The reconstructed history must round-trip identically. + assert restored.to_dict()["message_history"] == original.to_dict()["message_history"] + + def test_round_trip_through_json_string(self): + import json + + original = AgentSession( + user_id="U1", + channel_id="C1", + command_name="ask", + message_history=_sample_history(), + ) + restored = AgentSession.from_dict(json.loads(json.dumps(original.to_dict()))) + assert restored.command_name == "ask" + assert len(restored.message_history) == 2 + + def test_from_dict_rejects_unknown_schema_version(self): + data = AgentSession(user_id="U1", channel_id="C1", command_name="ask").to_dict() + data["schema_version"] = 999 + with pytest.raises(ValueError, match="schema version"): + AgentSession.from_dict(data) + + +class TestSessionStorePersistence: + """Sessions backed by a StateStore, including a durable backend.""" + + def test_store_accepts_serialized_dict_values(self): + """A JSON-style store that holds dicts is transparently reconstructed.""" + from csp_bot.persistence import InMemoryStateStore + + backend = InMemoryStateStore() + store = SessionStore(ttl_seconds=900.0, store=backend) + session = AgentSession( + user_id="U1", + channel_id="C1", + command_name="ask", + message_history=_sample_history(), + bot_response_id="resp1", + ) + # Simulate a durable backend that persists the serialized form. + backend.put(SessionStore.namespace, session.store_key, session.to_dict()) + backend.put(SessionStore.response_namespace, "resp1", session.store_key) + + loaded = store.get(session.store_key) + assert loaded is not None + assert loaded.user_id == "U1" + assert len(loaded.message_history) == 2 + assert store.get_by_response_id("resp1").store_key == session.store_key + + def test_survives_store_handoff(self): + """A fresh SessionStore over the same backend sees prior sessions.""" + from csp_bot.persistence import FsspecStateStore + + backend = FsspecStateStore("memory://agent-sessions-test") + backend.clear() + store = SessionStore(ttl_seconds=900.0, store=backend) + session = AgentSession( + user_id="U1", + channel_id="C1", + command_name="ask", + message_history=_sample_history(), + ) + store.put(session.store_key, session) + store.update_response_id(session.store_key, "bot-msg-1") + + # Simulate a restart: brand-new SessionStore over the same storage. + reopened = SessionStore(ttl_seconds=900.0, store=backend) + resumed = reopened.get_by_response_id("bot-msg-1") + assert resumed is not None + assert resumed.command_name == "ask" + assert len(resumed.message_history) == 2 + backend.clear() + + +class TestSessionStoreInjection: + """AgentCommand.set_session_store wiring.""" + + def test_set_session_store_swaps_backend(self): + from csp_bot.persistence import InMemoryStateStore + + backend = InMemoryStateStore() + AgentCommand.set_session_store(backend, ttl_seconds=123.0) + try: + assert AgentCommand._sessions.store is backend + assert AgentCommand._sessions._ttl == 123.0 + finally: + AgentCommand._sessions = SessionStore(ttl_seconds=900.0) + + def test_set_session_ttl_preserves_store(self): + from csp_bot.persistence import InMemoryStateStore + + backend = InMemoryStateStore() + AgentCommand.set_session_store(backend) + try: + AgentCommand.set_session_ttl(42.0) + assert AgentCommand._sessions.store is backend + assert AgentCommand._sessions._ttl == 42.0 + finally: + AgentCommand._sessions = SessionStore(ttl_seconds=900.0) + + class TestSessionIntegration: """Test session creation and resumption through AgentCommand.""" @@ -494,3 +639,103 @@ def test_download_failure_falls_back_to_text(self, cmd): # No image could be attached → fall back to the plain text prompt. assert result == "prompt text" + + def test_download_runs_on_provided_idle_loop(self): + """The download must run on the backend's own (idle) loop, not a fresh + one — the backend's aiohttp session is bound to that loop.""" + import asyncio + + loop = asyncio.new_event_loop() + seen = {} + + class _Backend: + async def download_attachment(self, attachment, message=None): + seen["loop"] = asyncio.get_running_loop() + return b"IMGDATA" + + try: + result = AgentCommand._download_on_loop(_Backend(), object(), None, loop) + finally: + loop.close() + + assert result == b"IMGDATA" + assert seen["loop"] is loop + + +class TestChannelContextNote: + """The agent must be told which channel it is running in.""" + + def test_note_includes_channel_id_and_name(self, cmd, bot_command): + note = cmd._channel_context_note(bot_command) + assert 'id="C456"' in note + assert "general" in note + assert "read_channel_history" in note + + def test_note_prefers_origin_message_channel(self, cmd): + from chatom import Channel + + command = BotCommand( + command="test-agent", + args=(), + source=User(id="U1", name="U"), + targets=(), + channel_id="REDIRECT", + channel_name="redirect", + backend="symphony", + variant=CommandVariant.REPLY, + message=Message(id="m", content="hi", channel=Channel(id="ORIGIN", name="the-room")), + delay=datetime.now(timezone.utc), + schedule="", + times_run=0, + ) + note = cmd._channel_context_note(command) + assert 'id="ORIGIN"' in note + assert "the-room" in note + + def test_note_empty_without_channel(self, cmd): + command = BotCommand( + command="test-agent", + args=(), + source=User(id="U1", name="U"), + targets=(), + channel_id="", + channel_name="", + backend="slack", + variant=CommandVariant.REPLY, + message=Message(id="m", content="hi"), + delay=datetime.now(timezone.utc), + schedule="", + times_run=0, + ) + assert cmd._channel_context_note(command) == "" + + +class TestPromptPrefix: + """Root prompt and channel injection are configurable.""" + + def test_default_prefix_is_channel_note(self, cmd, bot_command): + prefix = cmd._prompt_prefix(bot_command) + assert 'id="C456"' in prefix + + def test_inject_channel_false_omits_note(self, cmd, bot_command): + cmd.inject_channel = False + assert cmd._prompt_prefix(bot_command) == "" + + def test_root_prompt_prepended(self, cmd, bot_command): + cmd.root_prompt = "ROOT RULES" + prefix = cmd._prompt_prefix(bot_command) + assert prefix.startswith("ROOT RULES") + assert 'id="C456"' in prefix + + def test_root_prompt_only_when_channel_disabled(self, cmd, bot_command): + cmd.inject_channel = False + cmd.root_prompt = "ROOT RULES" + assert cmd._prompt_prefix(bot_command) == "ROOT RULES" + + def test_build_root_prompt_override(self, bot_command): + class CustomRoot(ConcreteAgentCommand): + def build_root_prompt(self, command): + return f"dynamic:{command.backend}" + + prefix = CustomRoot()._prompt_prefix(bot_command) + assert "dynamic:slack" in prefix From ee9ff5be5397ad4199b6c37fea7b56cc480a2859 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:17:39 -0400 Subject: [PATCH 2/2] Bump minimum chatom Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- csp_bot/commands/agent.py | 4 ++-- pyproject.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/csp_bot/commands/agent.py b/csp_bot/commands/agent.py index f687ee8..6fe91c3 100644 --- a/csp_bot/commands/agent.py +++ b/csp_bot/commands/agent.py @@ -513,10 +513,10 @@ def _channel_context_note(self, command: BotCommand) -> str: channel_name = command.channel_name or "" name_part = f' (name: "{channel_name}")' if channel_name else "" return ( - f'[Context: this conversation is taking place in the channel with ' + f"[Context: this conversation is taking place in the channel with " f'id="{channel_id}"{name_part}. When the user refers to "this channel", ' '"this room", "here", or "the current channel/room", they mean this ' - 'channel; pass this id to tools such as read_channel_history.]' + "channel; pass this id to tools such as read_channel_history.]" ) def _build_model_prompt(self, command: BotCommand, prompt: str) -> Union[str, List[Any]]: diff --git a/pyproject.toml b/pyproject.toml index 88be754..3a7dc5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ classifiers = [ dependencies = [ "beautifulsoup4", "ccflow>=0.5.2", - "chatom>=0.1.3", + "chatom>=0.2.0", "croniter", "csp>=0.9,<1", "csp-gateway>=2.5,<2.7", @@ -52,7 +52,7 @@ dependencies = [ [project.optional-dependencies] agent = [ - "chatom[agent]>=0.1.3", + "chatom[agent]>=0.2.0", ] persistence = [ "fsspec", @@ -60,7 +60,7 @@ persistence = [ develop = [ "build", "bump-my-version", - "chatom[agent]>=0.1.3", + "chatom[agent]>=0.2.0", "check-dist", "codespell", "csp-adapter-discord>=0.2,<0.3",