diff --git a/.github/workflows/build-push-to-main.yaml b/.github/workflows/build-push-to-main.yaml index 93bfbff2..499daebd 100644 --- a/.github/workflows/build-push-to-main.yaml +++ b/.github/workflows/build-push-to-main.yaml @@ -113,3 +113,10 @@ jobs: cd ext/dapr-ext-langgraph python setup.py sdist bdist_wheel twine upload dist/* + - name: Build and publish dapr-ext-strands + env: + TWINE_PASSWORD: ${{ secrets.PYPI_UPLOAD_PASS }} + run: | + cd ext/dapr-ext-strands + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/build-tag.yaml b/.github/workflows/build-tag.yaml index ebc4b129..42e95268 100644 --- a/.github/workflows/build-tag.yaml +++ b/.github/workflows/build-tag.yaml @@ -123,3 +123,11 @@ jobs: cd ext/dapr-ext-langgraph python setup.py sdist bdist_wheel twine upload dist/* + - name: Build and publish dapr-ext-strands + if: startsWith(github.ref_name, 'strands-v') + env: + TWINE_PASSWORD: ${{ secrets.PYPI_UPLOAD_PASS }} + run: | + cd ext/dapr-ext-strands + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/README.md b/README.md index 30f65e21..f205a1b6 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ pip3 install -e ./ext/dapr-ext-grpc/ pip3 install -e ./ext/dapr-ext-fastapi/ pip3 install -e ./ext/dapr-ext-workflow/ pip3 install -e ./ext/dapr-ext-langgraph/ +pip3 install -e ./ext/dapr-ext-strands/ ``` 3. Install required packages diff --git a/ext/dapr-ext-strands/LICENSE b/ext/dapr-ext-strands/LICENSE new file mode 100644 index 00000000..be033a7f --- /dev/null +++ b/ext/dapr-ext-strands/LICENSE @@ -0,0 +1,203 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 The Dapr Authors. + + and others that have contributed code to the public domain. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/ext/dapr-ext-strands/README.rst b/ext/dapr-ext-strands/README.rst new file mode 100644 index 00000000..882ae13b --- /dev/null +++ b/ext/dapr-ext-strands/README.rst @@ -0,0 +1,22 @@ +dapr-ext-strands extension +======================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/dapr-ext-strands.svg + :target: https://pypi.org/project/dapr-ext-strands/ + +This is the Dapr Session Manager for Strands Agents + +Installation +------------ + +:: + + pip install dapr-ext-strands + +References +---------- + +* `Dapr `_ +* `Dapr Python-SDK `_ diff --git a/ext/dapr-ext-strands/dapr/ext/strands/__init__.py b/ext/dapr-ext-strands/dapr/ext/strands/__init__.py new file mode 100644 index 00000000..52ab2ee8 --- /dev/null +++ b/ext/dapr-ext-strands/dapr/ext/strands/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +# Import your main classes here +from dapr.ext.strands.dapr_session_manager import DaprSessionManager + +__all__ = [ + 'DaprSessionManager', +] diff --git a/ext/dapr-ext-strands/dapr/ext/strands/dapr_session_manager.py b/ext/dapr-ext-strands/dapr/ext/strands/dapr_session_manager.py new file mode 100644 index 00000000..ead5e688 --- /dev/null +++ b/ext/dapr-ext-strands/dapr/ext/strands/dapr_session_manager.py @@ -0,0 +1,553 @@ +"""Dapr state store session manager for distributed storage.""" + +import json +import logging +from typing import Any, Dict, List, Literal, Optional, cast + +from dapr.clients import DaprClient +from dapr.clients.grpc._state import Consistency, StateOptions +from strands import _identifier +from strands.session.repository_session_manager import RepositorySessionManager +from strands.session.session_repository import SessionRepository +from strands.types.exceptions import SessionException +from strands.types.session import Session, SessionAgent, SessionMessage + +logger = logging.getLogger(__name__) + +# Type-safe consistency constants +ConsistencyLevel = Literal['eventual', 'strong'] +DAPR_CONSISTENCY_EVENTUAL: ConsistencyLevel = 'eventual' +DAPR_CONSISTENCY_STRONG: ConsistencyLevel = 'strong' + + +class DaprSessionManager(RepositorySessionManager, SessionRepository): + """Dapr state store session manager for distributed storage. + + Stores session data in Dapr state stores (Redis, PostgreSQL, MongoDB, Cosmos DB, etc.) + with support for TTL and consistency levels. + + Key structure: + - `{session_id}:session` - Session metadata + - `{session_id}:agents:{agent_id}` - Agent metadata + - `{session_id}:messages:{agent_id}` - Message list (JSON array) + """ + + def __init__( + self, + session_id: str, + state_store_name: str, + dapr_client: DaprClient, + ttl: Optional[int] = None, + consistency: ConsistencyLevel = DAPR_CONSISTENCY_EVENTUAL, + **kwargs: Any, + ): + """Initialize DaprSessionManager. + + Args: + session_id: ID for the session. + ID is not allowed to contain path separators (e.g., a/b). + state_store_name: Name of the Dapr state store component. + dapr_client: DaprClient instance for state operations. + ttl: Optional time-to-live in seconds for state items. + consistency: Consistency level for state operations ("eventual" or "strong"). + **kwargs: Additional keyword arguments for future extensibility. + """ + self._state_store_name = state_store_name + self._dapr_client = dapr_client + self._ttl = ttl + self._consistency = consistency + self._owns_client = False + + super().__init__(session_id=session_id, session_repository=self) + + @classmethod + def from_address( + cls, + session_id: str, + state_store_name: str, + dapr_address: str = 'localhost:50001', + **kwargs: Any, + ) -> 'DaprSessionManager': + """Create DaprSessionManager from Dapr address. + + Args: + session_id: ID for the session. + state_store_name: Name of the Dapr state store component. + dapr_address: Dapr gRPC endpoint (default: localhost:50001). + **kwargs: Additional arguments passed to __init__ (ttl, consistency, etc.). + + Returns: + DaprSessionManager instance with owned client. + """ + dapr_client = DaprClient(address=dapr_address) + manager = cls( + session_id, state_store_name=state_store_name, dapr_client=dapr_client, **kwargs + ) + manager._owns_client = True + return manager + + def _get_session_key(self, session_id: str) -> str: + """Get session state key. + + Args: + session_id: ID for the session. + + Returns: + State store key for the session. + + Raises: + ValueError: If session id contains a path separator. + """ + session_id = _identifier.validate(session_id, _identifier.Identifier.SESSION) + return f'{session_id}:session' + + def _get_agent_key(self, session_id: str, agent_id: str) -> str: + """Get agent state key. + + Args: + session_id: ID for the session. + agent_id: ID for the agent. + + Returns: + State store key for the agent. + + Raises: + ValueError: If session id or agent id contains a path separator. + """ + session_id = _identifier.validate(session_id, _identifier.Identifier.SESSION) + agent_id = _identifier.validate(agent_id, _identifier.Identifier.AGENT) + return f'{session_id}:agents:{agent_id}' + + def _get_messages_key(self, session_id: str, agent_id: str) -> str: + """Get messages list state key. + + Args: + session_id: ID for the session. + agent_id: ID for the agent. + + Returns: + State store key for the messages list. + + Raises: + ValueError: If session id or agent id contains a path separator. + """ + session_id = _identifier.validate(session_id, _identifier.Identifier.SESSION) + agent_id = _identifier.validate(agent_id, _identifier.Identifier.AGENT) + return f'{session_id}:messages:{agent_id}' + + def _get_read_metadata(self) -> Dict[str, str]: + """Get metadata for read operations (consistency). + + Returns: + Metadata dictionary for state reads. + """ + metadata: Dict[str, str] = {} + if self._consistency: + metadata['consistency'] = self._consistency + return metadata + + def _get_write_metadata(self) -> Dict[str, str]: + """Get metadata for write operations (TTL). + + Returns: + Metadata dictionary for state writes. + """ + metadata: Dict[str, str] = {} + if self._ttl is not None: + metadata['ttlInSeconds'] = str(self._ttl) + return metadata + + def _get_state_options(self) -> Optional[StateOptions]: + """Get state options for write/delete operations (consistency). + + Returns: + StateOptions for consistency or None. + """ + if self._consistency == DAPR_CONSISTENCY_STRONG: + return StateOptions(consistency=Consistency.strong) + elif self._consistency == DAPR_CONSISTENCY_EVENTUAL: + return StateOptions(consistency=Consistency.eventual) + return None + + def _read_state(self, key: str) -> Optional[Dict[str, Any]]: + """Read and parse JSON state from Dapr. + + Args: + key: State store key. + + Returns: + Parsed JSON dictionary or None if not found. + + Raises: + SessionException: If state is corrupted or read fails. + """ + try: + response = self._dapr_client.get_state( + store_name=self._state_store_name, + key=key, + state_metadata=self._get_read_metadata(), + ) + + if not response.data: + return None + + content = response.data.decode('utf-8') + return cast(Dict[str, Any], json.loads(content)) + + except json.JSONDecodeError as e: + raise SessionException(f'Invalid JSON in state key {key}: {e}') from e + except Exception as e: + raise SessionException(f'Failed to read state key {key}: {e}') from e + + def _write_state(self, key: str, data: Dict[str, Any]) -> None: + """Write JSON state to Dapr. + + Args: + key: State store key. + data: Dictionary to serialize and store. + + Raises: + SessionException: If write fails. + """ + try: + content = json.dumps(data, ensure_ascii=False) + self._dapr_client.save_state( + store_name=self._state_store_name, + key=key, + value=content, + state_metadata=self._get_write_metadata(), + options=self._get_state_options(), + ) + except Exception as e: + raise SessionException(f'Failed to write state key {key}: {e}') from e + + def _delete_state(self, key: str) -> None: + """Delete state from Dapr. + + Args: + key: State store key. + + Raises: + SessionException: If delete fails. + """ + try: + self._dapr_client.delete_state( + store_name=self._state_store_name, + key=key, + options=self._get_state_options(), + ) + except Exception as e: + raise SessionException(f'Failed to delete state key {key}: {e}') from e + + def _get_manifest_key(self, session_id: str) -> str: + """Get session manifest key (tracks agent_ids for deletion).""" + session_id = _identifier.validate(session_id, _identifier.Identifier.SESSION) + return f'{session_id}:manifest' + + def create_session(self, session: Session, **kwargs: Any) -> Session: + """Create a new session. + + Args: + session: Session to create. + **kwargs: Additional keyword arguments for future extensibility. + + Returns: + Created session. + + Raises: + SessionException: If session already exists or creation fails. + """ + session_key = self._get_session_key(session.session_id) + + # Check if session already exists + existing = self.read_session(session.session_id) + if existing is not None: + raise SessionException(f'Session {session.session_id} already exists') + + # Write session data + session_dict = session.to_dict() + self._write_state(session_key, session_dict) + return session + + def read_session(self, session_id: str, **kwargs: Any) -> Optional[Session]: + """Read session data. + + Args: + session_id: ID of the session to read. + **kwargs: Additional keyword arguments for future extensibility. + + Returns: + Session if found, None otherwise. + + Raises: + SessionException: If read fails. + """ + session_key = self._get_session_key(session_id) + + session_data = self._read_state(session_key) + if session_data is None: + return None + + return Session.from_dict(session_data) + + def delete_session(self, session_id: str, **kwargs: Any) -> None: + """Delete session and all associated data. + + Uses a session manifest to discover agent IDs for cleanup. + """ + session_key = self._get_session_key(session_id) + manifest_key = self._get_manifest_key(session_id) + + # Read manifest (may be missing if no agents created) + manifest = self._read_state(manifest_key) + agent_ids: list[str] = manifest.get('agents', []) if manifest else [] + + # Delete agent and message keys + for agent_id in agent_ids: + agent_key = self._get_agent_key(session_id, agent_id) + messages_key = self._get_messages_key(session_id, agent_id) + self._delete_state(agent_key) + self._delete_state(messages_key) + + # Delete manifest and session + self._delete_state(manifest_key) + self._delete_state(session_key) + + def create_agent(self, session_id: str, session_agent: SessionAgent, **kwargs: Any) -> None: + """Create a new agent in the session. + + Args: + session_id: ID of the session. + session_agent: Agent to create. + **kwargs: Additional keyword arguments for future extensibility. + + Raises: + SessionException: If creation fails. + """ + agent_key = self._get_agent_key(session_id, session_agent.agent_id) + agent_dict = session_agent.to_dict() + + self._write_state(agent_key, agent_dict) + + # Initialize empty messages list + messages_key = self._get_messages_key(session_id, session_agent.agent_id) + self._write_state(messages_key, {'messages': []}) + + # Update manifest with this agent + manifest_key = self._get_manifest_key(session_id) + manifest = self._read_state(manifest_key) or {'agents': []} + if session_agent.agent_id not in manifest['agents']: + manifest['agents'].append(session_agent.agent_id) + self._write_state(manifest_key, manifest) + + def read_agent(self, session_id: str, agent_id: str, **kwargs: Any) -> Optional[SessionAgent]: + """Read agent data. + + Args: + session_id: ID of the session. + agent_id: ID of the agent. + **kwargs: Additional keyword arguments for future extensibility. + + Returns: + SessionAgent if found, None otherwise. + + Raises: + SessionException: If read fails. + """ + agent_key = self._get_agent_key(session_id, agent_id) + + agent_data = self._read_state(agent_key) + if agent_data is None: + return None + + return SessionAgent.from_dict(agent_data) + + def update_agent(self, session_id: str, session_agent: SessionAgent, **kwargs: Any) -> None: + """Update agent data. + + Args: + session_id: ID of the session. + session_agent: Agent to update. + **kwargs: Additional keyword arguments for future extensibility. + + Raises: + SessionException: If agent doesn't exist or update fails. + """ + previous_agent = self.read_agent(session_id=session_id, agent_id=session_agent.agent_id) + if previous_agent is None: + raise SessionException( + f'Agent {session_agent.agent_id} in session {session_id} does not exist' + ) + + # Preserve creation timestamp + session_agent.created_at = previous_agent.created_at + + agent_key = self._get_agent_key(session_id, session_agent.agent_id) + + self._write_state(agent_key, session_agent.to_dict()) + + def create_message( + self, session_id: str, agent_id: str, session_message: SessionMessage, **kwargs: Any + ) -> None: + """Create a new message for the agent. + + Args: + session_id: ID of the session. + agent_id: ID of the agent. + session_message: Message to create. + **kwargs: Additional keyword arguments for future extensibility. + + Raises: + SessionException: If creation fails. + """ + messages_key = self._get_messages_key(session_id, agent_id) + + # Read existing messages + messages_data = self._read_state(messages_key) + if messages_data is None: + messages_list = [] + else: + messages_list = messages_data.get('messages', []) + if not isinstance(messages_list, list): + messages_list = [] + + # Append new message + messages_list.append(session_message.to_dict()) + + # Write back + self._write_state(messages_key, {'messages': messages_list}) + + def read_message( + self, session_id: str, agent_id: str, message_id: int, **kwargs: Any + ) -> Optional[SessionMessage]: + """Read message data. + + Args: + session_id: ID of the session. + agent_id: ID of the agent. + message_id: Index of the message. + **kwargs: Additional keyword arguments for future extensibility. + + Returns: + SessionMessage if found, None otherwise. + + Raises: + ValueError: If message_id is not an integer. + SessionException: If read fails. + """ + if not isinstance(message_id, int): + raise ValueError(f'message_id=<{message_id}> | message id must be an integer') + + messages_key = self._get_messages_key(session_id, agent_id) + + messages_data = self._read_state(messages_key) + if messages_data is None: + return None + + messages_list = messages_data.get('messages', []) + if not isinstance(messages_list, list): + messages_list = [] + + # Find message by ID + for msg_dict in messages_list: + if msg_dict.get('message_id') == message_id: + return SessionMessage.from_dict(msg_dict) + + return None + + def update_message( + self, session_id: str, agent_id: str, session_message: SessionMessage, **kwargs: Any + ) -> None: + """Update message data. + + Args: + session_id: ID of the session. + agent_id: ID of the agent. + session_message: Message to update. + **kwargs: Additional keyword arguments for future extensibility. + + Raises: + SessionException: If message doesn't exist or update fails. + """ + previous_message = self.read_message( + session_id=session_id, agent_id=agent_id, message_id=session_message.message_id + ) + if previous_message is None: + raise SessionException(f'Message {session_message.message_id} does not exist') + + # Preserve creation timestamp + session_message.created_at = previous_message.created_at + + messages_key = self._get_messages_key(session_id, agent_id) + + # Read existing messages + messages_data = self._read_state(messages_key) + if messages_data is None: + raise SessionException( + f'Messages not found for agent {agent_id} in session {session_id}' + ) + + messages_list = messages_data.get('messages', []) + if not isinstance(messages_list, list): + messages_list = [] + + # Find and update message + updated = False + for i, msg_dict in enumerate(messages_list): + if msg_dict.get('message_id') == session_message.message_id: + messages_list[i] = session_message.to_dict() + updated = True + break + + if not updated: + raise SessionException(f'Message {session_message.message_id} not found in list') + + # Write back + self._write_state(messages_key, {'messages': messages_list}) + + def list_messages( + self, + session_id: str, + agent_id: str, + limit: Optional[int] = None, + offset: int = 0, + **kwargs: Any, + ) -> List[SessionMessage]: + """List messages for an agent with pagination. + + Args: + session_id: ID of the session. + agent_id: ID of the agent. + limit: Maximum number of messages to return. + offset: Number of messages to skip. + **kwargs: Additional keyword arguments for future extensibility. + + Returns: + List of SessionMessage objects. + + Raises: + SessionException: If read fails. + """ + messages_key = self._get_messages_key(session_id, agent_id) + + messages_data = self._read_state(messages_key) + if messages_data is None: + return [] + + messages_list = messages_data.get('messages', []) + if not isinstance(messages_list, list): + messages_list = [] + + # Apply pagination + if limit is not None: + messages_list = messages_list[offset : offset + limit] + else: + messages_list = messages_list[offset:] + + # Convert to SessionMessage objects + return [SessionMessage.from_dict(msg_dict) for msg_dict in messages_list] + + def close(self) -> None: + """Close the Dapr client if owned by this manager.""" + if self._owns_client: + self._dapr_client.close() diff --git a/ext/dapr-ext-strands/dapr/ext/strands/version.py b/ext/dapr-ext-strands/dapr/ext/strands/version.py new file mode 100644 index 00000000..dae1485d --- /dev/null +++ b/ext/dapr-ext-strands/dapr/ext/strands/version.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__version__ = '1.16.0.dev' diff --git a/ext/dapr-ext-strands/setup.cfg b/ext/dapr-ext-strands/setup.cfg new file mode 100644 index 00000000..3149e8d2 --- /dev/null +++ b/ext/dapr-ext-strands/setup.cfg @@ -0,0 +1,42 @@ +[metadata] +url = https://dapr.io/ +author = Dapr Authors +author_email = daprweb@microsoft.com +license = Apache +license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 +project_urls = + Documentation = https://github.com/dapr/docs + Source = https://github.com/dapr/python-sdk + +[options] +python_requires = >=3.10 +packages = find_namespace: +include_package_data = True +install_requires = + dapr >= 1.16.1rc1 + strands-agents + strands-agents-tools + python-ulid >= 3.0.0 + msgpack-python >= 0.4.5 + +[options.packages.find] +include = + dapr.* + +exclude = + tests + +[options.package_data] +dapr.ext.strands = + py.typed \ No newline at end of file diff --git a/ext/dapr-ext-strands/setup.py b/ext/dapr-ext-strands/setup.py new file mode 100644 index 00000000..1d8c6732 --- /dev/null +++ b/ext/dapr-ext-strands/setup.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import os + +from setuptools import setup + +# Load version in dapr package. +version_info = {} +with open('dapr/ext/strands/version.py') as fp: + exec(fp.read(), version_info) +__version__ = version_info['__version__'] + + +def is_release(): + return '.dev' not in __version__ + + +name = 'dapr-ext-strands' +version = __version__ +description = 'The official release of Dapr Python SDK Strands Agents Extension.' +long_description = """ +This is the Dapr Session Manager extension for Strands Agents. + +Dapr is a portable, serverless, event-driven runtime that makes it easy for developers to +build resilient, stateless and stateful microservices that run on the cloud and edge and +embraces the diversity of languages and developer frameworks. + +Dapr codifies the best practices for building microservice applications into open, +independent, building blocks that enable you to build portable applications with the language +and framework of your choice. Each building block is independent and you can use one, some, +or all of them in your application. +""".lstrip() + +# Get build number from GITHUB_RUN_NUMBER environment variable +build_number = os.environ.get('GITHUB_RUN_NUMBER', '0') + +if not is_release(): + name += '-dev' + version = f'{__version__}{build_number}' + description = ( + 'The developmental release for the Dapr Session Manager extension for Strands Agents' + ) + long_description = 'This is the developmental release for the Dapr Session Manager extension for Strands Agents' + +print(f'package name: {name}, version: {version}', flush=True) + + +setup( + name=name, + version=version, + description=description, + long_description=long_description, +) diff --git a/ext/dapr-ext-strands/tests/__init__.py b/ext/dapr-ext-strands/tests/__init__.py new file mode 100644 index 00000000..ad87aedb --- /dev/null +++ b/ext/dapr-ext-strands/tests/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" diff --git a/ext/dapr-ext-strands/tests/test_session_manager.py b/ext/dapr-ext-strands/tests/test_session_manager.py new file mode 100644 index 00000000..0569e02b --- /dev/null +++ b/ext/dapr-ext-strands/tests/test_session_manager.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- + +import json +import time +import unittest +from unittest import mock + +from dapr.ext.strands.dapr_session_manager import DaprSessionManager +from strands.types.exceptions import SessionException +from strands.types.session import Session, SessionAgent, SessionMessage + + +def dapr_state(data): + """Simulate a real Dapr get_state() response.""" + resp = mock.Mock() + resp.data = None if data is None else json.dumps(data).encode('utf-8') + return resp + + +def make_session(session_id='s1'): + return Session.from_dict( + { + 'session_id': session_id, + 'session_type': 'chat', + 'created_at': time.time(), + 'metadata': {}, + } + ) + + +def make_agent(agent_id='a1'): + return SessionAgent.from_dict( + { + 'agent_id': agent_id, + 'state': {}, + 'conversation_manager_state': {}, + 'created_at': time.time(), + } + ) + + +def make_message(message_id=1, text='hello'): + return SessionMessage.from_dict( + { + 'message_id': message_id, + 'role': 'user', + 'message': text, + 'created_at': time.time(), + } + ) + + +@mock.patch('dapr.ext.strands.dapr_session_manager.DaprClient') +class DaprSessionManagerTest(unittest.TestCase): + def setUp(self): + self.session_id = 's1' + self.store = 'statestore' + + self.mock_client = mock.Mock() + self.mock_client.get_state.return_value = dapr_state(None) + + self.manager = DaprSessionManager( + session_id=self.session_id, + state_store_name=self.store, + dapr_client=self.mock_client, + ) + + # + # session + # + def test_create_and_read_session(self, _): + session = make_session(self.session_id) + + self.manager.create_session(session) + + self.mock_client.get_state.return_value = dapr_state(session.to_dict()) + read = self.manager.read_session(self.session_id) + + assert read.session_id == self.session_id + + def test_create_session_raises_if_exists(self, _): + session = make_session(self.session_id) + + self.mock_client.get_state.return_value = dapr_state(session.to_dict()) + + with self.assertRaises(SessionException): + self.manager.create_session(session) + + # + # agent + # + def test_create_and_read_agent(self, _): + agent = make_agent('a1') + + self.manager.create_agent(self.session_id, agent) + + self.mock_client.get_state.return_value = dapr_state(agent.to_dict()) + read = self.manager.read_agent(self.session_id, 'a1') + + assert read.agent_id == 'a1' + + def test_update_agent_preserves_created_at(self, _): + agent = make_agent('a1') + original_ts = agent.created_at + + self.mock_client.get_state.return_value = dapr_state(agent.to_dict()) + + agent.state['x'] = 1 + self.manager.update_agent(self.session_id, agent) + + saved = json.loads(self.mock_client.save_state.call_args[1]['value']) + assert saved['created_at'] == original_ts + + def test_create_and_read_message(self, _): + msg = make_message(1, 'hello') + + self.manager.create_message(self.session_id, 'a1', msg) + + messages = {'messages': [msg.to_dict()]} + self.mock_client.get_state.return_value = dapr_state(messages) + + read = self.manager.read_message(self.session_id, 'a1', 1) + assert read.message == 'hello' + + def test_update_message_preserves_created_at(self, _): + msg = make_message(1, 'old') + original_ts = msg.created_at + + messages = {'messages': [msg.to_dict()]} + self.mock_client.get_state.return_value = dapr_state(messages) + + msg.message = 'new' + self.manager.update_message(self.session_id, 'a1', msg) + + saved = json.loads(self.mock_client.save_state.call_args[1]['value']) + updated = saved['messages'][0] + + assert updated['created_at'] == original_ts + assert updated['message'] == 'new' + + def test_delete_session_deletes_agents_and_messages(self, _): + manifest = {'agents': ['a1', 'a2']} + self.mock_client.get_state.return_value = dapr_state(manifest) + + self.manager.delete_session(self.session_id) + assert self.mock_client.delete_state.call_count == 6 + + def test_close_only_closes_owned_client(self, _): + self.manager._owns_client = True + self.manager.close() + self.mock_client.close.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 0697a408..1bdb1792 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,7 @@ commands = coverage run -a -m unittest discover -v ./ext/dapr-ext-grpc/tests coverage run -a -m unittest discover -v ./ext/dapr-ext-fastapi/tests coverage run -a -m unittest discover -v ./ext/dapr-ext-langgraph/tests + coverage run -a -m unittest discover -v ./ext/dapr-ext-strands/tests coverage run -a -m unittest discover -v ./ext/flask_dapr/tests coverage xml commands_pre = @@ -24,6 +25,7 @@ commands_pre = pip3 install -e {toxinidir}/ext/dapr-ext-grpc/ pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ pip3 install -e {toxinidir}/ext/dapr-ext-langgraph/ + pip3 install -e {toxinidir}/ext/dapr-ext-strands/ pip3 install -e {toxinidir}/ext/flask_dapr/ [testenv:ruff] @@ -69,6 +71,7 @@ commands_pre = pip3 install -e {toxinidir}/ext/dapr-ext-grpc/ pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ pip3 install -e {toxinidir}/ext/dapr-ext-langgraph/ + pip3 install -e {toxinidir}/ext/dapr-ext-strands/ allowlist_externals=* [testenv:example-component] @@ -89,6 +92,7 @@ commands_pre = pip3 install -e {toxinidir}/ext/dapr-ext-grpc/ pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ pip3 install -e {toxinidir}/ext/dapr-ext-langgraph/ + pip3 install -e {toxinidir}/ext/dapr-ext-strands/ allowlist_externals=* [testenv:type] @@ -103,6 +107,7 @@ commands_pre = pip3 install -e {toxinidir}/ext/dapr-ext-grpc/ pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ pip3 install -e {toxinidir}/ext/dapr-ext-langgraph/ + pip3 install -e {toxinidir}/ext/dapr-ext-strands/ [testenv:doc] basepython = python3 usedevelop = False