From b1a53fed81e6cffbe7f4504029324bd9e90ddcb7 Mon Sep 17 00:00:00 2001 From: bwmac Date: Fri, 31 Jan 2025 10:06:51 -0500 Subject: [PATCH 01/11] update version v4.7.0 --- synapseclient/synapsePythonClient | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapseclient/synapsePythonClient b/synapseclient/synapsePythonClient index 3ccb1602e..5aeb673c5 100644 --- a/synapseclient/synapsePythonClient +++ b/synapseclient/synapsePythonClient @@ -1,6 +1,6 @@ { "client": "synapsePythonClient", - "latestVersion": "4.6.1", + "latestVersion": "4.7.0", "blacklist": [ "0.0.0", "0.4.1", From 155f4ba6fece2aa60e0bc6f7561f2a3e75f47916 Mon Sep 17 00:00:00 2001 From: bwmac Date: Fri, 31 Jan 2025 10:20:00 -0500 Subject: [PATCH 02/11] version v4.7.0 docs --- docs/news.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/news.md b/docs/news.md index 4aff7f747..028c9289c 100644 --- a/docs/news.md +++ b/docs/news.md @@ -9,6 +9,21 @@ detailing some of the changes. the 4.x.x versions hidden behind optional feature flags or different import paths. Any breaking changes will not be included until v5.0. +## 4.7.0 (2025-01-31) + +### Highlights +- **Added functionality for interacting with Synapse Agents:** + - The new `Agent` OOP model allows you to chat with the baseline Synapse Agent, + register and chat with custom Synapse Agents, manage multiple chat sessions and more. + - See the `Agent` documentation for more details and example code to get started. + +### Bug Fixes +- \[[SYNPY-1557](https://sagebionetworks.jira.com/browse/SYNPY-1557)\] - Synapse get recursive link download issue + +### Stories +- \[[SYNPY-1544](https://sagebionetworks.jira.com/browse/SYNPY-1544)\] - Create Synapse Agent OOP Model +- \[[SYNPY-1566](https://sagebionetworks.jira.com/browse/SYNPY-1566)\] - Release python client v4.7.0 + ## 4.6.1 (2024-12-17) ### Highlights From 98f56fc2e57c598efe458983ebda4d218650b635 Mon Sep 17 00:00:00 2001 From: bwmac Date: Fri, 31 Jan 2025 10:24:42 -0500 Subject: [PATCH 03/11] pre-commit --- docs/news.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/news.md b/docs/news.md index 028c9289c..9b16bf59e 100644 --- a/docs/news.md +++ b/docs/news.md @@ -12,11 +12,11 @@ breaking changes will not be included until v5.0. ## 4.7.0 (2025-01-31) ### Highlights -- **Added functionality for interacting with Synapse Agents:** +- **Added functionality for interacting with Synapse Agents:** - The new `Agent` OOP model allows you to chat with the baseline Synapse Agent, register and chat with custom Synapse Agents, manage multiple chat sessions and more. - See the `Agent` documentation for more details and example code to get started. - + ### Bug Fixes - \[[SYNPY-1557](https://sagebionetworks.jira.com/browse/SYNPY-1557)\] - Synapse get recursive link download issue From f39ee48ead4e5b9d1b9f95e35e584a79574a9b4e Mon Sep 17 00:00:00 2001 From: Brad Macdonald <52762200+BWMac@users.noreply.github.com> Date: Mon, 3 Feb 2025 12:34:29 -0500 Subject: [PATCH 04/11] Merge v4.7.0 into master (#1159) * [SYNPY-1544] Synapse Agent OOP Model (#1152) * Adds async convenience functions * expose convenience functions * updates convenience functions * updates agent_services * removes rest_get_async exception handling * pre-commit fixes * delete accidentally committed script * adds initial agent implementation * clean up agent * adds missing docstrings * pre-commit * updates agent_services * updates agent.py * Updates alias ID handling * adds syncronous interface * prevent cicular import in storable_entity_components * remove promt sending and receiving from agent_service * adds initial (dirty) async job mixin * pre-commit run * [SYNPY-1544] potential changes to mixin (#1153) * Changes for async mixin * Remove arg * bug fix * generalizes send_job_and_wait_async * removes typing.Self --------- Co-authored-by: bwmac * cleans up agent logic * adds async job unit tests * updates async job tests * adds agent unit tests * adds integration tests * pre-commit * adds examples to agent.py * removes todos * adds POC script * add to mixins * adds agent docs * updates agent docs * reorganize documentation * updates poc script * clean up * add docstring * removes unused imports * split too long lines * force synapse_client kwarg * updates agent.py * updates synapse_client docstring description * updates asynchronous_job * updates integration tests * pre-commit * agent inherited members * updates docs for inherited members * missing inherited members * updates doc formatting * try team formatting change * updates script description * adds Annotation lazy import * try team formatting change * more formatting changes * address review comments in agent.py * move synchronous docs up a layer * adds syn login * adds warning message to docs * updates docstring examples * updates docstrings * adds error handling for agent.get * async integration tests * fix conditional * disables integration tests * updates docstring for clarity --------- Co-authored-by: BryanFauble <17128019+BryanFauble@users.noreply.github.com> * [SYNPY-1544] Fixes docstring (#1155) * fixes docstring * protocol docstring * fix imports * Removes example setting annotations with Agent class (#1156) * removes annotation example * pre-commit * [SYNPY-1557] Sync a Linked Folder Bug (#1157) * fixes docstring * protocol docstring * fix imports * adds integration test for expected behavior * adds fix * merge weirdness * fix test docstring * [SYNPY-1544] Return the AgentPrompt when calling the prompt function (#1158) * update version v4.7.0 * version v4.7.0 docs * pre-commit --------- Co-authored-by: BryanFauble <17128019+BryanFauble@users.noreply.github.com> --- docs/news.md | 15 + docs/reference/experimental/async/activity.md | 24 + docs/reference/experimental/async/agent.md | 32 + docs/reference/experimental/async/file.md | 27 + docs/reference/experimental/async/folder.md | 20 + docs/reference/experimental/async/project.md | 19 + docs/reference/experimental/async/table.md | 21 + docs/reference/experimental/async/team.md | 19 + .../experimental/async/user_profile.md | 19 + .../mixins/access_controllable.md | 3 + .../mixins/asynchronous_communicator.md | 3 + .../experimental/mixins/failure_strategy.md | 3 + .../experimental/mixins/storable_container.md | 3 + docs/reference/experimental/sync/activity.md | 35 + docs/reference/experimental/sync/agent.md | 42 + docs/reference/experimental/sync/file.md | 37 + docs/reference/experimental/sync/folder.md | 30 + docs/reference/experimental/sync/project.md | 29 + docs/reference/experimental/sync/table.md | 31 + docs/reference/experimental/sync/team.md | 30 + .../experimental/sync/user_profile.md | 19 + docs/reference/oop/models.md | 169 ---- docs/reference/oop/models_async.md | 100 -- .../oop_poc_agent.py | 105 ++ mkdocs.yml | 24 +- synapseclient/api/__init__.py | 15 + synapseclient/api/agent_services.py | 189 ++++ synapseclient/client.py | 25 +- .../core/constants/concrete_types.py | 3 + synapseclient/models/__init__.py | 10 + synapseclient/models/agent.py | 945 ++++++++++++++++++ synapseclient/models/mixins/__init__.py | 2 + .../models/mixins/asynchronous_job.py | 410 ++++++++ .../models/mixins/storable_container.py | 2 + .../models/protocols/agent_protocol.py | 396 ++++++++ .../services/storable_entity_components.py | 3 +- synapseclient/synapsePythonClient | 2 +- .../models/async/test_agent_async.py | 228 +++++ .../models/synchronous/test_agent.py | 192 ++++ .../synapseutils/test_synapseutils_sync.py | 91 +- .../async/unit_test_asynchronous_job.py | 278 ++++++ .../models/async/unit_test_agent_async.py | 703 +++++++++++++ .../models/synchronous/unit_test_agent.py | 588 +++++++++++ 43 files changed, 4653 insertions(+), 288 deletions(-) create mode 100644 docs/reference/experimental/async/activity.md create mode 100644 docs/reference/experimental/async/agent.md create mode 100644 docs/reference/experimental/async/file.md create mode 100644 docs/reference/experimental/async/folder.md create mode 100644 docs/reference/experimental/async/project.md create mode 100644 docs/reference/experimental/async/table.md create mode 100644 docs/reference/experimental/async/team.md create mode 100644 docs/reference/experimental/async/user_profile.md create mode 100644 docs/reference/experimental/mixins/access_controllable.md create mode 100644 docs/reference/experimental/mixins/asynchronous_communicator.md create mode 100644 docs/reference/experimental/mixins/failure_strategy.md create mode 100644 docs/reference/experimental/mixins/storable_container.md create mode 100644 docs/reference/experimental/sync/activity.md create mode 100644 docs/reference/experimental/sync/agent.md create mode 100644 docs/reference/experimental/sync/file.md create mode 100644 docs/reference/experimental/sync/folder.md create mode 100644 docs/reference/experimental/sync/project.md create mode 100644 docs/reference/experimental/sync/table.md create mode 100644 docs/reference/experimental/sync/team.md create mode 100644 docs/reference/experimental/sync/user_profile.md delete mode 100644 docs/reference/oop/models.md delete mode 100644 docs/reference/oop/models_async.md create mode 100644 docs/scripts/object_orientated_programming_poc/oop_poc_agent.py create mode 100644 synapseclient/api/agent_services.py create mode 100644 synapseclient/models/agent.py create mode 100644 synapseclient/models/mixins/asynchronous_job.py create mode 100644 synapseclient/models/protocols/agent_protocol.py create mode 100644 tests/integration/synapseclient/models/async/test_agent_async.py create mode 100644 tests/integration/synapseclient/models/synchronous/test_agent.py create mode 100644 tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py create mode 100644 tests/unit/synapseclient/models/async/unit_test_agent_async.py create mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_agent.py diff --git a/docs/news.md b/docs/news.md index 4aff7f747..9b16bf59e 100644 --- a/docs/news.md +++ b/docs/news.md @@ -9,6 +9,21 @@ detailing some of the changes. the 4.x.x versions hidden behind optional feature flags or different import paths. Any breaking changes will not be included until v5.0. +## 4.7.0 (2025-01-31) + +### Highlights +- **Added functionality for interacting with Synapse Agents:** + - The new `Agent` OOP model allows you to chat with the baseline Synapse Agent, + register and chat with custom Synapse Agents, manage multiple chat sessions and more. + - See the `Agent` documentation for more details and example code to get started. + +### Bug Fixes +- \[[SYNPY-1557](https://sagebionetworks.jira.com/browse/SYNPY-1557)\] - Synapse get recursive link download issue + +### Stories +- \[[SYNPY-1544](https://sagebionetworks.jira.com/browse/SYNPY-1544)\] - Create Synapse Agent OOP Model +- \[[SYNPY-1566](https://sagebionetworks.jira.com/browse/SYNPY-1566)\] - Release python client v4.7.0 + ## 4.6.1 (2024-12-17) ### Highlights diff --git a/docs/reference/experimental/async/activity.md b/docs/reference/experimental/async/activity.md new file mode 100644 index 000000000..59e2f0061 --- /dev/null +++ b/docs/reference/experimental/async/activity.md @@ -0,0 +1,24 @@ +# Activity + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.Activity + options: + members: + - from_parent_async + - store_async + - delete_async +--- +::: synapseclient.models.UsedEntity + options: + filters: + - "!" +--- +::: synapseclient.models.UsedURL + options: + filters: + - "!" diff --git a/docs/reference/experimental/async/agent.md b/docs/reference/experimental/async/agent.md new file mode 100644 index 000000000..be2e74c36 --- /dev/null +++ b/docs/reference/experimental/async/agent.md @@ -0,0 +1,32 @@ +# Agent + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API reference + +::: synapseclient.models.Agent + options: + members: + - register_async + - get_async + - start_session_async + - get_session_async + - prompt_async + - get_chat_history +--- +::: synapseclient.models.AgentSession + options: + members: + - start_async + - get_async + - update_async + - prompt_async +--- +::: synapseclient.models.AgentPrompt + options: + inherited_members: true + members: + - send_job_and_wait_async +--- diff --git a/docs/reference/experimental/async/file.md b/docs/reference/experimental/async/file.md new file mode 100644 index 000000000..e2fe12300 --- /dev/null +++ b/docs/reference/experimental/async/file.md @@ -0,0 +1,27 @@ +# File + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.File + options: + inherited_members: true + members: + - get_async + - store_async + - copy_async + - delete_async + - from_id_async + - from_path_async + - change_metadata_async + - get_permissions_async + - get_acl_async + - set_permissions_async +--- +::: synapseclient.models.file.FileHandle + options: + filters: + - "!" diff --git a/docs/reference/experimental/async/folder.md b/docs/reference/experimental/async/folder.md new file mode 100644 index 000000000..c11983a99 --- /dev/null +++ b/docs/reference/experimental/async/folder.md @@ -0,0 +1,20 @@ +# Folder + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.Folder + options: + inherited_members: true + members: + - get_async + - store_async + - delete_async + - copy_async + - sync_from_synapse_async + - get_permissions_async + - get_acl_async + - set_permissions_async diff --git a/docs/reference/experimental/async/project.md b/docs/reference/experimental/async/project.md new file mode 100644 index 000000000..b628d4e19 --- /dev/null +++ b/docs/reference/experimental/async/project.md @@ -0,0 +1,19 @@ +# Project + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API reference + +::: synapseclient.models.Project + options: + inherited_members: true + members: + - get_async + - store_async + - delete_async + - sync_from_synapse_async + - get_permissions_async + - get_acl_async + - set_permissions_async diff --git a/docs/reference/experimental/async/table.md b/docs/reference/experimental/async/table.md new file mode 100644 index 000000000..63f3b3a0b --- /dev/null +++ b/docs/reference/experimental/async/table.md @@ -0,0 +1,21 @@ +# Table + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.Table + options: + inherited_members: true + members: + - get_async + - store_schema_async + - store_rows_from_csv_async + - delete_rows_async + - query_async + - delete_async + - get_permissions_async + - get_acl_async + - set_permissions_async diff --git a/docs/reference/experimental/async/team.md b/docs/reference/experimental/async/team.md new file mode 100644 index 000000000..0dd066e35 --- /dev/null +++ b/docs/reference/experimental/async/team.md @@ -0,0 +1,19 @@ +# Team + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.Team + options: + members: + - create_async + - delete_async + - from_id_async + - from_name_async + - members_async + - invite_async + - open_invitations_async +--- diff --git a/docs/reference/experimental/async/user_profile.md b/docs/reference/experimental/async/user_profile.md new file mode 100644 index 000000000..7174061d9 --- /dev/null +++ b/docs/reference/experimental/async/user_profile.md @@ -0,0 +1,19 @@ +# UserProfile + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.UserProfile + options: + inherited_members: true + members: + - get_async + - from_id_async + - from_username_async + - is_certified_async +--- +::: synapseclient.models.UserPreference +--- diff --git a/docs/reference/experimental/mixins/access_controllable.md b/docs/reference/experimental/mixins/access_controllable.md new file mode 100644 index 000000000..96e7f70b9 --- /dev/null +++ b/docs/reference/experimental/mixins/access_controllable.md @@ -0,0 +1,3 @@ +# AccessControllable + +::: synapseclient.models.mixins.AccessControllable diff --git a/docs/reference/experimental/mixins/asynchronous_communicator.md b/docs/reference/experimental/mixins/asynchronous_communicator.md new file mode 100644 index 000000000..bfc081057 --- /dev/null +++ b/docs/reference/experimental/mixins/asynchronous_communicator.md @@ -0,0 +1,3 @@ +# AsynchronousCommunicator + +::: synapseclient.models.mixins.AsynchronousCommunicator diff --git a/docs/reference/experimental/mixins/failure_strategy.md b/docs/reference/experimental/mixins/failure_strategy.md new file mode 100644 index 000000000..3809b74f5 --- /dev/null +++ b/docs/reference/experimental/mixins/failure_strategy.md @@ -0,0 +1,3 @@ +# FailureStrategy + +::: synapseclient.models.FailureStrategy diff --git a/docs/reference/experimental/mixins/storable_container.md b/docs/reference/experimental/mixins/storable_container.md new file mode 100644 index 000000000..49e10a5e3 --- /dev/null +++ b/docs/reference/experimental/mixins/storable_container.md @@ -0,0 +1,3 @@ +# StorableContainer + +::: synapseclient.models.mixins.StorableContainer diff --git a/docs/reference/experimental/sync/activity.md b/docs/reference/experimental/sync/activity.md new file mode 100644 index 000000000..f0547e13c --- /dev/null +++ b/docs/reference/experimental/sync/activity.md @@ -0,0 +1,35 @@ +# Activity + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script + +
+ Working with activities + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_activity.py!} +``` +
+ +## API Reference + +::: synapseclient.models.Activity + options: + inherited_members: true + members: + - from_parent + - store + - delete +--- +::: synapseclient.models.UsedEntity + options: + filters: + - "!" +--- +::: synapseclient.models.UsedURL + options: + filters: + - "!" diff --git a/docs/reference/experimental/sync/agent.md b/docs/reference/experimental/sync/agent.md new file mode 100644 index 000000000..3d8cb7f08 --- /dev/null +++ b/docs/reference/experimental/sync/agent.md @@ -0,0 +1,42 @@ +# Agent + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script: + +
+ Working with Synapse agents + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_agent.py!} +``` +
+ +## API Reference + +::: synapseclient.models.Agent + options: + inherited_members: true + members: + - register + - get + - start_session + - get_session + - prompt + - get_chat_history +--- +::: synapseclient.models.AgentSession + options: + inherited_members: true + members: + - start + - get + - update + - prompt +--- +::: synapseclient.models.AgentPrompt + options: + inherited_members: true +--- diff --git a/docs/reference/experimental/sync/file.md b/docs/reference/experimental/sync/file.md new file mode 100644 index 000000000..9b49e7603 --- /dev/null +++ b/docs/reference/experimental/sync/file.md @@ -0,0 +1,37 @@ +# File + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script + +
+ Working with files + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_file.py!} +``` +
+ +## API Reference + +::: synapseclient.models.File + options: + inherited_members: true + members: + - get + - store + - copy + - delete + - from_id + - from_path + - change_metadata + - get_permissions + - get_acl + - set_permissions +--- +::: synapseclient.models.file.FileHandle + options: + filters: + - "!" diff --git a/docs/reference/experimental/sync/folder.md b/docs/reference/experimental/sync/folder.md new file mode 100644 index 000000000..5a1cb5ddb --- /dev/null +++ b/docs/reference/experimental/sync/folder.md @@ -0,0 +1,30 @@ +# Folder + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script + +
+ Working with folders + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_folder.py!} +``` +
+ +## API Reference + +::: synapseclient.models.Folder + options: + inherited_members: true + members: + - get + - store + - delete + - copy + - sync_from_synapse + - get_permissions + - get_acl + - set_permissions diff --git a/docs/reference/experimental/sync/project.md b/docs/reference/experimental/sync/project.md new file mode 100644 index 000000000..e8cebfed5 --- /dev/null +++ b/docs/reference/experimental/sync/project.md @@ -0,0 +1,29 @@ +# Project + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script + +
+ Working with a project + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_project.py!} +``` +
+ +## API reference + +::: synapseclient.models.Project + options: + inherited_members: true + members: + - get + - store + - delete + - sync_from_synapse + - get_permissions + - get_acl + - set_permissions diff --git a/docs/reference/experimental/sync/table.md b/docs/reference/experimental/sync/table.md new file mode 100644 index 000000000..058826d0d --- /dev/null +++ b/docs/reference/experimental/sync/table.md @@ -0,0 +1,31 @@ +# Table + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script + +
+ Working with tables + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_table.py!} +``` +
+ +## API Reference + +::: synapseclient.models.Table + options: + inherited_members: true + members: + - get + - store_schema + - store_rows_from_csv + - delete_rows + - query + - delete + - get_permissions + - get_acl + - set_permissions diff --git a/docs/reference/experimental/sync/team.md b/docs/reference/experimental/sync/team.md new file mode 100644 index 000000000..46fc51305 --- /dev/null +++ b/docs/reference/experimental/sync/team.md @@ -0,0 +1,30 @@ +# Team + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Example Script + +
+ Working with teams + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_team.py!} +``` +
+ +## API Reference + +::: synapseclient.models.Team + options: + inherited_members: true + members: + - create + - delete + - from_id + - from_name + - members + - invite + - open_invitations +--- diff --git a/docs/reference/experimental/sync/user_profile.md b/docs/reference/experimental/sync/user_profile.md new file mode 100644 index 000000000..46424f4b5 --- /dev/null +++ b/docs/reference/experimental/sync/user_profile.md @@ -0,0 +1,19 @@ +# UserProfile + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.UserProfile + options: + inherited_members: true + members: + - get + - from_id + - from_username + - is_certified +--- +::: synapseclient.models.UserPreference +--- diff --git a/docs/reference/oop/models.md b/docs/reference/oop/models.md deleted file mode 100644 index 2c7ebc153..000000000 --- a/docs/reference/oop/models.md +++ /dev/null @@ -1,169 +0,0 @@ -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Sample Scripts: - -
- Working with a project - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_project.py!} -``` -
- -
- Working with folders - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_folder.py!} -``` -
- -
- Working with files - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_file.py!} -``` -
- -
- Working with tables - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_table.py!} -``` -
- -
- Current Synapse interface for working with a project - -```python -{!docs/scripts/object_orientated_programming_poc/synapse_project.py!} -``` -
- -
- Working with activities - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_activity.py!} -``` -
- -
- Working with teams - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_team.py!} -``` -
- -## API reference - -::: synapseclient.models.Project - options: - inherited_members: true - members: - - get - - store - - delete - - sync_from_synapse - - get_permissions - - get_acl - - set_permissions ---- -::: synapseclient.models.Folder - options: - inherited_members: true - members: - - get - - store - - delete - - copy - - sync_from_synapse - - get_permissions - - get_acl - - set_permissions ---- -::: synapseclient.models.File - options: - inherited_members: true - members: - - get - - store - - copy - - delete - - from_id - - from_path - - change_metadata - - get_permissions - - get_acl - - set_permissions -::: synapseclient.models.file.FileHandle - options: - filters: - - "!" ---- -::: synapseclient.models.Table - options: - inherited_members: true - members: - - get - - store_schema - - store_rows_from_csv - - delete_rows - - query - - delete - - get_permissions - - get_acl - - set_permissions ---- -::: synapseclient.models.Activity - options: - members: - - from_parent - - store - - delete - -::: synapseclient.models.UsedEntity - options: - filters: - - "!" -::: synapseclient.models.UsedURL - options: - filters: - - "!" ---- -::: synapseclient.models.Team - options: - members: - - create - - delete - - from_id - - from_name - - members - - invite - - open_invitations ---- -::: synapseclient.models.UserProfile - options: - members: - - get - - from_id - - from_username - - is_certified -::: synapseclient.models.UserPreference ---- -::: synapseclient.models.Annotations - options: - members: - - from_dict ---- -::: synapseclient.models.mixins.AccessControllable ---- - -::: synapseclient.models.mixins.StorableContainer ---- -::: synapseclient.models.FailureStrategy diff --git a/docs/reference/oop/models_async.md b/docs/reference/oop/models_async.md deleted file mode 100644 index c61ce0df6..000000000 --- a/docs/reference/oop/models_async.md +++ /dev/null @@ -1,100 +0,0 @@ -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -These APIs also introduce [AsyncIO](https://docs.python.org/3/library/asyncio.html) to -the client. - -## Sample Scripts: -See [this page for sample scripts](models.md#sample-scripts). -The sample scripts are from a synchronous context, -replace any of the method calls with the async counter-party and they will be -functionally equivalent. - -## API reference - -::: synapseclient.models.Project - options: - inherited_members: true - members: - - get_async - - store_async - - delete_async - - sync_from_synapse_async - - get_permissions_async - - get_acl_async - - set_permissions_async ---- -::: synapseclient.models.Folder - options: - inherited_members: true - members: - - get_async - - store_async - - delete_async - - copy_async - - sync_from_synapse_async - - get_permissions_async - - get_acl_async - - set_permissions_async ---- -::: synapseclient.models.File - options: - inherited_members: true - members: - - get_async - - store_async - - copy_async - - delete_async - - from_id_async - - from_path_async - - change_metadata_async - - get_permissions_async - - get_acl_async - - set_permissions_async ---- -::: synapseclient.models.Table - options: - inherited_members: true - members: - - get_async - - store_schema_async - - store_rows_from_csv_async - - delete_rows_async - - query_async - - delete_async - - get_permissions_async - - get_acl_async - - set_permissions_async ---- -::: synapseclient.models.Activity - options: - members: - - from_parent_async - - store_async - - delete_async - ---- -::: synapseclient.models.Team - options: - members: - - create_async - - delete_async - - from_id_async - - from_name_async - - members_async - - invite_async - - open_invitations_async ---- -::: synapseclient.models.UserProfile - options: - members: - - get_async - - from_id_async - - from_username_async - - is_certified_async ---- -::: synapseclient.models.Annotations - options: - members: - - store_async diff --git a/docs/scripts/object_orientated_programming_poc/oop_poc_agent.py b/docs/scripts/object_orientated_programming_poc/oop_poc_agent.py new file mode 100644 index 000000000..2703f41a9 --- /dev/null +++ b/docs/scripts/object_orientated_programming_poc/oop_poc_agent.py @@ -0,0 +1,105 @@ +""" +The purpose of this script is to demonstrate how to use the new OOP interface for Synapse AI Agents. + +1. Register and send a prompt to a custom agent +2. Send a prompt to the baseline Synapse Agent +3. Conduct more than one session with the same agent +4. Start a new session with a custom agent and send a prompt to it +5. Start a new session with the baseline Synapse Agent and send a prompt to it +6. Start a new session with a custom agent and then update what the agent has access to +""" + +import synapseclient +from synapseclient.models import Agent, AgentSession, AgentSessionAccessLevel + +# IDs for a bedrock agent with the instructions: +# "You are a test agent that when greeted with: 'hello' will always response with: 'world'" +CLOUD_AGENT_ID = "QOTV3KQM1X" +AGENT_REGISTRATION_ID = 29 + +syn = synapseclient.Synapse(debug=True) +syn.login() + +# Using the Agent class + + +# Register a custom agent and send a prompt to it +def register_and_send_prompt_to_custom_agent(): + my_custom_agent = Agent(cloud_agent_id=CLOUD_AGENT_ID) + my_custom_agent.register(synapse_client=syn) + my_custom_agent.prompt( + prompt="Hello", enable_trace=True, print_response=True, synapse_client=syn + ) + + +# Create an Agent Object and prompt. +# By default, this will send a prompt to a new session with the baseline Synapse Agent. +def get_baseline_agent_and_send_prompt_to_it(): + baseline_agent = Agent() + baseline_agent.prompt( + prompt="What is Synapse?", + enable_trace=True, + print_response=True, + synapse_client=syn, + ) + + +# Conduct more than one session with the same agent +def conduct_multiple_sessions_with_same_agent(): + my_agent = Agent(registration_id=AGENT_REGISTRATION_ID).get(synapse_client=syn) + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + synapse_client=syn, + ) + my_second_session = my_agent.start_session(synapse_client=syn) + my_agent.prompt( + prompt="Hello again", + enable_trace=True, + print_response=True, + session=my_second_session, + synapse_client=syn, + ) + + +# Using the AgentSession class + + +# Start a new session with a custom agent and send a prompt to it +def start_new_session_with_custom_agent_and_send_prompt_to_it(): + my_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID).start( + synapse_client=syn + ) + my_session.prompt( + prompt="Hello", enable_trace=True, print_response=True, synapse_client=syn + ) + + +# Start a new session with the baseline Synapse Agent and send a prompt to it +def start_new_session_with_baseline_agent_and_send_prompt_to_it(): + my_session = AgentSession().start(synapse_client=syn) + my_session.prompt( + prompt="What is Synapse?", + enable_trace=True, + print_response=True, + synapse_client=syn, + ) + + +# Start a new session with a custom agent and then update what the agent has access to +def start_new_session_with_custom_agent_and_update_access_to_it(): + my_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID).start( + synapse_client=syn + ) + print(f"Access level before update: {my_session.access_level}") + my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA + my_session.update(synapse_client=syn) + print(f"Access level after update: {my_session.access_level}") + + +register_and_send_prompt_to_custom_agent() +get_baseline_agent_and_send_prompt_to_it() +conduct_multiple_sessions_with_same_agent() +start_new_session_with_baseline_agent_and_send_prompt_to_it() +start_new_session_with_custom_agent_and_update_access_to_it() diff --git a/mkdocs.yml b/mkdocs.yml index 768dcd0e3..68f9e0053 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,8 +75,28 @@ nav: - Core: reference/core.md - REST Apis: reference/rest_apis.md - Experimental: - - Object-Orientated Models: reference/oop/models.md - - Async Object-Orientated Models: reference/oop/models_async.md + - Agent: reference/experimental/sync/agent.md + - Project: reference/experimental/sync/project.md + - Folder: reference/experimental/sync/folder.md + - File: reference/experimental/sync/file.md + - Table: reference/experimental/sync/table.md + - Activity: reference/experimental/sync/activity.md + - Team: reference/experimental/sync/team.md + - UserProfile: reference/experimental/sync/user_profile.md + - Asynchronous: + - Agent: reference/experimental/async/agent.md + - Project: reference/experimental/async/project.md + - Folder: reference/experimental/async/folder.md + - File: reference/experimental/async/file.md + - Table: reference/experimental/async/table.md + - Activity: reference/experimental/async/activity.md + - Team: reference/experimental/async/team.md + - UserProfile: reference/experimental/async/user_profile.md + - Mixins: + - AccessControllable: reference/experimental/mixins/access_controllable.md + - StorableContainer: reference/experimental/mixins/storable_container.md + - AsynchronousCommunicator: reference/experimental/mixins/asynchronous_communicator.md + - FailureStrategy: reference/experimental/mixins/failure_strategy.md - Further Reading: - Home: explanations/home.md - Domain Models of Synapse: explanations/domain_models_of_synapse.md diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 3211aaf38..f41f782fc 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -1,4 +1,12 @@ # These are all of the models that are used by the Synapse client. +from .agent_services import ( + get_agent, + get_session, + get_trace, + register_agent, + start_session, + update_session, +) from .annotations import set_annotations, set_annotations_async from .configuration_services import ( get_client_authenticated_s3_profile, @@ -78,4 +86,11 @@ "get_transfer_config", # entity_factory "get_from_entity_factory", + # agent_services + "register_agent", + "get_agent", + "start_session", + "get_session", + "update_session", + "get_trace", ] diff --git a/synapseclient/api/agent_services.py b/synapseclient/api/agent_services.py new file mode 100644 index 000000000..6cb65e1fd --- /dev/null +++ b/synapseclient/api/agent_services.py @@ -0,0 +1,189 @@ +"""This module is responsible for exposing the services defined at: + +""" + +import json +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from synapseclient import Synapse + + +async def register_agent( + cloud_agent_id: str, + cloud_alias_id: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Registers an agent with Synapse OR gets existing agent registration. + Sends a request matching + + + Arguments: + cloud_agent_id: The cloud provider ID of the agent to register. + cloud_alias_id: The cloud provider alias ID of the agent to register. + In the Synapse API, this defaults to 'TSTALIASID'. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The registered agent matching + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + request = {"awsAgentId": cloud_agent_id} + if cloud_alias_id: + request["awsAliasId"] = cloud_alias_id + return await client.rest_put_async( + uri="/agent/registration", body=json.dumps(request) + ) + + +async def get_agent( + registration_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Gets information about an existing agent registration. + + Arguments: + registration_id: The ID of the agent registration to get. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The requested agent registration matching + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async(uri=f"/agent/registration/{registration_id}") + + +async def start_session( + access_level: str, + agent_registration_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Starts a new chat session with an agent. + Sends a request matching + + + Arguments: + access_level: The access level of the agent. + agent_registration_id: The ID of the agent registration to start the session for. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + request = { + "agentAccessLevel": access_level, + "agentRegistrationId": agent_registration_id, + } + return await client.rest_post_async(uri="/agent/session", body=json.dumps(request)) + + +async def get_session( + id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Gets information about an existing chat session. + + Arguments: + id: The ID of the session to get. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The requested session matching + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async(uri=f"/agent/session/{id}") + + +async def update_session( + id: str, + access_level: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Updates the access level for a chat session. + Sends a request matching + + + Arguments: + id: The ID of the session to update. + access_level: The access level of the agent. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + request = { + "sessionId": id, + "agentAccessLevel": access_level, + } + return await client.rest_put_async( + uri=f"/agent/session/{id}", body=json.dumps(request) + ) + + +async def get_trace( + prompt_id: str, + *, + newer_than: Optional[int] = None, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Gets the trace of a prompt. + Sends a request matching + + + Arguments: + prompt_id: The token of the prompt to get the trace for. + newer_than: The timestamp to get trace results newer than. Defaults to None (all results). + Timestamps should be in milliseconds since the epoch per the API documentation. + https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/agent/TraceEvent.html + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The trace matching + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + request = { + "jobId": prompt_id, + "newerThanTimestamp": newer_than, + } + return await client.rest_post_async( + uri=f"/agent/chat/trace/{prompt_id}", body=json.dumps(request) + ) diff --git a/synapseclient/client.py b/synapseclient/client.py index 61bcf73c5..8aba3217d 100644 --- a/synapseclient/client.py +++ b/synapseclient/client.py @@ -6373,20 +6373,17 @@ async def rest_get_async( Returns: JSON encoding of response """ - try: - response = await self._rest_call_async( - "get", - uri, - None, - endpoint, - headers, - retry_policy, - requests_session_async_synapse, - **kwargs, - ) - return self._return_rest_body(response) - except Exception: - self.logger.exception("Error in rest_get_async") + response = await self._rest_call_async( + "get", + uri, + None, + endpoint, + headers, + retry_policy, + requests_session_async_synapse, + **kwargs, + ) + return self._return_rest_body(response) async def rest_post_async( self, diff --git a/synapseclient/core/constants/concrete_types.py b/synapseclient/core/constants/concrete_types.py index f8d4ee442..e2033c030 100644 --- a/synapseclient/core/constants/concrete_types.py +++ b/synapseclient/core/constants/concrete_types.py @@ -68,3 +68,6 @@ # Activity/Provenance USED_URL = "org.sagebionetworks.repo.model.provenance.UsedURL" USED_ENTITY = "org.sagebionetworks.repo.model.provenance.UsedEntity" + +# Agent +AGENT_CHAT_REQUEST = "org.sagebionetworks.repo.model.agent.AgentChatRequest" diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index a487a3827..1e2f686ed 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -1,5 +1,11 @@ # These are all of the models that are used by the Synapse client. from synapseclient.models.activity import Activity, UsedEntity, UsedURL +from synapseclient.models.agent import ( + Agent, + AgentPrompt, + AgentSession, + AgentSessionAccessLevel, +) from synapseclient.models.annotations import Annotations from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder @@ -38,4 +44,8 @@ "TeamMember", "UserProfile", "UserPreference", + "Agent", + "AgentSession", + "AgentSessionAccessLevel", + "AgentPrompt", ] diff --git a/synapseclient/models/agent.py b/synapseclient/models/agent.py new file mode 100644 index 000000000..3fe1306ac --- /dev/null +++ b/synapseclient/models/agent.py @@ -0,0 +1,945 @@ +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional, Union + +from synapseclient import Synapse +from synapseclient.api import ( + get_agent, + get_session, + get_trace, + register_agent, + start_session, + update_session, +) +from synapseclient.core.async_utils import async_to_sync, otel_trace_method +from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST +from synapseclient.models.mixins import AsynchronousCommunicator +from synapseclient.models.protocols.agent_protocol import ( + AgentSessionSynchronousProtocol, + AgentSynchronousProtocol, +) + + +class AgentType(str, Enum): + """ + Enum representing the type of agent as defined in + + + - BASELINE is a default agent provided by Synapse. + - CUSTOM is a custom agent that has been registered by a user. + """ + + BASELINE = "BASELINE" + CUSTOM = "CUSTOM" + + +class AgentSessionAccessLevel(str, Enum): + """ + Enum representing the access level of the agent session as defined in + + + - PUBLICLY_ACCESSIBLE: The agent can only access publicly accessible data. + - READ_YOUR_PRIVATE_DATA: The agent can read the user's private data. + - WRITE_YOUR_PRIVATE_DATA: The agent can write to the user's private data. + """ + + PUBLICLY_ACCESSIBLE = "PUBLICLY_ACCESSIBLE" + READ_YOUR_PRIVATE_DATA = "READ_YOUR_PRIVATE_DATA" + WRITE_YOUR_PRIVATE_DATA = "WRITE_YOUR_PRIVATE_DATA" + + +@dataclass +class AgentPrompt(AsynchronousCommunicator): + """Represents a prompt, response, and metadata within an AgentSession. + + Attributes: + id: The unique ID of the agent prompt. + session_id: The ID of the session that the prompt is associated with. + prompt: The prompt to send to the agent. + response: The response from the agent. + enable_trace: Whether tracing is enabled for the prompt. + trace: The trace of the agent session. + """ + + concrete_type: str = AGENT_CHAT_REQUEST + + id: Optional[str] = None + """The unique ID of the agent prompt.""" + + session_id: Optional[str] = None + """The ID of the session that the prompt is associated with.""" + + prompt: Optional[str] = None + """The prompt sent to the agent.""" + + response: Optional[str] = None + """The response from the agent.""" + + enable_trace: Optional[bool] = False + """Whether tracing is enabled for the prompt.""" + + trace: Optional[str] = None + """The trace or "thought process" of the agent when responding to the prompt.""" + + def to_synapse_request(self): + """Converts the request to a request expected of the Synapse REST API.""" + return { + "concreteType": self.concrete_type, + "sessionId": self.session_id, + "chatText": self.prompt, + "enableTrace": self.enable_trace, + } + + def fill_from_dict(self, synapse_response: Dict[str, str]) -> "AgentPrompt": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response from the REST API. + + Returns: + The AgentPrompt object. + """ + self.id = synapse_response.get("jobId", None) + self.session_id = synapse_response.get("sessionId", None) + self.response = synapse_response.get("responseText", None) + return self + + async def _post_exchange_async( + self, *, synapse_client: Optional[Synapse] = None, **kwargs + ) -> None: + """Retrieves information about the trace of this prompt with the agent. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + """ + if self.enable_trace: + trace_response = await get_trace( + prompt_id=self.id, + newer_than=kwargs.get("newer_than", None), + synapse_client=synapse_client, + ) + self.trace = trace_response["page"][0]["message"] + + +@dataclass +@async_to_sync +class AgentSession(AgentSessionSynchronousProtocol): + """Represents a [Synapse Agent Session](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/agent/AgentSession.html) + + Attributes: + id: The unique ID of the agent session. + Can only be used by the user that created it. + access_level: The access level of the agent session. + One of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, + or WRITE_YOUR_PRIVATE_DATA. + started_on: The date the agent session was started. + started_by: The ID of the user who started the agent session. + modified_on: The date the agent session was last modified. + agent_registration_id: The registration ID of the agent that will + be used for this session. + etag: The etag of the agent session. + + Note: It is recommended to use the `Agent` class to conduct chat sessions, + but you are free to use AgentSession directly if you wish. + + Example: Start a session and send a prompt. + Start a session with a custom agent by providing the agent's registration ID and calling `start()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + my_session = AgentSession(agent_registration_id="foo").start() + my_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + Example: Get an existing session and send a prompt. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + my_session = AgentSession(id="foo").get() + my_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + Example: Update the access level of an existing session. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, update the access level of the session and call `update()`. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession, AgentSessionAccessLevel + + syn = Synapse() + syn.login() + + my_session = AgentSession(id="foo").get() + my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA + my_session.update() + """ + + id: Optional[str] = None + """The unique ID of the agent session. + Can only be used by the user that created it.""" + + access_level: Optional[ + AgentSessionAccessLevel + ] = AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE + """The access level of the agent session. + One of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, or + WRITE_YOUR_PRIVATE_DATA. Defaults to PUBLICLY_ACCESSIBLE. + """ + + started_on: Optional[datetime] = None + """The date the agent session was started.""" + + started_by: Optional[int] = None + """The ID of the user who started the agent session.""" + + modified_on: Optional[datetime] = None + """The date the agent session was last modified.""" + + agent_registration_id: Optional[int] = None + """The registration ID of the agent that will be used for this session.""" + + etag: Optional[str] = None + """The etag of the agent session.""" + + chat_history: List[AgentPrompt] = field(default_factory=list) + """A list of AgentPrompt objects.""" + + def fill_from_dict(self, synapse_agent_session: Dict[str, str]) -> "AgentSession": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_agent_session: The response from the REST API. + + Returns: + The AgentSession object. + """ + self.id = synapse_agent_session.get("sessionId", None) + self.access_level = synapse_agent_session.get("agentAccessLevel", None) + self.started_on = synapse_agent_session.get("startedOn", None) + self.started_by = synapse_agent_session.get("startedBy", None) + self.modified_on = synapse_agent_session.get("modifiedOn", None) + self.agent_registration_id = synapse_agent_session.get( + "agentRegistrationId", None + ) + self.etag = synapse_agent_session.get("etag", None) + return self + + @otel_trace_method(method_to_trace_name=lambda self, **kwargs: "Start_Session") + async def start_async( + self, *, synapse_client: Optional[Synapse] = None + ) -> "AgentSession": + """Starts an agent session. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The new AgentSession object. + + Example: Start a session and send a prompt. + Start a session with a custom agent by providing the agent's registration ID and calling `start()`. + Then, send a prompt to the agent. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + async def main(): + my_session = await AgentSession(agent_registration_id="foo").start_async() + await my_session.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + session_response = await start_session( + access_level=self.access_level, + agent_registration_id=self.agent_registration_id, + synapse_client=synapse_client, + ) + return self.fill_from_dict(synapse_agent_session=session_response) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Session: {self.id}" + ) + async def get_async( + self, *, synapse_client: Optional[Synapse] = None + ) -> "AgentSession": + """Gets an agent session. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The retrieved AgentSession object. + + Example: Get an existing session and send a prompt. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + async def main(): + my_session = await AgentSession(id="foo").get_async() + await my_session.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + session_response = await get_session( + id=self.id, + synapse_client=synapse_client, + ) + return self.fill_from_dict(synapse_agent_session=session_response) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Update_Session: {self.id}" + ) + async def update_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "AgentSession": + """Updates an agent session. + Only updates to the access level are currently supported. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The updated AgentSession object. + + Example: Update the access level of an existing session. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, update the access level of the session and call `update()`. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession, AgentSessionAccessLevel + + syn = Synapse() + syn.login() + + async def main(): + my_session = await AgentSession(id="foo").get_async() + my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA + await my_session.update_async() + + asyncio.run(main()) + """ + session_response = await update_session( + id=self.id, + access_level=self.access_level, + synapse_client=synapse_client, + ) + return self.fill_from_dict(synapse_agent_session=session_response) + + @otel_trace_method(method_to_trace_name=lambda self, **kwargs: f"Prompt: {self.id}") + async def prompt_async( + self, + prompt: str, + enable_trace: bool = False, + print_response: bool = False, + newer_than: Optional[int] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> AgentPrompt: + """Sends a prompt to the agent and adds the response to the AgentSession's + chat history. A session must be started before sending a prompt. + + Arguments: + prompt: The prompt to send to the agent. + enable_trace: Whether to enable trace for the prompt. + print_response: Whether to print the response to the console. + newer_than: The timestamp to get trace results newer than. + Defaults to None (all results). + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Example: Send a prompt within an existing session. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + async def main(): + my_session = await AgentSession(id="foo").get_async() + await my_session.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + agent_prompt = await AgentPrompt( + prompt=prompt, session_id=self.id, enable_trace=enable_trace + ).send_job_and_wait_async( + synapse_client=synapse_client, post_exchange_args={"newer_than": newer_than} + ) + self.chat_history.append(agent_prompt) + if print_response: + client = Synapse.get_client(synapse_client=synapse_client) + client.logger.info(f"PROMPT:\n{prompt}\n") + client.logger.info(f"RESPONSE:\n{agent_prompt.response}\n") + if enable_trace: + client.logger.info(f"TRACE:\n{agent_prompt.trace}") + return agent_prompt + + +@dataclass +@async_to_sync +class Agent(AgentSynchronousProtocol): + """Represents a [Synapse Agent Registration](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/agent/AgentRegistration.html) + + Attributes: + cloud_agent_id: The unique ID of the agent in the cloud provider. + cloud_alias_id: The alias ID of the agent in the cloud provider. + Defaults to 'TSTALIASID' in the Synapse API. + registration_id: The ID number of the agent assigned by Synapse. + registered_on: The date the agent was registered. + type: The type of agent. + sessions: A dictionary of AgentSession objects, keyed by session ID. + current_session: The current session. Prompts will be sent to this session by default. + + Example: Chat with the baseline Synapse Agent + You can chat with the same agent which is available in the Synapse UI + at https://www.synapse.org/Chat:default. By default, this "baseline" agent + is used when a registration ID is not provided. In the background, + the Agent class will start a session and set that new session as the + current session if one is not already set. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent() + my_agent.prompt( + prompt="Can you tell me about the AD Knowledge Portal dataset?", + enable_trace=True, + print_response=True, + ) + + Example: Register and chat with a custom agent + **Only available for internal users (Sage Bionetworks employees)** + + Alternatively, you can register a custom agent and chat with it provided + you have already created it. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent(cloud_agent_id="foo") + my_agent.register() + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + Example: Get and chat with an existing agent + Retrieve an existing agent by providing the agent's registration ID and calling `get()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent(registration_id="foo").get() + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + Advanced Example: Start and prompt multiple sessions + Here, we connect to a custom agent and start one session with the prompt "Hello". + In the background, this first session is being set as the current session + and future prompts will be sent to this session by default. If we want to send a + prompt to a different session, we can do so by starting it and calling prompt again, + but with our new session as an argument. We now have two sessions, both stored in the + `my_agent.sessions` dictionary. After the second prompt, `my_second_session` is now + the current session. + + syn = Synapse() + syn.login() + + my_agent = Agent(registration_id="foo").get() + + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + my_second_session = my_agent.start_session() + my_agent.prompt( + prompt="Hello again", + enable_trace=True, + print_response=True, + session=my_second_session, + ) + """ + + cloud_agent_id: Optional[str] = None + """The unique ID of the agent in the cloud provider.""" + + cloud_alias_id: Optional[str] = None + """The alias ID of the agent in the cloud provider. + Defaults to 'TSTALIASID' in the Synapse API. + """ + + registration_id: Optional[int] = None + """The ID number of the agent assigned by Synapse.""" + + registered_on: Optional[datetime] = None + """The date the agent was registered.""" + + type: Optional[AgentType] = None + """The type of agent. One of either BASELINE or CUSTOM.""" + + sessions: Dict[str, AgentSession] = field(default_factory=dict) + """A dictionary of AgentSession objects, keyed by session ID.""" + + current_session: Optional[AgentSession] = None + """The current session. Prompts will be sent to this session by default.""" + + def fill_from_dict(self, agent_registration: Dict[str, str]) -> "Agent": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + agent_registration: The response from the REST API. + + Returns: + The Agent object. + """ + self.cloud_agent_id = agent_registration.get("awsAgentId", None) + self.cloud_alias_id = agent_registration.get("awsAliasId", None) + self.registration_id = agent_registration.get("agentRegistrationId", None) + self.registered_on = agent_registration.get("registeredOn", None) + self.type = ( + AgentType(agent_registration.get("type")) + if agent_registration.get("type", None) + else None + ) + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Register_Agent: {self.registration_id}" + ) + async def register_async( + self, *, synapse_client: Optional[Synapse] = None + ) -> "Agent": + """Registers an agent with the Synapse API. + If agent already exists, it will be retrieved. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The registered or existing Agent object. + + Example: Register and chat with a custom agent + **Only available for internal users (Sage Bionetworks employees)** + + Alternatively, you can register a custom agent and chat with it provided + you have already created it. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent(cloud_agent_id="foo") + await my_agent.register_async() + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + agent_response = await register_agent( + cloud_agent_id=self.cloud_agent_id, + cloud_alias_id=self.cloud_alias_id, + synapse_client=synapse_client, + ) + return self.fill_from_dict(agent_registration=agent_response) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Agent: {self.registration_id}" + ) + async def get_async(self, *, synapse_client: Optional[Synapse] = None) -> "Agent": + """Gets an existing custom agent. There is no need to use this method + if you are trying to use the baseline agent. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The existing Agent object. + + Example: Get and chat with an existing agent + Retrieve an existing custom agent by providing the agent's registration ID and calling `get_async()`. + Then, send a prompt to the agent. + + import asyncio + from synapseclient import Synapse + from synapseclient.models import Agent, AgentSessionAccessLevel + + syn = Synapse() + syn.login() + + async def main(): + my_agent = await Agent(registration_id="foo").get_async() + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + if self.registration_id is None: + raise ValueError( + "Registration ID is required to retrieve a custom agent. " + "If you are trying to use the baseline agent, you do not need to " + "use `get` or `get_async`. Instead, simply create an `Agent` object " + "and start prompting `my_agent = Agent(); my_agent.prompt(...)`.", + ) + agent_response = await get_agent( + registration_id=self.registration_id, + synapse_client=synapse_client, + ) + return self.fill_from_dict(agent_registration=agent_response) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Start_Agent_Session: {self.registration_id}" + ) + async def start_session_async( + self, + access_level: Optional[ + AgentSessionAccessLevel + ] = AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + *, + synapse_client: Optional[Synapse] = None, + ) -> "AgentSession": + """Starts an agent session. + Adds the session to the Agent's sessions dictionary and sets it as the current session. + + Arguments: + access_level: The access level of the agent session. + Must be one of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, + or WRITE_YOUR_PRIVATE_DATA. + Defaults to PUBLICLY_ACCESSIBLE. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The new AgentSession object. + + Example: Start a session and send a prompt with the baseline Synapse Agent. + The baseline Synapse Agent is the default agent used when a registration ID is not provided. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent() + await my_agent.start_session_async() + await my_agent.prompt_async( + prompt="Can you tell me about the AD Knowledge Portal dataset?", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + + Example: Start a session and send a prompt with a custom agent. + The baseline Synapse Agent is the default agent used when a registration ID is not provided. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent(cloud_agent_id="foo") + await my_agent.start_session_async() + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + access_level = AgentSessionAccessLevel(access_level) + session = await AgentSession( + agent_registration_id=self.registration_id, access_level=access_level + ).start_async(synapse_client=synapse_client) + self.sessions[session.id] = session + self.current_session = session + return session + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Agent_Session: {self.registration_id}" + ) + async def get_session_async( + self, session_id: str, *, synapse_client: Optional[Synapse] = None + ) -> "AgentSession": + """Gets an existing agent session. + Adds the session to the Agent's sessions dictionary and + sets it as the current session. + + Arguments: + session_id: The ID of the session to get. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The existing AgentSession object. + + Example: Get an existing session and send a prompt. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_session = await Agent().get_session_async(session_id="foo") + await my_session.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + """ + session = await AgentSession(id=session_id).get_async( + synapse_client=synapse_client + ) + if session.id not in self.sessions: + self.sessions[session.id] = session + self.current_session = session + return session + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Prompt_Agent_Session: {self.registration_id}" + ) + async def prompt_async( + self, + prompt: str, + enable_trace: bool = False, + print_response: bool = False, + session: Optional[AgentSession] = None, + newer_than: Optional[int] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> AgentPrompt: + """Sends a prompt to the agent for the current session. + If no session is currently active, a new session will be started. + + Arguments: + prompt: The prompt to send to the agent. + enable_trace: Whether to enable trace for the prompt. + print_response: Whether to print the response to the console. + session_id: The ID of the session to send the prompt to. + If None, the current session will be used. + newer_than: The timestamp to get trace results newer than. Defaults to None (all results). + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Example: Prompt the baseline Synapse Agent. + The baseline Synapse Agent is equivilent to the Agent available in the Synapse UI. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent() + await my_agent.prompt_async( + prompt="Can you tell me about the AD Knowledge Portal dataset?", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + + Example: Prompt a custom agent. + If you have already registered a custom agent, you can prompt it by providing the agent's registration ID. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent(registration_id="foo") + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + asyncio.run(main()) + + Advanced Example: Start and prompt multiple sessions + Here, we connect to a custom agent and start one session with the prompt "Hello". + In the background, this first session is being set as the current session + and future prompts will be sent to this session by default. If we want to send a + prompt to a different session, we can do so by starting it and calling prompt again, + but with our new session as an argument. We now have two sessions, both stored in the + `my_agent.sessions` dictionary. After the second prompt, `my_second_session` is now + the current session. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent(registration_id="foo").get() + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + my_second_session = await my_agent.start_session_async() + await my_agent.prompt_async( + prompt="Hello again", + enable_trace=True, + print_response=True, + session=my_second_session, + ) + + asyncio.run(main()) + """ + if session: + await self.get_session_async( + session_id=session.id, synapse_client=synapse_client + ) + else: + if not self.current_session: + await self.start_session_async(synapse_client=synapse_client) + + return await self.current_session.prompt_async( + prompt=prompt, + enable_trace=enable_trace, + newer_than=newer_than, + print_response=print_response, + synapse_client=synapse_client, + ) + + def get_chat_history(self) -> Union[List[AgentPrompt], None]: + """Gets the chat history for the current session. + + Example: Get the chat history for the current session. + First, send a prompt to the agent. + Then, retrieve the chat history for the current session by calling `get_chat_history()`. + + import asyncio + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + async def main(): + my_agent = Agent() + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + print(my_agent.get_chat_history()) + + asyncio.run(main()) + """ + return self.current_session.chat_history if self.current_session else None diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index 0fb23dac7..93a98589c 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -1,9 +1,11 @@ """References to the mixins that are used in the Synapse models.""" from synapseclient.models.mixins.access_control import AccessControllable +from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator from synapseclient.models.mixins.storable_container import StorableContainer __all__ = [ "AccessControllable", "StorableContainer", + "AsynchronousCommunicator", ] diff --git a/synapseclient/models/mixins/asynchronous_job.py b/synapseclient/models/mixins/asynchronous_job.py new file mode 100644 index 000000000..aac481663 --- /dev/null +++ b/synapseclient/models/mixins/asynchronous_job.py @@ -0,0 +1,410 @@ +import asyncio +import json +import time +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Optional + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST +from synapseclient.core.exceptions import SynapseError, SynapseTimeoutError + +ASYNC_JOB_URIS = { + AGENT_CHAT_REQUEST: "/agent/chat/async", +} + + +class AsynchronousCommunicator: + """Mixin to handle communication with the Synapse Asynchronous Job service.""" + + def to_synapse_request(self) -> None: + """Converts the request to a request expected of the Synapse REST API.""" + raise NotImplementedError("to_synapse_request must be implemented.") + + def fill_from_dict( + self, synapse_response: Dict[str, str] + ) -> "AsynchronousCommunicator": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response from the REST API. + + Returns: + An instance of this class. + """ + raise NotImplementedError("fill_from_dict must be implemented.") + + async def _post_exchange_async( + self, synapse_client: Optional[Synapse] = None, **kwargs + ) -> None: + """Any additional logic to run after the exchange with Synapse. + + Arguments: + synapse_client: The Synapse client to use for the request. + **kwargs: Additional arguments to pass to the request. + """ + pass + + async def send_job_and_wait_async( + self, + post_exchange_args: Optional[Dict[str, Any]] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> "AsynchronousCommunicator": + """Send the job to the Asynchronous Job service and wait for it to complete. + Intended to be called by a class inheriting from this mixin to start a job + in the Synapse API and wait for it to complete. The inheriting class needs to + represent an asynchronous job request and response and include all necessary attributes. + This was initially implemented to be used in the AgentPrompt class which can be used + as an example. + + Arguments: + post_exchange_args: Additional arguments to pass to the request. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + An instance of this class. + + Example: Using this function + This function was initially implemented to be used in the AgentPrompt class + to send a prompt to an AI agent and wait for the response. It can also be used + in any other class that needs to use an Asynchronous Job. + + The inheriting class (AgentPrompt) will typically not be used directly, but rather + through a higher level class (AgentSession), but this example shows how you would + use this function. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentPrompt + + syn = Synapse() + syn.login() + + agent_prompt = AgentPrompt( + id=None, + session_id="123", + prompt="Hello", + response=None, + enable_trace=True, + trace=None, + ) + # This will fill the id, response, and trace + # attributes with the response from the API + agent_prompt.send_job_and_wait_async() + """ + result = await send_job_and_wait_async( + request=self.to_synapse_request(), + request_type=self.concrete_type, + synapse_client=synapse_client, + ) + self.fill_from_dict(synapse_response=result) + await self._post_exchange_async( + **post_exchange_args, synapse_client=synapse_client + ) + return self + + +class AsynchronousJobState(str, Enum): + """Enum representing the state of a Synapse Asynchronous Job: + + + - PROCESSING: The job is being processed. + - FAILED: The job has failed. + - COMPLETE: The job has been completed. + """ + + PROCESSING = "PROCESSING" + FAILED = "FAILED" + COMPLETE = "COMPLETE" + + +class CallersContext(str, Enum): + """Enum representing information about a web service call: + + + - SESSION_ID: Each web service request is issued a unique session ID (UUID) + that is included in the call's access record. + Events that are triggered by a web service request should include the session ID + so that they can be linked to each other and the call's access record. + """ + + SESSION_ID = "SESSION_ID" + + +@dataclass +class AsynchronousJobStatus: + """Represents a Synapse Asynchronous Job Status object: + + + Attributes: + state: The state of the job. Either PROCESSING, FAILED, or COMPLETE. + canceling: Whether the job has been requested to be cancelled. + request_body: The body of an Asynchronous job request. + Will be one of the models described here: + + response_body: The body of an Asynchronous job response. + Will be one of the models described here: + + etag: The etag of the job status. Changes whenever the status changes. + id: The ID if the job issued when this job was started. + started_by_user_id: The ID of the user that started the job. + started_on: The date-time when the status was last changed to PROCESSING. + changed_on: The date-time when the status of this job was last changed. + progress_message: The current message of the progress tracker. + progress_current: A value indicating how much progress has been made. + I.e. a value of 50 indicates that 50% of the work has been + completed if progress_total is 100. + progress_total: A value indicating the total amount of work to complete. + exception: The exception that needs to be thrown if the job fails. + error_message: A one-line error message when the job fails. + error_details: Full stack trace of the error when the job fails. + runtime_ms: The number of milliseconds from the start to completion of this job. + callers_context: Contextual information about a web service call. + """ + + state: Optional["AsynchronousJobState"] = None + """The state of the job. Either PROCESSING, FAILED, or COMPLETE.""" + + canceling: Optional[bool] = False + """Whether the job has been requested to be cancelled.""" + + request_body: Optional[dict] = None + """The body of an Asynchronous job request. Will be one of the models described here: + """ + + response_body: Optional[dict] = None + """The body of an Asynchronous job response. Will be one of the models described here: + """ + + etag: Optional[str] = None + """The etag of the job status. Changes whenever the status changes.""" + + id: Optional[str] = None + """The ID if the job issued when this job was started.""" + + started_by_user_id: Optional[int] = None + """The ID of the user that started the job.""" + + started_on: Optional[str] = None + """The date-time when the status was last changed to PROCESSING.""" + + changed_on: Optional[str] = None + """The date-time when the status of this job was last changed.""" + + progress_message: Optional[str] = None + """The current message of the progress tracker.""" + + progress_current: Optional[int] = None + """A value indicating how much progress has been made. + I.e. a value of 50 indicates that 50% of the work has been + completed if progress_total is 100.""" + + progress_total: Optional[int] = None + """A value indicating the total amount of work to complete.""" + + exception: Optional[str] = None + """The exception that needs to be thrown if the job fails.""" + + error_message: Optional[str] = None + """A one-line error message when the job fails.""" + + error_details: Optional[str] = None + """Full stack trace of the error when the job fails.""" + + runtime_ms: Optional[int] = None + """The number of milliseconds from the start to completion of this job.""" + + callers_context: Optional["CallersContext"] = None + """Contextual information about a web service call.""" + + def fill_from_dict(self, async_job_status: dict) -> "AsynchronousJobStatus": + """Converts a response from the REST API into this dataclass. + + Arguments: + async_job_status: The response from the REST API. + + Returns: + A AsynchronousJobStatus object. + """ + self.state = ( + AsynchronousJobState(async_job_status.get("jobState")) + if async_job_status.get("jobState") + else None + ) + self.canceling = async_job_status.get("jobCanceling", None) + self.request_body = async_job_status.get("requestBody", None) + self.response_body = async_job_status.get("responseBody", None) + self.etag = async_job_status.get("etag", None) + self.id = async_job_status.get("jobId", None) + self.started_by_user_id = async_job_status.get("startedByUserId", None) + self.started_on = async_job_status.get("startedOn", None) + self.changed_on = async_job_status.get("changedOn", None) + self.progress_message = async_job_status.get("progressMessage", None) + self.progress_current = async_job_status.get("progressCurrent", None) + self.progress_total = async_job_status.get("progressTotal", None) + self.exception = async_job_status.get("exception", None) + self.error_message = async_job_status.get("errorMessage", None) + self.error_details = async_job_status.get("errorDetails", None) + self.runtime_ms = async_job_status.get("runtimeMs", None) + self.callers_context = async_job_status.get("callersContext", None) + return self + + +async def send_job_and_wait_async( + request: Dict[str, Any], + request_type: str, + endpoint: str = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Sends the job to the Synapse API and waits for the response. Request body matches: + + + Arguments: + request: A request matching . + endpoint: The endpoint to use for the request. Defaults to None. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The response body matching + + + Raises: + SynapseError: If the job fails. + SynapseTimeoutError: If the job does not complete within the timeout. + """ + job_id = await send_job_async(request=request, synapse_client=synapse_client) + return { + "jobId": job_id, + **await get_job_async( + job_id=job_id, + request_type=request_type, + synapse_client=synapse_client, + endpoint=endpoint, + ), + } + + +async def send_job_async( + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> str: + """ + Sends the job to the Synapse API. Request body matches: + + Returns the job ID. + + Arguments: + request: A request matching . + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The job ID retrieved from the response. + + """ + if not request: + raise ValueError("request must be provided.") + + request_type = request.get("concreteType") + + if not request_type or request_type not in ASYNC_JOB_URIS: + raise ValueError(f"Unsupported request type: {request_type}") + + client = Synapse.get_client(synapse_client=synapse_client) + response = await client.rest_post_async( + uri=f"{ASYNC_JOB_URIS[request_type]}/start", body=json.dumps(request) + ) + return response["token"] + + +async def get_job_async( + job_id: str, + request_type: str, + endpoint: str = None, + sleep: int = 1, + timeout: int = 60, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """ + Gets the job from the server using its ID. Handles progress tracking, failures and timeouts. + + Arguments: + job_id: The ID of the job to get. + request_type: The type of the job. + endpoint: The endpoint to use for the request. Defaults to None. + sleep: The number of seconds to wait between requests. Defaults to 1. + timeout: The number of seconds to wait for the job to complete or progress + before raising a SynapseTimeoutError. Defaults to 60. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The response body matching + + + Raises: + SynapseError: If the job fails. + SynapseTimeoutError: If the job does not complete or progress within the timeout interval. + """ + client = Synapse.get_client(synapse_client=synapse_client) + start_time = asyncio.get_event_loop().time() + + last_message = "" + last_progress = 0 + last_total = 1 + progressed = False + + while asyncio.get_event_loop().time() - start_time < timeout: + result = await client.rest_get_async( + uri=f"{ASYNC_JOB_URIS[request_type]}/get/{job_id}", + endpoint=endpoint, + ) + job_status = AsynchronousJobStatus().fill_from_dict(async_job_status=result) + if job_status.state == AsynchronousJobState.PROCESSING: + progress_tracking = any( + [ + job_status.progress_message, + job_status.progress_current, + job_status.progress_total, + ] + ) + progressed = ( + job_status.progress_message != last_message + or last_progress != job_status.progress_current + ) + if progress_tracking and progressed: + last_message = job_status.progress_message + last_progress = job_status.progress_current + last_total = job_status.progress_total + + client._print_transfer_progress( + last_progress, + last_total, + prefix=last_message, + isBytes=False, + ) + start_time = asyncio.get_event_loop().time() + await asyncio.sleep(sleep) + elif job_status.state == AsynchronousJobState.FAILED: + raise SynapseError( + f"{job_status.error_message}\n{job_status.error_details}", + ) + else: + break + else: + raise SynapseTimeoutError( + f"Timeout waiting for query results: {time.time() - start_time} seconds" + ) + + return result diff --git a/synapseclient/models/mixins/storable_container.py b/synapseclient/models/mixins/storable_container.py index e1815aedb..667766263 100644 --- a/synapseclient/models/mixins/storable_container.py +++ b/synapseclient/models/mixins/storable_container.py @@ -686,6 +686,7 @@ async def _follow_link( or not (entity := entity_bundle.get("entity", None)) or not (links_to := entity.get("linksTo", None)) or not (link_class_name := entity.get("linksToClassName", None)) + or not (link_target_name := entity.get("name", None)) or not (link_target_id := links_to.get("targetId", None)) ): return @@ -693,6 +694,7 @@ async def _follow_link( pending_tasks = self._create_task_for_child( child={ "id": link_target_id, + "name": link_target_name, "type": link_class_name, }, recursive=recursive, diff --git a/synapseclient/models/protocols/agent_protocol.py b/synapseclient/models/protocols/agent_protocol.py new file mode 100644 index 000000000..bc729e5f9 --- /dev/null +++ b/synapseclient/models/protocols/agent_protocol.py @@ -0,0 +1,396 @@ +"""Protocol for the methods of the Agent and AgentSession classes that have +synchronous counterparts generated at runtime.""" + +from typing import TYPE_CHECKING, Optional, Protocol + +from synapseclient import Synapse + +if TYPE_CHECKING: + from synapseclient.models import ( + Agent, + AgentPrompt, + AgentSession, + AgentSessionAccessLevel, + ) + + +class AgentSessionSynchronousProtocol(Protocol): + """Protocol for the methods of the AgentSession class that have synchronous counterparts + generated at runtime.""" + + def start(self, *, synapse_client: Optional[Synapse] = None) -> "AgentSession": + """Starts an agent session. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The new AgentSession object. + + Example: Start a session and send a prompt. + Start a session with a custom agent by providing the agent's registration ID and calling `start()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + my_session = AgentSession(agent_registration_id="foo").start() + my_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return self + + def get(self, *, synapse_client: Optional[Synapse] = None) -> "AgentSession": + """Gets an agent session. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The retrieved AgentSession object. + + Example: Get an existing session and send a prompt. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + my_session = AgentSession(id="foo").get() + my_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return self + + def update(self, *, synapse_client: Optional[Synapse] = None) -> "AgentSession": + """Updates an agent session. + Only updates to the access level are currently supported. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The updated AgentSession object. + + Example: Update the access level of an existing session. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, update the access level of the session and call `update()`. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession, AgentSessionAccessLevel + + syn = Synapse() + syn.login() + + my_session = AgentSession(id="foo").get() + my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA + my_session.update() + """ + return self + + def prompt( + self, + prompt: str, + enable_trace: bool = False, + print_response: bool = False, + newer_than: Optional[int] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> "AgentPrompt": + """Sends a prompt to the agent and adds the response to the AgentSession's + chat history. A session must be started before sending a prompt. + + Arguments: + prompt: The prompt to send to the agent. + enable_trace: Whether to enable trace for the prompt. + print_response: Whether to print the response to the console. + newer_than: The timestamp to get trace results newer than. + Defaults to None (all results). + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Example: Send a prompt within an existing session. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import AgentSession + + syn = Synapse() + syn.login() + + my_session = AgentSession(id="foo").get() + my_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return AgentPrompt() + + +class AgentSynchronousProtocol(Protocol): + """Protocol for the methods of the Agent class that have synchronous counterparts + generated at runtime.""" + + def register(self, *, synapse_client: Optional[Synapse] = None) -> "Agent": + """Registers an agent with the Synapse API. + If agent already exists, it will be retrieved. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The registered or existing Agent object. + + Example: Register and chat with a custom agent + **Only available for internal users (Sage Bionetworks employees)** + + Alternatively, you can register a custom agent and chat with it provided + you have already created it. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent(cloud_agent_id="foo") + my_agent.register() + + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return self + + def get(self, *, synapse_client: Optional[Synapse] = None) -> "Agent": + """Gets an existing agent. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The existing Agent object. + + Example: Get and chat with an existing agent + Retrieve an existing agent by providing the agent's registration ID and calling `get()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent(registration_id="foo").get() + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return self + + def start_session( + self, + access_level: Optional["AgentSessionAccessLevel"] = "PUBLICLY_ACCESSIBLE", + *, + synapse_client: Optional[Synapse] = None, + ) -> "AgentSession": + """Starts an agent session. + Adds the session to the Agent's sessions dictionary and sets it as the current session. + + Arguments: + access_level: The access level of the agent session. + Must be one of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, + or WRITE_YOUR_PRIVATE_DATA. + Defaults to PUBLICLY_ACCESSIBLE. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The new AgentSession object. + + Example: Start a session and send a prompt with the baseline Synapse Agent. + The baseline Synapse Agent is the default agent used when a registration ID is not provided. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent() + my_agent.start_session() + my_agent.prompt( + prompt="Can you tell me about the AD Knowledge Portal dataset?", + enable_trace=True, + print_response=True, + ) + + Example: Start a session and send a prompt with a custom agent. + The baseline Synapse Agent is the default agent used when a registration ID is not provided. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent(cloud_agent_id="foo") + my_agent.start_session() + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return AgentSession() + + def get_session( + self, session_id: str, *, synapse_client: Optional[Synapse] = None + ) -> "AgentSession": + """Gets an existing agent session. + Adds the session to the Agent's sessions dictionary and + sets it as the current session. + + Arguments: + session_id: The ID of the session to get. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The existing AgentSession object. + + Example: Get an existing session and send a prompt. + Retrieve an existing session by providing the session ID and calling `get()`. + Then, send a prompt to the agent. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_session = Agent().get_session(session_id="foo") + my_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + """ + return AgentSession() + + def prompt( + self, + prompt: str, + enable_trace: bool = False, + print_response: bool = False, + session: Optional["AgentSession"] = None, + newer_than: Optional[int] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> "AgentPrompt": + """Sends a prompt to the agent for the current session. + If no session is currently active, a new session will be started. + + Arguments: + prompt: The prompt to send to the agent. + enable_trace: Whether to enable trace for the prompt. + print_response: Whether to print the response to the console. + session_id: The ID of the session to send the prompt to. + If None, the current session will be used. + newer_than: The timestamp to get trace results newer than. Defaults to None (all results). + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Example: Prompt the baseline Synapse Agent. + The baseline Synapse Agent is equivilent to the Agent available in the Synapse UI. + + from synapseclient import Synapse + from synapseclient.models import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent() + my_agent.prompt( + prompt="Can you tell me about the AD Knowledge Portal dataset?", + enable_trace=True, + print_response=True, + ) + + Example: Prompt a custom agent. + If you have already registered a custom agent, you can prompt it by providing the agent's registration ID. + + from synapseclient import Synapse + from synapseclient.models.agent import Agent + + syn = Synapse() + syn.login() + + my_agent = Agent(registration_id="foo") + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + Advanced Example: Start and prompt multiple sessions + Here, we connect to a custom agent and start one session with the prompt "Hello". + In the background, this first session is being set as the current session + and future prompts will be sent to this session by default. If we want to send a + prompt to a different session, we can do so by starting it and calling prompt again, + but with our new session as an argument. We now have two sessions, both stored in the + `my_agent.sessions` dictionary. After the second prompt, `my_second_session` is now + the current session. + + syn = Synapse() + syn.login() + + my_agent = Agent(registration_id="foo").get() + + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + ) + + my_second_session = my_agent.start_session() + my_agent.prompt( + prompt="Hello again", + enable_trace=True, + print_response=True, + session=my_second_session, + ) + """ + return AgentPrompt() diff --git a/synapseclient/models/services/storable_entity_components.py b/synapseclient/models/services/storable_entity_components.py index 8615cb9c9..8eafa5739 100644 --- a/synapseclient/models/services/storable_entity_components.py +++ b/synapseclient/models/services/storable_entity_components.py @@ -4,7 +4,6 @@ from synapseclient import Synapse from synapseclient.core.exceptions import SynapseError -from synapseclient.models import Annotations if TYPE_CHECKING: from synapseclient.models import File, Folder, Project, Table @@ -243,6 +242,8 @@ async def _store_activity_and_annotations( or last_persistent_instance.annotations != root_resource.annotations ) ): + from synapseclient.models import Annotations + result = await Annotations( id=root_resource.id, etag=root_resource.etag, diff --git a/synapseclient/synapsePythonClient b/synapseclient/synapsePythonClient index 3ccb1602e..5aeb673c5 100644 --- a/synapseclient/synapsePythonClient +++ b/synapseclient/synapsePythonClient @@ -1,6 +1,6 @@ { "client": "synapsePythonClient", - "latestVersion": "4.6.1", + "latestVersion": "4.7.0", "blacklist": [ "0.0.0", "0.4.1", diff --git a/tests/integration/synapseclient/models/async/test_agent_async.py b/tests/integration/synapseclient/models/async/test_agent_async.py new file mode 100644 index 000000000..dd7ef53e4 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_agent_async.py @@ -0,0 +1,228 @@ +"""Integration tests for the asynchronous methods of the AgentPrompt, AgentSession, and Agent classes.""" + +# These tests have been disabled until out `test` user has needed permissions +# Context: https://sagebionetworks.jira.com/browse/SYNPY-1544?focusedCommentId=235070 +# import pytest + +# from synapseclient import Synapse +# from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST +# from synapseclient.models.agent import ( +# Agent, +# AgentPrompt, +# AgentSession, +# AgentSessionAccessLevel, +# ) + +# # These are the ID values for a "Hello World" agent registered on Synapse. +# # The Bedrock agent is hosted on Sage Bionetworks AWS infrastructure. +# # CFN Template: +# # https://raw.githubusercontent.com/Sage-Bionetworks-Workflows/dpe-agents/refs/heads/main/client_integration_test/template.json +# AGENT_AWS_ID = "QOTV3KQM1X" +# AGENT_REGISTRATION_ID = "29" + + +# class TestAgentPrompt: +# """Integration tests for the synchronous methods of the AgentPrompt class.""" + +# @pytest.fixture(autouse=True, scope="function") +# def init(self, syn: Synapse) -> None: +# self.syn = syn + +# async def test_send_job_and_wait_async_with_post_exchange_args(self) -> None: +# # GIVEN an AgentPrompt with a valid concrete type, prompt, and enable_trace +# test_prompt = AgentPrompt( +# concrete_type=AGENT_CHAT_REQUEST, +# prompt="hello", +# enable_trace=True, +# ) +# # AND the ID of an existing agent session +# test_session = await AgentSession( +# agent_registration_id=AGENT_REGISTRATION_ID +# ).start_async(synapse_client=self.syn) +# test_prompt.session_id = test_session.id +# # WHEN I send the job and wait for it to complete +# await test_prompt.send_job_and_wait_async( +# post_exchange_args={"newer_than": 0}, +# synapse_client=self.syn, +# ) +# # THEN I expect the AgentPrompt to be updated with the response and trace +# assert test_prompt.response is not None +# assert test_prompt.trace is not None + + +# class TestAgentSession: +# """Integration tests for the synchronous methods of the AgentSession class.""" + +# @pytest.fixture(autouse=True, scope="function") +# def init(self, syn: Synapse) -> None: +# self.syn = syn + +# async def test_start(self) -> None: +# # GIVEN an agent session with a valid agent registration id +# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) + +# # WHEN the start method is called +# result_session = await agent_session.start_async(synapse_client=self.syn) + +# # THEN the result should be an AgentSession object +# # with expected attributes including an empty chat history +# assert result_session.id is not None +# assert ( +# result_session.access_level == AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE +# ) +# assert result_session.started_on is not None +# assert result_session.started_by is not None +# assert result_session.modified_on is not None +# assert result_session.agent_registration_id == AGENT_REGISTRATION_ID +# assert result_session.etag is not None +# assert result_session.chat_history == [] + +# async def test_get(self) -> None: +# # GIVEN an agent session with a valid agent registration id +# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) +# # WHEN I start a session +# await agent_session.start_async(synapse_client=self.syn) +# # THEN I expect to be able to get the session with its id +# new_session = await AgentSession(id=agent_session.id).get_async( +# synapse_client=self.syn +# ) +# assert new_session == agent_session + +# async def test_update(self) -> None: +# # GIVEN an agent session with a valid agent +# # registration id and access level set +# agent_session = AgentSession( +# agent_registration_id=AGENT_REGISTRATION_ID, +# access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, +# ) +# # WHEN I start a session +# await agent_session.start_async(synapse_client=self.syn) +# # AND I update the access level of the session +# agent_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA +# await agent_session.update_async(synapse_client=self.syn) +# # THEN I expect the access level to be updated +# updated_session = await AgentSession(id=agent_session.id).get_async( +# synapse_client=self.syn +# ) +# assert ( +# updated_session.access_level +# == AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA +# ) + +# async def test_prompt(self) -> None: +# # GIVEN an agent session with a valid agent registration id +# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) +# # WHEN I start a session +# await agent_session.start_async(synapse_client=self.syn) +# # THEN I expect to be able to prompt the agent +# await agent_session.prompt_async( +# prompt="hello", +# enable_trace=True, +# ) +# # AND I expect the chat history to be updated with the prompt and response +# assert len(agent_session.chat_history) == 1 +# assert agent_session.chat_history[0].prompt == "hello" +# assert agent_session.chat_history[0].response is not None +# assert agent_session.chat_history[0].trace is not None + + +# class TestAgent: +# """Integration tests for the synchronous methods of the Agent class.""" + +# def get_test_agent(self) -> Agent: +# return Agent( +# cloud_agent_id=AGENT_AWS_ID, +# cloud_alias_id="TSTALIASID", +# registration_id=AGENT_REGISTRATION_ID, +# registered_on="2025-01-16T18:57:35.680Z", +# type="CUSTOM", +# sessions={}, +# current_session=None, +# ) + +# @pytest.fixture(autouse=True, scope="function") +# def init(self, syn: Synapse) -> None: +# self.syn = syn + +# async def test_register(self) -> None: +# # GIVEN an Agent with a valid agent AWS id +# agent = Agent(cloud_agent_id=AGENT_AWS_ID) +# # WHEN I register the agent +# await agent.register_async(synapse_client=self.syn) +# # THEN I expect the agent to be registered +# expected_agent = self.get_test_agent() +# assert agent == expected_agent + +# async def test_get(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID) +# # WHEN I get the agent +# await agent.get_async(synapse_client=self.syn) +# # THEN I expect the agent to be returned +# expected_agent = self.get_test_agent() +# assert agent == expected_agent + +# async def test_get_no_registration_id(self) -> None: +# # GIVEN an Agent with no registration id +# agent = Agent() +# # WHEN I get the agent, I expect a ValueError to be raised +# with pytest.raises(ValueError, match="Registration ID is required"): +# await agent.get_async(synapse_client=self.syn) + +# async def test_start_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID) +# # WHEN I start a session +# await agent.start_session_async(synapse_client=self.syn) +# # THEN I expect a current session to be set +# assert agent.current_session is not None +# # AND I expect the session to be in the sessions dictionary +# assert agent.sessions[agent.current_session.id] == agent.current_session + +# async def test_get_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID) +# # WHEN I start a session +# await agent.start_session_async(synapse_client=self.syn) +# # THEN I expect to be able to get the session with its id +# existing_session = await agent.get_session_async( +# session_id=agent.current_session.id +# ) +# # AND I expect those sessions to be the same +# assert existing_session == agent.current_session + +# async def test_prompt_with_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = await Agent(registration_id=AGENT_REGISTRATION_ID).get_async( +# synapse_client=self.syn +# ) +# # AND a session started separately +# session = await AgentSession( +# agent_registration_id=AGENT_REGISTRATION_ID +# ).start_async(synapse_client=self.syn) +# # WHEN I prompt the agent with a session +# await agent.prompt_async(prompt="hello", enable_trace=True, session=session) +# test_session = agent.sessions[session.id] +# # THEN I expect the chat history to be updated with the prompt and response +# assert len(test_session.chat_history) == 1 +# assert test_session.chat_history[0].prompt == "hello" +# assert test_session.chat_history[0].response is not None +# assert test_session.chat_history[0].trace is not None +# # AND I expect the current session to be the session provided +# assert agent.current_session.id == session.id + +# async def test_prompt_no_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = await Agent(registration_id=AGENT_REGISTRATION_ID).get_async( +# synapse_client=self.syn +# ) +# # WHEN I prompt the agent without a current session set +# # and no session provided +# await agent.prompt_async(prompt="hello", enable_trace=True) +# # THEN I expect a new session to be started and set as the current session +# assert agent.current_session is not None +# # AND I expect the chat history to be updated with the prompt and response +# assert len(agent.current_session.chat_history) == 1 +# assert agent.current_session.chat_history[0].prompt == "hello" +# assert agent.current_session.chat_history[0].response is not None +# assert agent.current_session.chat_history[0].trace is not None diff --git a/tests/integration/synapseclient/models/synchronous/test_agent.py b/tests/integration/synapseclient/models/synchronous/test_agent.py new file mode 100644 index 000000000..07b77291e --- /dev/null +++ b/tests/integration/synapseclient/models/synchronous/test_agent.py @@ -0,0 +1,192 @@ +"""Integration tests for the synchronous methods of the AgentSession and Agent classes.""" + +# These tests have been disabled until out `test` user has needed permissions +# Context: https://sagebionetworks.jira.com/browse/SYNPY-1544?focusedCommentId=235070 +# import pytest + +# from synapseclient import Synapse +# from synapseclient.models.agent import Agent, AgentSession, AgentSessionAccessLevel + +# # These are the ID values for a "Hello World" agent registered on Synapse. +# # The Bedrock agent is hosted on Sage Bionetworks AWS infrastructure. +# # CFN Template: +# # https://raw.githubusercontent.com/Sage-Bionetworks-Workflows/dpe-agents/refs/heads/main/client_integration_test/template.json +# CLOUD_AGENT_ID = "QOTV3KQM1X" +# AGENT_REGISTRATION_ID = "29" + + +# class TestAgentSession: +# """Integration tests for the synchronous methods of the AgentSession class.""" + +# @pytest.fixture(autouse=True, scope="function") +# def init(self, syn: Synapse) -> None: +# self.syn = syn + +# async def test_start(self) -> None: +# # GIVEN an agent session with a valid agent registration id +# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) + +# # WHEN the start method is called +# result_session = agent_session.start(synapse_client=self.syn) + +# # THEN the result should be an AgentSession object +# # with expected attributes including an empty chat history +# assert result_session.id is not None +# assert ( +# result_session.access_level == AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE +# ) +# assert result_session.started_on is not None +# assert result_session.started_by is not None +# assert result_session.modified_on is not None +# assert result_session.agent_registration_id == str(AGENT_REGISTRATION_ID) +# assert result_session.etag is not None +# assert result_session.chat_history == [] + +# async def test_get(self) -> None: +# # GIVEN an agent session with a valid agent registration id +# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) +# # WHEN I start a session +# agent_session.start(synapse_client=self.syn) +# # THEN I expect to be able to get the session with its id +# new_session = AgentSession(id=agent_session.id).get(synapse_client=self.syn) +# assert new_session == agent_session + +# async def test_update(self) -> None: +# # GIVEN an agent session with a valid agent registration id and access level set +# agent_session = AgentSession( +# agent_registration_id=AGENT_REGISTRATION_ID, +# access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, +# ) +# # WHEN I start a session +# agent_session.start(synapse_client=self.syn) +# # AND I update the access level of the session +# agent_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA +# agent_session.update(synapse_client=self.syn) +# # THEN I expect the access level to be updated +# updated_session = AgentSession(id=agent_session.id).get(synapse_client=self.syn) +# assert ( +# updated_session.access_level +# == AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA +# ) + +# async def test_prompt(self) -> None: +# # GIVEN an agent session with a valid agent registration id +# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) +# # WHEN I start a session +# agent_session.start(synapse_client=self.syn) +# # THEN I expect to be able to prompt the agent +# agent_session.prompt( +# prompt="hello", +# enable_trace=True, +# ) +# # AND I expect the chat history to be updated with the prompt and response +# assert len(agent_session.chat_history) == 1 +# assert agent_session.chat_history[0].prompt == "hello" +# assert agent_session.chat_history[0].response is not None +# assert agent_session.chat_history[0].trace is not None + + +# class TestAgent: +# """Integration tests for the synchronous methods of the Agent class.""" + +# def get_test_agent(self) -> Agent: +# return Agent( +# cloud_agent_id=CLOUD_AGENT_ID, +# cloud_alias_id="TSTALIASID", +# registration_id=AGENT_REGISTRATION_ID, +# registered_on="2025-01-16T18:57:35.680Z", +# type="CUSTOM", +# sessions={}, +# current_session=None, +# ) + +# @pytest.fixture(autouse=True, scope="function") +# def init(self, syn: Synapse) -> None: +# self.syn = syn + +# async def test_register(self) -> None: +# # GIVEN an Agent with a valid agent AWS id +# agent = Agent(cloud_agent_id=CLOUD_AGENT_ID) +# # WHEN I register the agent +# agent.register(synapse_client=self.syn) +# # THEN I expect the agent to be registered +# expected_agent = self.get_test_agent() +# assert agent == expected_agent + +# async def test_get(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID) +# # WHEN I get the agent +# agent.get(synapse_client=self.syn) +# # THEN I expect the agent to be returned +# expected_agent = self.get_test_agent() +# assert agent == expected_agent + +# async def test_get_no_registration_id(self) -> None: +# # GIVEN an Agent with no registration id +# agent = Agent() +# # WHEN I get the agent, I expect a ValueError to be raised +# with pytest.raises(ValueError, match="Registration ID is required"): +# agent.get(synapse_client=self.syn) + +# async def test_start_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( +# synapse_client=self.syn +# ) +# # WHEN I start a session +# agent.start_session(synapse_client=self.syn) +# # THEN I expect a current session to be set +# assert agent.current_session is not None +# # AND I expect the session to be in the sessions dictionary +# assert agent.sessions[agent.current_session.id] == agent.current_session + +# async def test_get_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( +# synapse_client=self.syn +# ) +# # WHEN I start a session +# session = agent.start_session(synapse_client=self.syn) +# # THEN I expect to be able to get the session with its id +# existing_session = agent.get_session(session_id=session.id) +# # AND I expect those sessions to be the same +# assert existing_session == session +# # AND I expect it to be the current session +# assert existing_session == agent.current_session + +# async def test_prompt_with_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( +# synapse_client=self.syn +# ) +# # AND a session started separately +# session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID).start( +# synapse_client=self.syn +# ) +# # WHEN I prompt the agent with a session +# agent.prompt(prompt="hello", enable_trace=True, session=session) +# test_session = agent.sessions[session.id] +# # THEN I expect the chat history to be updated with the prompt and response +# assert len(test_session.chat_history) == 1 +# assert test_session.chat_history[0].prompt == "hello" +# assert test_session.chat_history[0].response is not None +# assert test_session.chat_history[0].trace is not None +# # AND I expect the current session to be the session provided +# assert agent.current_session.id == session.id + +# async def test_prompt_no_session(self) -> None: +# # GIVEN an Agent with a valid agent registration id +# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( +# synapse_client=self.syn +# ) +# # WHEN I prompt the agent without a current session set +# # and no session provided +# agent.prompt(prompt="hello", enable_trace=True) +# # THEN I expect a new session to be started and set as the current session +# assert agent.current_session is not None +# # AND I expect the chat history to be updated with the prompt and response +# assert len(agent.current_session.chat_history) == 1 +# assert agent.current_session.chat_history[0].prompt == "hello" +# assert agent.current_session.chat_history[0].response is not None +# assert agent.current_session.chat_history[0].trace is not None diff --git a/tests/integration/synapseutils/test_synapseutils_sync.py b/tests/integration/synapseutils/test_synapseutils_sync.py index e985ba37a..635d69761 100644 --- a/tests/integration/synapseutils/test_synapseutils_sync.py +++ b/tests/integration/synapseutils/test_synapseutils_sync.py @@ -1992,7 +1992,7 @@ async def test_folder_sync_from_synapse_files_spread_across_folders( assert pd.isna(matching_row[ACTIVITY_DESCRIPTION_COLUMN].values[0]) assert found_matching_file - async def test_sync_from_synapse_follow_links( + async def test_sync_from_synapse_follow_links_files( self, syn: Synapse, schedule_for_cleanup: Callable[..., None], @@ -2082,6 +2082,95 @@ async def test_sync_from_synapse_follow_links( assert pd.isna(matching_row[ACTIVITY_NAME_COLUMN].values[0]) assert pd.isna(matching_row[ACTIVITY_DESCRIPTION_COLUMN].values[0]) + async def test_sync_from_synapse_follow_links_folder( + self, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], + project_model: Project, + ) -> None: + """ + Testing for this case: + + project_model (root) + ├── folder_with_files + │ ├── file1 (uploaded) + │ └── file2 (uploaded) + └── folder_with_links - This is the folder we are syncing from + └── link_to_folder_with_files -> ../folder_with_files + """ + # GIVEN a folder + folder_with_files = await Folder( + name=str(uuid.uuid4()), parent_id=project_model.id + ).store_async() + schedule_for_cleanup(folder_with_files.id) + + # AND two files in the folder + temp_files = [utils.make_bogus_uuid_file() for _ in range(2)] + file_entities = [] + for file in temp_files: + schedule_for_cleanup(file) + file_entity = syn.store(SynapseFile(path=file, parent=folder_with_files.id)) + schedule_for_cleanup(file_entity["id"]) + file_entities.append(file_entity) + + # AND a second folder to sync from + folder_with_links = await Folder( + name=str(uuid.uuid4()), parent_id=project_model.id + ).store_async() + schedule_for_cleanup(folder_with_links.id) + + # AND a link to folder_with_files in folder_with_links + syn.store(obj=Link(targetId=folder_with_files.id, parent=folder_with_links.id)) + + # AND a temp directory to write the manifest file to + temp_dir = tempfile.mkdtemp() + + # WHEN I sync the parent folder from Synapse + sync_result = synapseutils.syncFromSynapse( + syn=syn, entity=folder_with_links.id, path=temp_dir, followLink=True + ) + + # THEN I expect that the result has all of the files + assert len(sync_result) == 2 + + # AND each of the files are the ones we uploaded + for file in sync_result: + assert file in file_entities + + # AND the manifest that is created matches the expected values + manifest_df = pd.read_csv(os.path.join(temp_dir, MANIFEST_FILE), sep="\t") + assert manifest_df.shape[0] == 2 + assert PATH_COLUMN in manifest_df.columns + assert PARENT_COLUMN in manifest_df.columns + assert USED_COLUMN in manifest_df.columns + assert EXECUTED_COLUMN in manifest_df.columns + assert ACTIVITY_NAME_COLUMN in manifest_df.columns + assert ACTIVITY_DESCRIPTION_COLUMN in manifest_df.columns + assert CONTENT_TYPE_COLUMN in manifest_df.columns + assert ID_COLUMN in manifest_df.columns + assert SYNAPSE_STORE_COLUMN in manifest_df.columns + assert NAME_COLUMN in manifest_df.columns + assert manifest_df.shape[1] == 10 + + for file in sync_result: + matching_row = manifest_df[manifest_df[PATH_COLUMN] == file[PATH_COLUMN]] + assert not matching_row.empty + assert matching_row[PARENT_COLUMN].values[0] == file[PARENT_ATTRIBUTE] + assert ( + matching_row[CONTENT_TYPE_COLUMN].values[0] == file[CONTENT_TYPE_COLUMN] + ) + assert matching_row[ID_COLUMN].values[0] == file[ID_COLUMN] + assert ( + matching_row[SYNAPSE_STORE_COLUMN].values[0] + == file[SYNAPSE_STORE_COLUMN] + ) + assert matching_row[NAME_COLUMN].values[0] == file[NAME_COLUMN] + + assert pd.isna(matching_row[USED_COLUMN].values[0]) + assert pd.isna(matching_row[EXECUTED_COLUMN].values[0]) + assert pd.isna(matching_row[ACTIVITY_NAME_COLUMN].values[0]) + assert pd.isna(matching_row[ACTIVITY_DESCRIPTION_COLUMN].values[0]) + async def test_sync_from_synapse_follow_links_sync_contains_all_folders( self, syn: Synapse, diff --git a/tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py b/tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py new file mode 100644 index 000000000..056976dcc --- /dev/null +++ b/tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py @@ -0,0 +1,278 @@ +"""Unit tests for Asynchronous Job logic.""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST +from synapseclient.core.exceptions import SynapseError, SynapseTimeoutError +from synapseclient.models.mixins.asynchronous_job import ( + ASYNC_JOB_URIS, + AsynchronousJobState, + AsynchronousJobStatus, + get_job_async, + send_job_and_wait_async, + send_job_async, +) + + +class TestSendJobAsync: + """Unit tests for send_job_async.""" + + good_request = {"concreteType": AGENT_CHAT_REQUEST} + bad_request_no_concrete_type = {"otherKey": "otherValue"} + bad_request_invalid_concrete_type = {"concreteType": "InvalidConcreteType"} + request_type = AGENT_CHAT_REQUEST + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_send_job_async_when_request_is_missing(self) -> None: + with pytest.raises(ValueError, match="request must be provided."): + # WHEN I call send_job_async without a request + # THEN I should get a ValueError + await send_job_async(request=None) + + async def test_send_job_async_when_request_is_missing_concrete_type(self) -> None: + with pytest.raises(ValueError, match="Unsupported request type: None"): + # GIVEN a request with no concrete type + # WHEN I call send_job_async + # THEN I should get a ValueError + await send_job_async(request=self.bad_request_no_concrete_type) + + async def test_send_job_async_when_request_is_invalid_concrete_type(self) -> None: + with pytest.raises( + ValueError, match="Unsupported request type: InvalidConcreteType" + ): + # GIVEN a request with an invalid concrete type + # WHEN I call send_job_async + # THEN I should get a ValueError + await send_job_async(request=self.bad_request_invalid_concrete_type) + + async def test_send_job_async_when_request_is_valid(self) -> None: + with ( + patch( + "synapseclient.Synapse.get_client", + return_value=self.syn, + ) as mock_get_client, + patch( + "synapseclient.Synapse.rest_post_async", + new_callable=AsyncMock, + return_value={"token": "123"}, + ) as mock_rest_post_async, + ): + # WHEN I call send_job_async with a good request + job_id = await send_job_async( + request=self.good_request, synapse_client=self.syn + ) + # THEN the return value should be the token + assert job_id == "123" + # AND get_client should have been called + mock_get_client.assert_called_once_with(synapse_client=self.syn) + # AND rest_post_async should have been called with the correct arguments + mock_rest_post_async.assert_called_once_with( + uri=f"{ASYNC_JOB_URIS[self.request_type]}/start", + body=json.dumps(self.good_request), + ) + + +class TestGetJobAsync: + """Unit tests for get_job_async.""" + + request_type = AGENT_CHAT_REQUEST + job_id = "123" + + processing_job_status = AsynchronousJobStatus( + state=AsynchronousJobState.PROCESSING, + progress_message="Processing", + progress_current=1, + progress_total=100, + ) + failed_job_status = AsynchronousJobStatus( + state=AsynchronousJobState.FAILED, + progress_message="Failed", + progress_current=1, + progress_total=100, + error_message="Error", + error_details="Details", + id="123", + ) + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_get_job_async_when_job_fails(self) -> None: + with ( + patch( + "synapseclient.Synapse.rest_get_async", + new_callable=AsyncMock, + return_value={}, + ) as mock_rest_get_async, + patch.object( + AsynchronousJobStatus, + "fill_from_dict", + return_value=self.failed_job_status, + ) as mock_fill_from_dict, + ): + with pytest.raises( + SynapseError, + match=( + f"{self.failed_job_status.error_message}\n" + f"{self.failed_job_status.error_details}" + ), + ): + # WHEN I call get_job_async + # AND the job fails in the Synapse API + # THEN I should get a SynapseError with the error message and details + await get_job_async( + job_id="123", + request_type=AGENT_CHAT_REQUEST, + synapse_client=self.syn, + sleep=1, + timeout=60, + endpoint=None, + ) + # AND rest_get_async should have been called once with the correct arguments + mock_rest_get_async.assert_called_once_with( + uri=f"{ASYNC_JOB_URIS[AGENT_CHAT_REQUEST]}/get/{self.job_id}", + endpoint=None, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + async_job_status=mock_rest_get_async.return_value, + ) + + async def test_get_job_async_when_job_times_out(self) -> None: + with ( + patch( + "synapseclient.Synapse.rest_get_async", + new_callable=AsyncMock, + return_value={}, + ) as mock_rest_get_async, + patch.object( + AsynchronousJobStatus, + "fill_from_dict", + return_value=self.processing_job_status, + ) as mock_fill_from_dict, + ): + with pytest.raises( + SynapseTimeoutError, match="Timeout waiting for query results:" + ): + # WHEN I call get_job_async + # AND the job does not complete or progress within the timeout interval + # THEN I should get a SynapseTimeoutError + await get_job_async( + job_id=self.job_id, + request_type=self.request_type, + synapse_client=self.syn, + endpoint=None, + timeout=0, + sleep=1, + ) + # AND rest_get_async should not have been called + mock_rest_get_async.assert_not_called() + # AND fill_from_dict should not have been called + mock_fill_from_dict.assert_not_called() + + +class TestSendJobAndWaitAsync: + """Unit tests for send_job_and_wait_async.""" + + good_request = {"concreteType": AGENT_CHAT_REQUEST} + job_id = "123" + request_type = AGENT_CHAT_REQUEST + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_send_job_and_wait_async(self) -> None: + with ( + patch( + "synapseclient.models.mixins.asynchronous_job.send_job_async", + new_callable=AsyncMock, + return_value=self.job_id, + ) as mock_send_job_async, + patch( + "synapseclient.models.mixins.asynchronous_job.get_job_async", + new_callable=AsyncMock, + return_value={ + "key": "value", + }, + ) as mock_get_job_async, + ): + # WHEN I call send_job_and_wait_async with a good request + # THEN the return value should be a dictionary with the job ID + # and response key value pair(s) + assert await send_job_and_wait_async( + request=self.good_request, + request_type=self.request_type, + synapse_client=self.syn, + endpoint=None, + ) == { + "jobId": self.job_id, + "key": "value", + } + # AND send_job_async should have been called once with the correct arguments + mock_send_job_async.assert_called_once_with( + request=self.good_request, + synapse_client=self.syn, + ) + # AND get_job_async should have been called once with the correct arguments + mock_get_job_async.assert_called_once_with( + job_id=self.job_id, + request_type=self.request_type, + synapse_client=self.syn, + endpoint=None, + ) + + +class TestAsynchronousJobStatus: + """Unit tests for AsynchronousJobStatus.""" + + def test_fill_from_dict(self) -> None: + # GIVEN a dictionary with job status information + async_job_status_dict = { + "jobState": AsynchronousJobState.PROCESSING, + "jobCanceling": False, + "requestBody": {"key": "value"}, + "responseBody": {"key": "value"}, + "etag": "123", + "jobId": "123", + "startedByUserId": "123", + "startedOn": "123", + "changedOn": "123", + "progressMessage": "Processing", + "progressCurrent": 1, + "progressTotal": 100, + "exception": None, + "errorMessage": None, + "errorDetails": None, + "runtimeMs": 1000, + "callersContext": None, + } + # WHEN I call fill_from_dict on it + async_job_status = AsynchronousJobStatus().fill_from_dict(async_job_status_dict) + # THEN the resulting AsynchronousJobStatus object + # should have the correct attribute values + assert async_job_status.state == AsynchronousJobState.PROCESSING + assert async_job_status.canceling is False + assert async_job_status.request_body == {"key": "value"} + assert async_job_status.response_body == {"key": "value"} + assert async_job_status.etag == "123" + assert async_job_status.id == "123" + assert async_job_status.started_by_user_id == "123" + assert async_job_status.started_on == "123" + assert async_job_status.changed_on == "123" + assert async_job_status.progress_message == "Processing" + assert async_job_status.progress_current == 1 + assert async_job_status.progress_total == 100 + assert async_job_status.exception is None + assert async_job_status.error_message is None + assert async_job_status.error_details is None + assert async_job_status.runtime_ms == 1000 + assert async_job_status.callers_context is None diff --git a/tests/unit/synapseclient/models/async/unit_test_agent_async.py b/tests/unit/synapseclient/models/async/unit_test_agent_async.py new file mode 100644 index 000000000..290094301 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_agent_async.py @@ -0,0 +1,703 @@ +"""Unit tests for Asynchronous methods in Agent, AgentSession, and AgentPrompt classes.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST +from synapseclient.models.agent import ( + Agent, + AgentPrompt, + AgentSession, + AgentSessionAccessLevel, + AgentType, +) + + +class TestAgentPrompt: + """Unit tests for the AgentPrompt class' asynchronous methods.""" + + agent_prompt = AgentPrompt( + id="123", + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=True, + ) + synapse_request = { + "concreteType": agent_prompt.concrete_type, + "sessionId": agent_prompt.session_id, + "chatText": agent_prompt.prompt, + "enableTrace": agent_prompt.enable_trace, + } + synapse_response = { + "jobId": "123", + "sessionId": "456", + "responseText": "World", + } + trace_response = { + "page": [ + { + "message": "I'm a robot", + } + ] + } + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_to_synapse_request(self): + # WHEN I call to_synapse_request on an initialized AgentPrompt + result = self.agent_prompt.to_synapse_request() + # THEN the result should be a dictionary with the correct keys and values + assert result == { + "concreteType": self.agent_prompt.concrete_type, + "sessionId": self.agent_prompt.session_id, + "chatText": self.agent_prompt.prompt, + "enableTrace": self.agent_prompt.enable_trace, + } + + async def test_fill_from_dict(self): + # WHEN I call fill_from_dict on an initialized AgentPrompt with a synapse_response + result_agent_prompt = self.agent_prompt.fill_from_dict(self.synapse_response) + # THEN the result should be an AgentPrompt with the correct values + assert result_agent_prompt.id == self.synapse_response["jobId"] + assert result_agent_prompt.session_id == self.synapse_response["sessionId"] + assert result_agent_prompt.response == self.synapse_response["responseText"] + + async def test_post_exchange_async_trace_enabled(self): + with patch( + "synapseclient.models.agent.get_trace", + new_callable=AsyncMock, + return_value=self.trace_response, + ) as mock_get_trace: + # WHEN I call _post_exchange_async on an + # initialized AgentPrompt with enable_trace=True + await self.agent_prompt._post_exchange_async(synapse_client=self.syn) + # THEN the mock_get_trace should have been called with the correct arguments + mock_get_trace.assert_called_once_with( + prompt_id=self.agent_prompt.id, + newer_than=None, + synapse_client=self.syn, + ) + # AND the trace should be set to the response from the mock_get_trace + assert self.agent_prompt.trace == self.trace_response["page"][0]["message"] + + async def test_post_exchange_async_trace_disabled(self): + with patch( + "synapseclient.models.agent.get_trace", + new_callable=AsyncMock, + return_value=self.trace_response, + ) as mock_get_trace: + self.agent_prompt.enable_trace = False + # WHEN I call _post_exchange_async on an + # initialized AgentPrompt with enable_trace=False + await self.agent_prompt._post_exchange_async(synapse_client=self.syn) + # THEN the mock_get_trace should not have been called + mock_get_trace.assert_not_called() + + async def test_send_job_and_wait_async(self): + with ( + patch( + "synapseclient.models.mixins.asynchronous_job.send_job_and_wait_async", + new_callable=AsyncMock, + return_value=self.synapse_response, + ) as mock_send_job_and_wait_async, + patch.object( + self.agent_prompt, + "to_synapse_request", + return_value=self.synapse_request, + ) as mock_to_synapse_request, + patch.object( + self.agent_prompt, + "fill_from_dict", + ) as mock_fill_from_dict, + patch.object( + self.agent_prompt, + "_post_exchange_async", + new_callable=AsyncMock, + ) as mock_post_exchange_async, + ): + # WHEN I call send_job_and_wait_async on an initialized AgentPrompt + await self.agent_prompt.send_job_and_wait_async( + post_exchange_args={"foo": "bar"}, synapse_client=self.syn + ) + # THEN the mock_send_job_and_wait_async should + # have been called with the correct arguments + mock_send_job_and_wait_async.assert_called_once_with( + request=mock_to_synapse_request.return_value, + request_type=self.agent_prompt.concrete_type, + synapse_client=self.syn, + ) + # THEN the mock_fill_from_dict should have been called with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_response=self.synapse_response + ) + # AND the mock_post_exchange_async should have been called with the correct arguments + mock_post_exchange_async.assert_called_once_with( + synapse_client=self.syn, **{"foo": "bar"} + ) + + +class TestAgentSession: + """Unit tests for the AgentSession class' synchronous methods.""" + + test_session = AgentSession( + id="123", + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + started_on="2024-01-01T00:00:00Z", + started_by="123456789", + modified_on="2024-01-01T00:00:00Z", + agent_registration_id="0", + etag="11111111-1111-1111-1111-111111111111", + ) + + session_response = { + "sessionId": test_session.id, + "agentAccessLevel": test_session.access_level, + "startedOn": test_session.started_on, + "startedBy": test_session.started_by, + "modifiedOn": test_session.modified_on, + "agentRegistrationId": test_session.agent_registration_id, + "etag": test_session.etag, + } + + updated_test_session = AgentSession( + id=test_session.id, + access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, + started_on=test_session.started_on, + started_by=test_session.started_by, + modified_on=test_session.modified_on, + agent_registration_id=test_session.agent_registration_id, + etag=test_session.etag, + ) + + updated_session_response = { + "sessionId": updated_test_session.id, + "agentAccessLevel": updated_test_session.access_level, + "startedOn": updated_test_session.started_on, + "startedBy": updated_test_session.started_by, + "modifiedOn": updated_test_session.modified_on, + "agentRegistrationId": updated_test_session.agent_registration_id, + "etag": updated_test_session.etag, + } + + test_prompt_trace_enabled = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=True, + response="World", + trace="Trace", + ) + + test_prompt_trace_disabled = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=False, + response="World", + trace=None, + ) + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_fill_from_dict(self) -> None: + # WHEN I call fill_from_dict on an empty AgentSession with a synapse_response + result_session = AgentSession().fill_from_dict(self.session_response) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + + async def test_start_async(self) -> None: + with ( + patch( + "synapseclient.models.agent.start_session", + new_callable=AsyncMock, + return_value=self.session_response, + ) as mock_start_session, + patch.object( + AgentSession, + "fill_from_dict", + return_value=self.test_session, + ) as mock_fill_from_dict, + ): + # GIVEN an AgentSession with access_level and agent_registration_id + initial_session = AgentSession( + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + agent_registration_id=0, + ) + # WHEN I call start + result_session = await initial_session.start_async(synapse_client=self.syn) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND start_session should have been called once with the correct arguments + mock_start_session.assert_called_once_with( + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + agent_registration_id=0, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_agent_session=self.session_response + ) + + async def test_get_async(self) -> None: + with ( + patch( + "synapseclient.models.agent.get_session", + new_callable=AsyncMock, + return_value=self.session_response, + ) as mock_get_session, + patch.object( + AgentSession, + "fill_from_dict", + return_value=self.test_session, + ) as mock_fill_from_dict, + ): + # GIVEN an AgentSession with an agent_registration_id + initial_session = AgentSession( + agent_registration_id=0, + ) + # WHEN I call get + result_session = await initial_session.get_async(synapse_client=self.syn) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND get_session should have been called once with the correct arguments + mock_get_session.assert_called_once_with( + id=initial_session.id, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_agent_session=self.session_response + ) + + async def test_update_async(self) -> None: + with ( + patch( + "synapseclient.models.agent.update_session", + new_callable=AsyncMock, + return_value=self.updated_session_response, + ) as mock_update_session, + patch.object( + AgentSession, + "fill_from_dict", + return_value=self.updated_test_session, + ) as mock_fill_from_dict, + ): + # GIVEN an AgentSession with an updated access_level + # WHEN I call update + result_session = await self.updated_test_session.update_async( + synapse_client=self.syn + ) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.updated_test_session + # AND update_session should have been called once with the correct arguments + mock_update_session.assert_called_once_with( + id=self.updated_test_session.id, + access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_agent_session=self.updated_session_response + ) + + async def test_prompt_trace_enabled_print_response(self) -> None: + with ( + patch( + "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", + new_callable=AsyncMock, + return_value=self.test_prompt_trace_enabled, + ) as mock_send_job_and_wait_async, + patch.object( + self.syn.logger, + "info", + ) as mock_logger_info, + ): + # GIVEN an existing AgentSession + # WHEN I call prompt with trace enabled and print_response enabled + await self.test_session.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + newer_than=0, + synapse_client=self.syn, + ) + # THEN the result should be an AgentPrompt with the correct + # values appended to the chat history + assert self.test_prompt_trace_enabled in self.test_session.chat_history + # AND send_job_and_wait_async should have + # been called once with the correct arguments + mock_send_job_and_wait_async.assert_called_once_with( + synapse_client=self.syn, post_exchange_args={"newer_than": 0} + ) + # AND the trace should be printed + mock_logger_info.assert_called_with( + f"TRACE:\n{self.test_prompt_trace_enabled.trace}" + ) + + async def test_prompt_trace_disabled_no_print(self) -> None: + with ( + patch( + "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", + new_callable=AsyncMock, + return_value=self.test_prompt_trace_disabled, + ) as mock_send_job_and_wait_async, + patch.object( + self.syn.logger, + "info", + ) as mock_logger_info, + ): + # WHEN I call prompt with trace disabled and print_response disabled + await self.test_session.prompt_async( + prompt="Hello", + enable_trace=False, + print_response=False, + newer_than=0, + synapse_client=self.syn, + ) + # THEN the result should be an AgentPrompt with the + # correct values appended to the chat history + assert self.test_prompt_trace_disabled in self.test_session.chat_history + # AND send_job_and_wait_async should have been + # called once with the correct arguments + mock_send_job_and_wait_async.assert_called_once_with( + synapse_client=self.syn, post_exchange_args={"newer_than": 0} + ) + # AND print should not have been called + mock_logger_info.assert_not_called() + + +class TestAgent: + """Unit tests for the Agent class' synchronous methods.""" + + def get_example_agent(self) -> Agent: + return Agent( + cloud_agent_id="123", + cloud_alias_id="456", + registration_id=0, + type=AgentType.BASELINE, + registered_on="2024-01-01T00:00:00Z", + sessions={}, + current_session=None, + ) + + test_agent = Agent( + cloud_agent_id="123", + cloud_alias_id="456", + registration_id=0, + type=AgentType.BASELINE, + registered_on="2024-01-01T00:00:00Z", + sessions={}, + current_session=None, + ) + + agent_response = { + "awsAgentId": test_agent.cloud_agent_id, + "awsAliasId": test_agent.cloud_alias_id, + "agentRegistrationId": test_agent.registration_id, + "registeredOn": test_agent.registered_on, + "type": test_agent.type, + } + + test_session = AgentSession( + id="123", + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + started_on="2024-01-01T00:00:00Z", + started_by="123456789", + modified_on="2024-01-01T00:00:00Z", + agent_registration_id="0", + etag="11111111-1111-1111-1111-111111111111", + ) + + test_prompt = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=True, + response="World", + trace="Trace", + ) + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_fill_from_dict(self) -> None: + # GIVEN an empty Agent + empty_agent = Agent() + # WHEN I call fill_from_dict on an empty Agent with a synapse_response + result_agent = empty_agent.fill_from_dict( + agent_registration=self.agent_response + ) + # THEN the result should be an Agent with the correct values + assert result_agent == self.test_agent + + async def test_register_async(self) -> None: + with ( + patch( + "synapseclient.models.agent.register_agent", + new_callable=AsyncMock, + return_value=self.agent_response, + ) as mock_register_agent, + patch.object( + Agent, + "fill_from_dict", + return_value=self.test_agent, + ) as mock_fill_from_dict, + ): + # GIVEN an Agent with a cloud_agent_id + initial_agent = Agent( + cloud_agent_id="123", + cloud_alias_id="456", + ) + # WHEN I call register + result_agent = await initial_agent.register_async(synapse_client=self.syn) + # THEN the result should be an Agent with the correct values + assert result_agent == self.test_agent + # AND register_agent should have been called once with the correct arguments + mock_register_agent.assert_called_once_with( + cloud_agent_id="123", + cloud_alias_id="456", + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + agent_registration=self.agent_response + ) + + async def test_get_async(self) -> None: + with ( + patch( + "synapseclient.models.agent.get_agent", + new_callable=AsyncMock, + return_value=self.agent_response, + ) as mock_get_agent, + patch.object( + Agent, + "fill_from_dict", + return_value=self.test_agent, + ) as mock_fill_from_dict, + ): + # GIVEN an Agent with a registration_id + initial_agent = Agent( + registration_id=0, + ) + # WHEN I call get + result_agent = await initial_agent.get_async(synapse_client=self.syn) + # THEN the result should be an Agent with the correct values + assert result_agent == self.test_agent + # AND get_agent should have been called once with the correct arguments + mock_get_agent.assert_called_once_with( + registration_id=0, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + agent_registration=self.agent_response + ) + + async def test_start_session_async(self) -> None: + with ( + patch.object( + AgentSession, + "start_async", + new_callable=AsyncMock, + return_value=self.test_session, + ) as mock_start_session, + ): + # GIVEN an existing Agent + my_agent = self.get_example_agent() + # WHEN I call start_session + result_session = await my_agent.start_session_async( + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + synapse_client=self.syn, + ) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND start_session should have been called once with the correct arguments + mock_start_session.assert_called_once_with( + synapse_client=self.syn, + ) + # AND the current_session should be set to the new session + assert my_agent.current_session == self.test_session + # AND the sessions dictionary should have the new session + assert my_agent.sessions[self.test_session.id] == self.test_session + + async def test_get_session_async(self) -> None: + with ( + patch.object( + AgentSession, + "get_async", + new_callable=AsyncMock, + return_value=self.test_session, + ) as mock_get_session, + ): + # GIVEN an existing AgentSession + my_agent = self.get_example_agent() + # WHEN I call get_session + result_session = await my_agent.get_session_async( + session_id="123", synapse_client=self.syn + ) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND get_session should have been called once with the correct arguments + mock_get_session.assert_called_once_with( + synapse_client=self.syn, + ) + # AND the current_session should be set to the session + assert my_agent.current_session == self.test_session + # AND the sessions dictionary should have the session + assert my_agent.sessions[self.test_session.id] == self.test_session + + async def test_prompt_session_selected(self) -> None: + with ( + patch.object( + AgentSession, + "get_async", + new_callable=AsyncMock, + return_value=self.test_session, + ) as mock_get_async, + patch.object( + Agent, + "start_session_async", + new_callable=AsyncMock, + ) as mock_start_session, + patch.object( + AgentSession, + "prompt_async", + ) as mock_prompt_async, + ): + # GIVEN an existing AgentSession + my_agent = self.get_example_agent() + # WHEN I call prompt with a session selected + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + session=self.test_session, + newer_than=0, + synapse_client=self.syn, + ) + # AND get_session_async should have been called once with the correct arguments + mock_get_async.assert_called_once_with( + synapse_client=self.syn, + ) + # AND start_session_async should not have been called + mock_start_session.assert_not_called() + # AND prompt_async should have been called once with the correct arguments + mock_prompt_async.assert_called_once_with( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + + async def test_prompt_session_none_current_session_none(self) -> None: + with ( + patch.object( + Agent, + "get_session_async", + new_callable=AsyncMock, + ) as mock_get_session, + patch.object( + AgentSession, + "start_async", + new_callable=AsyncMock, + return_value=self.test_session, + ) as mock_start_async, + patch.object( + AgentSession, + "prompt_async", + ) as mock_prompt_async, + ): + # GIVEN an existing Agent with no current session + my_agent = self.get_example_agent() + # WHEN I call prompt with no session selected and no current session set + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + print_response=True, + newer_than=0, + synapse_client=self.syn, + ) + # THEN get_session_async should not have been called + mock_get_session.assert_not_called() + # AND start_session_async should have been called once with the correct arguments + mock_start_async.assert_called_once_with( + synapse_client=self.syn, + ) + # AND prompt_async should have been called once with the correct arguments + mock_prompt_async.assert_called_once_with( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + + async def test_prompt_session_none_current_session_present(self) -> None: + with ( + patch.object( + Agent, + "get_session_async", + new_callable=AsyncMock, + ) as mock_get_session, + patch.object( + AgentSession, + "start_async", + new_callable=AsyncMock, + ) as mock_start_async, + patch.object( + AgentSession, + "prompt_async", + ) as mock_prompt_async, + ): + # GIVEN an existing Agent with a current session + my_agent = self.get_example_agent() + my_agent.current_session = self.test_session + # WHEN I call prompt with no session selected and a current session set + await my_agent.prompt_async( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + # THEN get_session_async and start_session_async should not have been called + mock_get_session.assert_not_called() + mock_start_async.assert_not_called() + # AND prompt_async should have been called once with the correct arguments + mock_prompt_async.assert_called_once_with( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + + async def test_get_chat_history_when_current_session_none(self) -> None: + # GIVEN an existing Agent with no current session + my_agent = self.get_example_agent() + # WHEN I call get_chat_history + result_chat_history = my_agent.get_chat_history() + # THEN the result should be None + assert result_chat_history is None + + async def test_get_chat_history_when_current_session_and_chat_history_present( + self, + ) -> None: + # GIVEN an existing Agent with a current session and chat history + my_agent = self.get_example_agent() + my_agent.current_session = self.test_session + my_agent.current_session.chat_history = [self.test_prompt] + # WHEN I call get_chat_history + result_chat_history = my_agent.get_chat_history() + # THEN the result should be the chat history + assert self.test_prompt in result_chat_history diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_agent.py b/tests/unit/synapseclient/models/synchronous/unit_test_agent.py new file mode 100644 index 000000000..83f33cb7b --- /dev/null +++ b/tests/unit/synapseclient/models/synchronous/unit_test_agent.py @@ -0,0 +1,588 @@ +"""Unit tests for Synchronous methods in Agent, AgentSession, and AgentPrompt classes.""" + +from unittest.mock import patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST +from synapseclient.models.agent import ( + Agent, + AgentPrompt, + AgentSession, + AgentSessionAccessLevel, + AgentType, +) + + +class TestAgentPrompt: + """Unit tests for the AgentPrompt class' synchronous methods.""" + + test_prompt = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=True, + ) + prompt_request = { + "concreteType": test_prompt.concrete_type, + "sessionId": test_prompt.session_id, + "chatText": test_prompt.prompt, + "enableTrace": test_prompt.enable_trace, + } + prompt_response = { + "jobId": "123", + "sessionId": "456", + "responseText": "World", + } + + def test_to_synapse_request(self) -> None: + # GIVEN an existing AgentPrompt + # WHEN I call to_synapse_request + result_request = self.test_prompt.to_synapse_request() + # THEN the result should be a dictionary with the correct keys and values + assert result_request == self.prompt_request + + def test_fill_from_dict(self) -> None: + # GIVEN an existing AgentPrompt + # WHEN I call fill_from_dict + result_prompt = self.test_prompt.fill_from_dict(self.prompt_response) + # THEN the result should be an AgentPrompt with the correct values + assert result_prompt == self.test_prompt + + +class TestAgentSession: + """Unit tests for the AgentSession class' synchronous methods.""" + + test_session = AgentSession( + id="123", + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + started_on="2024-01-01T00:00:00Z", + started_by="123456789", + modified_on="2024-01-01T00:00:00Z", + agent_registration_id="0", + etag="11111111-1111-1111-1111-111111111111", + ) + + session_response = { + "sessionId": test_session.id, + "agentAccessLevel": test_session.access_level, + "startedOn": test_session.started_on, + "startedBy": test_session.started_by, + "modifiedOn": test_session.modified_on, + "agentRegistrationId": test_session.agent_registration_id, + "etag": test_session.etag, + } + + updated_test_session = AgentSession( + id=test_session.id, + access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, + started_on=test_session.started_on, + started_by=test_session.started_by, + modified_on=test_session.modified_on, + agent_registration_id=test_session.agent_registration_id, + etag=test_session.etag, + ) + + updated_session_response = { + "sessionId": updated_test_session.id, + "agentAccessLevel": updated_test_session.access_level, + "startedOn": updated_test_session.started_on, + "startedBy": updated_test_session.started_by, + "modifiedOn": updated_test_session.modified_on, + "agentRegistrationId": updated_test_session.agent_registration_id, + "etag": updated_test_session.etag, + } + + test_prompt_trace_enabled = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=True, + response="World", + trace="Trace", + ) + + test_prompt_trace_disabled = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=False, + response="World", + trace=None, + ) + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict(self) -> None: + # WHEN I call fill_from_dict on an empty AgentSession with a synapse_response + result_session = AgentSession().fill_from_dict(self.session_response) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + + def test_start(self) -> None: + with ( + patch( + "synapseclient.models.agent.start_session", + return_value=self.session_response, + ) as mock_start_session, + patch.object( + AgentSession, + "fill_from_dict", + return_value=self.test_session, + ) as mock_fill_from_dict, + ): + # GIVEN an AgentSession with access_level and agent_registration_id + initial_session = AgentSession( + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + agent_registration_id=0, + ) + # WHEN I call start + result_session = initial_session.start(synapse_client=self.syn) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND start_session should have been called once with the correct arguments + mock_start_session.assert_called_once_with( + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + agent_registration_id=0, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_agent_session=self.session_response + ) + + def test_get(self) -> None: + with ( + patch( + "synapseclient.models.agent.get_session", + return_value=self.session_response, + ) as mock_get_session, + patch.object( + AgentSession, + "fill_from_dict", + return_value=self.test_session, + ) as mock_fill_from_dict, + ): + # GIVEN an AgentSession with an agent_registration_id + initial_session = AgentSession( + agent_registration_id=0, + ) + # WHEN I call get + result_session = initial_session.get(synapse_client=self.syn) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND get_session should have been called once with the correct arguments + mock_get_session.assert_called_once_with( + id=initial_session.id, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_agent_session=self.session_response + ) + + def test_update(self) -> None: + with ( + patch( + "synapseclient.models.agent.update_session", + return_value=self.updated_session_response, + ) as mock_update_session, + patch.object( + AgentSession, + "fill_from_dict", + return_value=self.updated_test_session, + ) as mock_fill_from_dict, + ): + # GIVEN an AgentSession with an updated access_level + # WHEN I call update + result_session = self.updated_test_session.update(synapse_client=self.syn) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.updated_test_session + # AND update_session should have been called once with the correct arguments + mock_update_session.assert_called_once_with( + id=self.updated_test_session.id, + access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + synapse_agent_session=self.updated_session_response + ) + + def test_prompt_trace_enabled_print_response(self) -> None: + with ( + patch( + "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", + return_value=self.test_prompt_trace_enabled, + ) as mock_send_job_and_wait_async, + patch.object( + self.syn.logger, + "info", + ) as mock_logger_info, + ): + # GIVEN an existing AgentSession + # WHEN I call prompt with trace enabled and print_response enabled + self.test_session.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + newer_than=0, + synapse_client=self.syn, + ) + # THEN the result should be an AgentPrompt with the correct values appended to the chat history + assert self.test_prompt_trace_enabled in self.test_session.chat_history + # AND send_job_and_wait_async should have been called once with the correct arguments + mock_send_job_and_wait_async.assert_called_once_with( + synapse_client=self.syn, post_exchange_args={"newer_than": 0} + ) + # AND the trace should be printed + mock_logger_info.assert_called_with( + f"TRACE:\n{self.test_prompt_trace_enabled.trace}" + ) + + def test_prompt_trace_disabled_no_print(self) -> None: + with ( + patch( + "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", + return_value=self.test_prompt_trace_disabled, + ) as mock_send_job_and_wait_async, + patch.object( + self.syn.logger, + "info", + ) as mock_logger_info, + ): + # WHEN I call prompt with trace disabled and print_response disabled + self.test_session.prompt( + prompt="Hello", + enable_trace=False, + print_response=False, + newer_than=0, + synapse_client=self.syn, + ) + # THEN the result should be an AgentPrompt with the correct values appended to the chat history + assert self.test_prompt_trace_disabled in self.test_session.chat_history + # AND send_job_and_wait_async should have been called once with the correct arguments + mock_send_job_and_wait_async.assert_called_once_with( + synapse_client=self.syn, post_exchange_args={"newer_than": 0} + ) + # AND print should not have been called + mock_logger_info.assert_not_called() + + +class TestAgent: + """Unit tests for the Agent class' synchronous methods.""" + + def get_example_agent(self) -> Agent: + return Agent( + cloud_agent_id="123", + cloud_alias_id="456", + registration_id=0, + type=AgentType.BASELINE, + registered_on="2024-01-01T00:00:00Z", + sessions={}, + current_session=None, + ) + + test_agent = Agent( + cloud_agent_id="123", + cloud_alias_id="456", + registration_id=0, + type=AgentType.BASELINE, + registered_on="2024-01-01T00:00:00Z", + sessions={}, + current_session=None, + ) + + agent_response = { + "awsAgentId": test_agent.cloud_agent_id, + "awsAliasId": test_agent.cloud_alias_id, + "agentRegistrationId": test_agent.registration_id, + "registeredOn": test_agent.registered_on, + "type": test_agent.type, + } + + test_session = AgentSession( + id="123", + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + started_on="2024-01-01T00:00:00Z", + started_by="123456789", + modified_on="2024-01-01T00:00:00Z", + agent_registration_id="0", + etag="11111111-1111-1111-1111-111111111111", + ) + + test_prompt = AgentPrompt( + concrete_type=AGENT_CHAT_REQUEST, + session_id="456", + prompt="Hello", + enable_trace=True, + response="World", + trace="Trace", + ) + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict(self) -> None: + # GIVEN an empty Agent + empty_agent = Agent() + # WHEN I call fill_from_dict on an empty Agent with a synapse_response + result_agent = empty_agent.fill_from_dict( + agent_registration=self.agent_response + ) + # THEN the result should be an Agent with the correct values + assert result_agent == self.test_agent + + def test_register(self) -> None: + with ( + patch( + "synapseclient.models.agent.register_agent", + return_value=self.agent_response, + ) as mock_register_agent, + patch.object( + Agent, + "fill_from_dict", + return_value=self.test_agent, + ) as mock_fill_from_dict, + ): + # GIVEN an Agent with a cloud_agent_id + initial_agent = Agent( + cloud_agent_id="123", + cloud_alias_id="456", + ) + # WHEN I call register + result_agent = initial_agent.register(synapse_client=self.syn) + # THEN the result should be an Agent with the correct values + assert result_agent == self.test_agent + # AND register_agent should have been called once with the correct arguments + mock_register_agent.assert_called_once_with( + cloud_agent_id="123", + cloud_alias_id="456", + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + agent_registration=self.agent_response + ) + + def test_get(self) -> None: + with ( + patch( + "synapseclient.models.agent.get_agent", + return_value=self.agent_response, + ) as mock_get_agent, + patch.object( + Agent, + "fill_from_dict", + return_value=self.test_agent, + ) as mock_fill_from_dict, + ): + # GIVEN an Agent with a registration_id + initial_agent = Agent( + registration_id=0, + ) + # WHEN I call get + result_agent = initial_agent.get(synapse_client=self.syn) + # THEN the result should be an Agent with the correct values + assert result_agent == self.test_agent + # AND get_agent should have been called once with the correct arguments + mock_get_agent.assert_called_once_with( + registration_id=0, + synapse_client=self.syn, + ) + # AND fill_from_dict should have been called once with the correct arguments + mock_fill_from_dict.assert_called_once_with( + agent_registration=self.agent_response + ) + + def test_start_session(self) -> None: + with patch.object( + AgentSession, + "start_async", + return_value=self.test_session, + ) as mock_start_session: + # GIVEN an existing Agent + my_agent = self.get_example_agent() + # WHEN I call start_session + result_session = my_agent.start_session( + access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, + synapse_client=self.syn, + ) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND start_session should have been called once with the correct arguments + mock_start_session.assert_called_once_with( + synapse_client=self.syn, + ) + # AND the current_session should be set to the new session + assert my_agent.current_session == self.test_session + # AND the sessions dictionary should have the new session + assert my_agent.sessions[self.test_session.id] == self.test_session + + def test_get_session(self) -> None: + with patch.object( + AgentSession, + "get_async", + return_value=self.test_session, + ) as mock_get_session: + # GIVEN an existing AgentSession + my_agent = self.get_example_agent() + # WHEN I call get_session + result_session = my_agent.get_session( + session_id="123", synapse_client=self.syn + ) + # THEN the result should be an AgentSession with the correct values + assert result_session == self.test_session + # AND get_session should have been called once with the correct arguments + mock_get_session.assert_called_once_with( + synapse_client=self.syn, + ) + # AND the current_session should be set to the session + assert my_agent.current_session == self.test_session + # AND the sessions dictionary should have the session + assert my_agent.sessions[self.test_session.id] == self.test_session + + def test_prompt_session_selected(self) -> None: + with ( + patch.object( + AgentSession, + "get_async", + return_value=self.test_session, + ) as mock_get_async, + patch.object( + Agent, + "start_session_async", + ) as mock_start_session, + patch.object( + AgentSession, + "prompt_async", + ) as mock_prompt_async, + ): + # GIVEN an existing AgentSession + my_agent = self.get_example_agent() + # WHEN I call prompt with a session selected + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + session=self.test_session, + newer_than=0, + synapse_client=self.syn, + ) + # AND get_session_async should have been called once with the correct arguments + mock_get_async.assert_called_once_with( + synapse_client=self.syn, + ) + # AND start_session_async should not have been called + mock_start_session.assert_not_called() + # AND prompt_async should have been called once with the correct arguments + mock_prompt_async.assert_called_once_with( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + + def test_prompt_session_none_current_session_none(self) -> None: + with ( + patch.object( + Agent, + "get_session_async", + ) as mock_get_session, + patch.object( + AgentSession, + "start_async", + return_value=self.test_session, + ) as mock_start_async, + patch.object( + AgentSession, + "prompt_async", + ) as mock_prompt_async, + ): + # GIVEN an existing Agent with no current session + my_agent = self.get_example_agent() + # WHEN I call prompt with no session selected and no current session set + my_agent.prompt( + prompt="Hello", + enable_trace=True, + print_response=True, + newer_than=0, + synapse_client=self.syn, + ) + # THEN get_session_async should not have been called + mock_get_session.assert_not_called() + # AND start_session_async should have been called once with the correct arguments + mock_start_async.assert_called_once_with( + synapse_client=self.syn, + ) + # AND prompt_async should have been called once with the correct arguments + mock_prompt_async.assert_called_once_with( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + + def test_prompt_session_none_current_session_present(self) -> None: + with ( + patch.object( + Agent, + "get_session_async", + ) as mock_get_session, + patch.object( + AgentSession, + "start_async", + ) as mock_start_async, + patch.object( + AgentSession, + "prompt_async", + ) as mock_prompt_async, + ): + # GIVEN an existing Agent with a current session + my_agent = self.get_example_agent() + my_agent.current_session = self.test_session + # WHEN I call prompt with no session selected and a current session set + my_agent.prompt( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + # THEN get_session_async and start_session_async should not have been called + mock_get_session.assert_not_called() + mock_start_async.assert_not_called() + # AND prompt_async should have been called once with the correct arguments + mock_prompt_async.assert_called_once_with( + prompt="Hello", + enable_trace=True, + newer_than=0, + print_response=True, + synapse_client=self.syn, + ) + + def test_get_chat_history_when_current_session_none(self) -> None: + # GIVEN an existing Agent with no current session + my_agent = self.get_example_agent() + # WHEN I call get_chat_history + result_chat_history = my_agent.get_chat_history() + # THEN the result should be None + assert result_chat_history is None + + def test_get_chat_history_when_current_session_and_chat_history_present( + self, + ) -> None: + # GIVEN an existing Agent with a current session and chat history + my_agent = self.get_example_agent() + my_agent.current_session = self.test_session + my_agent.current_session.chat_history = [self.test_prompt] + # WHEN I call get_chat_history + result_chat_history = my_agent.get_chat_history() + # THEN the result should be the chat history + assert self.test_prompt in result_chat_history From 17e258f28c702d435a5bca7b190c132ea32b10a1 Mon Sep 17 00:00:00 2001 From: Brad Macdonald <52762200+BWMac@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:51:51 -0700 Subject: [PATCH 05/11] Revert "Merge v4.7.0 into master (#1159)" This reverts commit f39ee48ead4e5b9d1b9f95e35e584a79574a9b4e. --- docs/news.md | 15 - docs/reference/experimental/async/activity.md | 24 - docs/reference/experimental/async/agent.md | 32 - docs/reference/experimental/async/file.md | 27 - docs/reference/experimental/async/folder.md | 20 - docs/reference/experimental/async/project.md | 19 - docs/reference/experimental/async/table.md | 21 - docs/reference/experimental/async/team.md | 19 - .../experimental/async/user_profile.md | 19 - .../mixins/access_controllable.md | 3 - .../mixins/asynchronous_communicator.md | 3 - .../experimental/mixins/failure_strategy.md | 3 - .../experimental/mixins/storable_container.md | 3 - docs/reference/experimental/sync/activity.md | 35 - docs/reference/experimental/sync/agent.md | 42 - docs/reference/experimental/sync/file.md | 37 - docs/reference/experimental/sync/folder.md | 30 - docs/reference/experimental/sync/project.md | 29 - docs/reference/experimental/sync/table.md | 31 - docs/reference/experimental/sync/team.md | 30 - .../experimental/sync/user_profile.md | 19 - docs/reference/oop/models.md | 169 ++++ docs/reference/oop/models_async.md | 100 ++ .../oop_poc_agent.py | 105 -- mkdocs.yml | 24 +- synapseclient/api/__init__.py | 15 - synapseclient/api/agent_services.py | 189 ---- synapseclient/client.py | 25 +- .../core/constants/concrete_types.py | 3 - synapseclient/models/__init__.py | 10 - synapseclient/models/agent.py | 945 ------------------ synapseclient/models/mixins/__init__.py | 2 - .../models/mixins/asynchronous_job.py | 410 -------- .../models/mixins/storable_container.py | 2 - .../models/protocols/agent_protocol.py | 396 -------- .../services/storable_entity_components.py | 3 +- synapseclient/synapsePythonClient | 2 +- .../models/async/test_agent_async.py | 228 ----- .../models/synchronous/test_agent.py | 192 ---- .../synapseutils/test_synapseutils_sync.py | 91 +- .../async/unit_test_asynchronous_job.py | 278 ------ .../models/async/unit_test_agent_async.py | 703 ------------- .../models/synchronous/unit_test_agent.py | 588 ----------- 43 files changed, 288 insertions(+), 4653 deletions(-) delete mode 100644 docs/reference/experimental/async/activity.md delete mode 100644 docs/reference/experimental/async/agent.md delete mode 100644 docs/reference/experimental/async/file.md delete mode 100644 docs/reference/experimental/async/folder.md delete mode 100644 docs/reference/experimental/async/project.md delete mode 100644 docs/reference/experimental/async/table.md delete mode 100644 docs/reference/experimental/async/team.md delete mode 100644 docs/reference/experimental/async/user_profile.md delete mode 100644 docs/reference/experimental/mixins/access_controllable.md delete mode 100644 docs/reference/experimental/mixins/asynchronous_communicator.md delete mode 100644 docs/reference/experimental/mixins/failure_strategy.md delete mode 100644 docs/reference/experimental/mixins/storable_container.md delete mode 100644 docs/reference/experimental/sync/activity.md delete mode 100644 docs/reference/experimental/sync/agent.md delete mode 100644 docs/reference/experimental/sync/file.md delete mode 100644 docs/reference/experimental/sync/folder.md delete mode 100644 docs/reference/experimental/sync/project.md delete mode 100644 docs/reference/experimental/sync/table.md delete mode 100644 docs/reference/experimental/sync/team.md delete mode 100644 docs/reference/experimental/sync/user_profile.md create mode 100644 docs/reference/oop/models.md create mode 100644 docs/reference/oop/models_async.md delete mode 100644 docs/scripts/object_orientated_programming_poc/oop_poc_agent.py delete mode 100644 synapseclient/api/agent_services.py delete mode 100644 synapseclient/models/agent.py delete mode 100644 synapseclient/models/mixins/asynchronous_job.py delete mode 100644 synapseclient/models/protocols/agent_protocol.py delete mode 100644 tests/integration/synapseclient/models/async/test_agent_async.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_agent.py delete mode 100644 tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py delete mode 100644 tests/unit/synapseclient/models/async/unit_test_agent_async.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_agent.py diff --git a/docs/news.md b/docs/news.md index 9b16bf59e..4aff7f747 100644 --- a/docs/news.md +++ b/docs/news.md @@ -9,21 +9,6 @@ detailing some of the changes. the 4.x.x versions hidden behind optional feature flags or different import paths. Any breaking changes will not be included until v5.0. -## 4.7.0 (2025-01-31) - -### Highlights -- **Added functionality for interacting with Synapse Agents:** - - The new `Agent` OOP model allows you to chat with the baseline Synapse Agent, - register and chat with custom Synapse Agents, manage multiple chat sessions and more. - - See the `Agent` documentation for more details and example code to get started. - -### Bug Fixes -- \[[SYNPY-1557](https://sagebionetworks.jira.com/browse/SYNPY-1557)\] - Synapse get recursive link download issue - -### Stories -- \[[SYNPY-1544](https://sagebionetworks.jira.com/browse/SYNPY-1544)\] - Create Synapse Agent OOP Model -- \[[SYNPY-1566](https://sagebionetworks.jira.com/browse/SYNPY-1566)\] - Release python client v4.7.0 - ## 4.6.1 (2024-12-17) ### Highlights diff --git a/docs/reference/experimental/async/activity.md b/docs/reference/experimental/async/activity.md deleted file mode 100644 index 59e2f0061..000000000 --- a/docs/reference/experimental/async/activity.md +++ /dev/null @@ -1,24 +0,0 @@ -# Activity - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.Activity - options: - members: - - from_parent_async - - store_async - - delete_async ---- -::: synapseclient.models.UsedEntity - options: - filters: - - "!" ---- -::: synapseclient.models.UsedURL - options: - filters: - - "!" diff --git a/docs/reference/experimental/async/agent.md b/docs/reference/experimental/async/agent.md deleted file mode 100644 index be2e74c36..000000000 --- a/docs/reference/experimental/async/agent.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API reference - -::: synapseclient.models.Agent - options: - members: - - register_async - - get_async - - start_session_async - - get_session_async - - prompt_async - - get_chat_history ---- -::: synapseclient.models.AgentSession - options: - members: - - start_async - - get_async - - update_async - - prompt_async ---- -::: synapseclient.models.AgentPrompt - options: - inherited_members: true - members: - - send_job_and_wait_async ---- diff --git a/docs/reference/experimental/async/file.md b/docs/reference/experimental/async/file.md deleted file mode 100644 index e2fe12300..000000000 --- a/docs/reference/experimental/async/file.md +++ /dev/null @@ -1,27 +0,0 @@ -# File - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.File - options: - inherited_members: true - members: - - get_async - - store_async - - copy_async - - delete_async - - from_id_async - - from_path_async - - change_metadata_async - - get_permissions_async - - get_acl_async - - set_permissions_async ---- -::: synapseclient.models.file.FileHandle - options: - filters: - - "!" diff --git a/docs/reference/experimental/async/folder.md b/docs/reference/experimental/async/folder.md deleted file mode 100644 index c11983a99..000000000 --- a/docs/reference/experimental/async/folder.md +++ /dev/null @@ -1,20 +0,0 @@ -# Folder - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.Folder - options: - inherited_members: true - members: - - get_async - - store_async - - delete_async - - copy_async - - sync_from_synapse_async - - get_permissions_async - - get_acl_async - - set_permissions_async diff --git a/docs/reference/experimental/async/project.md b/docs/reference/experimental/async/project.md deleted file mode 100644 index b628d4e19..000000000 --- a/docs/reference/experimental/async/project.md +++ /dev/null @@ -1,19 +0,0 @@ -# Project - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API reference - -::: synapseclient.models.Project - options: - inherited_members: true - members: - - get_async - - store_async - - delete_async - - sync_from_synapse_async - - get_permissions_async - - get_acl_async - - set_permissions_async diff --git a/docs/reference/experimental/async/table.md b/docs/reference/experimental/async/table.md deleted file mode 100644 index 63f3b3a0b..000000000 --- a/docs/reference/experimental/async/table.md +++ /dev/null @@ -1,21 +0,0 @@ -# Table - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.Table - options: - inherited_members: true - members: - - get_async - - store_schema_async - - store_rows_from_csv_async - - delete_rows_async - - query_async - - delete_async - - get_permissions_async - - get_acl_async - - set_permissions_async diff --git a/docs/reference/experimental/async/team.md b/docs/reference/experimental/async/team.md deleted file mode 100644 index 0dd066e35..000000000 --- a/docs/reference/experimental/async/team.md +++ /dev/null @@ -1,19 +0,0 @@ -# Team - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.Team - options: - members: - - create_async - - delete_async - - from_id_async - - from_name_async - - members_async - - invite_async - - open_invitations_async ---- diff --git a/docs/reference/experimental/async/user_profile.md b/docs/reference/experimental/async/user_profile.md deleted file mode 100644 index 7174061d9..000000000 --- a/docs/reference/experimental/async/user_profile.md +++ /dev/null @@ -1,19 +0,0 @@ -# UserProfile - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.UserProfile - options: - inherited_members: true - members: - - get_async - - from_id_async - - from_username_async - - is_certified_async ---- -::: synapseclient.models.UserPreference ---- diff --git a/docs/reference/experimental/mixins/access_controllable.md b/docs/reference/experimental/mixins/access_controllable.md deleted file mode 100644 index 96e7f70b9..000000000 --- a/docs/reference/experimental/mixins/access_controllable.md +++ /dev/null @@ -1,3 +0,0 @@ -# AccessControllable - -::: synapseclient.models.mixins.AccessControllable diff --git a/docs/reference/experimental/mixins/asynchronous_communicator.md b/docs/reference/experimental/mixins/asynchronous_communicator.md deleted file mode 100644 index bfc081057..000000000 --- a/docs/reference/experimental/mixins/asynchronous_communicator.md +++ /dev/null @@ -1,3 +0,0 @@ -# AsynchronousCommunicator - -::: synapseclient.models.mixins.AsynchronousCommunicator diff --git a/docs/reference/experimental/mixins/failure_strategy.md b/docs/reference/experimental/mixins/failure_strategy.md deleted file mode 100644 index 3809b74f5..000000000 --- a/docs/reference/experimental/mixins/failure_strategy.md +++ /dev/null @@ -1,3 +0,0 @@ -# FailureStrategy - -::: synapseclient.models.FailureStrategy diff --git a/docs/reference/experimental/mixins/storable_container.md b/docs/reference/experimental/mixins/storable_container.md deleted file mode 100644 index 49e10a5e3..000000000 --- a/docs/reference/experimental/mixins/storable_container.md +++ /dev/null @@ -1,3 +0,0 @@ -# StorableContainer - -::: synapseclient.models.mixins.StorableContainer diff --git a/docs/reference/experimental/sync/activity.md b/docs/reference/experimental/sync/activity.md deleted file mode 100644 index f0547e13c..000000000 --- a/docs/reference/experimental/sync/activity.md +++ /dev/null @@ -1,35 +0,0 @@ -# Activity - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script - -
- Working with activities - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_activity.py!} -``` -
- -## API Reference - -::: synapseclient.models.Activity - options: - inherited_members: true - members: - - from_parent - - store - - delete ---- -::: synapseclient.models.UsedEntity - options: - filters: - - "!" ---- -::: synapseclient.models.UsedURL - options: - filters: - - "!" diff --git a/docs/reference/experimental/sync/agent.md b/docs/reference/experimental/sync/agent.md deleted file mode 100644 index 3d8cb7f08..000000000 --- a/docs/reference/experimental/sync/agent.md +++ /dev/null @@ -1,42 +0,0 @@ -# Agent - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script: - -
- Working with Synapse agents - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_agent.py!} -``` -
- -## API Reference - -::: synapseclient.models.Agent - options: - inherited_members: true - members: - - register - - get - - start_session - - get_session - - prompt - - get_chat_history ---- -::: synapseclient.models.AgentSession - options: - inherited_members: true - members: - - start - - get - - update - - prompt ---- -::: synapseclient.models.AgentPrompt - options: - inherited_members: true ---- diff --git a/docs/reference/experimental/sync/file.md b/docs/reference/experimental/sync/file.md deleted file mode 100644 index 9b49e7603..000000000 --- a/docs/reference/experimental/sync/file.md +++ /dev/null @@ -1,37 +0,0 @@ -# File - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script - -
- Working with files - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_file.py!} -``` -
- -## API Reference - -::: synapseclient.models.File - options: - inherited_members: true - members: - - get - - store - - copy - - delete - - from_id - - from_path - - change_metadata - - get_permissions - - get_acl - - set_permissions ---- -::: synapseclient.models.file.FileHandle - options: - filters: - - "!" diff --git a/docs/reference/experimental/sync/folder.md b/docs/reference/experimental/sync/folder.md deleted file mode 100644 index 5a1cb5ddb..000000000 --- a/docs/reference/experimental/sync/folder.md +++ /dev/null @@ -1,30 +0,0 @@ -# Folder - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script - -
- Working with folders - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_folder.py!} -``` -
- -## API Reference - -::: synapseclient.models.Folder - options: - inherited_members: true - members: - - get - - store - - delete - - copy - - sync_from_synapse - - get_permissions - - get_acl - - set_permissions diff --git a/docs/reference/experimental/sync/project.md b/docs/reference/experimental/sync/project.md deleted file mode 100644 index e8cebfed5..000000000 --- a/docs/reference/experimental/sync/project.md +++ /dev/null @@ -1,29 +0,0 @@ -# Project - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script - -
- Working with a project - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_project.py!} -``` -
- -## API reference - -::: synapseclient.models.Project - options: - inherited_members: true - members: - - get - - store - - delete - - sync_from_synapse - - get_permissions - - get_acl - - set_permissions diff --git a/docs/reference/experimental/sync/table.md b/docs/reference/experimental/sync/table.md deleted file mode 100644 index 058826d0d..000000000 --- a/docs/reference/experimental/sync/table.md +++ /dev/null @@ -1,31 +0,0 @@ -# Table - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script - -
- Working with tables - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_table.py!} -``` -
- -## API Reference - -::: synapseclient.models.Table - options: - inherited_members: true - members: - - get - - store_schema - - store_rows_from_csv - - delete_rows - - query - - delete - - get_permissions - - get_acl - - set_permissions diff --git a/docs/reference/experimental/sync/team.md b/docs/reference/experimental/sync/team.md deleted file mode 100644 index 46fc51305..000000000 --- a/docs/reference/experimental/sync/team.md +++ /dev/null @@ -1,30 +0,0 @@ -# Team - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## Example Script - -
- Working with teams - -```python -{!docs/scripts/object_orientated_programming_poc/oop_poc_team.py!} -``` -
- -## API Reference - -::: synapseclient.models.Team - options: - inherited_members: true - members: - - create - - delete - - from_id - - from_name - - members - - invite - - open_invitations ---- diff --git a/docs/reference/experimental/sync/user_profile.md b/docs/reference/experimental/sync/user_profile.md deleted file mode 100644 index 46424f4b5..000000000 --- a/docs/reference/experimental/sync/user_profile.md +++ /dev/null @@ -1,19 +0,0 @@ -# UserProfile - -Contained within this file are experimental interfaces for working with the Synapse Python -Client. Unless otherwise noted these interfaces are subject to change at any time. Use -at your own risk. - -## API Reference - -::: synapseclient.models.UserProfile - options: - inherited_members: true - members: - - get - - from_id - - from_username - - is_certified ---- -::: synapseclient.models.UserPreference ---- diff --git a/docs/reference/oop/models.md b/docs/reference/oop/models.md new file mode 100644 index 000000000..2c7ebc153 --- /dev/null +++ b/docs/reference/oop/models.md @@ -0,0 +1,169 @@ +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## Sample Scripts: + +
+ Working with a project + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_project.py!} +``` +
+ +
+ Working with folders + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_folder.py!} +``` +
+ +
+ Working with files + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_file.py!} +``` +
+ +
+ Working with tables + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_table.py!} +``` +
+ +
+ Current Synapse interface for working with a project + +```python +{!docs/scripts/object_orientated_programming_poc/synapse_project.py!} +``` +
+ +
+ Working with activities + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_activity.py!} +``` +
+ +
+ Working with teams + +```python +{!docs/scripts/object_orientated_programming_poc/oop_poc_team.py!} +``` +
+ +## API reference + +::: synapseclient.models.Project + options: + inherited_members: true + members: + - get + - store + - delete + - sync_from_synapse + - get_permissions + - get_acl + - set_permissions +--- +::: synapseclient.models.Folder + options: + inherited_members: true + members: + - get + - store + - delete + - copy + - sync_from_synapse + - get_permissions + - get_acl + - set_permissions +--- +::: synapseclient.models.File + options: + inherited_members: true + members: + - get + - store + - copy + - delete + - from_id + - from_path + - change_metadata + - get_permissions + - get_acl + - set_permissions +::: synapseclient.models.file.FileHandle + options: + filters: + - "!" +--- +::: synapseclient.models.Table + options: + inherited_members: true + members: + - get + - store_schema + - store_rows_from_csv + - delete_rows + - query + - delete + - get_permissions + - get_acl + - set_permissions +--- +::: synapseclient.models.Activity + options: + members: + - from_parent + - store + - delete + +::: synapseclient.models.UsedEntity + options: + filters: + - "!" +::: synapseclient.models.UsedURL + options: + filters: + - "!" +--- +::: synapseclient.models.Team + options: + members: + - create + - delete + - from_id + - from_name + - members + - invite + - open_invitations +--- +::: synapseclient.models.UserProfile + options: + members: + - get + - from_id + - from_username + - is_certified +::: synapseclient.models.UserPreference +--- +::: synapseclient.models.Annotations + options: + members: + - from_dict +--- +::: synapseclient.models.mixins.AccessControllable +--- + +::: synapseclient.models.mixins.StorableContainer +--- +::: synapseclient.models.FailureStrategy diff --git a/docs/reference/oop/models_async.md b/docs/reference/oop/models_async.md new file mode 100644 index 000000000..c61ce0df6 --- /dev/null +++ b/docs/reference/oop/models_async.md @@ -0,0 +1,100 @@ +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +These APIs also introduce [AsyncIO](https://docs.python.org/3/library/asyncio.html) to +the client. + +## Sample Scripts: +See [this page for sample scripts](models.md#sample-scripts). +The sample scripts are from a synchronous context, +replace any of the method calls with the async counter-party and they will be +functionally equivalent. + +## API reference + +::: synapseclient.models.Project + options: + inherited_members: true + members: + - get_async + - store_async + - delete_async + - sync_from_synapse_async + - get_permissions_async + - get_acl_async + - set_permissions_async +--- +::: synapseclient.models.Folder + options: + inherited_members: true + members: + - get_async + - store_async + - delete_async + - copy_async + - sync_from_synapse_async + - get_permissions_async + - get_acl_async + - set_permissions_async +--- +::: synapseclient.models.File + options: + inherited_members: true + members: + - get_async + - store_async + - copy_async + - delete_async + - from_id_async + - from_path_async + - change_metadata_async + - get_permissions_async + - get_acl_async + - set_permissions_async +--- +::: synapseclient.models.Table + options: + inherited_members: true + members: + - get_async + - store_schema_async + - store_rows_from_csv_async + - delete_rows_async + - query_async + - delete_async + - get_permissions_async + - get_acl_async + - set_permissions_async +--- +::: synapseclient.models.Activity + options: + members: + - from_parent_async + - store_async + - delete_async + +--- +::: synapseclient.models.Team + options: + members: + - create_async + - delete_async + - from_id_async + - from_name_async + - members_async + - invite_async + - open_invitations_async +--- +::: synapseclient.models.UserProfile + options: + members: + - get_async + - from_id_async + - from_username_async + - is_certified_async +--- +::: synapseclient.models.Annotations + options: + members: + - store_async diff --git a/docs/scripts/object_orientated_programming_poc/oop_poc_agent.py b/docs/scripts/object_orientated_programming_poc/oop_poc_agent.py deleted file mode 100644 index 2703f41a9..000000000 --- a/docs/scripts/object_orientated_programming_poc/oop_poc_agent.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -The purpose of this script is to demonstrate how to use the new OOP interface for Synapse AI Agents. - -1. Register and send a prompt to a custom agent -2. Send a prompt to the baseline Synapse Agent -3. Conduct more than one session with the same agent -4. Start a new session with a custom agent and send a prompt to it -5. Start a new session with the baseline Synapse Agent and send a prompt to it -6. Start a new session with a custom agent and then update what the agent has access to -""" - -import synapseclient -from synapseclient.models import Agent, AgentSession, AgentSessionAccessLevel - -# IDs for a bedrock agent with the instructions: -# "You are a test agent that when greeted with: 'hello' will always response with: 'world'" -CLOUD_AGENT_ID = "QOTV3KQM1X" -AGENT_REGISTRATION_ID = 29 - -syn = synapseclient.Synapse(debug=True) -syn.login() - -# Using the Agent class - - -# Register a custom agent and send a prompt to it -def register_and_send_prompt_to_custom_agent(): - my_custom_agent = Agent(cloud_agent_id=CLOUD_AGENT_ID) - my_custom_agent.register(synapse_client=syn) - my_custom_agent.prompt( - prompt="Hello", enable_trace=True, print_response=True, synapse_client=syn - ) - - -# Create an Agent Object and prompt. -# By default, this will send a prompt to a new session with the baseline Synapse Agent. -def get_baseline_agent_and_send_prompt_to_it(): - baseline_agent = Agent() - baseline_agent.prompt( - prompt="What is Synapse?", - enable_trace=True, - print_response=True, - synapse_client=syn, - ) - - -# Conduct more than one session with the same agent -def conduct_multiple_sessions_with_same_agent(): - my_agent = Agent(registration_id=AGENT_REGISTRATION_ID).get(synapse_client=syn) - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - synapse_client=syn, - ) - my_second_session = my_agent.start_session(synapse_client=syn) - my_agent.prompt( - prompt="Hello again", - enable_trace=True, - print_response=True, - session=my_second_session, - synapse_client=syn, - ) - - -# Using the AgentSession class - - -# Start a new session with a custom agent and send a prompt to it -def start_new_session_with_custom_agent_and_send_prompt_to_it(): - my_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID).start( - synapse_client=syn - ) - my_session.prompt( - prompt="Hello", enable_trace=True, print_response=True, synapse_client=syn - ) - - -# Start a new session with the baseline Synapse Agent and send a prompt to it -def start_new_session_with_baseline_agent_and_send_prompt_to_it(): - my_session = AgentSession().start(synapse_client=syn) - my_session.prompt( - prompt="What is Synapse?", - enable_trace=True, - print_response=True, - synapse_client=syn, - ) - - -# Start a new session with a custom agent and then update what the agent has access to -def start_new_session_with_custom_agent_and_update_access_to_it(): - my_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID).start( - synapse_client=syn - ) - print(f"Access level before update: {my_session.access_level}") - my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA - my_session.update(synapse_client=syn) - print(f"Access level after update: {my_session.access_level}") - - -register_and_send_prompt_to_custom_agent() -get_baseline_agent_and_send_prompt_to_it() -conduct_multiple_sessions_with_same_agent() -start_new_session_with_baseline_agent_and_send_prompt_to_it() -start_new_session_with_custom_agent_and_update_access_to_it() diff --git a/mkdocs.yml b/mkdocs.yml index 68f9e0053..768dcd0e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,28 +75,8 @@ nav: - Core: reference/core.md - REST Apis: reference/rest_apis.md - Experimental: - - Agent: reference/experimental/sync/agent.md - - Project: reference/experimental/sync/project.md - - Folder: reference/experimental/sync/folder.md - - File: reference/experimental/sync/file.md - - Table: reference/experimental/sync/table.md - - Activity: reference/experimental/sync/activity.md - - Team: reference/experimental/sync/team.md - - UserProfile: reference/experimental/sync/user_profile.md - - Asynchronous: - - Agent: reference/experimental/async/agent.md - - Project: reference/experimental/async/project.md - - Folder: reference/experimental/async/folder.md - - File: reference/experimental/async/file.md - - Table: reference/experimental/async/table.md - - Activity: reference/experimental/async/activity.md - - Team: reference/experimental/async/team.md - - UserProfile: reference/experimental/async/user_profile.md - - Mixins: - - AccessControllable: reference/experimental/mixins/access_controllable.md - - StorableContainer: reference/experimental/mixins/storable_container.md - - AsynchronousCommunicator: reference/experimental/mixins/asynchronous_communicator.md - - FailureStrategy: reference/experimental/mixins/failure_strategy.md + - Object-Orientated Models: reference/oop/models.md + - Async Object-Orientated Models: reference/oop/models_async.md - Further Reading: - Home: explanations/home.md - Domain Models of Synapse: explanations/domain_models_of_synapse.md diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index f41f782fc..3211aaf38 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -1,12 +1,4 @@ # These are all of the models that are used by the Synapse client. -from .agent_services import ( - get_agent, - get_session, - get_trace, - register_agent, - start_session, - update_session, -) from .annotations import set_annotations, set_annotations_async from .configuration_services import ( get_client_authenticated_s3_profile, @@ -86,11 +78,4 @@ "get_transfer_config", # entity_factory "get_from_entity_factory", - # agent_services - "register_agent", - "get_agent", - "start_session", - "get_session", - "update_session", - "get_trace", ] diff --git a/synapseclient/api/agent_services.py b/synapseclient/api/agent_services.py deleted file mode 100644 index 6cb65e1fd..000000000 --- a/synapseclient/api/agent_services.py +++ /dev/null @@ -1,189 +0,0 @@ -"""This module is responsible for exposing the services defined at: - -""" - -import json -from typing import TYPE_CHECKING, Any, Dict, Optional - -if TYPE_CHECKING: - from synapseclient import Synapse - - -async def register_agent( - cloud_agent_id: str, - cloud_alias_id: Optional[str] = None, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Registers an agent with Synapse OR gets existing agent registration. - Sends a request matching - - - Arguments: - cloud_agent_id: The cloud provider ID of the agent to register. - cloud_alias_id: The cloud provider alias ID of the agent to register. - In the Synapse API, this defaults to 'TSTALIASID'. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The registered agent matching - - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - - request = {"awsAgentId": cloud_agent_id} - if cloud_alias_id: - request["awsAliasId"] = cloud_alias_id - return await client.rest_put_async( - uri="/agent/registration", body=json.dumps(request) - ) - - -async def get_agent( - registration_id: str, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Gets information about an existing agent registration. - - Arguments: - registration_id: The ID of the agent registration to get. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The requested agent registration matching - - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - return await client.rest_get_async(uri=f"/agent/registration/{registration_id}") - - -async def start_session( - access_level: str, - agent_registration_id: str, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Starts a new chat session with an agent. - Sends a request matching - - - Arguments: - access_level: The access level of the agent. - agent_registration_id: The ID of the agent registration to start the session for. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - - request = { - "agentAccessLevel": access_level, - "agentRegistrationId": agent_registration_id, - } - return await client.rest_post_async(uri="/agent/session", body=json.dumps(request)) - - -async def get_session( - id: str, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Gets information about an existing chat session. - - Arguments: - id: The ID of the session to get. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The requested session matching - - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - return await client.rest_get_async(uri=f"/agent/session/{id}") - - -async def update_session( - id: str, - access_level: str, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Updates the access level for a chat session. - Sends a request matching - - - Arguments: - id: The ID of the session to update. - access_level: The access level of the agent. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - - request = { - "sessionId": id, - "agentAccessLevel": access_level, - } - return await client.rest_put_async( - uri=f"/agent/session/{id}", body=json.dumps(request) - ) - - -async def get_trace( - prompt_id: str, - *, - newer_than: Optional[int] = None, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Gets the trace of a prompt. - Sends a request matching - - - Arguments: - prompt_id: The token of the prompt to get the trace for. - newer_than: The timestamp to get trace results newer than. Defaults to None (all results). - Timestamps should be in milliseconds since the epoch per the API documentation. - https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/agent/TraceEvent.html - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The trace matching - - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - - request = { - "jobId": prompt_id, - "newerThanTimestamp": newer_than, - } - return await client.rest_post_async( - uri=f"/agent/chat/trace/{prompt_id}", body=json.dumps(request) - ) diff --git a/synapseclient/client.py b/synapseclient/client.py index 8aba3217d..61bcf73c5 100644 --- a/synapseclient/client.py +++ b/synapseclient/client.py @@ -6373,17 +6373,20 @@ async def rest_get_async( Returns: JSON encoding of response """ - response = await self._rest_call_async( - "get", - uri, - None, - endpoint, - headers, - retry_policy, - requests_session_async_synapse, - **kwargs, - ) - return self._return_rest_body(response) + try: + response = await self._rest_call_async( + "get", + uri, + None, + endpoint, + headers, + retry_policy, + requests_session_async_synapse, + **kwargs, + ) + return self._return_rest_body(response) + except Exception: + self.logger.exception("Error in rest_get_async") async def rest_post_async( self, diff --git a/synapseclient/core/constants/concrete_types.py b/synapseclient/core/constants/concrete_types.py index e2033c030..f8d4ee442 100644 --- a/synapseclient/core/constants/concrete_types.py +++ b/synapseclient/core/constants/concrete_types.py @@ -68,6 +68,3 @@ # Activity/Provenance USED_URL = "org.sagebionetworks.repo.model.provenance.UsedURL" USED_ENTITY = "org.sagebionetworks.repo.model.provenance.UsedEntity" - -# Agent -AGENT_CHAT_REQUEST = "org.sagebionetworks.repo.model.agent.AgentChatRequest" diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 1e2f686ed..a487a3827 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -1,11 +1,5 @@ # These are all of the models that are used by the Synapse client. from synapseclient.models.activity import Activity, UsedEntity, UsedURL -from synapseclient.models.agent import ( - Agent, - AgentPrompt, - AgentSession, - AgentSessionAccessLevel, -) from synapseclient.models.annotations import Annotations from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder @@ -44,8 +38,4 @@ "TeamMember", "UserProfile", "UserPreference", - "Agent", - "AgentSession", - "AgentSessionAccessLevel", - "AgentPrompt", ] diff --git a/synapseclient/models/agent.py b/synapseclient/models/agent.py deleted file mode 100644 index 3fe1306ac..000000000 --- a/synapseclient/models/agent.py +++ /dev/null @@ -1,945 +0,0 @@ -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from typing import Dict, List, Optional, Union - -from synapseclient import Synapse -from synapseclient.api import ( - get_agent, - get_session, - get_trace, - register_agent, - start_session, - update_session, -) -from synapseclient.core.async_utils import async_to_sync, otel_trace_method -from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -from synapseclient.models.mixins import AsynchronousCommunicator -from synapseclient.models.protocols.agent_protocol import ( - AgentSessionSynchronousProtocol, - AgentSynchronousProtocol, -) - - -class AgentType(str, Enum): - """ - Enum representing the type of agent as defined in - - - - BASELINE is a default agent provided by Synapse. - - CUSTOM is a custom agent that has been registered by a user. - """ - - BASELINE = "BASELINE" - CUSTOM = "CUSTOM" - - -class AgentSessionAccessLevel(str, Enum): - """ - Enum representing the access level of the agent session as defined in - - - - PUBLICLY_ACCESSIBLE: The agent can only access publicly accessible data. - - READ_YOUR_PRIVATE_DATA: The agent can read the user's private data. - - WRITE_YOUR_PRIVATE_DATA: The agent can write to the user's private data. - """ - - PUBLICLY_ACCESSIBLE = "PUBLICLY_ACCESSIBLE" - READ_YOUR_PRIVATE_DATA = "READ_YOUR_PRIVATE_DATA" - WRITE_YOUR_PRIVATE_DATA = "WRITE_YOUR_PRIVATE_DATA" - - -@dataclass -class AgentPrompt(AsynchronousCommunicator): - """Represents a prompt, response, and metadata within an AgentSession. - - Attributes: - id: The unique ID of the agent prompt. - session_id: The ID of the session that the prompt is associated with. - prompt: The prompt to send to the agent. - response: The response from the agent. - enable_trace: Whether tracing is enabled for the prompt. - trace: The trace of the agent session. - """ - - concrete_type: str = AGENT_CHAT_REQUEST - - id: Optional[str] = None - """The unique ID of the agent prompt.""" - - session_id: Optional[str] = None - """The ID of the session that the prompt is associated with.""" - - prompt: Optional[str] = None - """The prompt sent to the agent.""" - - response: Optional[str] = None - """The response from the agent.""" - - enable_trace: Optional[bool] = False - """Whether tracing is enabled for the prompt.""" - - trace: Optional[str] = None - """The trace or "thought process" of the agent when responding to the prompt.""" - - def to_synapse_request(self): - """Converts the request to a request expected of the Synapse REST API.""" - return { - "concreteType": self.concrete_type, - "sessionId": self.session_id, - "chatText": self.prompt, - "enableTrace": self.enable_trace, - } - - def fill_from_dict(self, synapse_response: Dict[str, str]) -> "AgentPrompt": - """ - Converts a response from the REST API into this dataclass. - - Arguments: - synapse_response: The response from the REST API. - - Returns: - The AgentPrompt object. - """ - self.id = synapse_response.get("jobId", None) - self.session_id = synapse_response.get("sessionId", None) - self.response = synapse_response.get("responseText", None) - return self - - async def _post_exchange_async( - self, *, synapse_client: Optional[Synapse] = None, **kwargs - ) -> None: - """Retrieves information about the trace of this prompt with the agent. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - """ - if self.enable_trace: - trace_response = await get_trace( - prompt_id=self.id, - newer_than=kwargs.get("newer_than", None), - synapse_client=synapse_client, - ) - self.trace = trace_response["page"][0]["message"] - - -@dataclass -@async_to_sync -class AgentSession(AgentSessionSynchronousProtocol): - """Represents a [Synapse Agent Session](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/agent/AgentSession.html) - - Attributes: - id: The unique ID of the agent session. - Can only be used by the user that created it. - access_level: The access level of the agent session. - One of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, - or WRITE_YOUR_PRIVATE_DATA. - started_on: The date the agent session was started. - started_by: The ID of the user who started the agent session. - modified_on: The date the agent session was last modified. - agent_registration_id: The registration ID of the agent that will - be used for this session. - etag: The etag of the agent session. - - Note: It is recommended to use the `Agent` class to conduct chat sessions, - but you are free to use AgentSession directly if you wish. - - Example: Start a session and send a prompt. - Start a session with a custom agent by providing the agent's registration ID and calling `start()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - my_session = AgentSession(agent_registration_id="foo").start() - my_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - Example: Get an existing session and send a prompt. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - my_session = AgentSession(id="foo").get() - my_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - Example: Update the access level of an existing session. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, update the access level of the session and call `update()`. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession, AgentSessionAccessLevel - - syn = Synapse() - syn.login() - - my_session = AgentSession(id="foo").get() - my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA - my_session.update() - """ - - id: Optional[str] = None - """The unique ID of the agent session. - Can only be used by the user that created it.""" - - access_level: Optional[ - AgentSessionAccessLevel - ] = AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE - """The access level of the agent session. - One of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, or - WRITE_YOUR_PRIVATE_DATA. Defaults to PUBLICLY_ACCESSIBLE. - """ - - started_on: Optional[datetime] = None - """The date the agent session was started.""" - - started_by: Optional[int] = None - """The ID of the user who started the agent session.""" - - modified_on: Optional[datetime] = None - """The date the agent session was last modified.""" - - agent_registration_id: Optional[int] = None - """The registration ID of the agent that will be used for this session.""" - - etag: Optional[str] = None - """The etag of the agent session.""" - - chat_history: List[AgentPrompt] = field(default_factory=list) - """A list of AgentPrompt objects.""" - - def fill_from_dict(self, synapse_agent_session: Dict[str, str]) -> "AgentSession": - """ - Converts a response from the REST API into this dataclass. - - Arguments: - synapse_agent_session: The response from the REST API. - - Returns: - The AgentSession object. - """ - self.id = synapse_agent_session.get("sessionId", None) - self.access_level = synapse_agent_session.get("agentAccessLevel", None) - self.started_on = synapse_agent_session.get("startedOn", None) - self.started_by = synapse_agent_session.get("startedBy", None) - self.modified_on = synapse_agent_session.get("modifiedOn", None) - self.agent_registration_id = synapse_agent_session.get( - "agentRegistrationId", None - ) - self.etag = synapse_agent_session.get("etag", None) - return self - - @otel_trace_method(method_to_trace_name=lambda self, **kwargs: "Start_Session") - async def start_async( - self, *, synapse_client: Optional[Synapse] = None - ) -> "AgentSession": - """Starts an agent session. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The new AgentSession object. - - Example: Start a session and send a prompt. - Start a session with a custom agent by providing the agent's registration ID and calling `start()`. - Then, send a prompt to the agent. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - async def main(): - my_session = await AgentSession(agent_registration_id="foo").start_async() - await my_session.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - session_response = await start_session( - access_level=self.access_level, - agent_registration_id=self.agent_registration_id, - synapse_client=synapse_client, - ) - return self.fill_from_dict(synapse_agent_session=session_response) - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Get_Session: {self.id}" - ) - async def get_async( - self, *, synapse_client: Optional[Synapse] = None - ) -> "AgentSession": - """Gets an agent session. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The retrieved AgentSession object. - - Example: Get an existing session and send a prompt. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - async def main(): - my_session = await AgentSession(id="foo").get_async() - await my_session.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - session_response = await get_session( - id=self.id, - synapse_client=synapse_client, - ) - return self.fill_from_dict(synapse_agent_session=session_response) - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Update_Session: {self.id}" - ) - async def update_async( - self, - *, - synapse_client: Optional[Synapse] = None, - ) -> "AgentSession": - """Updates an agent session. - Only updates to the access level are currently supported. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The updated AgentSession object. - - Example: Update the access level of an existing session. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, update the access level of the session and call `update()`. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession, AgentSessionAccessLevel - - syn = Synapse() - syn.login() - - async def main(): - my_session = await AgentSession(id="foo").get_async() - my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA - await my_session.update_async() - - asyncio.run(main()) - """ - session_response = await update_session( - id=self.id, - access_level=self.access_level, - synapse_client=synapse_client, - ) - return self.fill_from_dict(synapse_agent_session=session_response) - - @otel_trace_method(method_to_trace_name=lambda self, **kwargs: f"Prompt: {self.id}") - async def prompt_async( - self, - prompt: str, - enable_trace: bool = False, - print_response: bool = False, - newer_than: Optional[int] = None, - *, - synapse_client: Optional[Synapse] = None, - ) -> AgentPrompt: - """Sends a prompt to the agent and adds the response to the AgentSession's - chat history. A session must be started before sending a prompt. - - Arguments: - prompt: The prompt to send to the agent. - enable_trace: Whether to enable trace for the prompt. - print_response: Whether to print the response to the console. - newer_than: The timestamp to get trace results newer than. - Defaults to None (all results). - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Example: Send a prompt within an existing session. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - async def main(): - my_session = await AgentSession(id="foo").get_async() - await my_session.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - agent_prompt = await AgentPrompt( - prompt=prompt, session_id=self.id, enable_trace=enable_trace - ).send_job_and_wait_async( - synapse_client=synapse_client, post_exchange_args={"newer_than": newer_than} - ) - self.chat_history.append(agent_prompt) - if print_response: - client = Synapse.get_client(synapse_client=synapse_client) - client.logger.info(f"PROMPT:\n{prompt}\n") - client.logger.info(f"RESPONSE:\n{agent_prompt.response}\n") - if enable_trace: - client.logger.info(f"TRACE:\n{agent_prompt.trace}") - return agent_prompt - - -@dataclass -@async_to_sync -class Agent(AgentSynchronousProtocol): - """Represents a [Synapse Agent Registration](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/agent/AgentRegistration.html) - - Attributes: - cloud_agent_id: The unique ID of the agent in the cloud provider. - cloud_alias_id: The alias ID of the agent in the cloud provider. - Defaults to 'TSTALIASID' in the Synapse API. - registration_id: The ID number of the agent assigned by Synapse. - registered_on: The date the agent was registered. - type: The type of agent. - sessions: A dictionary of AgentSession objects, keyed by session ID. - current_session: The current session. Prompts will be sent to this session by default. - - Example: Chat with the baseline Synapse Agent - You can chat with the same agent which is available in the Synapse UI - at https://www.synapse.org/Chat:default. By default, this "baseline" agent - is used when a registration ID is not provided. In the background, - the Agent class will start a session and set that new session as the - current session if one is not already set. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent() - my_agent.prompt( - prompt="Can you tell me about the AD Knowledge Portal dataset?", - enable_trace=True, - print_response=True, - ) - - Example: Register and chat with a custom agent - **Only available for internal users (Sage Bionetworks employees)** - - Alternatively, you can register a custom agent and chat with it provided - you have already created it. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent(cloud_agent_id="foo") - my_agent.register() - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - Example: Get and chat with an existing agent - Retrieve an existing agent by providing the agent's registration ID and calling `get()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent(registration_id="foo").get() - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - Advanced Example: Start and prompt multiple sessions - Here, we connect to a custom agent and start one session with the prompt "Hello". - In the background, this first session is being set as the current session - and future prompts will be sent to this session by default. If we want to send a - prompt to a different session, we can do so by starting it and calling prompt again, - but with our new session as an argument. We now have two sessions, both stored in the - `my_agent.sessions` dictionary. After the second prompt, `my_second_session` is now - the current session. - - syn = Synapse() - syn.login() - - my_agent = Agent(registration_id="foo").get() - - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - my_second_session = my_agent.start_session() - my_agent.prompt( - prompt="Hello again", - enable_trace=True, - print_response=True, - session=my_second_session, - ) - """ - - cloud_agent_id: Optional[str] = None - """The unique ID of the agent in the cloud provider.""" - - cloud_alias_id: Optional[str] = None - """The alias ID of the agent in the cloud provider. - Defaults to 'TSTALIASID' in the Synapse API. - """ - - registration_id: Optional[int] = None - """The ID number of the agent assigned by Synapse.""" - - registered_on: Optional[datetime] = None - """The date the agent was registered.""" - - type: Optional[AgentType] = None - """The type of agent. One of either BASELINE or CUSTOM.""" - - sessions: Dict[str, AgentSession] = field(default_factory=dict) - """A dictionary of AgentSession objects, keyed by session ID.""" - - current_session: Optional[AgentSession] = None - """The current session. Prompts will be sent to this session by default.""" - - def fill_from_dict(self, agent_registration: Dict[str, str]) -> "Agent": - """ - Converts a response from the REST API into this dataclass. - - Arguments: - agent_registration: The response from the REST API. - - Returns: - The Agent object. - """ - self.cloud_agent_id = agent_registration.get("awsAgentId", None) - self.cloud_alias_id = agent_registration.get("awsAliasId", None) - self.registration_id = agent_registration.get("agentRegistrationId", None) - self.registered_on = agent_registration.get("registeredOn", None) - self.type = ( - AgentType(agent_registration.get("type")) - if agent_registration.get("type", None) - else None - ) - return self - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Register_Agent: {self.registration_id}" - ) - async def register_async( - self, *, synapse_client: Optional[Synapse] = None - ) -> "Agent": - """Registers an agent with the Synapse API. - If agent already exists, it will be retrieved. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The registered or existing Agent object. - - Example: Register and chat with a custom agent - **Only available for internal users (Sage Bionetworks employees)** - - Alternatively, you can register a custom agent and chat with it provided - you have already created it. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent(cloud_agent_id="foo") - await my_agent.register_async() - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - agent_response = await register_agent( - cloud_agent_id=self.cloud_agent_id, - cloud_alias_id=self.cloud_alias_id, - synapse_client=synapse_client, - ) - return self.fill_from_dict(agent_registration=agent_response) - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Get_Agent: {self.registration_id}" - ) - async def get_async(self, *, synapse_client: Optional[Synapse] = None) -> "Agent": - """Gets an existing custom agent. There is no need to use this method - if you are trying to use the baseline agent. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The existing Agent object. - - Example: Get and chat with an existing agent - Retrieve an existing custom agent by providing the agent's registration ID and calling `get_async()`. - Then, send a prompt to the agent. - - import asyncio - from synapseclient import Synapse - from synapseclient.models import Agent, AgentSessionAccessLevel - - syn = Synapse() - syn.login() - - async def main(): - my_agent = await Agent(registration_id="foo").get_async() - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - if self.registration_id is None: - raise ValueError( - "Registration ID is required to retrieve a custom agent. " - "If you are trying to use the baseline agent, you do not need to " - "use `get` or `get_async`. Instead, simply create an `Agent` object " - "and start prompting `my_agent = Agent(); my_agent.prompt(...)`.", - ) - agent_response = await get_agent( - registration_id=self.registration_id, - synapse_client=synapse_client, - ) - return self.fill_from_dict(agent_registration=agent_response) - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Start_Agent_Session: {self.registration_id}" - ) - async def start_session_async( - self, - access_level: Optional[ - AgentSessionAccessLevel - ] = AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - *, - synapse_client: Optional[Synapse] = None, - ) -> "AgentSession": - """Starts an agent session. - Adds the session to the Agent's sessions dictionary and sets it as the current session. - - Arguments: - access_level: The access level of the agent session. - Must be one of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, - or WRITE_YOUR_PRIVATE_DATA. - Defaults to PUBLICLY_ACCESSIBLE. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The new AgentSession object. - - Example: Start a session and send a prompt with the baseline Synapse Agent. - The baseline Synapse Agent is the default agent used when a registration ID is not provided. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent() - await my_agent.start_session_async() - await my_agent.prompt_async( - prompt="Can you tell me about the AD Knowledge Portal dataset?", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - - Example: Start a session and send a prompt with a custom agent. - The baseline Synapse Agent is the default agent used when a registration ID is not provided. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent(cloud_agent_id="foo") - await my_agent.start_session_async() - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - access_level = AgentSessionAccessLevel(access_level) - session = await AgentSession( - agent_registration_id=self.registration_id, access_level=access_level - ).start_async(synapse_client=synapse_client) - self.sessions[session.id] = session - self.current_session = session - return session - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Get_Agent_Session: {self.registration_id}" - ) - async def get_session_async( - self, session_id: str, *, synapse_client: Optional[Synapse] = None - ) -> "AgentSession": - """Gets an existing agent session. - Adds the session to the Agent's sessions dictionary and - sets it as the current session. - - Arguments: - session_id: The ID of the session to get. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The existing AgentSession object. - - Example: Get an existing session and send a prompt. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_session = await Agent().get_session_async(session_id="foo") - await my_session.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - """ - session = await AgentSession(id=session_id).get_async( - synapse_client=synapse_client - ) - if session.id not in self.sessions: - self.sessions[session.id] = session - self.current_session = session - return session - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Prompt_Agent_Session: {self.registration_id}" - ) - async def prompt_async( - self, - prompt: str, - enable_trace: bool = False, - print_response: bool = False, - session: Optional[AgentSession] = None, - newer_than: Optional[int] = None, - *, - synapse_client: Optional[Synapse] = None, - ) -> AgentPrompt: - """Sends a prompt to the agent for the current session. - If no session is currently active, a new session will be started. - - Arguments: - prompt: The prompt to send to the agent. - enable_trace: Whether to enable trace for the prompt. - print_response: Whether to print the response to the console. - session_id: The ID of the session to send the prompt to. - If None, the current session will be used. - newer_than: The timestamp to get trace results newer than. Defaults to None (all results). - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Example: Prompt the baseline Synapse Agent. - The baseline Synapse Agent is equivilent to the Agent available in the Synapse UI. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent() - await my_agent.prompt_async( - prompt="Can you tell me about the AD Knowledge Portal dataset?", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - - Example: Prompt a custom agent. - If you have already registered a custom agent, you can prompt it by providing the agent's registration ID. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent(registration_id="foo") - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - asyncio.run(main()) - - Advanced Example: Start and prompt multiple sessions - Here, we connect to a custom agent and start one session with the prompt "Hello". - In the background, this first session is being set as the current session - and future prompts will be sent to this session by default. If we want to send a - prompt to a different session, we can do so by starting it and calling prompt again, - but with our new session as an argument. We now have two sessions, both stored in the - `my_agent.sessions` dictionary. After the second prompt, `my_second_session` is now - the current session. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent(registration_id="foo").get() - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - my_second_session = await my_agent.start_session_async() - await my_agent.prompt_async( - prompt="Hello again", - enable_trace=True, - print_response=True, - session=my_second_session, - ) - - asyncio.run(main()) - """ - if session: - await self.get_session_async( - session_id=session.id, synapse_client=synapse_client - ) - else: - if not self.current_session: - await self.start_session_async(synapse_client=synapse_client) - - return await self.current_session.prompt_async( - prompt=prompt, - enable_trace=enable_trace, - newer_than=newer_than, - print_response=print_response, - synapse_client=synapse_client, - ) - - def get_chat_history(self) -> Union[List[AgentPrompt], None]: - """Gets the chat history for the current session. - - Example: Get the chat history for the current session. - First, send a prompt to the agent. - Then, retrieve the chat history for the current session by calling `get_chat_history()`. - - import asyncio - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - async def main(): - my_agent = Agent() - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - print(my_agent.get_chat_history()) - - asyncio.run(main()) - """ - return self.current_session.chat_history if self.current_session else None diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index 93a98589c..0fb23dac7 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -1,11 +1,9 @@ """References to the mixins that are used in the Synapse models.""" from synapseclient.models.mixins.access_control import AccessControllable -from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator from synapseclient.models.mixins.storable_container import StorableContainer __all__ = [ "AccessControllable", "StorableContainer", - "AsynchronousCommunicator", ] diff --git a/synapseclient/models/mixins/asynchronous_job.py b/synapseclient/models/mixins/asynchronous_job.py deleted file mode 100644 index aac481663..000000000 --- a/synapseclient/models/mixins/asynchronous_job.py +++ /dev/null @@ -1,410 +0,0 @@ -import asyncio -import json -import time -from dataclasses import dataclass -from enum import Enum -from typing import Any, Dict, Optional - -from synapseclient import Synapse -from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -from synapseclient.core.exceptions import SynapseError, SynapseTimeoutError - -ASYNC_JOB_URIS = { - AGENT_CHAT_REQUEST: "/agent/chat/async", -} - - -class AsynchronousCommunicator: - """Mixin to handle communication with the Synapse Asynchronous Job service.""" - - def to_synapse_request(self) -> None: - """Converts the request to a request expected of the Synapse REST API.""" - raise NotImplementedError("to_synapse_request must be implemented.") - - def fill_from_dict( - self, synapse_response: Dict[str, str] - ) -> "AsynchronousCommunicator": - """ - Converts a response from the REST API into this dataclass. - - Arguments: - synapse_response: The response from the REST API. - - Returns: - An instance of this class. - """ - raise NotImplementedError("fill_from_dict must be implemented.") - - async def _post_exchange_async( - self, synapse_client: Optional[Synapse] = None, **kwargs - ) -> None: - """Any additional logic to run after the exchange with Synapse. - - Arguments: - synapse_client: The Synapse client to use for the request. - **kwargs: Additional arguments to pass to the request. - """ - pass - - async def send_job_and_wait_async( - self, - post_exchange_args: Optional[Dict[str, Any]] = None, - *, - synapse_client: Optional[Synapse] = None, - ) -> "AsynchronousCommunicator": - """Send the job to the Asynchronous Job service and wait for it to complete. - Intended to be called by a class inheriting from this mixin to start a job - in the Synapse API and wait for it to complete. The inheriting class needs to - represent an asynchronous job request and response and include all necessary attributes. - This was initially implemented to be used in the AgentPrompt class which can be used - as an example. - - Arguments: - post_exchange_args: Additional arguments to pass to the request. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - An instance of this class. - - Example: Using this function - This function was initially implemented to be used in the AgentPrompt class - to send a prompt to an AI agent and wait for the response. It can also be used - in any other class that needs to use an Asynchronous Job. - - The inheriting class (AgentPrompt) will typically not be used directly, but rather - through a higher level class (AgentSession), but this example shows how you would - use this function. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentPrompt - - syn = Synapse() - syn.login() - - agent_prompt = AgentPrompt( - id=None, - session_id="123", - prompt="Hello", - response=None, - enable_trace=True, - trace=None, - ) - # This will fill the id, response, and trace - # attributes with the response from the API - agent_prompt.send_job_and_wait_async() - """ - result = await send_job_and_wait_async( - request=self.to_synapse_request(), - request_type=self.concrete_type, - synapse_client=synapse_client, - ) - self.fill_from_dict(synapse_response=result) - await self._post_exchange_async( - **post_exchange_args, synapse_client=synapse_client - ) - return self - - -class AsynchronousJobState(str, Enum): - """Enum representing the state of a Synapse Asynchronous Job: - - - - PROCESSING: The job is being processed. - - FAILED: The job has failed. - - COMPLETE: The job has been completed. - """ - - PROCESSING = "PROCESSING" - FAILED = "FAILED" - COMPLETE = "COMPLETE" - - -class CallersContext(str, Enum): - """Enum representing information about a web service call: - - - - SESSION_ID: Each web service request is issued a unique session ID (UUID) - that is included in the call's access record. - Events that are triggered by a web service request should include the session ID - so that they can be linked to each other and the call's access record. - """ - - SESSION_ID = "SESSION_ID" - - -@dataclass -class AsynchronousJobStatus: - """Represents a Synapse Asynchronous Job Status object: - - - Attributes: - state: The state of the job. Either PROCESSING, FAILED, or COMPLETE. - canceling: Whether the job has been requested to be cancelled. - request_body: The body of an Asynchronous job request. - Will be one of the models described here: - - response_body: The body of an Asynchronous job response. - Will be one of the models described here: - - etag: The etag of the job status. Changes whenever the status changes. - id: The ID if the job issued when this job was started. - started_by_user_id: The ID of the user that started the job. - started_on: The date-time when the status was last changed to PROCESSING. - changed_on: The date-time when the status of this job was last changed. - progress_message: The current message of the progress tracker. - progress_current: A value indicating how much progress has been made. - I.e. a value of 50 indicates that 50% of the work has been - completed if progress_total is 100. - progress_total: A value indicating the total amount of work to complete. - exception: The exception that needs to be thrown if the job fails. - error_message: A one-line error message when the job fails. - error_details: Full stack trace of the error when the job fails. - runtime_ms: The number of milliseconds from the start to completion of this job. - callers_context: Contextual information about a web service call. - """ - - state: Optional["AsynchronousJobState"] = None - """The state of the job. Either PROCESSING, FAILED, or COMPLETE.""" - - canceling: Optional[bool] = False - """Whether the job has been requested to be cancelled.""" - - request_body: Optional[dict] = None - """The body of an Asynchronous job request. Will be one of the models described here: - """ - - response_body: Optional[dict] = None - """The body of an Asynchronous job response. Will be one of the models described here: - """ - - etag: Optional[str] = None - """The etag of the job status. Changes whenever the status changes.""" - - id: Optional[str] = None - """The ID if the job issued when this job was started.""" - - started_by_user_id: Optional[int] = None - """The ID of the user that started the job.""" - - started_on: Optional[str] = None - """The date-time when the status was last changed to PROCESSING.""" - - changed_on: Optional[str] = None - """The date-time when the status of this job was last changed.""" - - progress_message: Optional[str] = None - """The current message of the progress tracker.""" - - progress_current: Optional[int] = None - """A value indicating how much progress has been made. - I.e. a value of 50 indicates that 50% of the work has been - completed if progress_total is 100.""" - - progress_total: Optional[int] = None - """A value indicating the total amount of work to complete.""" - - exception: Optional[str] = None - """The exception that needs to be thrown if the job fails.""" - - error_message: Optional[str] = None - """A one-line error message when the job fails.""" - - error_details: Optional[str] = None - """Full stack trace of the error when the job fails.""" - - runtime_ms: Optional[int] = None - """The number of milliseconds from the start to completion of this job.""" - - callers_context: Optional["CallersContext"] = None - """Contextual information about a web service call.""" - - def fill_from_dict(self, async_job_status: dict) -> "AsynchronousJobStatus": - """Converts a response from the REST API into this dataclass. - - Arguments: - async_job_status: The response from the REST API. - - Returns: - A AsynchronousJobStatus object. - """ - self.state = ( - AsynchronousJobState(async_job_status.get("jobState")) - if async_job_status.get("jobState") - else None - ) - self.canceling = async_job_status.get("jobCanceling", None) - self.request_body = async_job_status.get("requestBody", None) - self.response_body = async_job_status.get("responseBody", None) - self.etag = async_job_status.get("etag", None) - self.id = async_job_status.get("jobId", None) - self.started_by_user_id = async_job_status.get("startedByUserId", None) - self.started_on = async_job_status.get("startedOn", None) - self.changed_on = async_job_status.get("changedOn", None) - self.progress_message = async_job_status.get("progressMessage", None) - self.progress_current = async_job_status.get("progressCurrent", None) - self.progress_total = async_job_status.get("progressTotal", None) - self.exception = async_job_status.get("exception", None) - self.error_message = async_job_status.get("errorMessage", None) - self.error_details = async_job_status.get("errorDetails", None) - self.runtime_ms = async_job_status.get("runtimeMs", None) - self.callers_context = async_job_status.get("callersContext", None) - return self - - -async def send_job_and_wait_async( - request: Dict[str, Any], - request_type: str, - endpoint: str = None, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Sends the job to the Synapse API and waits for the response. Request body matches: - - - Arguments: - request: A request matching . - endpoint: The endpoint to use for the request. Defaults to None. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The response body matching - - - Raises: - SynapseError: If the job fails. - SynapseTimeoutError: If the job does not complete within the timeout. - """ - job_id = await send_job_async(request=request, synapse_client=synapse_client) - return { - "jobId": job_id, - **await get_job_async( - job_id=job_id, - request_type=request_type, - synapse_client=synapse_client, - endpoint=endpoint, - ), - } - - -async def send_job_async( - request: Dict[str, Any], - *, - synapse_client: Optional["Synapse"] = None, -) -> str: - """ - Sends the job to the Synapse API. Request body matches: - - Returns the job ID. - - Arguments: - request: A request matching . - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The job ID retrieved from the response. - - """ - if not request: - raise ValueError("request must be provided.") - - request_type = request.get("concreteType") - - if not request_type or request_type not in ASYNC_JOB_URIS: - raise ValueError(f"Unsupported request type: {request_type}") - - client = Synapse.get_client(synapse_client=synapse_client) - response = await client.rest_post_async( - uri=f"{ASYNC_JOB_URIS[request_type]}/start", body=json.dumps(request) - ) - return response["token"] - - -async def get_job_async( - job_id: str, - request_type: str, - endpoint: str = None, - sleep: int = 1, - timeout: int = 60, - *, - synapse_client: Optional["Synapse"] = None, -) -> Dict[str, Any]: - """ - Gets the job from the server using its ID. Handles progress tracking, failures and timeouts. - - Arguments: - job_id: The ID of the job to get. - request_type: The type of the job. - endpoint: The endpoint to use for the request. Defaults to None. - sleep: The number of seconds to wait between requests. Defaults to 1. - timeout: The number of seconds to wait for the job to complete or progress - before raising a SynapseTimeoutError. Defaults to 60. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The response body matching - - - Raises: - SynapseError: If the job fails. - SynapseTimeoutError: If the job does not complete or progress within the timeout interval. - """ - client = Synapse.get_client(synapse_client=synapse_client) - start_time = asyncio.get_event_loop().time() - - last_message = "" - last_progress = 0 - last_total = 1 - progressed = False - - while asyncio.get_event_loop().time() - start_time < timeout: - result = await client.rest_get_async( - uri=f"{ASYNC_JOB_URIS[request_type]}/get/{job_id}", - endpoint=endpoint, - ) - job_status = AsynchronousJobStatus().fill_from_dict(async_job_status=result) - if job_status.state == AsynchronousJobState.PROCESSING: - progress_tracking = any( - [ - job_status.progress_message, - job_status.progress_current, - job_status.progress_total, - ] - ) - progressed = ( - job_status.progress_message != last_message - or last_progress != job_status.progress_current - ) - if progress_tracking and progressed: - last_message = job_status.progress_message - last_progress = job_status.progress_current - last_total = job_status.progress_total - - client._print_transfer_progress( - last_progress, - last_total, - prefix=last_message, - isBytes=False, - ) - start_time = asyncio.get_event_loop().time() - await asyncio.sleep(sleep) - elif job_status.state == AsynchronousJobState.FAILED: - raise SynapseError( - f"{job_status.error_message}\n{job_status.error_details}", - ) - else: - break - else: - raise SynapseTimeoutError( - f"Timeout waiting for query results: {time.time() - start_time} seconds" - ) - - return result diff --git a/synapseclient/models/mixins/storable_container.py b/synapseclient/models/mixins/storable_container.py index 667766263..e1815aedb 100644 --- a/synapseclient/models/mixins/storable_container.py +++ b/synapseclient/models/mixins/storable_container.py @@ -686,7 +686,6 @@ async def _follow_link( or not (entity := entity_bundle.get("entity", None)) or not (links_to := entity.get("linksTo", None)) or not (link_class_name := entity.get("linksToClassName", None)) - or not (link_target_name := entity.get("name", None)) or not (link_target_id := links_to.get("targetId", None)) ): return @@ -694,7 +693,6 @@ async def _follow_link( pending_tasks = self._create_task_for_child( child={ "id": link_target_id, - "name": link_target_name, "type": link_class_name, }, recursive=recursive, diff --git a/synapseclient/models/protocols/agent_protocol.py b/synapseclient/models/protocols/agent_protocol.py deleted file mode 100644 index bc729e5f9..000000000 --- a/synapseclient/models/protocols/agent_protocol.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Protocol for the methods of the Agent and AgentSession classes that have -synchronous counterparts generated at runtime.""" - -from typing import TYPE_CHECKING, Optional, Protocol - -from synapseclient import Synapse - -if TYPE_CHECKING: - from synapseclient.models import ( - Agent, - AgentPrompt, - AgentSession, - AgentSessionAccessLevel, - ) - - -class AgentSessionSynchronousProtocol(Protocol): - """Protocol for the methods of the AgentSession class that have synchronous counterparts - generated at runtime.""" - - def start(self, *, synapse_client: Optional[Synapse] = None) -> "AgentSession": - """Starts an agent session. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The new AgentSession object. - - Example: Start a session and send a prompt. - Start a session with a custom agent by providing the agent's registration ID and calling `start()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - my_session = AgentSession(agent_registration_id="foo").start() - my_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return self - - def get(self, *, synapse_client: Optional[Synapse] = None) -> "AgentSession": - """Gets an agent session. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The retrieved AgentSession object. - - Example: Get an existing session and send a prompt. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - my_session = AgentSession(id="foo").get() - my_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return self - - def update(self, *, synapse_client: Optional[Synapse] = None) -> "AgentSession": - """Updates an agent session. - Only updates to the access level are currently supported. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The updated AgentSession object. - - Example: Update the access level of an existing session. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, update the access level of the session and call `update()`. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession, AgentSessionAccessLevel - - syn = Synapse() - syn.login() - - my_session = AgentSession(id="foo").get() - my_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA - my_session.update() - """ - return self - - def prompt( - self, - prompt: str, - enable_trace: bool = False, - print_response: bool = False, - newer_than: Optional[int] = None, - *, - synapse_client: Optional[Synapse] = None, - ) -> "AgentPrompt": - """Sends a prompt to the agent and adds the response to the AgentSession's - chat history. A session must be started before sending a prompt. - - Arguments: - prompt: The prompt to send to the agent. - enable_trace: Whether to enable trace for the prompt. - print_response: Whether to print the response to the console. - newer_than: The timestamp to get trace results newer than. - Defaults to None (all results). - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Example: Send a prompt within an existing session. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import AgentSession - - syn = Synapse() - syn.login() - - my_session = AgentSession(id="foo").get() - my_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return AgentPrompt() - - -class AgentSynchronousProtocol(Protocol): - """Protocol for the methods of the Agent class that have synchronous counterparts - generated at runtime.""" - - def register(self, *, synapse_client: Optional[Synapse] = None) -> "Agent": - """Registers an agent with the Synapse API. - If agent already exists, it will be retrieved. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The registered or existing Agent object. - - Example: Register and chat with a custom agent - **Only available for internal users (Sage Bionetworks employees)** - - Alternatively, you can register a custom agent and chat with it provided - you have already created it. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent(cloud_agent_id="foo") - my_agent.register() - - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return self - - def get(self, *, synapse_client: Optional[Synapse] = None) -> "Agent": - """Gets an existing agent. - - Arguments: - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The existing Agent object. - - Example: Get and chat with an existing agent - Retrieve an existing agent by providing the agent's registration ID and calling `get()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent(registration_id="foo").get() - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return self - - def start_session( - self, - access_level: Optional["AgentSessionAccessLevel"] = "PUBLICLY_ACCESSIBLE", - *, - synapse_client: Optional[Synapse] = None, - ) -> "AgentSession": - """Starts an agent session. - Adds the session to the Agent's sessions dictionary and sets it as the current session. - - Arguments: - access_level: The access level of the agent session. - Must be one of PUBLICLY_ACCESSIBLE, READ_YOUR_PRIVATE_DATA, - or WRITE_YOUR_PRIVATE_DATA. - Defaults to PUBLICLY_ACCESSIBLE. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The new AgentSession object. - - Example: Start a session and send a prompt with the baseline Synapse Agent. - The baseline Synapse Agent is the default agent used when a registration ID is not provided. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent() - my_agent.start_session() - my_agent.prompt( - prompt="Can you tell me about the AD Knowledge Portal dataset?", - enable_trace=True, - print_response=True, - ) - - Example: Start a session and send a prompt with a custom agent. - The baseline Synapse Agent is the default agent used when a registration ID is not provided. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent(cloud_agent_id="foo") - my_agent.start_session() - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return AgentSession() - - def get_session( - self, session_id: str, *, synapse_client: Optional[Synapse] = None - ) -> "AgentSession": - """Gets an existing agent session. - Adds the session to the Agent's sessions dictionary and - sets it as the current session. - - Arguments: - session_id: The ID of the session to get. - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Returns: - The existing AgentSession object. - - Example: Get an existing session and send a prompt. - Retrieve an existing session by providing the session ID and calling `get()`. - Then, send a prompt to the agent. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_session = Agent().get_session(session_id="foo") - my_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - """ - return AgentSession() - - def prompt( - self, - prompt: str, - enable_trace: bool = False, - print_response: bool = False, - session: Optional["AgentSession"] = None, - newer_than: Optional[int] = None, - *, - synapse_client: Optional[Synapse] = None, - ) -> "AgentPrompt": - """Sends a prompt to the agent for the current session. - If no session is currently active, a new session will be started. - - Arguments: - prompt: The prompt to send to the agent. - enable_trace: Whether to enable trace for the prompt. - print_response: Whether to print the response to the console. - session_id: The ID of the session to send the prompt to. - If None, the current session will be used. - newer_than: The timestamp to get trace results newer than. Defaults to None (all results). - synapse_client: If not passed in and caching was not disabled by - `Synapse.allow_client_caching(False)` this will use the last created - instance from the Synapse class constructor. - - Example: Prompt the baseline Synapse Agent. - The baseline Synapse Agent is equivilent to the Agent available in the Synapse UI. - - from synapseclient import Synapse - from synapseclient.models import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent() - my_agent.prompt( - prompt="Can you tell me about the AD Knowledge Portal dataset?", - enable_trace=True, - print_response=True, - ) - - Example: Prompt a custom agent. - If you have already registered a custom agent, you can prompt it by providing the agent's registration ID. - - from synapseclient import Synapse - from synapseclient.models.agent import Agent - - syn = Synapse() - syn.login() - - my_agent = Agent(registration_id="foo") - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - Advanced Example: Start and prompt multiple sessions - Here, we connect to a custom agent and start one session with the prompt "Hello". - In the background, this first session is being set as the current session - and future prompts will be sent to this session by default. If we want to send a - prompt to a different session, we can do so by starting it and calling prompt again, - but with our new session as an argument. We now have two sessions, both stored in the - `my_agent.sessions` dictionary. After the second prompt, `my_second_session` is now - the current session. - - syn = Synapse() - syn.login() - - my_agent = Agent(registration_id="foo").get() - - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - ) - - my_second_session = my_agent.start_session() - my_agent.prompt( - prompt="Hello again", - enable_trace=True, - print_response=True, - session=my_second_session, - ) - """ - return AgentPrompt() diff --git a/synapseclient/models/services/storable_entity_components.py b/synapseclient/models/services/storable_entity_components.py index 8eafa5739..8615cb9c9 100644 --- a/synapseclient/models/services/storable_entity_components.py +++ b/synapseclient/models/services/storable_entity_components.py @@ -4,6 +4,7 @@ from synapseclient import Synapse from synapseclient.core.exceptions import SynapseError +from synapseclient.models import Annotations if TYPE_CHECKING: from synapseclient.models import File, Folder, Project, Table @@ -242,8 +243,6 @@ async def _store_activity_and_annotations( or last_persistent_instance.annotations != root_resource.annotations ) ): - from synapseclient.models import Annotations - result = await Annotations( id=root_resource.id, etag=root_resource.etag, diff --git a/synapseclient/synapsePythonClient b/synapseclient/synapsePythonClient index 5aeb673c5..3ccb1602e 100644 --- a/synapseclient/synapsePythonClient +++ b/synapseclient/synapsePythonClient @@ -1,6 +1,6 @@ { "client": "synapsePythonClient", - "latestVersion": "4.7.0", + "latestVersion": "4.6.1", "blacklist": [ "0.0.0", "0.4.1", diff --git a/tests/integration/synapseclient/models/async/test_agent_async.py b/tests/integration/synapseclient/models/async/test_agent_async.py deleted file mode 100644 index dd7ef53e4..000000000 --- a/tests/integration/synapseclient/models/async/test_agent_async.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Integration tests for the asynchronous methods of the AgentPrompt, AgentSession, and Agent classes.""" - -# These tests have been disabled until out `test` user has needed permissions -# Context: https://sagebionetworks.jira.com/browse/SYNPY-1544?focusedCommentId=235070 -# import pytest - -# from synapseclient import Synapse -# from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -# from synapseclient.models.agent import ( -# Agent, -# AgentPrompt, -# AgentSession, -# AgentSessionAccessLevel, -# ) - -# # These are the ID values for a "Hello World" agent registered on Synapse. -# # The Bedrock agent is hosted on Sage Bionetworks AWS infrastructure. -# # CFN Template: -# # https://raw.githubusercontent.com/Sage-Bionetworks-Workflows/dpe-agents/refs/heads/main/client_integration_test/template.json -# AGENT_AWS_ID = "QOTV3KQM1X" -# AGENT_REGISTRATION_ID = "29" - - -# class TestAgentPrompt: -# """Integration tests for the synchronous methods of the AgentPrompt class.""" - -# @pytest.fixture(autouse=True, scope="function") -# def init(self, syn: Synapse) -> None: -# self.syn = syn - -# async def test_send_job_and_wait_async_with_post_exchange_args(self) -> None: -# # GIVEN an AgentPrompt with a valid concrete type, prompt, and enable_trace -# test_prompt = AgentPrompt( -# concrete_type=AGENT_CHAT_REQUEST, -# prompt="hello", -# enable_trace=True, -# ) -# # AND the ID of an existing agent session -# test_session = await AgentSession( -# agent_registration_id=AGENT_REGISTRATION_ID -# ).start_async(synapse_client=self.syn) -# test_prompt.session_id = test_session.id -# # WHEN I send the job and wait for it to complete -# await test_prompt.send_job_and_wait_async( -# post_exchange_args={"newer_than": 0}, -# synapse_client=self.syn, -# ) -# # THEN I expect the AgentPrompt to be updated with the response and trace -# assert test_prompt.response is not None -# assert test_prompt.trace is not None - - -# class TestAgentSession: -# """Integration tests for the synchronous methods of the AgentSession class.""" - -# @pytest.fixture(autouse=True, scope="function") -# def init(self, syn: Synapse) -> None: -# self.syn = syn - -# async def test_start(self) -> None: -# # GIVEN an agent session with a valid agent registration id -# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) - -# # WHEN the start method is called -# result_session = await agent_session.start_async(synapse_client=self.syn) - -# # THEN the result should be an AgentSession object -# # with expected attributes including an empty chat history -# assert result_session.id is not None -# assert ( -# result_session.access_level == AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE -# ) -# assert result_session.started_on is not None -# assert result_session.started_by is not None -# assert result_session.modified_on is not None -# assert result_session.agent_registration_id == AGENT_REGISTRATION_ID -# assert result_session.etag is not None -# assert result_session.chat_history == [] - -# async def test_get(self) -> None: -# # GIVEN an agent session with a valid agent registration id -# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) -# # WHEN I start a session -# await agent_session.start_async(synapse_client=self.syn) -# # THEN I expect to be able to get the session with its id -# new_session = await AgentSession(id=agent_session.id).get_async( -# synapse_client=self.syn -# ) -# assert new_session == agent_session - -# async def test_update(self) -> None: -# # GIVEN an agent session with a valid agent -# # registration id and access level set -# agent_session = AgentSession( -# agent_registration_id=AGENT_REGISTRATION_ID, -# access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, -# ) -# # WHEN I start a session -# await agent_session.start_async(synapse_client=self.syn) -# # AND I update the access level of the session -# agent_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA -# await agent_session.update_async(synapse_client=self.syn) -# # THEN I expect the access level to be updated -# updated_session = await AgentSession(id=agent_session.id).get_async( -# synapse_client=self.syn -# ) -# assert ( -# updated_session.access_level -# == AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA -# ) - -# async def test_prompt(self) -> None: -# # GIVEN an agent session with a valid agent registration id -# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) -# # WHEN I start a session -# await agent_session.start_async(synapse_client=self.syn) -# # THEN I expect to be able to prompt the agent -# await agent_session.prompt_async( -# prompt="hello", -# enable_trace=True, -# ) -# # AND I expect the chat history to be updated with the prompt and response -# assert len(agent_session.chat_history) == 1 -# assert agent_session.chat_history[0].prompt == "hello" -# assert agent_session.chat_history[0].response is not None -# assert agent_session.chat_history[0].trace is not None - - -# class TestAgent: -# """Integration tests for the synchronous methods of the Agent class.""" - -# def get_test_agent(self) -> Agent: -# return Agent( -# cloud_agent_id=AGENT_AWS_ID, -# cloud_alias_id="TSTALIASID", -# registration_id=AGENT_REGISTRATION_ID, -# registered_on="2025-01-16T18:57:35.680Z", -# type="CUSTOM", -# sessions={}, -# current_session=None, -# ) - -# @pytest.fixture(autouse=True, scope="function") -# def init(self, syn: Synapse) -> None: -# self.syn = syn - -# async def test_register(self) -> None: -# # GIVEN an Agent with a valid agent AWS id -# agent = Agent(cloud_agent_id=AGENT_AWS_ID) -# # WHEN I register the agent -# await agent.register_async(synapse_client=self.syn) -# # THEN I expect the agent to be registered -# expected_agent = self.get_test_agent() -# assert agent == expected_agent - -# async def test_get(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID) -# # WHEN I get the agent -# await agent.get_async(synapse_client=self.syn) -# # THEN I expect the agent to be returned -# expected_agent = self.get_test_agent() -# assert agent == expected_agent - -# async def test_get_no_registration_id(self) -> None: -# # GIVEN an Agent with no registration id -# agent = Agent() -# # WHEN I get the agent, I expect a ValueError to be raised -# with pytest.raises(ValueError, match="Registration ID is required"): -# await agent.get_async(synapse_client=self.syn) - -# async def test_start_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID) -# # WHEN I start a session -# await agent.start_session_async(synapse_client=self.syn) -# # THEN I expect a current session to be set -# assert agent.current_session is not None -# # AND I expect the session to be in the sessions dictionary -# assert agent.sessions[agent.current_session.id] == agent.current_session - -# async def test_get_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID) -# # WHEN I start a session -# await agent.start_session_async(synapse_client=self.syn) -# # THEN I expect to be able to get the session with its id -# existing_session = await agent.get_session_async( -# session_id=agent.current_session.id -# ) -# # AND I expect those sessions to be the same -# assert existing_session == agent.current_session - -# async def test_prompt_with_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = await Agent(registration_id=AGENT_REGISTRATION_ID).get_async( -# synapse_client=self.syn -# ) -# # AND a session started separately -# session = await AgentSession( -# agent_registration_id=AGENT_REGISTRATION_ID -# ).start_async(synapse_client=self.syn) -# # WHEN I prompt the agent with a session -# await agent.prompt_async(prompt="hello", enable_trace=True, session=session) -# test_session = agent.sessions[session.id] -# # THEN I expect the chat history to be updated with the prompt and response -# assert len(test_session.chat_history) == 1 -# assert test_session.chat_history[0].prompt == "hello" -# assert test_session.chat_history[0].response is not None -# assert test_session.chat_history[0].trace is not None -# # AND I expect the current session to be the session provided -# assert agent.current_session.id == session.id - -# async def test_prompt_no_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = await Agent(registration_id=AGENT_REGISTRATION_ID).get_async( -# synapse_client=self.syn -# ) -# # WHEN I prompt the agent without a current session set -# # and no session provided -# await agent.prompt_async(prompt="hello", enable_trace=True) -# # THEN I expect a new session to be started and set as the current session -# assert agent.current_session is not None -# # AND I expect the chat history to be updated with the prompt and response -# assert len(agent.current_session.chat_history) == 1 -# assert agent.current_session.chat_history[0].prompt == "hello" -# assert agent.current_session.chat_history[0].response is not None -# assert agent.current_session.chat_history[0].trace is not None diff --git a/tests/integration/synapseclient/models/synchronous/test_agent.py b/tests/integration/synapseclient/models/synchronous/test_agent.py deleted file mode 100644 index 07b77291e..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_agent.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Integration tests for the synchronous methods of the AgentSession and Agent classes.""" - -# These tests have been disabled until out `test` user has needed permissions -# Context: https://sagebionetworks.jira.com/browse/SYNPY-1544?focusedCommentId=235070 -# import pytest - -# from synapseclient import Synapse -# from synapseclient.models.agent import Agent, AgentSession, AgentSessionAccessLevel - -# # These are the ID values for a "Hello World" agent registered on Synapse. -# # The Bedrock agent is hosted on Sage Bionetworks AWS infrastructure. -# # CFN Template: -# # https://raw.githubusercontent.com/Sage-Bionetworks-Workflows/dpe-agents/refs/heads/main/client_integration_test/template.json -# CLOUD_AGENT_ID = "QOTV3KQM1X" -# AGENT_REGISTRATION_ID = "29" - - -# class TestAgentSession: -# """Integration tests for the synchronous methods of the AgentSession class.""" - -# @pytest.fixture(autouse=True, scope="function") -# def init(self, syn: Synapse) -> None: -# self.syn = syn - -# async def test_start(self) -> None: -# # GIVEN an agent session with a valid agent registration id -# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) - -# # WHEN the start method is called -# result_session = agent_session.start(synapse_client=self.syn) - -# # THEN the result should be an AgentSession object -# # with expected attributes including an empty chat history -# assert result_session.id is not None -# assert ( -# result_session.access_level == AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE -# ) -# assert result_session.started_on is not None -# assert result_session.started_by is not None -# assert result_session.modified_on is not None -# assert result_session.agent_registration_id == str(AGENT_REGISTRATION_ID) -# assert result_session.etag is not None -# assert result_session.chat_history == [] - -# async def test_get(self) -> None: -# # GIVEN an agent session with a valid agent registration id -# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) -# # WHEN I start a session -# agent_session.start(synapse_client=self.syn) -# # THEN I expect to be able to get the session with its id -# new_session = AgentSession(id=agent_session.id).get(synapse_client=self.syn) -# assert new_session == agent_session - -# async def test_update(self) -> None: -# # GIVEN an agent session with a valid agent registration id and access level set -# agent_session = AgentSession( -# agent_registration_id=AGENT_REGISTRATION_ID, -# access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, -# ) -# # WHEN I start a session -# agent_session.start(synapse_client=self.syn) -# # AND I update the access level of the session -# agent_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA -# agent_session.update(synapse_client=self.syn) -# # THEN I expect the access level to be updated -# updated_session = AgentSession(id=agent_session.id).get(synapse_client=self.syn) -# assert ( -# updated_session.access_level -# == AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA -# ) - -# async def test_prompt(self) -> None: -# # GIVEN an agent session with a valid agent registration id -# agent_session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID) -# # WHEN I start a session -# agent_session.start(synapse_client=self.syn) -# # THEN I expect to be able to prompt the agent -# agent_session.prompt( -# prompt="hello", -# enable_trace=True, -# ) -# # AND I expect the chat history to be updated with the prompt and response -# assert len(agent_session.chat_history) == 1 -# assert agent_session.chat_history[0].prompt == "hello" -# assert agent_session.chat_history[0].response is not None -# assert agent_session.chat_history[0].trace is not None - - -# class TestAgent: -# """Integration tests for the synchronous methods of the Agent class.""" - -# def get_test_agent(self) -> Agent: -# return Agent( -# cloud_agent_id=CLOUD_AGENT_ID, -# cloud_alias_id="TSTALIASID", -# registration_id=AGENT_REGISTRATION_ID, -# registered_on="2025-01-16T18:57:35.680Z", -# type="CUSTOM", -# sessions={}, -# current_session=None, -# ) - -# @pytest.fixture(autouse=True, scope="function") -# def init(self, syn: Synapse) -> None: -# self.syn = syn - -# async def test_register(self) -> None: -# # GIVEN an Agent with a valid agent AWS id -# agent = Agent(cloud_agent_id=CLOUD_AGENT_ID) -# # WHEN I register the agent -# agent.register(synapse_client=self.syn) -# # THEN I expect the agent to be registered -# expected_agent = self.get_test_agent() -# assert agent == expected_agent - -# async def test_get(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID) -# # WHEN I get the agent -# agent.get(synapse_client=self.syn) -# # THEN I expect the agent to be returned -# expected_agent = self.get_test_agent() -# assert agent == expected_agent - -# async def test_get_no_registration_id(self) -> None: -# # GIVEN an Agent with no registration id -# agent = Agent() -# # WHEN I get the agent, I expect a ValueError to be raised -# with pytest.raises(ValueError, match="Registration ID is required"): -# agent.get(synapse_client=self.syn) - -# async def test_start_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( -# synapse_client=self.syn -# ) -# # WHEN I start a session -# agent.start_session(synapse_client=self.syn) -# # THEN I expect a current session to be set -# assert agent.current_session is not None -# # AND I expect the session to be in the sessions dictionary -# assert agent.sessions[agent.current_session.id] == agent.current_session - -# async def test_get_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( -# synapse_client=self.syn -# ) -# # WHEN I start a session -# session = agent.start_session(synapse_client=self.syn) -# # THEN I expect to be able to get the session with its id -# existing_session = agent.get_session(session_id=session.id) -# # AND I expect those sessions to be the same -# assert existing_session == session -# # AND I expect it to be the current session -# assert existing_session == agent.current_session - -# async def test_prompt_with_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( -# synapse_client=self.syn -# ) -# # AND a session started separately -# session = AgentSession(agent_registration_id=AGENT_REGISTRATION_ID).start( -# synapse_client=self.syn -# ) -# # WHEN I prompt the agent with a session -# agent.prompt(prompt="hello", enable_trace=True, session=session) -# test_session = agent.sessions[session.id] -# # THEN I expect the chat history to be updated with the prompt and response -# assert len(test_session.chat_history) == 1 -# assert test_session.chat_history[0].prompt == "hello" -# assert test_session.chat_history[0].response is not None -# assert test_session.chat_history[0].trace is not None -# # AND I expect the current session to be the session provided -# assert agent.current_session.id == session.id - -# async def test_prompt_no_session(self) -> None: -# # GIVEN an Agent with a valid agent registration id -# agent = Agent(registration_id=AGENT_REGISTRATION_ID).get( -# synapse_client=self.syn -# ) -# # WHEN I prompt the agent without a current session set -# # and no session provided -# agent.prompt(prompt="hello", enable_trace=True) -# # THEN I expect a new session to be started and set as the current session -# assert agent.current_session is not None -# # AND I expect the chat history to be updated with the prompt and response -# assert len(agent.current_session.chat_history) == 1 -# assert agent.current_session.chat_history[0].prompt == "hello" -# assert agent.current_session.chat_history[0].response is not None -# assert agent.current_session.chat_history[0].trace is not None diff --git a/tests/integration/synapseutils/test_synapseutils_sync.py b/tests/integration/synapseutils/test_synapseutils_sync.py index 635d69761..e985ba37a 100644 --- a/tests/integration/synapseutils/test_synapseutils_sync.py +++ b/tests/integration/synapseutils/test_synapseutils_sync.py @@ -1992,7 +1992,7 @@ async def test_folder_sync_from_synapse_files_spread_across_folders( assert pd.isna(matching_row[ACTIVITY_DESCRIPTION_COLUMN].values[0]) assert found_matching_file - async def test_sync_from_synapse_follow_links_files( + async def test_sync_from_synapse_follow_links( self, syn: Synapse, schedule_for_cleanup: Callable[..., None], @@ -2082,95 +2082,6 @@ async def test_sync_from_synapse_follow_links_files( assert pd.isna(matching_row[ACTIVITY_NAME_COLUMN].values[0]) assert pd.isna(matching_row[ACTIVITY_DESCRIPTION_COLUMN].values[0]) - async def test_sync_from_synapse_follow_links_folder( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - project_model: Project, - ) -> None: - """ - Testing for this case: - - project_model (root) - ├── folder_with_files - │ ├── file1 (uploaded) - │ └── file2 (uploaded) - └── folder_with_links - This is the folder we are syncing from - └── link_to_folder_with_files -> ../folder_with_files - """ - # GIVEN a folder - folder_with_files = await Folder( - name=str(uuid.uuid4()), parent_id=project_model.id - ).store_async() - schedule_for_cleanup(folder_with_files.id) - - # AND two files in the folder - temp_files = [utils.make_bogus_uuid_file() for _ in range(2)] - file_entities = [] - for file in temp_files: - schedule_for_cleanup(file) - file_entity = syn.store(SynapseFile(path=file, parent=folder_with_files.id)) - schedule_for_cleanup(file_entity["id"]) - file_entities.append(file_entity) - - # AND a second folder to sync from - folder_with_links = await Folder( - name=str(uuid.uuid4()), parent_id=project_model.id - ).store_async() - schedule_for_cleanup(folder_with_links.id) - - # AND a link to folder_with_files in folder_with_links - syn.store(obj=Link(targetId=folder_with_files.id, parent=folder_with_links.id)) - - # AND a temp directory to write the manifest file to - temp_dir = tempfile.mkdtemp() - - # WHEN I sync the parent folder from Synapse - sync_result = synapseutils.syncFromSynapse( - syn=syn, entity=folder_with_links.id, path=temp_dir, followLink=True - ) - - # THEN I expect that the result has all of the files - assert len(sync_result) == 2 - - # AND each of the files are the ones we uploaded - for file in sync_result: - assert file in file_entities - - # AND the manifest that is created matches the expected values - manifest_df = pd.read_csv(os.path.join(temp_dir, MANIFEST_FILE), sep="\t") - assert manifest_df.shape[0] == 2 - assert PATH_COLUMN in manifest_df.columns - assert PARENT_COLUMN in manifest_df.columns - assert USED_COLUMN in manifest_df.columns - assert EXECUTED_COLUMN in manifest_df.columns - assert ACTIVITY_NAME_COLUMN in manifest_df.columns - assert ACTIVITY_DESCRIPTION_COLUMN in manifest_df.columns - assert CONTENT_TYPE_COLUMN in manifest_df.columns - assert ID_COLUMN in manifest_df.columns - assert SYNAPSE_STORE_COLUMN in manifest_df.columns - assert NAME_COLUMN in manifest_df.columns - assert manifest_df.shape[1] == 10 - - for file in sync_result: - matching_row = manifest_df[manifest_df[PATH_COLUMN] == file[PATH_COLUMN]] - assert not matching_row.empty - assert matching_row[PARENT_COLUMN].values[0] == file[PARENT_ATTRIBUTE] - assert ( - matching_row[CONTENT_TYPE_COLUMN].values[0] == file[CONTENT_TYPE_COLUMN] - ) - assert matching_row[ID_COLUMN].values[0] == file[ID_COLUMN] - assert ( - matching_row[SYNAPSE_STORE_COLUMN].values[0] - == file[SYNAPSE_STORE_COLUMN] - ) - assert matching_row[NAME_COLUMN].values[0] == file[NAME_COLUMN] - - assert pd.isna(matching_row[USED_COLUMN].values[0]) - assert pd.isna(matching_row[EXECUTED_COLUMN].values[0]) - assert pd.isna(matching_row[ACTIVITY_NAME_COLUMN].values[0]) - assert pd.isna(matching_row[ACTIVITY_DESCRIPTION_COLUMN].values[0]) - async def test_sync_from_synapse_follow_links_sync_contains_all_folders( self, syn: Synapse, diff --git a/tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py b/tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py deleted file mode 100644 index 056976dcc..000000000 --- a/tests/unit/synapseclient/mixins/async/unit_test_asynchronous_job.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Unit tests for Asynchronous Job logic.""" - -import json -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -from synapseclient.core.exceptions import SynapseError, SynapseTimeoutError -from synapseclient.models.mixins.asynchronous_job import ( - ASYNC_JOB_URIS, - AsynchronousJobState, - AsynchronousJobStatus, - get_job_async, - send_job_and_wait_async, - send_job_async, -) - - -class TestSendJobAsync: - """Unit tests for send_job_async.""" - - good_request = {"concreteType": AGENT_CHAT_REQUEST} - bad_request_no_concrete_type = {"otherKey": "otherValue"} - bad_request_invalid_concrete_type = {"concreteType": "InvalidConcreteType"} - request_type = AGENT_CHAT_REQUEST - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - async def test_send_job_async_when_request_is_missing(self) -> None: - with pytest.raises(ValueError, match="request must be provided."): - # WHEN I call send_job_async without a request - # THEN I should get a ValueError - await send_job_async(request=None) - - async def test_send_job_async_when_request_is_missing_concrete_type(self) -> None: - with pytest.raises(ValueError, match="Unsupported request type: None"): - # GIVEN a request with no concrete type - # WHEN I call send_job_async - # THEN I should get a ValueError - await send_job_async(request=self.bad_request_no_concrete_type) - - async def test_send_job_async_when_request_is_invalid_concrete_type(self) -> None: - with pytest.raises( - ValueError, match="Unsupported request type: InvalidConcreteType" - ): - # GIVEN a request with an invalid concrete type - # WHEN I call send_job_async - # THEN I should get a ValueError - await send_job_async(request=self.bad_request_invalid_concrete_type) - - async def test_send_job_async_when_request_is_valid(self) -> None: - with ( - patch( - "synapseclient.Synapse.get_client", - return_value=self.syn, - ) as mock_get_client, - patch( - "synapseclient.Synapse.rest_post_async", - new_callable=AsyncMock, - return_value={"token": "123"}, - ) as mock_rest_post_async, - ): - # WHEN I call send_job_async with a good request - job_id = await send_job_async( - request=self.good_request, synapse_client=self.syn - ) - # THEN the return value should be the token - assert job_id == "123" - # AND get_client should have been called - mock_get_client.assert_called_once_with(synapse_client=self.syn) - # AND rest_post_async should have been called with the correct arguments - mock_rest_post_async.assert_called_once_with( - uri=f"{ASYNC_JOB_URIS[self.request_type]}/start", - body=json.dumps(self.good_request), - ) - - -class TestGetJobAsync: - """Unit tests for get_job_async.""" - - request_type = AGENT_CHAT_REQUEST - job_id = "123" - - processing_job_status = AsynchronousJobStatus( - state=AsynchronousJobState.PROCESSING, - progress_message="Processing", - progress_current=1, - progress_total=100, - ) - failed_job_status = AsynchronousJobStatus( - state=AsynchronousJobState.FAILED, - progress_message="Failed", - progress_current=1, - progress_total=100, - error_message="Error", - error_details="Details", - id="123", - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - async def test_get_job_async_when_job_fails(self) -> None: - with ( - patch( - "synapseclient.Synapse.rest_get_async", - new_callable=AsyncMock, - return_value={}, - ) as mock_rest_get_async, - patch.object( - AsynchronousJobStatus, - "fill_from_dict", - return_value=self.failed_job_status, - ) as mock_fill_from_dict, - ): - with pytest.raises( - SynapseError, - match=( - f"{self.failed_job_status.error_message}\n" - f"{self.failed_job_status.error_details}" - ), - ): - # WHEN I call get_job_async - # AND the job fails in the Synapse API - # THEN I should get a SynapseError with the error message and details - await get_job_async( - job_id="123", - request_type=AGENT_CHAT_REQUEST, - synapse_client=self.syn, - sleep=1, - timeout=60, - endpoint=None, - ) - # AND rest_get_async should have been called once with the correct arguments - mock_rest_get_async.assert_called_once_with( - uri=f"{ASYNC_JOB_URIS[AGENT_CHAT_REQUEST]}/get/{self.job_id}", - endpoint=None, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - async_job_status=mock_rest_get_async.return_value, - ) - - async def test_get_job_async_when_job_times_out(self) -> None: - with ( - patch( - "synapseclient.Synapse.rest_get_async", - new_callable=AsyncMock, - return_value={}, - ) as mock_rest_get_async, - patch.object( - AsynchronousJobStatus, - "fill_from_dict", - return_value=self.processing_job_status, - ) as mock_fill_from_dict, - ): - with pytest.raises( - SynapseTimeoutError, match="Timeout waiting for query results:" - ): - # WHEN I call get_job_async - # AND the job does not complete or progress within the timeout interval - # THEN I should get a SynapseTimeoutError - await get_job_async( - job_id=self.job_id, - request_type=self.request_type, - synapse_client=self.syn, - endpoint=None, - timeout=0, - sleep=1, - ) - # AND rest_get_async should not have been called - mock_rest_get_async.assert_not_called() - # AND fill_from_dict should not have been called - mock_fill_from_dict.assert_not_called() - - -class TestSendJobAndWaitAsync: - """Unit tests for send_job_and_wait_async.""" - - good_request = {"concreteType": AGENT_CHAT_REQUEST} - job_id = "123" - request_type = AGENT_CHAT_REQUEST - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - async def test_send_job_and_wait_async(self) -> None: - with ( - patch( - "synapseclient.models.mixins.asynchronous_job.send_job_async", - new_callable=AsyncMock, - return_value=self.job_id, - ) as mock_send_job_async, - patch( - "synapseclient.models.mixins.asynchronous_job.get_job_async", - new_callable=AsyncMock, - return_value={ - "key": "value", - }, - ) as mock_get_job_async, - ): - # WHEN I call send_job_and_wait_async with a good request - # THEN the return value should be a dictionary with the job ID - # and response key value pair(s) - assert await send_job_and_wait_async( - request=self.good_request, - request_type=self.request_type, - synapse_client=self.syn, - endpoint=None, - ) == { - "jobId": self.job_id, - "key": "value", - } - # AND send_job_async should have been called once with the correct arguments - mock_send_job_async.assert_called_once_with( - request=self.good_request, - synapse_client=self.syn, - ) - # AND get_job_async should have been called once with the correct arguments - mock_get_job_async.assert_called_once_with( - job_id=self.job_id, - request_type=self.request_type, - synapse_client=self.syn, - endpoint=None, - ) - - -class TestAsynchronousJobStatus: - """Unit tests for AsynchronousJobStatus.""" - - def test_fill_from_dict(self) -> None: - # GIVEN a dictionary with job status information - async_job_status_dict = { - "jobState": AsynchronousJobState.PROCESSING, - "jobCanceling": False, - "requestBody": {"key": "value"}, - "responseBody": {"key": "value"}, - "etag": "123", - "jobId": "123", - "startedByUserId": "123", - "startedOn": "123", - "changedOn": "123", - "progressMessage": "Processing", - "progressCurrent": 1, - "progressTotal": 100, - "exception": None, - "errorMessage": None, - "errorDetails": None, - "runtimeMs": 1000, - "callersContext": None, - } - # WHEN I call fill_from_dict on it - async_job_status = AsynchronousJobStatus().fill_from_dict(async_job_status_dict) - # THEN the resulting AsynchronousJobStatus object - # should have the correct attribute values - assert async_job_status.state == AsynchronousJobState.PROCESSING - assert async_job_status.canceling is False - assert async_job_status.request_body == {"key": "value"} - assert async_job_status.response_body == {"key": "value"} - assert async_job_status.etag == "123" - assert async_job_status.id == "123" - assert async_job_status.started_by_user_id == "123" - assert async_job_status.started_on == "123" - assert async_job_status.changed_on == "123" - assert async_job_status.progress_message == "Processing" - assert async_job_status.progress_current == 1 - assert async_job_status.progress_total == 100 - assert async_job_status.exception is None - assert async_job_status.error_message is None - assert async_job_status.error_details is None - assert async_job_status.runtime_ms == 1000 - assert async_job_status.callers_context is None diff --git a/tests/unit/synapseclient/models/async/unit_test_agent_async.py b/tests/unit/synapseclient/models/async/unit_test_agent_async.py deleted file mode 100644 index 290094301..000000000 --- a/tests/unit/synapseclient/models/async/unit_test_agent_async.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Unit tests for Asynchronous methods in Agent, AgentSession, and AgentPrompt classes.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -from synapseclient.models.agent import ( - Agent, - AgentPrompt, - AgentSession, - AgentSessionAccessLevel, - AgentType, -) - - -class TestAgentPrompt: - """Unit tests for the AgentPrompt class' asynchronous methods.""" - - agent_prompt = AgentPrompt( - id="123", - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - ) - synapse_request = { - "concreteType": agent_prompt.concrete_type, - "sessionId": agent_prompt.session_id, - "chatText": agent_prompt.prompt, - "enableTrace": agent_prompt.enable_trace, - } - synapse_response = { - "jobId": "123", - "sessionId": "456", - "responseText": "World", - } - trace_response = { - "page": [ - { - "message": "I'm a robot", - } - ] - } - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - async def test_to_synapse_request(self): - # WHEN I call to_synapse_request on an initialized AgentPrompt - result = self.agent_prompt.to_synapse_request() - # THEN the result should be a dictionary with the correct keys and values - assert result == { - "concreteType": self.agent_prompt.concrete_type, - "sessionId": self.agent_prompt.session_id, - "chatText": self.agent_prompt.prompt, - "enableTrace": self.agent_prompt.enable_trace, - } - - async def test_fill_from_dict(self): - # WHEN I call fill_from_dict on an initialized AgentPrompt with a synapse_response - result_agent_prompt = self.agent_prompt.fill_from_dict(self.synapse_response) - # THEN the result should be an AgentPrompt with the correct values - assert result_agent_prompt.id == self.synapse_response["jobId"] - assert result_agent_prompt.session_id == self.synapse_response["sessionId"] - assert result_agent_prompt.response == self.synapse_response["responseText"] - - async def test_post_exchange_async_trace_enabled(self): - with patch( - "synapseclient.models.agent.get_trace", - new_callable=AsyncMock, - return_value=self.trace_response, - ) as mock_get_trace: - # WHEN I call _post_exchange_async on an - # initialized AgentPrompt with enable_trace=True - await self.agent_prompt._post_exchange_async(synapse_client=self.syn) - # THEN the mock_get_trace should have been called with the correct arguments - mock_get_trace.assert_called_once_with( - prompt_id=self.agent_prompt.id, - newer_than=None, - synapse_client=self.syn, - ) - # AND the trace should be set to the response from the mock_get_trace - assert self.agent_prompt.trace == self.trace_response["page"][0]["message"] - - async def test_post_exchange_async_trace_disabled(self): - with patch( - "synapseclient.models.agent.get_trace", - new_callable=AsyncMock, - return_value=self.trace_response, - ) as mock_get_trace: - self.agent_prompt.enable_trace = False - # WHEN I call _post_exchange_async on an - # initialized AgentPrompt with enable_trace=False - await self.agent_prompt._post_exchange_async(synapse_client=self.syn) - # THEN the mock_get_trace should not have been called - mock_get_trace.assert_not_called() - - async def test_send_job_and_wait_async(self): - with ( - patch( - "synapseclient.models.mixins.asynchronous_job.send_job_and_wait_async", - new_callable=AsyncMock, - return_value=self.synapse_response, - ) as mock_send_job_and_wait_async, - patch.object( - self.agent_prompt, - "to_synapse_request", - return_value=self.synapse_request, - ) as mock_to_synapse_request, - patch.object( - self.agent_prompt, - "fill_from_dict", - ) as mock_fill_from_dict, - patch.object( - self.agent_prompt, - "_post_exchange_async", - new_callable=AsyncMock, - ) as mock_post_exchange_async, - ): - # WHEN I call send_job_and_wait_async on an initialized AgentPrompt - await self.agent_prompt.send_job_and_wait_async( - post_exchange_args={"foo": "bar"}, synapse_client=self.syn - ) - # THEN the mock_send_job_and_wait_async should - # have been called with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - request=mock_to_synapse_request.return_value, - request_type=self.agent_prompt.concrete_type, - synapse_client=self.syn, - ) - # THEN the mock_fill_from_dict should have been called with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_response=self.synapse_response - ) - # AND the mock_post_exchange_async should have been called with the correct arguments - mock_post_exchange_async.assert_called_once_with( - synapse_client=self.syn, **{"foo": "bar"} - ) - - -class TestAgentSession: - """Unit tests for the AgentSession class' synchronous methods.""" - - test_session = AgentSession( - id="123", - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - started_on="2024-01-01T00:00:00Z", - started_by="123456789", - modified_on="2024-01-01T00:00:00Z", - agent_registration_id="0", - etag="11111111-1111-1111-1111-111111111111", - ) - - session_response = { - "sessionId": test_session.id, - "agentAccessLevel": test_session.access_level, - "startedOn": test_session.started_on, - "startedBy": test_session.started_by, - "modifiedOn": test_session.modified_on, - "agentRegistrationId": test_session.agent_registration_id, - "etag": test_session.etag, - } - - updated_test_session = AgentSession( - id=test_session.id, - access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, - started_on=test_session.started_on, - started_by=test_session.started_by, - modified_on=test_session.modified_on, - agent_registration_id=test_session.agent_registration_id, - etag=test_session.etag, - ) - - updated_session_response = { - "sessionId": updated_test_session.id, - "agentAccessLevel": updated_test_session.access_level, - "startedOn": updated_test_session.started_on, - "startedBy": updated_test_session.started_by, - "modifiedOn": updated_test_session.modified_on, - "agentRegistrationId": updated_test_session.agent_registration_id, - "etag": updated_test_session.etag, - } - - test_prompt_trace_enabled = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - response="World", - trace="Trace", - ) - - test_prompt_trace_disabled = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=False, - response="World", - trace=None, - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - async def test_fill_from_dict(self) -> None: - # WHEN I call fill_from_dict on an empty AgentSession with a synapse_response - result_session = AgentSession().fill_from_dict(self.session_response) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - - async def test_start_async(self) -> None: - with ( - patch( - "synapseclient.models.agent.start_session", - new_callable=AsyncMock, - return_value=self.session_response, - ) as mock_start_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with access_level and agent_registration_id - initial_session = AgentSession( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - agent_registration_id=0, - ) - # WHEN I call start - result_session = await initial_session.start_async(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND start_session should have been called once with the correct arguments - mock_start_session.assert_called_once_with( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - agent_registration_id=0, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.session_response - ) - - async def test_get_async(self) -> None: - with ( - patch( - "synapseclient.models.agent.get_session", - new_callable=AsyncMock, - return_value=self.session_response, - ) as mock_get_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with an agent_registration_id - initial_session = AgentSession( - agent_registration_id=0, - ) - # WHEN I call get - result_session = await initial_session.get_async(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND get_session should have been called once with the correct arguments - mock_get_session.assert_called_once_with( - id=initial_session.id, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.session_response - ) - - async def test_update_async(self) -> None: - with ( - patch( - "synapseclient.models.agent.update_session", - new_callable=AsyncMock, - return_value=self.updated_session_response, - ) as mock_update_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.updated_test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with an updated access_level - # WHEN I call update - result_session = await self.updated_test_session.update_async( - synapse_client=self.syn - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.updated_test_session - # AND update_session should have been called once with the correct arguments - mock_update_session.assert_called_once_with( - id=self.updated_test_session.id, - access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.updated_session_response - ) - - async def test_prompt_trace_enabled_print_response(self) -> None: - with ( - patch( - "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", - new_callable=AsyncMock, - return_value=self.test_prompt_trace_enabled, - ) as mock_send_job_and_wait_async, - patch.object( - self.syn.logger, - "info", - ) as mock_logger_info, - ): - # GIVEN an existing AgentSession - # WHEN I call prompt with trace enabled and print_response enabled - await self.test_session.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - newer_than=0, - synapse_client=self.syn, - ) - # THEN the result should be an AgentPrompt with the correct - # values appended to the chat history - assert self.test_prompt_trace_enabled in self.test_session.chat_history - # AND send_job_and_wait_async should have - # been called once with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - synapse_client=self.syn, post_exchange_args={"newer_than": 0} - ) - # AND the trace should be printed - mock_logger_info.assert_called_with( - f"TRACE:\n{self.test_prompt_trace_enabled.trace}" - ) - - async def test_prompt_trace_disabled_no_print(self) -> None: - with ( - patch( - "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", - new_callable=AsyncMock, - return_value=self.test_prompt_trace_disabled, - ) as mock_send_job_and_wait_async, - patch.object( - self.syn.logger, - "info", - ) as mock_logger_info, - ): - # WHEN I call prompt with trace disabled and print_response disabled - await self.test_session.prompt_async( - prompt="Hello", - enable_trace=False, - print_response=False, - newer_than=0, - synapse_client=self.syn, - ) - # THEN the result should be an AgentPrompt with the - # correct values appended to the chat history - assert self.test_prompt_trace_disabled in self.test_session.chat_history - # AND send_job_and_wait_async should have been - # called once with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - synapse_client=self.syn, post_exchange_args={"newer_than": 0} - ) - # AND print should not have been called - mock_logger_info.assert_not_called() - - -class TestAgent: - """Unit tests for the Agent class' synchronous methods.""" - - def get_example_agent(self) -> Agent: - return Agent( - cloud_agent_id="123", - cloud_alias_id="456", - registration_id=0, - type=AgentType.BASELINE, - registered_on="2024-01-01T00:00:00Z", - sessions={}, - current_session=None, - ) - - test_agent = Agent( - cloud_agent_id="123", - cloud_alias_id="456", - registration_id=0, - type=AgentType.BASELINE, - registered_on="2024-01-01T00:00:00Z", - sessions={}, - current_session=None, - ) - - agent_response = { - "awsAgentId": test_agent.cloud_agent_id, - "awsAliasId": test_agent.cloud_alias_id, - "agentRegistrationId": test_agent.registration_id, - "registeredOn": test_agent.registered_on, - "type": test_agent.type, - } - - test_session = AgentSession( - id="123", - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - started_on="2024-01-01T00:00:00Z", - started_by="123456789", - modified_on="2024-01-01T00:00:00Z", - agent_registration_id="0", - etag="11111111-1111-1111-1111-111111111111", - ) - - test_prompt = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - response="World", - trace="Trace", - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - async def test_fill_from_dict(self) -> None: - # GIVEN an empty Agent - empty_agent = Agent() - # WHEN I call fill_from_dict on an empty Agent with a synapse_response - result_agent = empty_agent.fill_from_dict( - agent_registration=self.agent_response - ) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - - async def test_register_async(self) -> None: - with ( - patch( - "synapseclient.models.agent.register_agent", - new_callable=AsyncMock, - return_value=self.agent_response, - ) as mock_register_agent, - patch.object( - Agent, - "fill_from_dict", - return_value=self.test_agent, - ) as mock_fill_from_dict, - ): - # GIVEN an Agent with a cloud_agent_id - initial_agent = Agent( - cloud_agent_id="123", - cloud_alias_id="456", - ) - # WHEN I call register - result_agent = await initial_agent.register_async(synapse_client=self.syn) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - # AND register_agent should have been called once with the correct arguments - mock_register_agent.assert_called_once_with( - cloud_agent_id="123", - cloud_alias_id="456", - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - agent_registration=self.agent_response - ) - - async def test_get_async(self) -> None: - with ( - patch( - "synapseclient.models.agent.get_agent", - new_callable=AsyncMock, - return_value=self.agent_response, - ) as mock_get_agent, - patch.object( - Agent, - "fill_from_dict", - return_value=self.test_agent, - ) as mock_fill_from_dict, - ): - # GIVEN an Agent with a registration_id - initial_agent = Agent( - registration_id=0, - ) - # WHEN I call get - result_agent = await initial_agent.get_async(synapse_client=self.syn) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - # AND get_agent should have been called once with the correct arguments - mock_get_agent.assert_called_once_with( - registration_id=0, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - agent_registration=self.agent_response - ) - - async def test_start_session_async(self) -> None: - with ( - patch.object( - AgentSession, - "start_async", - new_callable=AsyncMock, - return_value=self.test_session, - ) as mock_start_session, - ): - # GIVEN an existing Agent - my_agent = self.get_example_agent() - # WHEN I call start_session - result_session = await my_agent.start_session_async( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - synapse_client=self.syn, - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND start_session should have been called once with the correct arguments - mock_start_session.assert_called_once_with( - synapse_client=self.syn, - ) - # AND the current_session should be set to the new session - assert my_agent.current_session == self.test_session - # AND the sessions dictionary should have the new session - assert my_agent.sessions[self.test_session.id] == self.test_session - - async def test_get_session_async(self) -> None: - with ( - patch.object( - AgentSession, - "get_async", - new_callable=AsyncMock, - return_value=self.test_session, - ) as mock_get_session, - ): - # GIVEN an existing AgentSession - my_agent = self.get_example_agent() - # WHEN I call get_session - result_session = await my_agent.get_session_async( - session_id="123", synapse_client=self.syn - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND get_session should have been called once with the correct arguments - mock_get_session.assert_called_once_with( - synapse_client=self.syn, - ) - # AND the current_session should be set to the session - assert my_agent.current_session == self.test_session - # AND the sessions dictionary should have the session - assert my_agent.sessions[self.test_session.id] == self.test_session - - async def test_prompt_session_selected(self) -> None: - with ( - patch.object( - AgentSession, - "get_async", - new_callable=AsyncMock, - return_value=self.test_session, - ) as mock_get_async, - patch.object( - Agent, - "start_session_async", - new_callable=AsyncMock, - ) as mock_start_session, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing AgentSession - my_agent = self.get_example_agent() - # WHEN I call prompt with a session selected - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - session=self.test_session, - newer_than=0, - synapse_client=self.syn, - ) - # AND get_session_async should have been called once with the correct arguments - mock_get_async.assert_called_once_with( - synapse_client=self.syn, - ) - # AND start_session_async should not have been called - mock_start_session.assert_not_called() - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - - async def test_prompt_session_none_current_session_none(self) -> None: - with ( - patch.object( - Agent, - "get_session_async", - new_callable=AsyncMock, - ) as mock_get_session, - patch.object( - AgentSession, - "start_async", - new_callable=AsyncMock, - return_value=self.test_session, - ) as mock_start_async, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing Agent with no current session - my_agent = self.get_example_agent() - # WHEN I call prompt with no session selected and no current session set - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - print_response=True, - newer_than=0, - synapse_client=self.syn, - ) - # THEN get_session_async should not have been called - mock_get_session.assert_not_called() - # AND start_session_async should have been called once with the correct arguments - mock_start_async.assert_called_once_with( - synapse_client=self.syn, - ) - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - - async def test_prompt_session_none_current_session_present(self) -> None: - with ( - patch.object( - Agent, - "get_session_async", - new_callable=AsyncMock, - ) as mock_get_session, - patch.object( - AgentSession, - "start_async", - new_callable=AsyncMock, - ) as mock_start_async, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing Agent with a current session - my_agent = self.get_example_agent() - my_agent.current_session = self.test_session - # WHEN I call prompt with no session selected and a current session set - await my_agent.prompt_async( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - # THEN get_session_async and start_session_async should not have been called - mock_get_session.assert_not_called() - mock_start_async.assert_not_called() - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - - async def test_get_chat_history_when_current_session_none(self) -> None: - # GIVEN an existing Agent with no current session - my_agent = self.get_example_agent() - # WHEN I call get_chat_history - result_chat_history = my_agent.get_chat_history() - # THEN the result should be None - assert result_chat_history is None - - async def test_get_chat_history_when_current_session_and_chat_history_present( - self, - ) -> None: - # GIVEN an existing Agent with a current session and chat history - my_agent = self.get_example_agent() - my_agent.current_session = self.test_session - my_agent.current_session.chat_history = [self.test_prompt] - # WHEN I call get_chat_history - result_chat_history = my_agent.get_chat_history() - # THEN the result should be the chat history - assert self.test_prompt in result_chat_history diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_agent.py b/tests/unit/synapseclient/models/synchronous/unit_test_agent.py deleted file mode 100644 index 83f33cb7b..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_agent.py +++ /dev/null @@ -1,588 +0,0 @@ -"""Unit tests for Synchronous methods in Agent, AgentSession, and AgentPrompt classes.""" - -from unittest.mock import patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -from synapseclient.models.agent import ( - Agent, - AgentPrompt, - AgentSession, - AgentSessionAccessLevel, - AgentType, -) - - -class TestAgentPrompt: - """Unit tests for the AgentPrompt class' synchronous methods.""" - - test_prompt = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - ) - prompt_request = { - "concreteType": test_prompt.concrete_type, - "sessionId": test_prompt.session_id, - "chatText": test_prompt.prompt, - "enableTrace": test_prompt.enable_trace, - } - prompt_response = { - "jobId": "123", - "sessionId": "456", - "responseText": "World", - } - - def test_to_synapse_request(self) -> None: - # GIVEN an existing AgentPrompt - # WHEN I call to_synapse_request - result_request = self.test_prompt.to_synapse_request() - # THEN the result should be a dictionary with the correct keys and values - assert result_request == self.prompt_request - - def test_fill_from_dict(self) -> None: - # GIVEN an existing AgentPrompt - # WHEN I call fill_from_dict - result_prompt = self.test_prompt.fill_from_dict(self.prompt_response) - # THEN the result should be an AgentPrompt with the correct values - assert result_prompt == self.test_prompt - - -class TestAgentSession: - """Unit tests for the AgentSession class' synchronous methods.""" - - test_session = AgentSession( - id="123", - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - started_on="2024-01-01T00:00:00Z", - started_by="123456789", - modified_on="2024-01-01T00:00:00Z", - agent_registration_id="0", - etag="11111111-1111-1111-1111-111111111111", - ) - - session_response = { - "sessionId": test_session.id, - "agentAccessLevel": test_session.access_level, - "startedOn": test_session.started_on, - "startedBy": test_session.started_by, - "modifiedOn": test_session.modified_on, - "agentRegistrationId": test_session.agent_registration_id, - "etag": test_session.etag, - } - - updated_test_session = AgentSession( - id=test_session.id, - access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, - started_on=test_session.started_on, - started_by=test_session.started_by, - modified_on=test_session.modified_on, - agent_registration_id=test_session.agent_registration_id, - etag=test_session.etag, - ) - - updated_session_response = { - "sessionId": updated_test_session.id, - "agentAccessLevel": updated_test_session.access_level, - "startedOn": updated_test_session.started_on, - "startedBy": updated_test_session.started_by, - "modifiedOn": updated_test_session.modified_on, - "agentRegistrationId": updated_test_session.agent_registration_id, - "etag": updated_test_session.etag, - } - - test_prompt_trace_enabled = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - response="World", - trace="Trace", - ) - - test_prompt_trace_disabled = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=False, - response="World", - trace=None, - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def test_fill_from_dict(self) -> None: - # WHEN I call fill_from_dict on an empty AgentSession with a synapse_response - result_session = AgentSession().fill_from_dict(self.session_response) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - - def test_start(self) -> None: - with ( - patch( - "synapseclient.models.agent.start_session", - return_value=self.session_response, - ) as mock_start_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with access_level and agent_registration_id - initial_session = AgentSession( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - agent_registration_id=0, - ) - # WHEN I call start - result_session = initial_session.start(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND start_session should have been called once with the correct arguments - mock_start_session.assert_called_once_with( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - agent_registration_id=0, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.session_response - ) - - def test_get(self) -> None: - with ( - patch( - "synapseclient.models.agent.get_session", - return_value=self.session_response, - ) as mock_get_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with an agent_registration_id - initial_session = AgentSession( - agent_registration_id=0, - ) - # WHEN I call get - result_session = initial_session.get(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND get_session should have been called once with the correct arguments - mock_get_session.assert_called_once_with( - id=initial_session.id, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.session_response - ) - - def test_update(self) -> None: - with ( - patch( - "synapseclient.models.agent.update_session", - return_value=self.updated_session_response, - ) as mock_update_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.updated_test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with an updated access_level - # WHEN I call update - result_session = self.updated_test_session.update(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.updated_test_session - # AND update_session should have been called once with the correct arguments - mock_update_session.assert_called_once_with( - id=self.updated_test_session.id, - access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.updated_session_response - ) - - def test_prompt_trace_enabled_print_response(self) -> None: - with ( - patch( - "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", - return_value=self.test_prompt_trace_enabled, - ) as mock_send_job_and_wait_async, - patch.object( - self.syn.logger, - "info", - ) as mock_logger_info, - ): - # GIVEN an existing AgentSession - # WHEN I call prompt with trace enabled and print_response enabled - self.test_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - newer_than=0, - synapse_client=self.syn, - ) - # THEN the result should be an AgentPrompt with the correct values appended to the chat history - assert self.test_prompt_trace_enabled in self.test_session.chat_history - # AND send_job_and_wait_async should have been called once with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - synapse_client=self.syn, post_exchange_args={"newer_than": 0} - ) - # AND the trace should be printed - mock_logger_info.assert_called_with( - f"TRACE:\n{self.test_prompt_trace_enabled.trace}" - ) - - def test_prompt_trace_disabled_no_print(self) -> None: - with ( - patch( - "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", - return_value=self.test_prompt_trace_disabled, - ) as mock_send_job_and_wait_async, - patch.object( - self.syn.logger, - "info", - ) as mock_logger_info, - ): - # WHEN I call prompt with trace disabled and print_response disabled - self.test_session.prompt( - prompt="Hello", - enable_trace=False, - print_response=False, - newer_than=0, - synapse_client=self.syn, - ) - # THEN the result should be an AgentPrompt with the correct values appended to the chat history - assert self.test_prompt_trace_disabled in self.test_session.chat_history - # AND send_job_and_wait_async should have been called once with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - synapse_client=self.syn, post_exchange_args={"newer_than": 0} - ) - # AND print should not have been called - mock_logger_info.assert_not_called() - - -class TestAgent: - """Unit tests for the Agent class' synchronous methods.""" - - def get_example_agent(self) -> Agent: - return Agent( - cloud_agent_id="123", - cloud_alias_id="456", - registration_id=0, - type=AgentType.BASELINE, - registered_on="2024-01-01T00:00:00Z", - sessions={}, - current_session=None, - ) - - test_agent = Agent( - cloud_agent_id="123", - cloud_alias_id="456", - registration_id=0, - type=AgentType.BASELINE, - registered_on="2024-01-01T00:00:00Z", - sessions={}, - current_session=None, - ) - - agent_response = { - "awsAgentId": test_agent.cloud_agent_id, - "awsAliasId": test_agent.cloud_alias_id, - "agentRegistrationId": test_agent.registration_id, - "registeredOn": test_agent.registered_on, - "type": test_agent.type, - } - - test_session = AgentSession( - id="123", - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - started_on="2024-01-01T00:00:00Z", - started_by="123456789", - modified_on="2024-01-01T00:00:00Z", - agent_registration_id="0", - etag="11111111-1111-1111-1111-111111111111", - ) - - test_prompt = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - response="World", - trace="Trace", - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def test_fill_from_dict(self) -> None: - # GIVEN an empty Agent - empty_agent = Agent() - # WHEN I call fill_from_dict on an empty Agent with a synapse_response - result_agent = empty_agent.fill_from_dict( - agent_registration=self.agent_response - ) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - - def test_register(self) -> None: - with ( - patch( - "synapseclient.models.agent.register_agent", - return_value=self.agent_response, - ) as mock_register_agent, - patch.object( - Agent, - "fill_from_dict", - return_value=self.test_agent, - ) as mock_fill_from_dict, - ): - # GIVEN an Agent with a cloud_agent_id - initial_agent = Agent( - cloud_agent_id="123", - cloud_alias_id="456", - ) - # WHEN I call register - result_agent = initial_agent.register(synapse_client=self.syn) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - # AND register_agent should have been called once with the correct arguments - mock_register_agent.assert_called_once_with( - cloud_agent_id="123", - cloud_alias_id="456", - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - agent_registration=self.agent_response - ) - - def test_get(self) -> None: - with ( - patch( - "synapseclient.models.agent.get_agent", - return_value=self.agent_response, - ) as mock_get_agent, - patch.object( - Agent, - "fill_from_dict", - return_value=self.test_agent, - ) as mock_fill_from_dict, - ): - # GIVEN an Agent with a registration_id - initial_agent = Agent( - registration_id=0, - ) - # WHEN I call get - result_agent = initial_agent.get(synapse_client=self.syn) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - # AND get_agent should have been called once with the correct arguments - mock_get_agent.assert_called_once_with( - registration_id=0, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - agent_registration=self.agent_response - ) - - def test_start_session(self) -> None: - with patch.object( - AgentSession, - "start_async", - return_value=self.test_session, - ) as mock_start_session: - # GIVEN an existing Agent - my_agent = self.get_example_agent() - # WHEN I call start_session - result_session = my_agent.start_session( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - synapse_client=self.syn, - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND start_session should have been called once with the correct arguments - mock_start_session.assert_called_once_with( - synapse_client=self.syn, - ) - # AND the current_session should be set to the new session - assert my_agent.current_session == self.test_session - # AND the sessions dictionary should have the new session - assert my_agent.sessions[self.test_session.id] == self.test_session - - def test_get_session(self) -> None: - with patch.object( - AgentSession, - "get_async", - return_value=self.test_session, - ) as mock_get_session: - # GIVEN an existing AgentSession - my_agent = self.get_example_agent() - # WHEN I call get_session - result_session = my_agent.get_session( - session_id="123", synapse_client=self.syn - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND get_session should have been called once with the correct arguments - mock_get_session.assert_called_once_with( - synapse_client=self.syn, - ) - # AND the current_session should be set to the session - assert my_agent.current_session == self.test_session - # AND the sessions dictionary should have the session - assert my_agent.sessions[self.test_session.id] == self.test_session - - def test_prompt_session_selected(self) -> None: - with ( - patch.object( - AgentSession, - "get_async", - return_value=self.test_session, - ) as mock_get_async, - patch.object( - Agent, - "start_session_async", - ) as mock_start_session, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing AgentSession - my_agent = self.get_example_agent() - # WHEN I call prompt with a session selected - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - session=self.test_session, - newer_than=0, - synapse_client=self.syn, - ) - # AND get_session_async should have been called once with the correct arguments - mock_get_async.assert_called_once_with( - synapse_client=self.syn, - ) - # AND start_session_async should not have been called - mock_start_session.assert_not_called() - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - - def test_prompt_session_none_current_session_none(self) -> None: - with ( - patch.object( - Agent, - "get_session_async", - ) as mock_get_session, - patch.object( - AgentSession, - "start_async", - return_value=self.test_session, - ) as mock_start_async, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing Agent with no current session - my_agent = self.get_example_agent() - # WHEN I call prompt with no session selected and no current session set - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - newer_than=0, - synapse_client=self.syn, - ) - # THEN get_session_async should not have been called - mock_get_session.assert_not_called() - # AND start_session_async should have been called once with the correct arguments - mock_start_async.assert_called_once_with( - synapse_client=self.syn, - ) - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - - def test_prompt_session_none_current_session_present(self) -> None: - with ( - patch.object( - Agent, - "get_session_async", - ) as mock_get_session, - patch.object( - AgentSession, - "start_async", - ) as mock_start_async, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing Agent with a current session - my_agent = self.get_example_agent() - my_agent.current_session = self.test_session - # WHEN I call prompt with no session selected and a current session set - my_agent.prompt( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - # THEN get_session_async and start_session_async should not have been called - mock_get_session.assert_not_called() - mock_start_async.assert_not_called() - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - - def test_get_chat_history_when_current_session_none(self) -> None: - # GIVEN an existing Agent with no current session - my_agent = self.get_example_agent() - # WHEN I call get_chat_history - result_chat_history = my_agent.get_chat_history() - # THEN the result should be None - assert result_chat_history is None - - def test_get_chat_history_when_current_session_and_chat_history_present( - self, - ) -> None: - # GIVEN an existing Agent with a current session and chat history - my_agent = self.get_example_agent() - my_agent.current_session = self.test_session - my_agent.current_session.chat_history = [self.test_prompt] - # WHEN I call get_chat_history - result_chat_history = my_agent.get_chat_history() - # THEN the result should be the chat history - assert self.test_prompt in result_chat_history From 061bfef55dff6570cbabd3f041aaa62b41732783 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 23 Apr 2025 12:45:59 -0700 Subject: [PATCH 06/11] Release 4.8.0 --- docs/news.md | 42 +++++++++++++++++++++++++++++++ synapseclient/synapsePythonClient | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/news.md b/docs/news.md index 9b16bf59e..a3de332b6 100644 --- a/docs/news.md +++ b/docs/news.md @@ -9,6 +9,48 @@ detailing some of the changes. the 4.x.x versions hidden behind optional feature flags or different import paths. Any breaking changes will not be included until v5.0. +## 4.8.0 (2025-04-23) + +### Highlights + +- Introduced new object-oriented models for working with Synapse [Datasets](https://python-docs.synapse.org/en/stable/tutorials/python/dataset/), [DatasetCollections](https://python-docs.synapse.org/en/stable/tutorials/python/dataset_collection/), [EntityViews](https://python-docs.synapse.org/en/stable/tutorials/python/entityview/), [MaterializedViews](https://python-docs.synapse.org/en/stable/tutorials/python/materializedview/), and [SubmissionViews](https://python-docs.synapse.org/en/stable/tutorials/python/submissionview/). This includes tutorials for each of these models. +- Improved handling of progress bars, logging, and error messages +- Added support for Python 3.13 + +### Features + +- [SYNPY-1571] Introduced `Dataset` model and composition model for `Table`/`View`-like classes ([#1175](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1175)) +- [SYNPY-1575] Introduced `EntityView` model ([#1181](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1181)) +- [SYNPY-1579] Introduced `MaterializedView` model ([#1190](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1190)) +- [SYNPY-1577] Introduced `SubmissionView` model ([#1192](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1192)) +- [SYNPY-1578] Introduced `DatasetCollection` model ([#1189](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1189)) + +### Bug Fixes + +- [SYNPY-1547] Fixed `parentWikiId=""` bug ([#1165](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1165)) +- [SYNPY-1553] Removed blank auth header and improved error message for unauthenticated requests ([#1171](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1171), [#1185](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1185)) +- [SYNPY-1584] Fixed issue with DataFrame upload and header writing ([#1193](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1193)) + +### Tech Debt + +- [SYNPY-1551] Refactored `Table` model ([#1151](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1151)) +- [SYNPY-1488] Patched nested TQDM progress bars and messages to logger ([#1177](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1177)) +- [SYNPY-1497] Refactored version check to use PyPI for version info ([#1191](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1191)) + +### Other + +- [DPE-1253] Added PR template for GitHub Pull requests ([#1182](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1182)) +- [SYNPY-1542] Upgraded ReadTheDocs OS, Python version, and search ranking ([#1184](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1184)) +- Updated Dockerfile to fix `pandas` installation ([#1169](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1169)) +- Updated table and file versioning tutorials ([#1172](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1172)) +- Prevented concurrent builds per branch ([#1178](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1178)) +- Included default timeout for HTTP requests ([#1188](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1188)) +- Corrected regular expression for invalid column name ([#1187](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1187)) +- Updated docstring for `setPermissions` function ([#1164](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1164)) +- Added SECURITY.md ([#1166](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1166)) +- Fixed typo in dataset tutorial ([#1186](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1186)) + + ## 4.7.0 (2025-01-31) ### Highlights diff --git a/synapseclient/synapsePythonClient b/synapseclient/synapsePythonClient index 5aeb673c5..237a9470d 100644 --- a/synapseclient/synapsePythonClient +++ b/synapseclient/synapsePythonClient @@ -1,6 +1,6 @@ { "client": "synapsePythonClient", - "latestVersion": "4.7.0", + "latestVersion": "4.8.0", "blacklist": [ "0.0.0", "0.4.1", From 249fc6688dcd618a7e7fe458051a1542e89e718e Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Mon, 28 Apr 2025 09:36:44 -0700 Subject: [PATCH 07/11] Update news --- docs/news.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/news.md b/docs/news.md index 07a67869e..8b7a64765 100644 --- a/docs/news.md +++ b/docs/news.md @@ -9,7 +9,7 @@ detailing some of the changes. the 4.x.x versions hidden behind optional feature flags or different import paths. Any breaking changes will not be included until v5.0. -## 4.8.0 (2025-04-23) +## 4.8.0 (2025-04-28) ### Highlights @@ -27,6 +27,7 @@ breaking changes will not be included until v5.0. ### Bug Fixes +- [SYNPY-1593] Enforce minimum httpcore dependency to prevent critical CVE ([#1197](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1197)) - [SYNPY-1547] Fixed `parentWikiId=""` bug ([#1165](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1165)) - [SYNPY-1553] Removed blank auth header and improved error message for unauthenticated requests ([#1171](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1171), [#1185](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1185)) - [SYNPY-1584] Fixed issue with DataFrame upload and header writing ([#1193](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1193)) From 9e35d8a65d7b033051542d8427d2b815a221ae5a Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Mon, 28 Apr 2025 09:37:40 -0700 Subject: [PATCH 08/11] temp: Skip integration test run due to time --- .github/workflows/build.yml | 108 ++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0beef3f99..427407aff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -161,61 +161,61 @@ jobs: # run integration tests on the oldest and newest supported versions of python. # we don't run on the entire matrix to avoid a 3xN set of concurrent tests against # the target server where N is the number of supported python versions. - - name: run-integration-tests - shell: bash - - # keep versions consistent with the first and last from the strategy matrix - if: ${{ (contains(fromJSON('["3.9"]'), matrix.python) || contains(fromJSON('["3.13"]'), matrix.python)) && steps.secret-check.outputs.secrets_available == 'true'}} - run: | - # decrypt the encrypted test synapse configuration - openssl aes-256-cbc -K ${{ secrets.encrypted_d17283647768_key }} -iv ${{ secrets.encrypted_d17283647768_iv }} -in test.synapseConfig.enc -out test.synapseConfig -d - mv test.synapseConfig ~/.synapseConfig - - if [ "${{ startsWith(matrix.os, 'ubuntu') }}" == "true" ]; then - # on linux only we can build and run a docker container to serve as an SFTP host for our SFTP tests. - # Docker is not available on GH Action runners on Mac and Windows. - - docker build -t sftp_tests - < tests/integration/synapseclient/core/upload/Dockerfile_sftp - docker run -d sftp_tests:latest - - # get the internal IP address of the just launched container - export SFTP_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q)) - - printf "[sftp://$SFTP_HOST]\nusername: test\npassword: test\n" >> ~/.synapseConfig - - # add to known_hosts so the ssh connections can be made without any prompting/errors - mkdir -p ~/.ssh - ssh-keyscan -H $SFTP_HOST >> ~/.ssh/known_hosts - fi - - # set env vars used in external bucket tests from secrets - export EXTERNAL_S3_BUCKET_NAME="${{secrets.EXTERNAL_S3_BUCKET_NAME}}" - export EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID="${{secrets.EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID}}" - export EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY="${{secrets.EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY}}" - if [ ${{ steps.otel-check.outputs.run_opentelemetry }} == "true" ]; then - # Set to 'file' to enable OpenTelemetry export to file - export SYNAPSE_OTEL_INTEGRATION_TEST_EXPORTER="file" - fi - - # use loadscope to avoid issues running tests concurrently that share scoped fixtures - pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration -n 8 --ignore=tests/integration/synapseclient/test_command_line_client.py --dist loadscope + # - name: run-integration-tests + # shell: bash - # Execute the CLI tests in a non-dist way because they were causing some test instability when being run concurrently - pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration/synapseclient/test_command_line_client.py - - name: Upload otel spans - uses: actions/upload-artifact@v4 - if: always() - with: - name: otel_spans_integration_testing_${{ matrix.os }} - path: tests/integration/otel_spans_integration_testing_*.ndjson - if-no-files-found: ignore - - name: Upload coverage report - id: upload_coverage_report - uses: actions/upload-artifact@v4 - if: ${{ contains(fromJSON('["3.13"]'), matrix.python) && contains(fromJSON('["ubuntu-22.04"]'), matrix.os)}} - with: - name: coverage-report - path: coverage.xml + # # keep versions consistent with the first and last from the strategy matrix + # if: ${{ (contains(fromJSON('["3.9"]'), matrix.python) || contains(fromJSON('["3.13"]'), matrix.python)) && steps.secret-check.outputs.secrets_available == 'true'}} + # run: | + # # decrypt the encrypted test synapse configuration + # openssl aes-256-cbc -K ${{ secrets.encrypted_d17283647768_key }} -iv ${{ secrets.encrypted_d17283647768_iv }} -in test.synapseConfig.enc -out test.synapseConfig -d + # mv test.synapseConfig ~/.synapseConfig + + # if [ "${{ startsWith(matrix.os, 'ubuntu') }}" == "true" ]; then + # # on linux only we can build and run a docker container to serve as an SFTP host for our SFTP tests. + # # Docker is not available on GH Action runners on Mac and Windows. + + # docker build -t sftp_tests - < tests/integration/synapseclient/core/upload/Dockerfile_sftp + # docker run -d sftp_tests:latest + + # # get the internal IP address of the just launched container + # export SFTP_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q)) + + # printf "[sftp://$SFTP_HOST]\nusername: test\npassword: test\n" >> ~/.synapseConfig + + # # add to known_hosts so the ssh connections can be made without any prompting/errors + # mkdir -p ~/.ssh + # ssh-keyscan -H $SFTP_HOST >> ~/.ssh/known_hosts + # fi + + # # set env vars used in external bucket tests from secrets + # export EXTERNAL_S3_BUCKET_NAME="${{secrets.EXTERNAL_S3_BUCKET_NAME}}" + # export EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID="${{secrets.EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID}}" + # export EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY="${{secrets.EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY}}" + # if [ ${{ steps.otel-check.outputs.run_opentelemetry }} == "true" ]; then + # # Set to 'file' to enable OpenTelemetry export to file + # export SYNAPSE_OTEL_INTEGRATION_TEST_EXPORTER="file" + # fi + + # # use loadscope to avoid issues running tests concurrently that share scoped fixtures + # pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration -n 8 --ignore=tests/integration/synapseclient/test_command_line_client.py --dist loadscope + + # # Execute the CLI tests in a non-dist way because they were causing some test instability when being run concurrently + # pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration/synapseclient/test_command_line_client.py + # - name: Upload otel spans + # uses: actions/upload-artifact@v4 + # if: always() + # with: + # name: otel_spans_integration_testing_${{ matrix.os }} + # path: tests/integration/otel_spans_integration_testing_*.ndjson + # if-no-files-found: ignore + # - name: Upload coverage report + # id: upload_coverage_report + # uses: actions/upload-artifact@v4 + # if: ${{ contains(fromJSON('["3.13"]'), matrix.python) && contains(fromJSON('["ubuntu-22.04"]'), matrix.os)}} + # with: + # name: coverage-report + # path: coverage.xml sonarcloud: needs: [test] From 0cfddb3f71c4a39c96d06a1f722a78b3735f18c2 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Mon, 28 Apr 2025 09:47:02 -0700 Subject: [PATCH 09/11] Revert "temp: Skip integration test run due to time" This reverts commit 9e35d8a65d7b033051542d8427d2b815a221ae5a. --- .github/workflows/build.yml | 108 ++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 427407aff..0beef3f99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -161,61 +161,61 @@ jobs: # run integration tests on the oldest and newest supported versions of python. # we don't run on the entire matrix to avoid a 3xN set of concurrent tests against # the target server where N is the number of supported python versions. - # - name: run-integration-tests - # shell: bash + - name: run-integration-tests + shell: bash - # # keep versions consistent with the first and last from the strategy matrix - # if: ${{ (contains(fromJSON('["3.9"]'), matrix.python) || contains(fromJSON('["3.13"]'), matrix.python)) && steps.secret-check.outputs.secrets_available == 'true'}} - # run: | - # # decrypt the encrypted test synapse configuration - # openssl aes-256-cbc -K ${{ secrets.encrypted_d17283647768_key }} -iv ${{ secrets.encrypted_d17283647768_iv }} -in test.synapseConfig.enc -out test.synapseConfig -d - # mv test.synapseConfig ~/.synapseConfig - - # if [ "${{ startsWith(matrix.os, 'ubuntu') }}" == "true" ]; then - # # on linux only we can build and run a docker container to serve as an SFTP host for our SFTP tests. - # # Docker is not available on GH Action runners on Mac and Windows. - - # docker build -t sftp_tests - < tests/integration/synapseclient/core/upload/Dockerfile_sftp - # docker run -d sftp_tests:latest - - # # get the internal IP address of the just launched container - # export SFTP_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q)) - - # printf "[sftp://$SFTP_HOST]\nusername: test\npassword: test\n" >> ~/.synapseConfig - - # # add to known_hosts so the ssh connections can be made without any prompting/errors - # mkdir -p ~/.ssh - # ssh-keyscan -H $SFTP_HOST >> ~/.ssh/known_hosts - # fi - - # # set env vars used in external bucket tests from secrets - # export EXTERNAL_S3_BUCKET_NAME="${{secrets.EXTERNAL_S3_BUCKET_NAME}}" - # export EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID="${{secrets.EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID}}" - # export EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY="${{secrets.EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY}}" - # if [ ${{ steps.otel-check.outputs.run_opentelemetry }} == "true" ]; then - # # Set to 'file' to enable OpenTelemetry export to file - # export SYNAPSE_OTEL_INTEGRATION_TEST_EXPORTER="file" - # fi - - # # use loadscope to avoid issues running tests concurrently that share scoped fixtures - # pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration -n 8 --ignore=tests/integration/synapseclient/test_command_line_client.py --dist loadscope - - # # Execute the CLI tests in a non-dist way because they were causing some test instability when being run concurrently - # pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration/synapseclient/test_command_line_client.py - # - name: Upload otel spans - # uses: actions/upload-artifact@v4 - # if: always() - # with: - # name: otel_spans_integration_testing_${{ matrix.os }} - # path: tests/integration/otel_spans_integration_testing_*.ndjson - # if-no-files-found: ignore - # - name: Upload coverage report - # id: upload_coverage_report - # uses: actions/upload-artifact@v4 - # if: ${{ contains(fromJSON('["3.13"]'), matrix.python) && contains(fromJSON('["ubuntu-22.04"]'), matrix.os)}} - # with: - # name: coverage-report - # path: coverage.xml + # keep versions consistent with the first and last from the strategy matrix + if: ${{ (contains(fromJSON('["3.9"]'), matrix.python) || contains(fromJSON('["3.13"]'), matrix.python)) && steps.secret-check.outputs.secrets_available == 'true'}} + run: | + # decrypt the encrypted test synapse configuration + openssl aes-256-cbc -K ${{ secrets.encrypted_d17283647768_key }} -iv ${{ secrets.encrypted_d17283647768_iv }} -in test.synapseConfig.enc -out test.synapseConfig -d + mv test.synapseConfig ~/.synapseConfig + + if [ "${{ startsWith(matrix.os, 'ubuntu') }}" == "true" ]; then + # on linux only we can build and run a docker container to serve as an SFTP host for our SFTP tests. + # Docker is not available on GH Action runners on Mac and Windows. + + docker build -t sftp_tests - < tests/integration/synapseclient/core/upload/Dockerfile_sftp + docker run -d sftp_tests:latest + + # get the internal IP address of the just launched container + export SFTP_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q)) + + printf "[sftp://$SFTP_HOST]\nusername: test\npassword: test\n" >> ~/.synapseConfig + + # add to known_hosts so the ssh connections can be made without any prompting/errors + mkdir -p ~/.ssh + ssh-keyscan -H $SFTP_HOST >> ~/.ssh/known_hosts + fi + + # set env vars used in external bucket tests from secrets + export EXTERNAL_S3_BUCKET_NAME="${{secrets.EXTERNAL_S3_BUCKET_NAME}}" + export EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID="${{secrets.EXTERNAL_S3_BUCKET_AWS_ACCESS_KEY_ID}}" + export EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY="${{secrets.EXTERNAL_S3_BUCKET_AWS_SECRET_ACCESS_KEY}}" + if [ ${{ steps.otel-check.outputs.run_opentelemetry }} == "true" ]; then + # Set to 'file' to enable OpenTelemetry export to file + export SYNAPSE_OTEL_INTEGRATION_TEST_EXPORTER="file" + fi + + # use loadscope to avoid issues running tests concurrently that share scoped fixtures + pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration -n 8 --ignore=tests/integration/synapseclient/test_command_line_client.py --dist loadscope + + # Execute the CLI tests in a non-dist way because they were causing some test instability when being run concurrently + pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration/synapseclient/test_command_line_client.py + - name: Upload otel spans + uses: actions/upload-artifact@v4 + if: always() + with: + name: otel_spans_integration_testing_${{ matrix.os }} + path: tests/integration/otel_spans_integration_testing_*.ndjson + if-no-files-found: ignore + - name: Upload coverage report + id: upload_coverage_report + uses: actions/upload-artifact@v4 + if: ${{ contains(fromJSON('["3.13"]'), matrix.python) && contains(fromJSON('["ubuntu-22.04"]'), matrix.os)}} + with: + name: coverage-report + path: coverage.xml sonarcloud: needs: [test] From 97c236453886ff786d430c871fc8f321bdb68f76 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:40:32 +0000 Subject: [PATCH 10/11] Release notes for 4.9.0 --- docs/news.md | 35 +++++++++++++++++++++++++++++++ synapseclient/synapsePythonClient | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/news.md b/docs/news.md index e9330dd26..a443abb2e 100644 --- a/docs/news.md +++ b/docs/news.md @@ -9,6 +9,41 @@ detailing some of the changes. the 4.x.x versions hidden behind optional feature flags or different import paths. Any breaking changes will not be included until v5.0. +## 4.9.0 (2025-07-09) + +## Highlights + +- Multi-Profile support is now available when using the `.synapseConfig` file. Check out the [updated Authentication instructions](https://python-docs.synapse.org/en/latest/tutorials/authentication/#use-synapseconfig) that covers how to take advantage of this feature. +- Introduced streamlined functionality for [managing JSON schemas](https://python-docs.synapse.org/en/latest/tutorials/python/json_schema/) and [access control lists](https://python-docs.synapse.org/en/latest/tutorials/python/sharing_settings/) (ACLs) +- Enhanced OpenTelemetry tracing for file transfers and MD5 calculations +- Added support for [Virtual Tables](https://python-docs.synapse.org/en/latest/tutorials/python/virtualtable/) + +## Features + +- [SYNPY-893] Added support for multiple authentication profiles ([#1194](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1194)) +- [SYNPY-1580] Implemented `VirtualTable` OOP model ([#1195](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1195)) +- [SYNPY-1599] Added JSON schema mixin class for binding, validating, and unbinding schemas ([#1205](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1205)) +- [SYNPY-1607] Enabled string-based conversion for `ColumnType` and `FacetType` ([#1210](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1210)) +- [SYNPY-1604] Introduced `dry_run` flag and `list_acl` method for ACL management ([#1207](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1207)) +- [SYNPY-1244] Implemented recursive ACL deletion and permission inheritance detection ([#1200](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1200), [#1202](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1202)) + +## Bug Fixes + +- [SYNPY-1581] Removed exception logging and raising in async methods ([#1203](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1203)) + +## Tech Debt + +- [SYNPY-1295] Trimmed down integration tests and combined similar logic ([#1199](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1199)) +- [SYNPY-1606] Added OpenTelemetry metrics for file uploads, downloads, and MD5 calculations ([#1204](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1204)) +- [SYNPY-1618] Added scripts for cleaning up test resources in Synapse ([#1209](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1209)) +- [SYNPY-1599] Patched JSON schema code and improved examples ([#1211](https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1211)) + +## New Contributors +* @SageGJ made their first contribution in https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1203 +* @carmmmm made their first contribution in https://github.com/Sage-Bionetworks/synapsePythonClient/pull/1183 + +Full Changelog: https://github.com/Sage-Bionetworks/synapsePythonClient/compare/v4.8.0...v4.9.0-rc + ## 4.8.0 (2025-04-28) ### Highlights diff --git a/synapseclient/synapsePythonClient b/synapseclient/synapsePythonClient index 237a9470d..7ada7c506 100644 --- a/synapseclient/synapsePythonClient +++ b/synapseclient/synapsePythonClient @@ -1,6 +1,6 @@ { "client": "synapsePythonClient", - "latestVersion": "4.8.0", + "latestVersion": "4.9.0", "blacklist": [ "0.0.0", "0.4.1", From 7cdada651f16ae3e280bfbbf75617433c1f0d539 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Thu, 10 Jul 2025 18:13:12 +0000 Subject: [PATCH 11/11] Fix line references in JSON schema tutorial and update logging to use Synapse client logger --- docs/tutorials/python/json_schema.md | 20 +++---- .../python/tutorial_scripts/json_schema.py | 55 ++++++------------- 2 files changed, 26 insertions(+), 49 deletions(-) diff --git a/docs/tutorials/python/json_schema.md b/docs/tutorials/python/json_schema.md index fd07b8efb..ba9151a6a 100644 --- a/docs/tutorials/python/json_schema.md +++ b/docs/tutorials/python/json_schema.md @@ -30,7 +30,7 @@ By the end of this tutorial, you will: ## 2. Take a Look at the Constants and Structure of the JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=22-49} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=21-49} ``` Derived annotations allow you to define default values for annotations based on schema rules, ensuring consistency and reducing manual input errors. As you can see here, you could use derived annotations to prescribe default annotation values. Please read more about derived annotations [here](https://help.synapse.org/docs/JSON-Schemas.3107291536.html#JSONSchemas-DerivedAnnotations). @@ -39,12 +39,12 @@ Derived annotations allow you to define default values for annotations based on ## 3. Try Create Test Organization and JSON Schema if They Do Not Exist Next, try creating a test organization and register a schema if they do not already exist: ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=52-65} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=51-65} ``` Note: If you update your schema, you can re-register it with the organization by assigning a new version number to reflect the changes. Synapse does not allow re-creating a schema with the same version number, so please ensure that each schema version within an organization is unique: ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=68-99} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=67-99} ``` ## 4. Bind the JSON Schema to the Folder @@ -53,7 +53,7 @@ After creating the organization, you can now bind your json schema to a test fol When you bind the schema, you may also include the boolean property `enable_derived_annotations` to have Synapse automatically calculate derived annotations based on the schema: ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=102-108} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=101-108} ```
@@ -76,7 +76,7 @@ JSON schema was bound successfully. Please see details below: ## 5. Retrieve the Bound Schema Next, we can retrieve the bound schema: ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=111-113} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=110-113} ```
@@ -105,12 +105,12 @@ JSON Schema was retrieved successfully. Please see details below: ## 6. Add Invalid Annotations to the Folder and Store, and Validate the Folder against the Schema Try adding invalid annotations to your folder: This step and the step below demonstrate how the system handles invalid annotations and how the schema validation process works. ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=116-120} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=115-119} ``` Try validating the folder. You should be able to see messages related to invalid annotations. ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=124-126} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=123-125} ``` @@ -146,12 +146,12 @@ This step is only relevant for container entities, such as a folder or a project Try creating a test file locally and store the file in the folder that we created earlier. Then, try adding invalid annotations to that file. This step demonstrates how the files inside a folder also inherit the schema from the parent entity. ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=130-155} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=129-134} ``` You could then use `get_schema_validation_statistics` to get information such as the number of children with invalid annotations inside a container. ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=158-160} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=137-141} ``` @@ -170,7 +170,7 @@ Validation statistics were retrieved successfully. Please see details below: You could also use `get_invalid_validation` to see more detailed results of all the children inside a container, which includes all validation messages and validation exception details. ```python -{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=162-169} +{!docs/tutorials/python/tutorial_scripts/json_schema.py!lines=143-146} ```
diff --git a/docs/tutorials/python/tutorial_scripts/json_schema.py b/docs/tutorials/python/tutorial_scripts/json_schema.py index 786e6a3f3..501295754 100644 --- a/docs/tutorials/python/tutorial_scripts/json_schema.py +++ b/docs/tutorials/python/tutorial_scripts/json_schema.py @@ -1,14 +1,13 @@ -import os import time from pprint import pprint import synapseclient +from synapseclient.core.utils import make_bogus_data_file from synapseclient.models import File, Folder # 1. Set up Synapse Python client and retrieve project syn = synapseclient.Synapse() syn.login() -client = synapseclient.Synapse().get_client(synapse_client=syn) # Retrieve test project PROJECT_ID = syn.findEntityId( @@ -19,7 +18,7 @@ test_folder = Folder(name="clinical_data_folder", parent_id=PROJECT_ID).store() # 2. Take a look at the constants and structure of the JSON schema -ORG_NAME = "myUniqueAlzheimersResearchOrgTurtorial" +ORG_NAME = "myUniqueAlzheimersResearchOrgTutorial" VERSION = "0.0.1" NEW_VERSION = "0.0.2" @@ -53,10 +52,10 @@ all_orgs = js.list_organizations() for org in all_orgs: if org["name"] == ORG_NAME: - client.logger.info(f"Organization {ORG_NAME} already exists.") + syn.logger.info(f"Organization {ORG_NAME} already exists.") break else: - client.logger.info(f"Creating organization {ORG_NAME}.") + syn.logger.info(f"Creating organization {ORG_NAME}.") js.create_organization(ORG_NAME) my_test_org = js.JsonSchemaOrganization(ORG_NAME) @@ -92,7 +91,7 @@ ) except synapseclient.core.exceptions.SynapseHTTPError as e: if e.response.status_code == 400 and "already exists" in e.response.text: - client.logger.warning( + syn.logger.warning( f"Schema {SCHEMA_NAME} already exists. Please switch to use a new version number." ) else: @@ -101,15 +100,15 @@ # 4. Bind the JSON schema to the folder schema_uri = ORG_NAME + "-" + SCHEMA_NAME + "-" + VERSION bound_schema = test_folder.bind_schema( - json_schema_uri=schema_uri, synapse_client=syn, enable_derived_annotations=True + json_schema_uri=schema_uri, enable_derived_annotations=True ) json_schema_version_info = bound_schema.json_schema_version_info -client.logger.info("JSON schema was bound successfully. Please see details below:") +syn.logger.info("JSON schema was bound successfully. Please see details below:") pprint(vars(json_schema_version_info)) # 5. Retrieve the Bound Schema schema = test_folder.get_schema() -client.logger.info("JSON Schema was retrieved successfully. Please see details below:") +syn.logger.info("JSON Schema was retrieved successfully. Please see details below:") pprint(vars(schema)) # 6. Add Invalid Annotations to the Folder and Store @@ -117,53 +116,31 @@ "patient_id": "1234", "cognitive_score": "invalid str", } -test_folder.store(synapse_client=syn) +test_folder.store() time.sleep(2) validation_results = test_folder.validate_schema() -client.logger.info("Validation was completed. Please see details below:") +syn.logger.info("Validation was completed. Please see details below:") pprint(vars(validation_results)) # 7. Create a File with Invalid Annotations and Upload It # Then, view validation statistics and invalid validation results -if not os.path.exists(os.path.expanduser("~/temp")): - os.makedirs(os.path.expanduser("~/temp/testJSONSchemaFiles"), exist_ok=True) - -name_of_file = "test_file.txt" -path_to_file = os.path.join( - os.path.expanduser("~/temp/testJSONSchemaFiles"), name_of_file -) - - -def create_random_file( - path: str, -) -> None: - """Create a random file with random data. - - :param path: The path to create the file at. - """ - with open(path, "wb") as f: - f.write(os.urandom(1)) - - -create_random_file(path_to_file) +path_to_file = make_bogus_data_file(n=5) annotations = {"patient_id": "123456", "cognitive_score": "invalid child str"} child_file = File(path=path_to_file, parent_id=test_folder.id, annotations=annotations) -child_file = child_file.store(synapse_client=syn) +child_file = child_file.store() time.sleep(2) -validation_statistics = test_folder.get_schema_validation_statistics(synapse_client=syn) -client.logger.info( +validation_statistics = test_folder.get_schema_validation_statistics() +syn.logger.info( "Validation statistics were retrieved successfully. Please see details below:" ) pprint(vars(validation_statistics)) -invalid_validation = invalid_results = test_folder.get_invalid_validation( - synapse_client=syn -) +invalid_validation = invalid_results = test_folder.get_invalid_validation() for child in invalid_validation: - client.logger.info("See details of validation results: ") + syn.logger.info("See details of validation results: ") pprint(vars(child))