#
tokens: 84953/50000 1/1784 files (page 110/114)
lines: off (toggle) GitHub
raw markdown copy
This is page 110 of 114. Use http://codebase.md/microsoft/semanticworkbench?page={x} to view the full context.

# Directory Structure

```
├── .devcontainer
│   ├── .vscode
│   │   └── settings.json
│   ├── devcontainer.json
│   ├── OPTIMIZING_FOR_CODESPACES.md
│   ├── POST_SETUP_README.md
│   └── README.md
├── .dockerignore
├── .gitattributes
├── .github
│   ├── policheck.yml
│   └── workflows
│       ├── assistants-codespace-assistant.yml
│       ├── assistants-document-assistant.yml
│       ├── assistants-explorer-assistant.yml
│       ├── assistants-guided-conversation-assistant.yml
│       ├── assistants-knowledge-transfer-assistant.yml
│       ├── assistants-navigator-assistant.yml
│       ├── assistants-project-assistant.yml
│       ├── assistants-prospector-assistant.yml
│       ├── assistants-skill-assistant.yml
│       ├── libraries.yml
│       ├── mcp-server-giphy.yml
│       ├── mcp-server-memory-filesystem-edit.yml
│       ├── mcp-server-memory-user-bio.yml
│       ├── mcp-server-memory-whiteboard.yml
│       ├── mcp-server-open-deep-research-clone.yml
│       ├── mcp-server-web-research.yml
│       ├── workbench-app.yml
│       └── workbench-service.yml
├── .gitignore
├── .multi-root-tools
│   ├── Makefile
│   └── README.md
├── .vscode
│   ├── extensions.json
│   ├── launch.json
│   └── settings.json
├── ai_context
│   └── generated
│       ├── ASPIRE_ORCHESTRATOR.md
│       ├── ASSISTANT_CODESPACE.md
│       ├── ASSISTANT_DOCUMENT.md
│       ├── ASSISTANT_NAVIGATOR.md
│       ├── ASSISTANT_PROJECT.md
│       ├── ASSISTANT_PROSPECTOR.md
│       ├── ASSISTANTS_OTHER.md
│       ├── ASSISTANTS_OVERVIEW.md
│       ├── CONFIGURATION.md
│       ├── DOTNET_LIBRARIES.md
│       ├── EXAMPLES.md
│       ├── MCP_SERVERS.md
│       ├── PYTHON_LIBRARIES_AI_CLIENTS.md
│       ├── PYTHON_LIBRARIES_CORE.md
│       ├── PYTHON_LIBRARIES_EXTENSIONS.md
│       ├── PYTHON_LIBRARIES_SKILLS.md
│       ├── PYTHON_LIBRARIES_SPECIALIZED.md
│       ├── TOOLS.md
│       ├── WORKBENCH_FRONTEND.md
│       └── WORKBENCH_SERVICE.md
├── aspire-orchestrator
│   ├── .editorconfig
│   ├── Aspire.AppHost
│   │   ├── .gitignore
│   │   ├── appsettings.json
│   │   ├── Aspire.AppHost.csproj
│   │   ├── Program.cs
│   │   └── Properties
│   │       └── launchSettings.json
│   ├── Aspire.Extensions
│   │   ├── Aspire.Extensions.csproj
│   │   ├── Dashboard.cs
│   │   ├── DockerFileExtensions.cs
│   │   ├── PathNormalizer.cs
│   │   ├── UvAppHostingExtensions.cs
│   │   ├── UvAppResource.cs
│   │   ├── VirtualEnvironment.cs
│   │   └── WorkbenchServiceHostingExtensions.cs
│   ├── Aspire.ServiceDefaults
│   │   ├── Aspire.ServiceDefaults.csproj
│   │   └── Extensions.cs
│   ├── README.md
│   ├── run.sh
│   ├── SemanticWorkbench.Aspire.sln
│   └── SemanticWorkbench.Aspire.sln.DotSettings
├── assistants
│   ├── codespace-assistant
│   │   ├── .claude
│   │   │   └── settings.local.json
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── extensions.json
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── assets
│   │   │   │   ├── icon_context_transfer.svg
│   │   │   │   └── icon.svg
│   │   │   ├── chat.py
│   │   │   ├── config.py
│   │   │   ├── helpers.py
│   │   │   ├── response
│   │   │   │   ├── __init__.py
│   │   │   │   ├── completion_handler.py
│   │   │   │   ├── models.py
│   │   │   │   ├── request_builder.py
│   │   │   │   ├── response.py
│   │   │   │   ├── step_handler.py
│   │   │   │   └── utils
│   │   │   │       ├── __init__.py
│   │   │   │       ├── abbreviations.py
│   │   │   │       ├── formatting_utils.py
│   │   │   │       ├── message_utils.py
│   │   │   │       └── openai_utils.py
│   │   │   ├── text_includes
│   │   │   │   ├── card_content_context_transfer.md
│   │   │   │   ├── card_content.md
│   │   │   │   ├── codespace_assistant_info.md
│   │   │   │   ├── context_transfer_assistant_info.md
│   │   │   │   ├── guardrails_prompt.txt
│   │   │   │   ├── guidance_prompt_context_transfer.txt
│   │   │   │   ├── guidance_prompt.txt
│   │   │   │   ├── instruction_prompt_context_transfer.txt
│   │   │   │   └── instruction_prompt.txt
│   │   │   └── whiteboard
│   │   │       ├── __init__.py
│   │   │       ├── _inspector.py
│   │   │       └── _whiteboard.py
│   │   ├── assistant.code-workspace
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── document-assistant
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── assets
│   │   │   │   └── icon.svg
│   │   │   ├── chat.py
│   │   │   ├── config.py
│   │   │   ├── context_management
│   │   │   │   ├── __init__.py
│   │   │   │   └── inspector.py
│   │   │   ├── filesystem
│   │   │   │   ├── __init__.py
│   │   │   │   ├── _convert.py
│   │   │   │   ├── _file_sources.py
│   │   │   │   ├── _filesystem.py
│   │   │   │   ├── _inspector.py
│   │   │   │   ├── _model.py
│   │   │   │   ├── _prompts.py
│   │   │   │   └── _tasks.py
│   │   │   ├── guidance
│   │   │   │   ├── __init__.py
│   │   │   │   ├── dynamic_ui_inspector.py
│   │   │   │   ├── guidance_config.py
│   │   │   │   ├── guidance_prompts.py
│   │   │   │   └── README.md
│   │   │   ├── response
│   │   │   │   ├── __init__.py
│   │   │   │   ├── completion_handler.py
│   │   │   │   ├── models.py
│   │   │   │   ├── prompts.py
│   │   │   │   ├── responder.py
│   │   │   │   └── utils
│   │   │   │       ├── __init__.py
│   │   │   │       ├── formatting_utils.py
│   │   │   │       ├── message_utils.py
│   │   │   │       ├── openai_utils.py
│   │   │   │       ├── tokens_tiktoken.py
│   │   │   │       └── workbench_messages.py
│   │   │   ├── text_includes
│   │   │   │   └── document_assistant_info.md
│   │   │   ├── types.py
│   │   │   └── whiteboard
│   │   │       ├── __init__.py
│   │   │       ├── _inspector.py
│   │   │       └── _whiteboard.py
│   │   ├── assistant.code-workspace
│   │   ├── CLAUDE.md
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   ├── __init__.py
│   │   │   ├── test_convert.py
│   │   │   └── test_data
│   │   │       ├── blank_image.png
│   │   │       ├── Formatting Test.docx
│   │   │       ├── sample_data.csv
│   │   │       ├── sample_data.xlsx
│   │   │       ├── sample_page.html
│   │   │       ├── sample_presentation.pptx
│   │   │       └── simple_pdf.pdf
│   │   └── uv.lock
│   ├── explorer-assistant
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── chat.py
│   │   │   ├── config.py
│   │   │   ├── helpers.py
│   │   │   ├── response
│   │   │   │   ├── __init__.py
│   │   │   │   ├── model.py
│   │   │   │   ├── response_anthropic.py
│   │   │   │   ├── response_openai.py
│   │   │   │   └── response.py
│   │   │   └── text_includes
│   │   │       └── guardrails_prompt.txt
│   │   ├── assistant.code-workspace
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── guided-conversation-assistant
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── agents
│   │   │   │   ├── guided_conversation
│   │   │   │   │   ├── config.py
│   │   │   │   │   ├── definition.py
│   │   │   │   │   └── definitions
│   │   │   │   │       ├── er_triage.py
│   │   │   │   │       ├── interview.py
│   │   │   │   │       ├── patient_intake.py
│   │   │   │   │       └── poem_feedback.py
│   │   │   │   └── guided_conversation_agent.py
│   │   │   ├── chat.py
│   │   │   ├── config.py
│   │   │   └── text_includes
│   │   │       └── guardrails_prompt.txt
│   │   ├── assistant.code-workspace
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── knowledge-transfer-assistant
│   │   ├── .claude
│   │   │   └── settings.local.json
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── agentic
│   │   │   │   ├── __init__.py
│   │   │   │   ├── analysis.py
│   │   │   │   ├── coordinator_support.py
│   │   │   │   └── team_welcome.py
│   │   │   ├── assets
│   │   │   │   ├── icon-knowledge-transfer.svg
│   │   │   │   └── icon.svg
│   │   │   ├── assistant.py
│   │   │   ├── common.py
│   │   │   ├── config.py
│   │   │   ├── conversation_clients.py
│   │   │   ├── conversation_share_link.py
│   │   │   ├── data.py
│   │   │   ├── domain
│   │   │   │   ├── __init__.py
│   │   │   │   ├── audience_manager.py
│   │   │   │   ├── information_request_manager.py
│   │   │   │   ├── knowledge_brief_manager.py
│   │   │   │   ├── knowledge_digest_manager.py
│   │   │   │   ├── learning_objectives_manager.py
│   │   │   │   └── share_manager.py
│   │   │   ├── files.py
│   │   │   ├── logging.py
│   │   │   ├── notifications.py
│   │   │   ├── respond.py
│   │   │   ├── storage_models.py
│   │   │   ├── storage.py
│   │   │   ├── string_utils.py
│   │   │   ├── text_includes
│   │   │   │   ├── assistant_info.md
│   │   │   │   ├── card_content.md
│   │   │   │   ├── coordinator_instructions.txt
│   │   │   │   ├── coordinator_role.txt
│   │   │   │   ├── knowledge_digest_instructions.txt
│   │   │   │   ├── knowledge_digest_prompt.txt
│   │   │   │   ├── share_information_request_detection.txt
│   │   │   │   ├── team_instructions.txt
│   │   │   │   ├── team_role.txt
│   │   │   │   └── welcome_message_generation.txt
│   │   │   ├── tools
│   │   │   │   ├── __init__.py
│   │   │   │   ├── base.py
│   │   │   │   ├── information_requests.py
│   │   │   │   ├── learning_objectives.py
│   │   │   │   ├── learning_outcomes.py
│   │   │   │   ├── progress_tracking.py
│   │   │   │   └── share_setup.py
│   │   │   ├── ui_tabs
│   │   │   │   ├── __init__.py
│   │   │   │   ├── brief.py
│   │   │   │   ├── common.py
│   │   │   │   ├── debug.py
│   │   │   │   ├── learning.py
│   │   │   │   └── sharing.py
│   │   │   └── utils.py
│   │   ├── CLAUDE.md
│   │   ├── docs
│   │   │   ├── design
│   │   │   │   ├── actions.md
│   │   │   │   └── inference.md
│   │   │   ├── DEV_GUIDE.md
│   │   │   ├── how-kta-works.md
│   │   │   ├── JTBD.md
│   │   │   ├── knowledge-transfer-goals.md
│   │   │   ├── learning_assistance.md
│   │   │   ├── notable_claude_conversations
│   │   │   │   ├── clarifying_quad_modal_design.md
│   │   │   │   ├── CLAUDE_PROMPTS.md
│   │   │   │   ├── transfer_state.md
│   │   │   │   └── trying_the_context_agent.md
│   │   │   └── opportunities-of-knowledge-transfer.md
│   │   ├── knowledge-transfer-assistant.code-workspace
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   ├── __init__.py
│   │   │   ├── test_artifact_loading.py
│   │   │   ├── test_inspector.py
│   │   │   ├── test_share_manager.py
│   │   │   ├── test_share_storage.py
│   │   │   ├── test_share_tools.py
│   │   │   └── test_team_mode.py
│   │   └── uv.lock
│   ├── Makefile
│   ├── navigator-assistant
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── assets
│   │   │   │   ├── card_content.md
│   │   │   │   └── icon.svg
│   │   │   ├── chat.py
│   │   │   ├── config.py
│   │   │   ├── helpers.py
│   │   │   ├── response
│   │   │   │   ├── __init__.py
│   │   │   │   ├── completion_handler.py
│   │   │   │   ├── completion_requestor.py
│   │   │   │   ├── local_tool
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── add_assistant_to_conversation.py
│   │   │   │   │   ├── list_assistant_services.py
│   │   │   │   │   └── model.py
│   │   │   │   ├── models.py
│   │   │   │   ├── prompt.py
│   │   │   │   ├── request_builder.py
│   │   │   │   ├── response.py
│   │   │   │   ├── step_handler.py
│   │   │   │   └── utils
│   │   │   │       ├── __init__.py
│   │   │   │       ├── formatting_utils.py
│   │   │   │       ├── message_utils.py
│   │   │   │       ├── openai_utils.py
│   │   │   │       └── tools.py
│   │   │   ├── text_includes
│   │   │   │   ├── guardrails_prompt.md
│   │   │   │   ├── guidance_prompt.md
│   │   │   │   ├── instruction_prompt.md
│   │   │   │   ├── navigator_assistant_info.md
│   │   │   │   └── semantic_workbench_features.md
│   │   │   └── whiteboard
│   │   │       ├── __init__.py
│   │   │       ├── _inspector.py
│   │   │       └── _whiteboard.py
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── project-assistant
│   │   ├── .cspell
│   │   │   └── custom-dictionary-workspace.txt
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── agentic
│   │   │   │   ├── __init__.py
│   │   │   │   ├── act.py
│   │   │   │   ├── coordinator_next_action.py
│   │   │   │   ├── create_invitation.py
│   │   │   │   ├── detect_audience_and_takeaways.py
│   │   │   │   ├── detect_coordinator_actions.py
│   │   │   │   ├── detect_information_request_needs.py
│   │   │   │   ├── detect_knowledge_package_gaps.py
│   │   │   │   ├── focus.py
│   │   │   │   ├── respond.py
│   │   │   │   ├── team_welcome.py
│   │   │   │   └── update_digest.py
│   │   │   ├── assets
│   │   │   │   ├── icon-knowledge-transfer.svg
│   │   │   │   └── icon.svg
│   │   │   ├── assistant.py
│   │   │   ├── common.py
│   │   │   ├── config.py
│   │   │   ├── conversation_clients.py
│   │   │   ├── data.py
│   │   │   ├── domain
│   │   │   │   ├── __init__.py
│   │   │   │   ├── audience_manager.py
│   │   │   │   ├── conversation_preferences_manager.py
│   │   │   │   ├── information_request_manager.py
│   │   │   │   ├── knowledge_brief_manager.py
│   │   │   │   ├── knowledge_digest_manager.py
│   │   │   │   ├── learning_objectives_manager.py
│   │   │   │   ├── share_manager.py
│   │   │   │   ├── tasks_manager.py
│   │   │   │   └── transfer_manager.py
│   │   │   ├── errors.py
│   │   │   ├── files.py
│   │   │   ├── logging.py
│   │   │   ├── notifications.py
│   │   │   ├── prompt_utils.py
│   │   │   ├── storage.py
│   │   │   ├── string_utils.py
│   │   │   ├── text_includes
│   │   │   │   ├── actor_instructions.md
│   │   │   │   ├── assistant_info.md
│   │   │   │   ├── card_content.md
│   │   │   │   ├── coordinator_instructions copy.md
│   │   │   │   ├── coordinator_instructions.md
│   │   │   │   ├── create_invitation.md
│   │   │   │   ├── detect_audience.md
│   │   │   │   ├── detect_coordinator_actions.md
│   │   │   │   ├── detect_information_request_needs.md
│   │   │   │   ├── detect_knowledge_package_gaps.md
│   │   │   │   ├── focus.md
│   │   │   │   ├── knowledge_digest_instructions.txt
│   │   │   │   ├── team_instructions.txt
│   │   │   │   ├── to_do.md
│   │   │   │   ├── update_knowledge_brief.md
│   │   │   │   ├── update_knowledge_digest.md
│   │   │   │   └── welcome_message_generation.txt
│   │   │   ├── tools
│   │   │   │   ├── __init__.py
│   │   │   │   ├── base.py
│   │   │   │   ├── conversation_preferences.py
│   │   │   │   ├── information_requests.py
│   │   │   │   ├── learning_objectives.py
│   │   │   │   ├── learning_outcomes.py
│   │   │   │   ├── progress_tracking.py
│   │   │   │   ├── share_setup.py
│   │   │   │   ├── system_reminders.py
│   │   │   │   ├── tasks.py
│   │   │   │   └── todo.py
│   │   │   ├── ui_tabs
│   │   │   │   ├── __init__.py
│   │   │   │   ├── brief.py
│   │   │   │   ├── common.py
│   │   │   │   ├── debug.py
│   │   │   │   ├── learning.py
│   │   │   │   └── sharing.py
│   │   │   └── utils.py
│   │   ├── CLAUDE.md
│   │   ├── docs
│   │   │   ├── design
│   │   │   │   ├── actions.md
│   │   │   │   ├── control_options.md
│   │   │   │   ├── design.md
│   │   │   │   ├── inference.md
│   │   │   │   └── PXL_20250814_190140267.jpg
│   │   │   ├── DEV_GUIDE.md
│   │   │   ├── how-kta-works.md
│   │   │   ├── JTBD.md
│   │   │   ├── knowledge-transfer-goals.md
│   │   │   ├── learning_assistance.md
│   │   │   ├── notable_claude_conversations
│   │   │   │   ├── clarifying_quad_modal_design.md
│   │   │   │   ├── CLAUDE_PROMPTS.md
│   │   │   │   ├── transfer_state.md
│   │   │   │   └── trying_the_context_agent.md
│   │   │   └── opportunities-of-knowledge-transfer.md
│   │   ├── knowledge-transfer-assistant.code-workspace
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   ├── __init__.py
│   │   │   ├── test_artifact_loading.py
│   │   │   ├── test_inspector.py
│   │   │   ├── test_share_manager.py
│   │   │   ├── test_share_storage.py
│   │   │   └── test_team_mode.py
│   │   └── uv.lock
│   ├── prospector-assistant
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── assistant
│   │   │   ├── __init__.py
│   │   │   ├── agents
│   │   │   │   ├── artifact_agent.py
│   │   │   │   ├── document
│   │   │   │   │   ├── config.py
│   │   │   │   │   ├── gc_draft_content_feedback_config.py
│   │   │   │   │   ├── gc_draft_outline_feedback_config.py
│   │   │   │   │   ├── guided_conversation.py
│   │   │   │   │   └── state.py
│   │   │   │   └── document_agent.py
│   │   │   ├── artifact_creation_extension
│   │   │   │   ├── __init__.py
│   │   │   │   ├── _llm.py
│   │   │   │   ├── config.py
│   │   │   │   ├── document.py
│   │   │   │   ├── extension.py
│   │   │   │   ├── store.py
│   │   │   │   ├── test
│   │   │   │   │   ├── conftest.py
│   │   │   │   │   ├── evaluation.py
│   │   │   │   │   ├── test_completion_with_tools.py
│   │   │   │   │   └── test_extension.py
│   │   │   │   └── tools.py
│   │   │   ├── chat.py
│   │   │   ├── config.py
│   │   │   ├── form_fill_extension
│   │   │   │   ├── __init__.py
│   │   │   │   ├── config.py
│   │   │   │   ├── extension.py
│   │   │   │   ├── inspector.py
│   │   │   │   ├── state.py
│   │   │   │   └── steps
│   │   │   │       ├── __init__.py
│   │   │   │       ├── _guided_conversation.py
│   │   │   │       ├── _llm.py
│   │   │   │       ├── acquire_form_step.py
│   │   │   │       ├── extract_form_fields_step.py
│   │   │   │       ├── fill_form_step.py
│   │   │   │       └── types.py
│   │   │   ├── helpers.py
│   │   │   ├── legacy.py
│   │   │   └── text_includes
│   │   │       ├── artifact_agent_enabled.md
│   │   │       ├── guardrails_prompt.txt
│   │   │       ├── guided_conversation_agent_enabled.md
│   │   │       └── skills_agent_enabled.md
│   │   ├── assistant.code-workspace
│   │   ├── gc_learnings
│   │   │   ├── gc_learnings.md
│   │   │   └── images
│   │   │       ├── gc_conversation_plan_fcn.png
│   │   │       ├── gc_conversation_plan_template.png
│   │   │       ├── gc_execute_plan_callstack.png
│   │   │       ├── gc_functions.png
│   │   │       ├── gc_generate_plan_callstack.png
│   │   │       ├── gc_get_resource_instructions.png
│   │   │       ├── gc_get_termination_instructions.png
│   │   │       ├── gc_kernel_arguments.png
│   │   │       ├── gc_plan_calls.png
│   │   │       ├── gc_termination_instructions.png
│   │   │       ├── sk_get_chat_message_contents.png
│   │   │       ├── sk_inner_get_chat_message_contents.png
│   │   │       ├── sk_send_request_prep.png
│   │   │       └── sk_send_request.png
│   │   ├── Makefile
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   └── skill-assistant
│       ├── .env.example
│       ├── .vscode
│       │   ├── launch.json
│       │   └── settings.json
│       ├── assistant
│       │   ├── __init__.py
│       │   ├── config.py
│       │   ├── logging.py
│       │   ├── skill_assistant.py
│       │   ├── skill_engine_registry.py
│       │   ├── skill_event_mapper.py
│       │   ├── text_includes
│       │   │   └── guardrails_prompt.txt
│       │   └── workbench_helpers.py
│       ├── assistant.code-workspace
│       ├── Makefile
│       ├── pyproject.toml
│       ├── README.md
│       ├── tests
│       │   └── test_setup.py
│       └── uv.lock
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── docs
│   ├── .vscode
│   │   └── settings.json
│   ├── ASSISTANT_CONFIG.md
│   ├── ASSISTANT_DEVELOPMENT_GUIDE.md
│   ├── CUSTOM_APP_REGISTRATION.md
│   ├── HOSTED_ASSISTANT_WITH_LOCAL_MCP_SERVERS.md
│   ├── images
│   │   ├── architecture-animation.gif
│   │   ├── configure_assistant.png
│   │   ├── conversation_canvas_open.png
│   │   ├── conversation_duplicate.png
│   │   ├── conversation_export.png
│   │   ├── conversation_share_dialog.png
│   │   ├── conversation_share_link.png
│   │   ├── dashboard_configured_view.png
│   │   ├── dashboard_view.png
│   │   ├── license_agreement.png
│   │   ├── message_bar.png
│   │   ├── message_inspection.png
│   │   ├── message_link.png
│   │   ├── new_prospector_assistant_dialog.png
│   │   ├── open_conversation_canvas.png
│   │   ├── prospector_example.png
│   │   ├── readme1.png
│   │   ├── readme2.png
│   │   ├── readme3.png
│   │   ├── rewind.png
│   │   ├── signin_page.png
│   │   └── splash_screen.png
│   ├── LOCAL_ASSISTANT_WITH_REMOTE_WORKBENCH.md
│   ├── SETUP_DEV_ENVIRONMENT.md
│   └── WORKBENCH_APP.md
├── examples
│   ├── dotnet
│   │   ├── .editorconfig
│   │   ├── dotnet-01-echo-bot
│   │   │   ├── appsettings.json
│   │   │   ├── dotnet-01-echo-bot.csproj
│   │   │   ├── MyAgent.cs
│   │   │   ├── MyAgentConfig.cs
│   │   │   ├── MyWorkbenchConnector.cs
│   │   │   ├── Program.cs
│   │   │   └── README.md
│   │   ├── dotnet-02-message-types-demo
│   │   │   ├── appsettings.json
│   │   │   ├── ConnectorExtensions.cs
│   │   │   ├── docs
│   │   │   │   ├── abc.png
│   │   │   │   ├── code.png
│   │   │   │   ├── config.png
│   │   │   │   ├── echo.png
│   │   │   │   ├── markdown.png
│   │   │   │   ├── mermaid.png
│   │   │   │   ├── reverse.png
│   │   │   │   └── safety-check.png
│   │   │   ├── dotnet-02-message-types-demo.csproj
│   │   │   ├── MyAgent.cs
│   │   │   ├── MyAgentConfig.cs
│   │   │   ├── MyWorkbenchConnector.cs
│   │   │   ├── Program.cs
│   │   │   └── README.md
│   │   └── dotnet-03-simple-chatbot
│   │       ├── appsettings.json
│   │       ├── ConnectorExtensions.cs
│   │       ├── dotnet-03-simple-chatbot.csproj
│   │       ├── MyAgent.cs
│   │       ├── MyAgentConfig.cs
│   │       ├── MyWorkbenchConnector.cs
│   │       ├── Program.cs
│   │       └── README.md
│   ├── Makefile
│   └── python
│       ├── python-01-echo-bot
│       │   ├── .env.example
│       │   ├── .vscode
│       │   │   ├── launch.json
│       │   │   └── settings.json
│       │   ├── assistant
│       │   │   ├── __init__.py
│       │   │   ├── chat.py
│       │   │   └── config.py
│       │   ├── assistant.code-workspace
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── python-02-simple-chatbot
│       │   ├── .env.example
│       │   ├── .vscode
│       │   │   ├── launch.json
│       │   │   └── settings.json
│       │   ├── assistant
│       │   │   ├── __init__.py
│       │   │   ├── chat.py
│       │   │   ├── config.py
│       │   │   └── text_includes
│       │   │       └── guardrails_prompt.txt
│       │   ├── assistant.code-workspace
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       └── python-03-multimodel-chatbot
│           ├── .env.example
│           ├── .vscode
│           │   ├── launch.json
│           │   └── settings.json
│           ├── assistant
│           │   ├── __init__.py
│           │   ├── chat.py
│           │   ├── config.py
│           │   ├── model_adapters.py
│           │   └── text_includes
│           │       └── guardrails_prompt.txt
│           ├── assistant.code-workspace
│           ├── Makefile
│           ├── pyproject.toml
│           ├── README.md
│           └── uv.lock
├── KNOWN_ISSUES.md
├── libraries
│   ├── dotnet
│   │   ├── .editorconfig
│   │   ├── pack.sh
│   │   ├── README.md
│   │   ├── SemanticWorkbench.sln
│   │   ├── SemanticWorkbench.sln.DotSettings
│   │   └── WorkbenchConnector
│   │       ├── AgentBase.cs
│   │       ├── AgentConfig
│   │       │   ├── AgentConfigBase.cs
│   │       │   ├── AgentConfigPropertyAttribute.cs
│   │       │   └── ConfigUtils.cs
│   │       ├── Constants.cs
│   │       ├── IAgentBase.cs
│   │       ├── icon.png
│   │       ├── Models
│   │       │   ├── Command.cs
│   │       │   ├── Conversation.cs
│   │       │   ├── ConversationEvent.cs
│   │       │   ├── DebugInfo.cs
│   │       │   ├── Insight.cs
│   │       │   ├── Message.cs
│   │       │   ├── MessageMetadata.cs
│   │       │   ├── Participant.cs
│   │       │   ├── Sender.cs
│   │       │   └── ServiceInfo.cs
│   │       ├── Storage
│   │       │   ├── AgentInfo.cs
│   │       │   ├── AgentServiceStorage.cs
│   │       │   └── IAgentServiceStorage.cs
│   │       ├── StringLoggingExtensions.cs
│   │       ├── Webservice.cs
│   │       ├── WorkbenchConfig.cs
│   │       ├── WorkbenchConnector.cs
│   │       └── WorkbenchConnector.csproj
│   ├── Makefile
│   └── python
│       ├── anthropic-client
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── anthropic_client
│       │   │   ├── __init__.py
│       │   │   ├── client.py
│       │   │   ├── config.py
│       │   │   └── messages.py
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── assistant-data-gen
│       │   ├── .vscode
│       │   │   ├── launch.json
│       │   │   └── settings.json
│       │   ├── assistant_data_gen
│       │   │   ├── __init__.py
│       │   │   ├── assistant_api.py
│       │   │   ├── config.py
│       │   │   ├── gce
│       │   │   │   ├── __init__.py
│       │   │   │   ├── gce_agent.py
│       │   │   │   └── prompts.py
│       │   │   └── pydantic_ai_utils.py
│       │   ├── configs
│       │   │   └── document_assistant_example_config.yaml
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── scripts
│       │   │   ├── gce_simulation.py
│       │   │   └── generate_scenario.py
│       │   └── uv.lock
│       ├── assistant-drive
│       │   ├── .gitignore
│       │   ├── .vscode
│       │   │   ├── extensions.json
│       │   │   └── settings.json
│       │   ├── assistant_drive
│       │   │   ├── __init__.py
│       │   │   ├── drive.py
│       │   │   └── tests
│       │   │       └── test_basic.py
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── pytest.ini
│       │   ├── README.md
│       │   ├── usage.ipynb
│       │   └── uv.lock
│       ├── assistant-extensions
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── assistant_extensions
│       │   │   ├── __init__.py
│       │   │   ├── ai_clients
│       │   │   │   └── config.py
│       │   │   ├── artifacts
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _artifacts.py
│       │   │   │   ├── _inspector.py
│       │   │   │   └── _model.py
│       │   │   ├── attachments
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _attachments.py
│       │   │   │   ├── _convert.py
│       │   │   │   ├── _model.py
│       │   │   │   ├── _shared.py
│       │   │   │   └── _summarizer.py
│       │   │   ├── chat_context_toolkit
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _config.py
│       │   │   │   ├── archive
│       │   │   │   │   ├── __init__.py
│       │   │   │   │   ├── _archive.py
│       │   │   │   │   └── _summarizer.py
│       │   │   │   ├── message_history
│       │   │   │   │   ├── __init__.py
│       │   │   │   │   ├── _history.py
│       │   │   │   │   └── _message.py
│       │   │   │   └── virtual_filesystem
│       │   │   │       ├── __init__.py
│       │   │   │       ├── _archive_file_source.py
│       │   │   │       └── _attachments_file_source.py
│       │   │   ├── dashboard_card
│       │   │   │   ├── __init__.py
│       │   │   │   └── _dashboard_card.py
│       │   │   ├── document_editor
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _extension.py
│       │   │   │   ├── _inspector.py
│       │   │   │   └── _model.py
│       │   │   ├── mcp
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _assistant_file_resource_handler.py
│       │   │   │   ├── _client_utils.py
│       │   │   │   ├── _devtunnel.py
│       │   │   │   ├── _model.py
│       │   │   │   ├── _openai_utils.py
│       │   │   │   ├── _sampling_handler.py
│       │   │   │   ├── _tool_utils.py
│       │   │   │   └── _workbench_file_resource_handler.py
│       │   │   ├── navigator
│       │   │   │   ├── __init__.py
│       │   │   │   └── _navigator.py
│       │   │   └── workflows
│       │   │       ├── __init__.py
│       │   │       ├── _model.py
│       │   │       ├── _workflows.py
│       │   │       └── runners
│       │   │           └── _user_proxy.py
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── test
│       │   │   └── attachments
│       │   │       └── test_attachments.py
│       │   └── uv.lock
│       ├── chat-context-toolkit
│       │   ├── .claude
│       │   │   └── settings.local.json
│       │   ├── .env.sample
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── assets
│       │   │   ├── archive_v1.png
│       │   │   ├── history_v1.png
│       │   │   └── vfs_v1.png
│       │   ├── chat_context_toolkit
│       │   │   ├── __init__.py
│       │   │   ├── archive
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _archive_reader.py
│       │   │   │   ├── _archive_task_queue.py
│       │   │   │   ├── _state.py
│       │   │   │   ├── _types.py
│       │   │   │   └── summarization
│       │   │   │       ├── __init__.py
│       │   │   │       └── _summarizer.py
│       │   │   ├── history
│       │   │   │   ├── __init__.py
│       │   │   │   ├── _budget.py
│       │   │   │   ├── _decorators.py
│       │   │   │   ├── _history.py
│       │   │   │   ├── _prioritize.py
│       │   │   │   ├── _types.py
│       │   │   │   └── tool_abbreviations
│       │   │   │       ├── __init__.py
│       │   │   │       └── _tool_abbreviations.py
│       │   │   └── virtual_filesystem
│       │   │       ├── __init__.py
│       │   │       ├── _types.py
│       │   │       ├── _virtual_filesystem.py
│       │   │       ├── README.md
│       │   │       └── tools
│       │   │           ├── __init__.py
│       │   │           ├── _ls_tool.py
│       │   │           ├── _tools.py
│       │   │           └── _view_tool.py
│       │   ├── CLAUDE.md
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── test
│       │   │   ├── archive
│       │   │   │   └── test_archive_reader.py
│       │   │   ├── history
│       │   │   │   ├── test_abbreviate_messages.py
│       │   │   │   ├── test_history.py
│       │   │   │   ├── test_pair_and_order_tool_messages.py
│       │   │   │   ├── test_prioritize.py
│       │   │   │   └── test_truncate_messages.py
│       │   │   └── virtual_filesystem
│       │   │       ├── test_virtual_filesystem.py
│       │   │       └── tools
│       │   │           ├── test_ls_tool.py
│       │   │           ├── test_tools.py
│       │   │           └── test_view_tool.py
│       │   └── uv.lock
│       ├── content-safety
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── content_safety
│       │   │   ├── __init__.py
│       │   │   ├── evaluators
│       │   │   │   ├── __init__.py
│       │   │   │   ├── azure_content_safety
│       │   │   │   │   ├── __init__.py
│       │   │   │   │   ├── config.py
│       │   │   │   │   └── evaluator.py
│       │   │   │   ├── config.py
│       │   │   │   ├── evaluator.py
│       │   │   │   └── openai_moderations
│       │   │   │       ├── __init__.py
│       │   │   │       ├── config.py
│       │   │   │       └── evaluator.py
│       │   │   └── README.md
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── events
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── events
│       │   │   ├── __init__.py
│       │   │   └── events.py
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── guided-conversation
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── guided_conversation
│       │   │   ├── __init__.py
│       │   │   ├── functions
│       │   │   │   ├── __init__.py
│       │   │   │   ├── conversation_plan.py
│       │   │   │   ├── execution.py
│       │   │   │   └── final_update_plan.py
│       │   │   ├── guided_conversation_agent.py
│       │   │   ├── plugins
│       │   │   │   ├── __init__.py
│       │   │   │   ├── agenda.py
│       │   │   │   └── artifact.py
│       │   │   └── utils
│       │   │       ├── __init__.py
│       │   │       ├── base_model_llm.py
│       │   │       ├── conversation_helpers.py
│       │   │       ├── openai_tool_calling.py
│       │   │       ├── plugin_helpers.py
│       │   │       └── resources.py
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── llm-client
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── llm_client
│       │   │   ├── __init__.py
│       │   │   └── model.py
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── Makefile
│       ├── mcp-extensions
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── Makefile
│       │   ├── mcp_extensions
│       │   │   ├── __init__.py
│       │   │   ├── _client_session.py
│       │   │   ├── _model.py
│       │   │   ├── _sampling.py
│       │   │   ├── _server_extensions.py
│       │   │   ├── _tool_utils.py
│       │   │   ├── llm
│       │   │   │   ├── __init__.py
│       │   │   │   ├── chat_completion.py
│       │   │   │   ├── helpers.py
│       │   │   │   ├── llm_types.py
│       │   │   │   ├── mcp_chat_completion.py
│       │   │   │   └── openai_chat_completion.py
│       │   │   └── server
│       │   │       ├── __init__.py
│       │   │       └── storage.py
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── tests
│       │   │   └── test_tool_utils.py
│       │   └── uv.lock
│       ├── mcp-tunnel
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── Makefile
│       │   ├── mcp_tunnel
│       │   │   ├── __init__.py
│       │   │   ├── _devtunnel.py
│       │   │   ├── _dir.py
│       │   │   └── _main.py
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   └── uv.lock
│       ├── openai-client
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── Makefile
│       │   ├── openai_client
│       │   │   ├── __init__.py
│       │   │   ├── chat_driver
│       │   │   │   ├── __init__.py
│       │   │   │   ├── chat_driver.ipynb
│       │   │   │   ├── chat_driver.py
│       │   │   │   ├── message_history_providers
│       │   │   │   │   ├── __init__.py
│       │   │   │   │   ├── in_memory_message_history_provider.py
│       │   │   │   │   ├── local_message_history_provider.py
│       │   │   │   │   ├── message_history_provider.py
│       │   │   │   │   └── tests
│       │   │   │   │       └── formatted_instructions_test.py
│       │   │   │   └── README.md
│       │   │   ├── client.py
│       │   │   ├── completion.py
│       │   │   ├── config.py
│       │   │   ├── errors.py
│       │   │   ├── logging.py
│       │   │   ├── messages.py
│       │   │   ├── tokens.py
│       │   │   └── tools.py
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── tests
│       │   │   ├── test_command_parsing.py
│       │   │   ├── test_formatted_messages.py
│       │   │   ├── test_messages.py
│       │   │   └── test_tokens.py
│       │   └── uv.lock
│       ├── semantic-workbench-api-model
│       │   ├── .vscode
│       │   │   └── settings.json
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── semantic_workbench_api_model
│       │   │   ├── __init__.py
│       │   │   ├── assistant_model.py
│       │   │   ├── assistant_service_client.py
│       │   │   ├── workbench_model.py
│       │   │   └── workbench_service_client.py
│       │   └── uv.lock
│       ├── semantic-workbench-assistant
│       │   ├── .vscode
│       │   │   ├── launch.json
│       │   │   └── settings.json
│       │   ├── Makefile
│       │   ├── pyproject.toml
│       │   ├── README.md
│       │   ├── semantic_workbench_assistant
│       │   │   ├── __init__.py
│       │   │   ├── assistant_app
│       │   │   │   ├── __init__.py
│       │   │   │   ├── assistant.py
│       │   │   │   ├── config.py
│       │   │   │   ├── content_safety.py
│       │   │   │   ├── context.py
│       │   │   │   ├── error.py
│       │   │   │   ├── export_import.py
│       │   │   │   ├── protocol.py
│       │   │   │   └── service.py
│       │   │   ├── assistant_service.py
│       │   │   ├── auth.py
│       │   │   ├── canonical.py
│       │   │   ├── command.py
│       │   │   ├── config.py
│       │   │   ├── logging_config.py
│       │   │   ├── settings.py
│       │   │   ├── start.py
│       │   │   └── storage.py
│       │   ├── tests
│       │   │   ├── conftest.py
│       │   │   ├── test_assistant_app.py
│       │   │   ├── test_canonical.py
│       │   │   ├── test_config.py
│       │   │   └── test_storage.py
│       │   └── uv.lock
│       └── skills
│           ├── .vscode
│           │   └── settings.json
│           ├── Makefile
│           ├── README.md
│           └── skill-library
│               ├── .vscode
│               │   └── settings.json
│               ├── docs
│               │   └── vs-recipe-tool.md
│               ├── Makefile
│               ├── pyproject.toml
│               ├── README.md
│               ├── skill_library
│               │   ├── __init__.py
│               │   ├── chat_driver_helpers.py
│               │   ├── cli
│               │   │   ├── azure_openai.py
│               │   │   ├── conversation_history.py
│               │   │   ├── README.md
│               │   │   ├── run_routine.py
│               │   │   ├── settings.py
│               │   │   └── skill_logger.py
│               │   ├── engine.py
│               │   ├── llm_info.txt
│               │   ├── logging.py
│               │   ├── README.md
│               │   ├── routine_stack.py
│               │   ├── skill.py
│               │   ├── skills
│               │   │   ├── common
│               │   │   │   ├── __init__.py
│               │   │   │   ├── common_skill.py
│               │   │   │   └── routines
│               │   │   │       ├── bing_search.py
│               │   │   │       ├── consolidate.py
│               │   │   │       ├── echo.py
│               │   │   │       ├── gather_context.py
│               │   │   │       ├── get_content_from_url.py
│               │   │   │       ├── gpt_complete.py
│               │   │   │       ├── select_user_intent.py
│               │   │   │       └── summarize.py
│               │   │   ├── eval
│               │   │   │   ├── __init__.py
│               │   │   │   ├── eval_skill.py
│               │   │   │   └── routines
│               │   │   │       └── eval.py
│               │   │   ├── fabric
│               │   │   │   ├── __init__.py
│               │   │   │   ├── fabric_skill.py
│               │   │   │   ├── patterns
│               │   │   │   │   ├── agility_story
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── ai
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_answers
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_candidates
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_cfp_submission
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_claims
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_comments
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_debate
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_email_headers
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_incident
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_interviewer_techniques
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_logs
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_malware
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_military_strategy
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_mistakes
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_paper
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_patent
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_personality
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_presentation
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_product_feedback
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_proposition
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_prose
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_prose_json
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_prose_pinker
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_risk
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_sales_call
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_spiritual_text
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_tech_impact
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_threat_report
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── analyze_threat_report_cmds
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── analyze_threat_report_trends
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── answer_interview_question
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── ask_secure_by_design_questions
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── ask_uncle_duke
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── capture_thinkers_work
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── check_agreement
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── clean_text
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── coding_master
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── compare_and_contrast
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── convert_to_markdown
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_5_sentence_summary
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_academic_paper
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_ai_jobs_analysis
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_aphorisms
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_art_prompt
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_better_frame
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_coding_project
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_command
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_cyber_summary
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_design_document
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_diy
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_formal_email
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_git_diff_commit
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_graph_from_input
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_hormozi_offer
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_idea_compass
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_investigation_visualization
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_keynote
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_logo
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_markmap_visualization
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_mermaid_visualization
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_mermaid_visualization_for_github
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_micro_summary
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_network_threat_landscape
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_newsletter_entry
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_npc
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_pattern
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_prd
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_prediction_block
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_quiz
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_reading_plan
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_recursive_outline
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_report_finding
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_rpg_summary
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_security_update
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_show_intro
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_sigma_rules
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_story_explanation
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_stride_threat_model
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_summary
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_tags
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_threat_scenarios
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_ttrc_graph
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_ttrc_narrative
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_upgrade_pack
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_user_story
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── create_video_chapters
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── create_visualization
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── dialog_with_socrates
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── enrich_blog_post
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── explain_code
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── explain_docs
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── explain_math
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── explain_project
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── explain_terms
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── export_data_as_csv
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_algorithm_update_recommendations
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── extract_article_wisdom
│               │   │   │   │   │   ├── dmiessler
│               │   │   │   │   │   │   └── extract_wisdom-1.0.0
│               │   │   │   │   │   │       ├── system.md
│               │   │   │   │   │   │       └── user.md
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── extract_book_ideas
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_book_recommendations
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_business_ideas
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_controversial_ideas
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_core_message
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_ctf_writeup
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_domains
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_extraordinary_claims
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_ideas
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_insights
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_insights_dm
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_instructions
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_jokes
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_latest_video
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_main_idea
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_most_redeeming_thing
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_patterns
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_poc
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── extract_predictions
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_primary_problem
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_primary_solution
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_product_features
│               │   │   │   │   │   ├── dmiessler
│               │   │   │   │   │   │   └── extract_wisdom-1.0.0
│               │   │   │   │   │   │       ├── system.md
│               │   │   │   │   │   │       └── user.md
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_questions
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_recipe
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_recommendations
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── extract_references
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── extract_skills
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_song_meaning
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_sponsors
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_videoid
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── extract_wisdom
│               │   │   │   │   │   ├── dmiessler
│               │   │   │   │   │   │   └── extract_wisdom-1.0.0
│               │   │   │   │   │   │       ├── system.md
│               │   │   │   │   │   │       └── user.md
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_wisdom_agents
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_wisdom_dm
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── extract_wisdom_nometa
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── find_hidden_message
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── find_logical_fallacies
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── get_wow_per_minute
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── get_youtube_rss
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── humanize
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── identify_dsrp_distinctions
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── identify_dsrp_perspectives
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── identify_dsrp_relationships
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── identify_dsrp_systems
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── identify_job_stories
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── improve_academic_writing
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── improve_prompt
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── improve_report_finding
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── improve_writing
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── judge_output
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── label_and_rate
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── loaded
│               │   │   │   │   ├── md_callout
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── official_pattern_template
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── pattern_explanations.md
│               │   │   │   │   ├── prepare_7s_strategy
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── provide_guidance
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── rate_ai_response
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── rate_ai_result
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── rate_content
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── rate_value
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── raw_query
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── raycast
│               │   │   │   │   │   ├── capture_thinkers_work
│               │   │   │   │   │   ├── create_story_explanation
│               │   │   │   │   │   ├── extract_primary_problem
│               │   │   │   │   │   ├── extract_wisdom
│               │   │   │   │   │   └── yt
│               │   │   │   │   ├── recommend_artists
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── recommend_pipeline_upgrades
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── recommend_talkpanel_topics
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── refine_design_document
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── review_design
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── sanitize_broken_html_to_markdown
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── show_fabric_options_markmap
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── solve_with_cot
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── stringify
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── suggest_pattern
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── summarize
│               │   │   │   │   │   ├── dmiessler
│               │   │   │   │   │   │   └── summarize
│               │   │   │   │   │   │       ├── system.md
│               │   │   │   │   │   │       └── user.md
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── summarize_debate
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_git_changes
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_git_diff
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_lecture
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_legislation
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_meeting
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_micro
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── summarize_newsletter
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── summarize_paper
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── summarize_prompt
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── summarize_pull-requests
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── summarize_rpg_session
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_analyze_challenge_handling
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_check_metrics
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_create_h3_career
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_create_opening_sentences
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_describe_life_outlook
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_extract_intro_sentences
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_extract_panel_topics
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_find_blindspots
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_find_negative_thinking
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_find_neglected_goals
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_give_encouragement
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_red_team_thinking
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_threat_model_plans
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_visualize_mission_goals_projects
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── t_year_in_review
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── to_flashcards
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── transcribe_minutes
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── translate
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── tweet
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── write_essay
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── write_hackerone_report
│               │   │   │   │   │   ├── README.md
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── write_latex
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── write_micro_essay
│               │   │   │   │   │   └── system.md
│               │   │   │   │   ├── write_nuclei_template_rule
│               │   │   │   │   │   ├── system.md
│               │   │   │   │   │   └── user.md
│               │   │   │   │   ├── write_pull-request
│               │   │   │   │   │   └── system.md
│               │   │   │   │   └── write_semgrep_rule
│               │   │   │   │       ├── system.md
│               │   │   │   │       └── user.md
│               │   │   │   └── routines
│               │   │   │       ├── list.py
│               │   │   │       ├── run.py
│               │   │   │       └── show.py
│               │   │   ├── guided_conversation
│               │   │   │   ├── __init__.py
│               │   │   │   ├── agenda.py
│               │   │   │   ├── artifact_helpers.py
│               │   │   │   ├── chat_completions
│               │   │   │   │   ├── fix_agenda_error.py
│               │   │   │   │   ├── fix_artifact_error.py
│               │   │   │   │   ├── generate_agenda.py
│               │   │   │   │   ├── generate_artifact_updates.py
│               │   │   │   │   ├── generate_final_artifact.py
│               │   │   │   │   └── generate_message.py
│               │   │   │   ├── conversation_guides
│               │   │   │   │   ├── __init__.py
│               │   │   │   │   ├── acrostic_poem.py
│               │   │   │   │   ├── er_triage.py
│               │   │   │   │   ├── interview.py
│               │   │   │   │   └── patient_intake.py
│               │   │   │   ├── guide.py
│               │   │   │   ├── guided_conversation_skill.py
│               │   │   │   ├── logging.py
│               │   │   │   ├── message.py
│               │   │   │   ├── resources.py
│               │   │   │   ├── routines
│               │   │   │   │   └── guided_conversation.py
│               │   │   │   └── tests
│               │   │   │       ├── conftest.py
│               │   │   │       ├── test_artifact_helpers.py
│               │   │   │       ├── test_generate_agenda.py
│               │   │   │       ├── test_generate_artifact_updates.py
│               │   │   │       ├── test_generate_final_artifact.py
│               │   │   │       └── test_resource.py
│               │   │   ├── meta
│               │   │   │   ├── __init__.py
│               │   │   │   ├── meta_skill.py
│               │   │   │   ├── README.md
│               │   │   │   └── routines
│               │   │   │       └── generate_routine.py
│               │   │   ├── posix
│               │   │   │   ├── __init__.py
│               │   │   │   ├── posix_skill.py
│               │   │   │   ├── routines
│               │   │   │   │   ├── append_file.py
│               │   │   │   │   ├── cd.py
│               │   │   │   │   ├── ls.py
│               │   │   │   │   ├── make_home_dir.py
│               │   │   │   │   ├── mkdir.py
│               │   │   │   │   ├── mv.py
│               │   │   │   │   ├── pwd.py
│               │   │   │   │   ├── read_file.py
│               │   │   │   │   ├── rm.py
│               │   │   │   │   ├── touch.py
│               │   │   │   │   └── write_file.py
│               │   │   │   └── sandbox_shell.py
│               │   │   ├── README.md
│               │   │   ├── research
│               │   │   │   ├── __init__.py
│               │   │   │   ├── README.md
│               │   │   │   ├── research_skill.py
│               │   │   │   └── routines
│               │   │   │       ├── answer_question_about_content.py
│               │   │   │       ├── evaluate_answer.py
│               │   │   │       ├── generate_research_plan.py
│               │   │   │       ├── generate_search_query.py
│               │   │   │       ├── update_research_plan.py
│               │   │   │       ├── web_research.py
│               │   │   │       └── web_search.py
│               │   │   ├── research2
│               │   │   │   ├── __init__.py
│               │   │   │   ├── README.md
│               │   │   │   ├── research_skill.py
│               │   │   │   └── routines
│               │   │   │       ├── facts.py
│               │   │   │       ├── make_final_report.py
│               │   │   │       ├── research.py
│               │   │   │       ├── search_plan.py
│               │   │   │       ├── search.py
│               │   │   │       └── visit_pages.py
│               │   │   └── web_research
│               │   │       ├── __init__.py
│               │   │       ├── README.md
│               │   │       ├── research_skill.py
│               │   │       └── routines
│               │   │           ├── facts.py
│               │   │           ├── make_final_report.py
│               │   │           ├── research.py
│               │   │           ├── search_plan.py
│               │   │           ├── search.py
│               │   │           └── visit_pages.py
│               │   ├── tests
│               │   │   ├── test_common_skill.py
│               │   │   ├── test_integration.py
│               │   │   ├── test_routine_stack.py
│               │   │   ├── tst_skill
│               │   │   │   ├── __init__.py
│               │   │   │   └── routines
│               │   │   │       ├── __init__.py
│               │   │   │       └── a_routine.py
│               │   │   └── utilities
│               │   │       ├── test_find_template_vars.py
│               │   │       ├── test_make_arg_set.py
│               │   │       ├── test_paramspec.py
│               │   │       ├── test_parse_command_string.py
│               │   │       └── test_to_string.py
│               │   ├── types.py
│               │   ├── usage.py
│               │   └── utilities.py
│               └── uv.lock
├── LICENSE
├── Makefile
├── mcp-servers
│   ├── ai-assist-content
│   │   ├── .vscode
│   │   │   └── settings.json
│   │   ├── mcp-example-brave-search.md
│   │   ├── mcp-fastmcp-typescript-README.md
│   │   ├── mcp-llms-full.txt
│   │   ├── mcp-metadata-tips.md
│   │   ├── mcp-python-sdk-README.md
│   │   ├── mcp-typescript-sdk-README.md
│   │   ├── pydanticai-documentation.md
│   │   ├── pydanticai-example-question-graph.md
│   │   ├── pydanticai-example-weather.md
│   │   ├── pydanticai-tutorial.md
│   │   └── README.md
│   ├── Makefile
│   ├── mcp-server-bing-search
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server_bing_search
│   │   │   ├── __init__.py
│   │   │   ├── config.py
│   │   │   ├── prompts
│   │   │   │   ├── __init__.py
│   │   │   │   ├── clean_website.py
│   │   │   │   └── filter_links.py
│   │   │   ├── server.py
│   │   │   ├── start.py
│   │   │   ├── tools.py
│   │   │   ├── types.py
│   │   │   ├── utils.py
│   │   │   └── web
│   │   │       ├── __init__.py
│   │   │       ├── get_content.py
│   │   │       ├── llm_processing.py
│   │   │       ├── process_website.py
│   │   │       └── search_bing.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   └── test_tools.py
│   │   └── uv.lock
│   ├── mcp-server-bundle
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server_bundle
│   │   │   ├── __init__.py
│   │   │   └── main.py
│   │   ├── pyinstaller.spec
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── mcp-server-filesystem
│   │   ├── .env.example
│   │   ├── .github
│   │   │   └── workflows
│   │   │       └── ci.yml
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server_filesystem
│   │   │   ├── __init__.py
│   │   │   ├── config.py
│   │   │   ├── server.py
│   │   │   └── start.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   └── test_filesystem.py
│   │   └── uv.lock
│   ├── mcp-server-filesystem-edit
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── data
│   │   │   ├── attachments
│   │   │   │   ├── Daily Game Ideas.txt
│   │   │   │   ├── Frontend Framework Proposal.txt
│   │   │   │   ├── ReDoodle.txt
│   │   │   │   └── Research Template.tex
│   │   │   ├── test_cases.yaml
│   │   │   └── transcripts
│   │   │       ├── transcript_research_simple.md
│   │   │       ├── transcript_Startup_Idea_1_202503031513.md
│   │   │       ├── transcript_Startup_Idea_2_202503031659.md
│   │   │       └── transcript_Web_Frontends_202502281551.md
│   │   ├── Makefile
│   │   ├── mcp_server_filesystem_edit
│   │   │   ├── __init__.py
│   │   │   ├── app_handling
│   │   │   │   ├── __init__.py
│   │   │   │   ├── excel.py
│   │   │   │   ├── miktex.py
│   │   │   │   ├── office_common.py
│   │   │   │   ├── powerpoint.py
│   │   │   │   └── word.py
│   │   │   ├── config.py
│   │   │   ├── evals
│   │   │   │   ├── __init__.py
│   │   │   │   ├── common.py
│   │   │   │   ├── run_comments.py
│   │   │   │   ├── run_edit.py
│   │   │   │   └── run_ppt_edit.py
│   │   │   ├── prompts
│   │   │   │   ├── __init__.py
│   │   │   │   ├── add_comments.py
│   │   │   │   ├── analyze_comments.py
│   │   │   │   ├── latex_edit.py
│   │   │   │   ├── markdown_draft.py
│   │   │   │   ├── markdown_edit.py
│   │   │   │   └── powerpoint_edit.py
│   │   │   ├── server.py
│   │   │   ├── start.py
│   │   │   ├── tools
│   │   │   │   ├── __init__.py
│   │   │   │   ├── add_comments.py
│   │   │   │   ├── edit_adapters
│   │   │   │   │   ├── __init__.py
│   │   │   │   │   ├── common.py
│   │   │   │   │   ├── latex.py
│   │   │   │   │   └── markdown.py
│   │   │   │   ├── edit.py
│   │   │   │   └── helpers.py
│   │   │   └── types.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   ├── app_handling
│   │   │   │   ├── test_excel.py
│   │   │   │   ├── test_miktext.py
│   │   │   │   ├── test_office_common.py
│   │   │   │   ├── test_powerpoint.py
│   │   │   │   └── test_word.py
│   │   │   ├── conftest.py
│   │   │   └── tools
│   │   │       └── edit_adapters
│   │   │           ├── test_latex.py
│   │   │           └── test_markdown.py
│   │   └── uv.lock
│   ├── mcp-server-fusion
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── AddInIcon.svg
│   │   ├── config.py
│   │   ├── FusionMCPServerAddIn.manifest
│   │   ├── FusionMCPServerAddIn.py
│   │   ├── mcp_server_fusion
│   │   │   ├── __init__.py
│   │   │   ├── fusion_mcp_server.py
│   │   │   ├── fusion_utils
│   │   │   │   ├── __init__.py
│   │   │   │   ├── event_utils.py
│   │   │   │   ├── general_utils.py
│   │   │   │   └── tool_utils.py
│   │   │   ├── mcp_tools
│   │   │   │   ├── __init__.py
│   │   │   │   ├── fusion_3d_operation.py
│   │   │   │   ├── fusion_geometry.py
│   │   │   │   ├── fusion_pattern.py
│   │   │   │   └── fusion_sketch.py
│   │   │   └── vendor
│   │   │       └── README.md
│   │   ├── README.md
│   │   └── requirements.txt
│   ├── mcp-server-giphy
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server
│   │   │   ├── __init__.py
│   │   │   ├── config.py
│   │   │   ├── giphy_search.py
│   │   │   ├── sampling.py
│   │   │   ├── server.py
│   │   │   ├── start.py
│   │   │   └── utils.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── mcp-server-memory-user-bio
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server_memory_user_bio
│   │   │   ├── __init__.py
│   │   │   ├── config.py
│   │   │   ├── server.py
│   │   │   └── start.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── mcp-server-memory-whiteboard
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server_memory_whiteboard
│   │   │   ├── __init__.py
│   │   │   ├── config.py
│   │   │   ├── server.py
│   │   │   └── start.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── mcp-server-office
│   │   ├── .env.example
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── build.sh
│   │   ├── data
│   │   │   ├── attachments
│   │   │   │   ├── Daily Game Ideas.txt
│   │   │   │   ├── Frontend Framework Proposal.txt
│   │   │   │   └── ReDoodle.txt
│   │   │   └── word
│   │   │       ├── test_cases.yaml
│   │   │       └── transcripts
│   │   │           ├── transcript_Startup_Idea_1_202503031513.md
│   │   │           ├── transcript_Startup_Idea_2_202503031659.md
│   │   │           └── transcript_Web_Frontends_202502281551.md
│   │   ├── Makefile
│   │   ├── mcp_server
│   │   │   ├── __init__.py
│   │   │   ├── app_interaction
│   │   │   │   ├── __init__.py
│   │   │   │   ├── excel_editor.py
│   │   │   │   ├── powerpoint_editor.py
│   │   │   │   └── word_editor.py
│   │   │   ├── config.py
│   │   │   ├── constants.py
│   │   │   ├── evals
│   │   │   │   ├── __init__.py
│   │   │   │   ├── common.py
│   │   │   │   ├── run_comment_analysis.py
│   │   │   │   ├── run_feedback.py
│   │   │   │   └── run_markdown_edit.py
│   │   │   ├── helpers.py
│   │   │   ├── markdown_edit
│   │   │   │   ├── __init__.py
│   │   │   │   ├── comment_analysis.py
│   │   │   │   ├── feedback_step.py
│   │   │   │   ├── markdown_edit.py
│   │   │   │   └── utils.py
│   │   │   ├── prompts
│   │   │   │   ├── __init__.py
│   │   │   │   ├── comment_analysis.py
│   │   │   │   ├── feedback.py
│   │   │   │   ├── markdown_draft.py
│   │   │   │   └── markdown_edit.py
│   │   │   ├── server.py
│   │   │   ├── start.py
│   │   │   └── types.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── tests
│   │   │   └── test_word_editor.py
│   │   └── uv.lock
│   ├── mcp-server-open-deep-research
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server
│   │   │   ├── __init__.py
│   │   │   ├── config.py
│   │   │   ├── libs
│   │   │   │   └── open_deep_research
│   │   │   │       ├── cookies.py
│   │   │   │       ├── mdconvert.py
│   │   │   │       ├── run_agents.py
│   │   │   │       ├── text_inspector_tool.py
│   │   │   │       ├── text_web_browser.py
│   │   │   │       └── visual_qa.py
│   │   │   ├── open_deep_research.py
│   │   │   ├── server.py
│   │   │   └── start.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── mcp-server-open-deep-research-clone
│   │   ├── .env.example
│   │   ├── .gitignore
│   │   ├── .vscode
│   │   │   ├── launch.json
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── mcp_server_open_deep_research_clone
│   │   │   ├── __init__.py
│   │   │   ├── azure_openai.py
│   │   │   ├── config.py
│   │   │   ├── logging.py
│   │   │   ├── sampling.py
│   │   │   ├── server.py
│   │   │   ├── start.py
│   │   │   ├── utils.py
│   │   │   └── web_research.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   ├── test
│   │   │   └── test_open_deep_research_clone.py
│   │   └── uv.lock
│   ├── mcp-server-template
│   │   ├── .taplo.toml
│   │   ├── .vscode
│   │   │   └── settings.json
│   │   ├── copier.yml
│   │   ├── README.md
│   │   └── template
│   │       └── {{ project_slug }}
│   │           ├── .env.example.jinja
│   │           ├── .gitignore
│   │           ├── .vscode
│   │           │   ├── launch.json.jinja
│   │           │   └── settings.json
│   │           ├── {{ module_name }}
│   │           │   ├── __init__.py
│   │           │   ├── config.py.jinja
│   │           │   ├── server.py.jinja
│   │           │   └── start.py.jinja
│   │           ├── Makefile.jinja
│   │           ├── pyproject.toml.jinja
│   │           └── README.md.jinja
│   ├── mcp-server-vscode
│   │   ├── .eslintrc.cjs
│   │   ├── .gitignore
│   │   ├── .npmrc
│   │   ├── .vscode
│   │   │   ├── extensions.json
│   │   │   ├── launch.json
│   │   │   ├── settings.json
│   │   │   └── tasks.json
│   │   ├── .vscode-test.mjs
│   │   ├── .vscodeignore
│   │   ├── ASSISTANT_BOOTSTRAP.md
│   │   ├── eslint.config.mjs
│   │   ├── images
│   │   │   └── icon.png
│   │   ├── LICENSE
│   │   ├── Makefile
│   │   ├── out
│   │   │   ├── extension.d.ts
│   │   │   ├── extension.js
│   │   │   ├── test
│   │   │   │   ├── extension.test.d.ts
│   │   │   │   └── extension.test.js
│   │   │   ├── tools
│   │   │   │   ├── code_checker.d.ts
│   │   │   │   ├── code_checker.js
│   │   │   │   ├── debug_tools.d.ts
│   │   │   │   ├── debug_tools.js
│   │   │   │   ├── focus_editor.d.ts
│   │   │   │   ├── focus_editor.js
│   │   │   │   ├── search_symbol.d.ts
│   │   │   │   └── search_symbol.js
│   │   │   └── utils
│   │   │       ├── port.d.ts
│   │   │       └── port.js
│   │   ├── package.json
│   │   ├── pnpm-lock.yaml
│   │   ├── prettier.config.cjs
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── extension.d.ts
│   │   │   ├── extension.ts
│   │   │   ├── test
│   │   │   │   ├── extension.test.d.ts
│   │   │   │   └── extension.test.ts
│   │   │   ├── tools
│   │   │   │   ├── code_checker.d.ts
│   │   │   │   ├── code_checker.ts
│   │   │   │   ├── debug_tools.d.ts
│   │   │   │   ├── debug_tools.ts
│   │   │   │   ├── focus_editor.d.ts
│   │   │   │   ├── focus_editor.ts
│   │   │   │   ├── search_symbol.d.ts
│   │   │   │   └── search_symbol.ts
│   │   │   └── utils
│   │   │       ├── port.d.ts
│   │   │       └── port.ts
│   │   ├── tsconfig.json
│   │   ├── tsconfig.tsbuildinfo
│   │   ├── vsc-extension-quickstart.md
│   │   └── webpack.config.js
│   └── mcp-server-web-research
│       ├── .env.example
│       ├── .gitignore
│       ├── .vscode
│       │   ├── launch.json
│       │   └── settings.json
│       ├── Makefile
│       ├── mcp_server_web_research
│       │   ├── __init__.py
│       │   ├── azure_openai.py
│       │   ├── config.py
│       │   ├── logging.py
│       │   ├── sampling.py
│       │   ├── server.py
│       │   ├── start.py
│       │   ├── utils.py
│       │   └── web_research.py
│       ├── pyproject.toml
│       ├── README.md
│       ├── test
│       │   └── test_web_research.py
│       └── uv.lock
├── README.md
├── RESPONSIBLE_AI_FAQ.md
├── ruff.toml
├── SECURITY.md
├── semantic-workbench.code-workspace
├── SUPPORT.md
├── tools
│   ├── build_ai_context_files.py
│   ├── collect_files.py
│   ├── docker
│   │   ├── azure_website_sshd.conf
│   │   ├── docker-entrypoint.sh
│   │   ├── Dockerfile.assistant
│   │   └── Dockerfile.mcp-server
│   ├── makefiles
│   │   ├── docker-assistant.mk
│   │   ├── docker-mcp-server.mk
│   │   ├── docker.mk
│   │   ├── python.mk
│   │   ├── recursive.mk
│   │   └── shell.mk
│   ├── reset-service-data.ps1
│   ├── reset-service-data.sh
│   ├── run-app.ps1
│   ├── run-app.sh
│   ├── run-canonical-agent.ps1
│   ├── run-canonical-agent.sh
│   ├── run-dotnet-examples-with-aspire.sh
│   ├── run-python-example1.sh
│   ├── run-python-example2.ps1
│   ├── run-python-example2.sh
│   ├── run-service.ps1
│   ├── run-service.sh
│   ├── run-workbench-chatbot.ps1
│   └── run-workbench-chatbot.sh
├── workbench-app
│   ├── .dockerignore
│   ├── .env.example
│   ├── .eslintrc.cjs
│   ├── .gitignore
│   ├── .vscode
│   │   ├── launch.json
│   │   └── settings.json
│   ├── docker-entrypoint.sh
│   ├── Dockerfile
│   ├── docs
│   │   ├── APP_DEV_GUIDE.md
│   │   ├── MESSAGE_METADATA.md
│   │   ├── MESSAGE_TYPES.md
│   │   ├── README.md
│   │   └── STATE_INSPECTORS.md
│   ├── index.html
│   ├── Makefile
│   ├── nginx.conf
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── prettier.config.cjs
│   ├── public
│   │   └── assets
│   │       ├── background-1-upscaled.jpg
│   │       ├── background-1-upscaled.png
│   │       ├── background-1.jpg
│   │       ├── background-1.png
│   │       ├── background-2.jpg
│   │       ├── background-2.png
│   │       ├── experimental-feature.jpg
│   │       ├── favicon.svg
│   │       ├── workflow-designer-1.jpg
│   │       ├── workflow-designer-outlets.jpg
│   │       ├── workflow-designer-states.jpg
│   │       └── workflow-designer-transitions.jpg
│   ├── README.md
│   ├── run.sh
│   ├── src
│   │   ├── components
│   │   │   ├── App
│   │   │   │   ├── AppFooter.tsx
│   │   │   │   ├── AppHeader.tsx
│   │   │   │   ├── AppMenu.tsx
│   │   │   │   ├── AppView.tsx
│   │   │   │   ├── CodeLabel.tsx
│   │   │   │   ├── CommandButton.tsx
│   │   │   │   ├── ConfirmLeave.tsx
│   │   │   │   ├── ContentExport.tsx
│   │   │   │   ├── ContentImport.tsx
│   │   │   │   ├── CopyButton.tsx
│   │   │   │   ├── DialogControl.tsx
│   │   │   │   ├── DynamicIframe.tsx
│   │   │   │   ├── ErrorListFromAppState.tsx
│   │   │   │   ├── ErrorMessageBar.tsx
│   │   │   │   ├── ExperimentalNotice.tsx
│   │   │   │   ├── FormWidgets
│   │   │   │   │   ├── BaseModelEditorWidget.tsx
│   │   │   │   │   ├── CustomizedArrayFieldTemplate.tsx
│   │   │   │   │   ├── CustomizedFieldTemplate.tsx
│   │   │   │   │   ├── CustomizedObjectFieldTemplate.tsx
│   │   │   │   │   └── InspectableWidget.tsx
│   │   │   │   ├── LabelWithDescription.tsx
│   │   │   │   ├── Loading.tsx
│   │   │   │   ├── MenuItemControl.tsx
│   │   │   │   ├── MiniControl.tsx
│   │   │   │   ├── MyAssistantServiceRegistrations.tsx
│   │   │   │   ├── MyItemsManager.tsx
│   │   │   │   ├── OverflowMenu.tsx
│   │   │   │   ├── PresenceMotionList.tsx
│   │   │   │   ├── ProfileSettings.tsx
│   │   │   │   └── TooltipWrapper.tsx
│   │   │   ├── Assistants
│   │   │   │   ├── ApplyConfigButton.tsx
│   │   │   │   ├── AssistantAdd.tsx
│   │   │   │   ├── AssistantConfigExportButton.tsx
│   │   │   │   ├── AssistantConfigImportButton.tsx
│   │   │   │   ├── AssistantConfiguration.tsx
│   │   │   │   ├── AssistantConfigure.tsx
│   │   │   │   ├── AssistantCreate.tsx
│   │   │   │   ├── AssistantDelete.tsx
│   │   │   │   ├── AssistantDuplicate.tsx
│   │   │   │   ├── AssistantExport.tsx
│   │   │   │   ├── AssistantImport.tsx
│   │   │   │   ├── AssistantRemove.tsx
│   │   │   │   ├── AssistantRename.tsx
│   │   │   │   ├── AssistantServiceInfo.tsx
│   │   │   │   ├── AssistantServiceMetadata.tsx
│   │   │   │   └── MyAssistants.tsx
│   │   │   ├── AssistantServiceRegistrations
│   │   │   │   ├── AssistantServiceRegistrationApiKey.tsx
│   │   │   │   ├── AssistantServiceRegistrationApiKeyReset.tsx
│   │   │   │   ├── AssistantServiceRegistrationCreate.tsx
│   │   │   │   └── AssistantServiceRegistrationRemove.tsx
│   │   │   ├── Conversations
│   │   │   │   ├── Canvas
│   │   │   │   │   ├── AssistantCanvas.tsx
│   │   │   │   │   ├── AssistantCanvasList.tsx
│   │   │   │   │   ├── AssistantInspector.tsx
│   │   │   │   │   ├── AssistantInspectorList.tsx
│   │   │   │   │   └── ConversationCanvas.tsx
│   │   │   │   ├── ChatInputPlugins
│   │   │   │   │   ├── ClearEditorPlugin.tsx
│   │   │   │   │   ├── LexicalMenu.ts
│   │   │   │   │   ├── ParticipantMentionsPlugin.tsx
│   │   │   │   │   ├── TypeaheadMenuPlugin.css
│   │   │   │   │   └── TypeaheadMenuPlugin.tsx
│   │   │   │   ├── ContentRenderers
│   │   │   │   │   ├── CodeContentRenderer.tsx
│   │   │   │   │   ├── ContentListRenderer.tsx
│   │   │   │   │   ├── ContentRenderer.tsx
│   │   │   │   │   ├── DiffRenderer.tsx
│   │   │   │   │   ├── HtmlContentRenderer.tsx
│   │   │   │   │   ├── JsonSchemaContentRenderer.tsx
│   │   │   │   │   ├── MarkdownContentRenderer.tsx
│   │   │   │   │   ├── MarkdownEditorRenderer.tsx
│   │   │   │   │   ├── MermaidContentRenderer.tsx
│   │   │   │   │   ├── MusicABCContentRenderer.css
│   │   │   │   │   └── MusicABCContentRenderer.tsx
│   │   │   │   ├── ContextWindow.tsx
│   │   │   │   ├── ConversationCreate.tsx
│   │   │   │   ├── ConversationDuplicate.tsx
│   │   │   │   ├── ConversationExport.tsx
│   │   │   │   ├── ConversationFileIcon.tsx
│   │   │   │   ├── ConversationRemove.tsx
│   │   │   │   ├── ConversationRename.tsx
│   │   │   │   ├── ConversationShare.tsx
│   │   │   │   ├── ConversationShareCreate.tsx
│   │   │   │   ├── ConversationShareList.tsx
│   │   │   │   ├── ConversationShareView.tsx
│   │   │   │   ├── ConversationsImport.tsx
│   │   │   │   ├── ConversationTranscript.tsx
│   │   │   │   ├── DebugInspector.tsx
│   │   │   │   ├── FileItem.tsx
│   │   │   │   ├── FileList.tsx
│   │   │   │   ├── InputAttachmentList.tsx
│   │   │   │   ├── InputOptionsControl.tsx
│   │   │   │   ├── InteractHistory.tsx
│   │   │   │   ├── InteractInput.tsx
│   │   │   │   ├── Message
│   │   │   │   │   ├── AttachmentSection.tsx
│   │   │   │   │   ├── ContentRenderer.tsx
│   │   │   │   │   ├── ContentSafetyNotice.tsx
│   │   │   │   │   ├── InteractMessage.tsx
│   │   │   │   │   ├── MessageActions.tsx
│   │   │   │   │   ├── MessageBase.tsx
│   │   │   │   │   ├── MessageBody.tsx
│   │   │   │   │   ├── MessageContent.tsx
│   │   │   │   │   ├── MessageFooter.tsx
│   │   │   │   │   ├── MessageHeader.tsx
│   │   │   │   │   ├── NotificationAccordion.tsx
│   │   │   │   │   └── ToolResultMessage.tsx
│   │   │   │   ├── MessageDelete.tsx
│   │   │   │   ├── MessageLink.tsx
│   │   │   │   ├── MyConversations.tsx
│   │   │   │   ├── MyShares.tsx
│   │   │   │   ├── ParticipantAvatar.tsx
│   │   │   │   ├── ParticipantAvatarGroup.tsx
│   │   │   │   ├── ParticipantItem.tsx
│   │   │   │   ├── ParticipantList.tsx
│   │   │   │   ├── ParticipantStatus.tsx
│   │   │   │   ├── RewindConversation.tsx
│   │   │   │   ├── ShareRemove.tsx
│   │   │   │   ├── SpeechButton.tsx
│   │   │   │   └── ToolCalls.tsx
│   │   │   └── FrontDoor
│   │   │       ├── Chat
│   │   │       │   ├── AssistantDrawer.tsx
│   │   │       │   ├── CanvasDrawer.tsx
│   │   │       │   ├── Chat.tsx
│   │   │       │   ├── ChatCanvas.tsx
│   │   │       │   ├── ChatControls.tsx
│   │   │       │   └── ConversationDrawer.tsx
│   │   │       ├── Controls
│   │   │       │   ├── AssistantCard.tsx
│   │   │       │   ├── AssistantSelector.tsx
│   │   │       │   ├── AssistantServiceSelector.tsx
│   │   │       │   ├── ConversationItem.tsx
│   │   │       │   ├── ConversationList.tsx
│   │   │       │   ├── ConversationListOptions.tsx
│   │   │       │   ├── NewConversationButton.tsx
│   │   │       │   ├── NewConversationForm.tsx
│   │   │       │   └── SiteMenuButton.tsx
│   │   │       ├── GlobalContent.tsx
│   │   │       └── MainContent.tsx
│   │   ├── Constants.ts
│   │   ├── global.d.ts
│   │   ├── index.css
│   │   ├── libs
│   │   │   ├── AppStorage.ts
│   │   │   ├── AuthHelper.ts
│   │   │   ├── EventSubscriptionManager.ts
│   │   │   ├── Theme.ts
│   │   │   ├── useAssistantCapabilities.ts
│   │   │   ├── useChatCanvasController.ts
│   │   │   ├── useConversationEvents.ts
│   │   │   ├── useConversationUtility.ts
│   │   │   ├── useCreateConversation.ts
│   │   │   ├── useDebugComponentLifecycle.ts
│   │   │   ├── useDragAndDrop.ts
│   │   │   ├── useEnvironment.ts
│   │   │   ├── useExportUtility.ts
│   │   │   ├── useHistoryUtility.ts
│   │   │   ├── useKeySequence.ts
│   │   │   ├── useMediaQuery.ts
│   │   │   ├── useMicrosoftGraph.ts
│   │   │   ├── useNotify.tsx
│   │   │   ├── useParticipantUtility.tsx
│   │   │   ├── useSiteUtility.ts
│   │   │   ├── useWorkbenchEventSource.ts
│   │   │   ├── useWorkbenchService.ts
│   │   │   └── Utility.ts
│   │   ├── main.tsx
│   │   ├── models
│   │   │   ├── Assistant.ts
│   │   │   ├── AssistantCapability.ts
│   │   │   ├── AssistantServiceInfo.ts
│   │   │   ├── AssistantServiceRegistration.ts
│   │   │   ├── Config.ts
│   │   │   ├── Conversation.ts
│   │   │   ├── ConversationFile.ts
│   │   │   ├── ConversationMessage.ts
│   │   │   ├── ConversationMessageDebug.ts
│   │   │   ├── ConversationParticipant.ts
│   │   │   ├── ConversationShare.ts
│   │   │   ├── ConversationShareRedemption.ts
│   │   │   ├── ConversationState.ts
│   │   │   ├── ConversationStateDescription.ts
│   │   │   ├── ServiceEnvironment.ts
│   │   │   └── User.ts
│   │   ├── redux
│   │   │   ├── app
│   │   │   │   ├── hooks.ts
│   │   │   │   ├── rtkQueryErrorLogger.ts
│   │   │   │   └── store.ts
│   │   │   └── features
│   │   │       ├── app
│   │   │       │   ├── appSlice.ts
│   │   │       │   └── AppState.ts
│   │   │       ├── chatCanvas
│   │   │       │   ├── chatCanvasSlice.ts
│   │   │       │   └── ChatCanvasState.ts
│   │   │       ├── localUser
│   │   │       │   ├── localUserSlice.ts
│   │   │       │   └── LocalUserState.ts
│   │   │       └── settings
│   │   │           ├── settingsSlice.ts
│   │   │           └── SettingsState.ts
│   │   ├── Root.tsx
│   │   ├── routes
│   │   │   ├── AcceptTerms.tsx
│   │   │   ├── AssistantEditor.tsx
│   │   │   ├── AssistantServiceRegistrationEditor.tsx
│   │   │   ├── Dashboard.tsx
│   │   │   ├── ErrorPage.tsx
│   │   │   ├── FrontDoor.tsx
│   │   │   ├── Login.tsx
│   │   │   ├── Settings.tsx
│   │   │   ├── ShareRedeem.tsx
│   │   │   └── Shares.tsx
│   │   ├── services
│   │   │   └── workbench
│   │   │       ├── assistant.ts
│   │   │       ├── assistantService.ts
│   │   │       ├── conversation.ts
│   │   │       ├── file.ts
│   │   │       ├── index.ts
│   │   │       ├── participant.ts
│   │   │       ├── share.ts
│   │   │       ├── state.ts
│   │   │       └── workbench.ts
│   │   └── vite-env.d.ts
│   ├── tools
│   │   └── filtered-ts-prune.cjs
│   ├── tsconfig.json
│   └── vite.config.ts
└── workbench-service
    ├── .env.example
    ├── .vscode
    │   ├── extensions.json
    │   ├── launch.json
    │   └── settings.json
    ├── alembic.ini
    ├── devdb
    │   ├── docker-compose.yaml
    │   └── postgresql-init.sh
    ├── Dockerfile
    ├── Makefile
    ├── migrations
    │   ├── env.py
    │   ├── README
    │   ├── script.py.mako
    │   └── versions
    │       ├── 2024_09_19_000000_69dcda481c14_init.py
    │       ├── 2024_09_19_190029_dffb1d7e219a_file_version_filename.py
    │       ├── 2024_09_20_204130_b29524775484_share.py
    │       ├── 2024_10_30_231536_039bec8edc33_index_message_type.py
    │       ├── 2024_11_04_204029_5149c7fb5a32_conversationmessagedebug.py
    │       ├── 2024_11_05_015124_245baf258e11_double_check_debugs.py
    │       ├── 2024_11_25_191056_a106de176394_drop_workflow.py
    │       ├── 2025_03_19_140136_aaaf792d4d72_set_user_title_set.py
    │       ├── 2025_03_21_153250_3763629295ad_add_assistant_template_id.py
    │       ├── 2025_05_19_163613_b2f86e981885_delete_context_transfer_assistants.py
    │       └── 2025_06_18_174328_503c739152f3_delete_knowlege_transfer_assistants.py
    ├── pyproject.toml
    ├── README.md
    ├── semantic_workbench_service
    │   ├── __init__.py
    │   ├── api.py
    │   ├── assistant_api_key.py
    │   ├── auth.py
    │   ├── azure_speech.py
    │   ├── config.py
    │   ├── controller
    │   │   ├── __init__.py
    │   │   ├── assistant_service_client_pool.py
    │   │   ├── assistant_service_registration.py
    │   │   ├── assistant.py
    │   │   ├── conversation_share.py
    │   │   ├── conversation.py
    │   │   ├── convert.py
    │   │   ├── exceptions.py
    │   │   ├── export_import.py
    │   │   ├── file.py
    │   │   ├── participant.py
    │   │   └── user.py
    │   ├── db.py
    │   ├── event.py
    │   ├── files.py
    │   ├── logging_config.py
    │   ├── middleware.py
    │   ├── query.py
    │   ├── service_user_principals.py
    │   ├── service.py
    │   └── start.py
    ├── tests
    │   ├── __init__.py
    │   ├── conftest.py
    │   ├── docker-compose.yaml
    │   ├── test_assistant_api_key.py
    │   ├── test_files.py
    │   ├── test_integration.py
    │   ├── test_middleware.py
    │   ├── test_migrations.py
    │   ├── test_workbench_service.py
    │   └── types.py
    └── uv.lock
```

# Files

--------------------------------------------------------------------------------
/ai_context/generated/PYTHON_LIBRARIES_CORE.md:
--------------------------------------------------------------------------------

```markdown
# libraries/python/semantic-workbench-api-model | libraries/python/semantic-workbench-assistant | libraries/python/events

[collect-files]

**Search:** ['libraries/python/semantic-workbench-api-model', 'libraries/python/semantic-workbench-assistant', 'libraries/python/events']
**Exclude:** ['.venv', 'node_modules', '*.lock', '.git', '__pycache__', '*.pyc', '*.ruff_cache', 'logs', 'output']
**Include:** ['pyproject.toml', 'README.md']
**Date:** 5/29/2025, 11:45:28 AM
**Files:** 45

=== File: README.md ===
# Semantic Workbench

Semantic Workbench is a versatile tool designed to help prototype intelligent assistants quickly.
It supports the creation of new assistants or the integration of existing ones, all within a
cohesive interface. The workbench provides a user-friendly UI for creating conversations with one
or more assistants, configuring settings, and exposing various behaviors.

The Semantic Workbench is composed of three main components:

- [Workbench Service](workbench-service/README.md) (Python): The backend service that
  handles core functionalities.
- [Workbench App](workbench-app/README.md) (React/Typescript): The frontend web user
  interface for interacting with workbench and assistants.
- [Assistant Services](examples) (Python, C#, etc.): any number of assistant services that implement the service protocols/APIs,
  developed using any framework and programming language of your choice.

Designed to be agnostic of any agent framework, language, or platform, the Semantic Workbench
facilitates experimentation, development, testing, and measurement of agent behaviors and workflows.
Assistants integrate with the workbench via a RESTful API, allowing for flexibility and broad applicability in various development environments.

![Semantic Workbench architecture](https://raw.githubusercontent.com/microsoft/semanticworkbench/main/docs/images/architecture-animation.gif)

# Workbench interface examples

![Configured dashboard example](docs/images/dashboard_configured_view.png)

![Prospector Assistant example](docs/images/prospector_example.png)

![Message debug inspection](docs/images/message_inspection.png)

![Mermaid graph example](examples/dotnet/dotnet-02-message-types-demo/docs/mermaid.png)

![ABC music example](examples/dotnet/dotnet-02-message-types-demo/docs/abc.png)

# Quick start (Recommended) - GitHub Codespaces for turn-key development environment

GitHub Codespaces provides a cloud-based development environment for your repository. It allows you to develop, build, and test your code
in a consistent environment, without needing to install dependencies or configure your local machine. It works with any system with a web
browser and internet connection, including Windows, MacOS, Linux, Chromebooks, tablets, and mobile devices.

See the [GitHub Codespaces / devcontainer README](.devcontainer/README.md) for more information on how to set up and use GitHub Codespaces
with Semantic Workbench.

## Local development environment

See the [setup guide](docs/SETUP_DEV_ENVIRONMENT.md) on how to configure your dev environment. Or if you have Docker installed you can use dev containers with VS Code which will function similarly to Codespaces.

## Using VS Code

Codespaces will is configured to use `semantic-workbench.code-workspace`, if you are working locally that is recommended over opening the repo root. This ensures that all project configurations, such as tools, formatters, and linters, are correctly applied in VS Code. This avoids issues like incorrect error reporting and non-functional tools.

Workspace files allow us to manage multiple projects within a monorepo more effectively. Each project can use its own virtual environment (venv), maintaining isolation and avoiding dependency conflicts. Multi-root workspaces (\*.code-workspace files) can point to multiple projects, each configured with its own Python interpreter, ensuring seamless functionality of Python tools and extensions.

### Start the app and service

- Use VS Code > `Run and Debug` (Ctrl/Cmd+Shift+D) > `semantic-workbench` to start the project
- Open your browser and navigate to `https://127.0.0.1:4000`
  - You may receive a warning about the app not being secure; click `Advanced` and `Proceed to localhost` to continue
- You can now interact with the app and service in the browser

### Start an assistant service:

- Launch an example an [example](examples/) assistant service:
  - No llm api keys needed
    - Use VS Code > `Run and Debug` (Ctrl/Cmd+Shift+D) > `examples: python-01-echo-bot` to start the example assistant that echos your messages. This is a good base to understand the basics of building your own assistant.
  - Bring your own llm api keys
    - Use VS Code > `Run and Debug` (Ctrl/Cmd+Shift+D) > `examples: python-02-simple-chatbot` to start the example chatbot assistant. Either set your keys in your .env file or after creating the assistant as described below, select it and provide the keys in the configuration page.

## Open the Workbench and create an Assistant

Open the app in your browser at [`https://localhost:4000`](https://localhost:4000). When you first log into the Semantic Workbench, follow these steps to get started:

1. **Create an Assistant**: On the dashboard, click the `New Assistant` button. Select a template from the available assistant services, provide a name, and click `Save`.

2. **Start a Conversation**: On the dashboard, click the `New Conversation` button. Provide a title for the conversation and click `Save`.

3. **Add the Assistant**: In the conversation window, click the conversation canvas icon and add your assistant to the conversation from the conversation canvas. Now you can converse with your assistant using the message box at the bottom of the conversation window.

   ![Open Conversation Canvas](docs/images/conversation_canvas_open.png)

   ![Open Canvas](docs/images/open_conversation_canvas.png)

Expected: You get a response from your assistant!

Note that the workbench provides capabilities that not all examples use, for example providing attachments. See the [Semantic Workbench](docs/WORKBENCH_APP.md) for more details.

# Developing your own assistants

To develop new assistants and connect existing ones, see the [Assistant Development Guide](docs/ASSISTANT_DEVELOPMENT_GUIDE.md) or any check out one of the [examples](examples).

- [Python example 1](examples/python/python-01-echo-bot/README.md): a simple assistant echoing text back.
- [Python example 2](examples/python/python-02-simple-chatbot/README.md): a simple chatbot implementing metaprompt guardrails and content moderation.
- [Python example 3](examples/python/python-03-multimodel-chatbot/README.md): an extension of the simple chatbot that supports configuration against additional llms.
- [.NET example 1](examples/dotnet/dotnet-01-echo-bot/README.md): a simple agent with echo and support for a basic `/say` command.
- [.NET example 2](examples/dotnet/dotnet-02-message-types-demo/README.md): a simple assistants showcasing Azure AI Content Safety integration and some workbench features like Mermaid graphs.
- [.NET example 3](examples/dotnet/dotnet-03-simple-chatbot/README.md): a functional chatbot implementing metaprompt guardrails and content moderation.

## Starting the workbench from the command line

- Run the script `tools\run-workbench-chatbot.sh` or `tools\run-workbench-chatbot.ps` which does the following:
  - Starts the backend service, see [here for instructions](workbench-service/README.md).
  - Starts the frontend app, see [here for instructions](workbench-app/README.md).
  - Starts the [Python chatbot example](examples/python/python-02-simple-chatbot/README.md)

## Refreshing Dev Environment

- Use the `tools\reset-service-data.sh` or `tools\reset-service-data.sh` script to reset all service data. You can also delete `~/workbench-service/.data` or specific files if you know which one(s).
- From repo root, run `make clean install`.
  - This will perform a `git clean` and run installs in all sub-directories
- Or a faster option if you just want to install semantic workbench related stuff:
  - From repo root, run `make clean`
  - From `~/workbench-app`, run `make install`
  - From `~/workbench-service`, run `make install`

# Contributing

This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit <https://cla.opensource.microsoft.com>.

When you submit a pull request, a CLA bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.

Please see the detailed [contributing guide](CONTRIBUTING.md) for more information on how you can get involved.

This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [[email protected]](mailto:[email protected]) with any additional questions or comments.

# Trademarks

This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
trademarks or logos is subject to and must follow
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
Any use of third-party trademarks or logos are subject to those third-party's policies.


=== File: libraries/python/events/.vscode/settings.json ===
{
  "editor.bracketPairColorization.enabled": true,
  "editor.codeActionsOnSave": {
    "source.fixAll": "always",
    "source.organizeImports": "always"
  },
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnPaste": true,
  "editor.formatOnSave": true,
  "editor.formatOnType": true,
  "editor.guides.bracketPairs": "active",
  "files.eol": "\n",
  "files.trimTrailingWhitespace": true,
  "flake8.ignorePatterns": ["**/*.py"], // disable flake8 in favor of ruff
  "jupyter.debugJustMyCode": false,
  "python.analysis.autoFormatStrings": true,
  "python.analysis.autoImportCompletions": true,
  "python.analysis.diagnosticMode": "workspace",
  "python.analysis.fixAll": ["source.unusedImports"],
  "python.analysis.inlayHints.functionReturnTypes": true,
  "python.analysis.typeCheckingMode": "standard",
  "python.defaultInterpreterPath": "${workspaceFolder}/.venv",
  "python.testing.cwd": "${workspaceFolder}",
  "search.exclude": {
    "**/.venv": true,
    "**/data": true
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
  },
  "[jsonc]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
  },
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.fixAll": "explicit",
      "source.unusedImports": "explicit",
      "source.organizeImports": "explicit",
      "source.formatDocument": "explicit"
    }
  },
  "ruff.nativeServer": "on",
  // For use with optional extension: "streetsidesoftware.code-spell-checker"
  "cSpell.ignorePaths": [
    ".venv",
    "node_modules",
    "package-lock.json",
    "settings.json",
    "uv.lock"
  ],
  "cSpell.words": [
    "dotenv",
    "httpx",
    "openai",
    "pydantic",
    "pypdf",
    "runtimes",
    "tiktoken"
  ]
}


=== File: libraries/python/events/Makefile ===
repo_root = $(shell git rev-parse --show-toplevel)
include $(repo_root)/tools/makefiles/python.mk


=== File: libraries/python/events/README.md ===
# Events

These are standard events used by the [Skill Library](../skills/skill-library/README.md), [Chat
Driver](../chat-driver/README.md), and other MADE:Exploration projects.

The events here mirror are designed to mirror the event types used in the
[Semantic Workbench](../../../workbench-service/README.md). Think of these as
the types of events that can be emitted by an assistant and handled by the
workbench.


=== File: libraries/python/events/events/__init__.py ===
from .events import (
    BaseEvent,
    ErrorEvent,
    EventProtocol,
    InformationEvent,
    MessageEvent,
    NoticeEvent,
    StatusUpdatedEvent,
    TEvent,
)

__all__ = [
    "BaseEvent",
    "ErrorEvent",
    "EventProtocol",
    "InformationEvent",
    "MessageEvent",
    "NoticeEvent",
    "StatusUpdatedEvent",
    "TEvent",
]


=== File: libraries/python/events/events/events.py ===
"""
Chat drivers integrate with other systems primarily by emitting events. The
driver consumer is responsible for handling all events emitted by the driver.

When integrating a driver with the the Semantic Workbench, you may find it
helpful to handle all Information, or Error, or Status events in particular
Semantic Workbench ways by default. For that reason, the driver should generally
prefer to emit events (from its functions) that inherit from one of these
events.
"""

from datetime import datetime
from typing import Any, Callable, Optional, Protocol, TypeVar
from uuid import UUID, uuid4

from pydantic import BaseModel, Field


class EventProtocol(Protocol):
    id: UUID
    session_id: Optional[str]
    timestamp: datetime
    message: Optional[str]
    metadata: dict[str, Any]
    to_json: Callable[[], str]


TEvent = TypeVar("TEvent", covariant=True, bound=EventProtocol)


class BaseEvent(BaseModel):
    """
    All events inherit from the `BaseEvent` class. The `BaseEvent` class defines
    the common fields that, by convention,  all events must have.
    """

    id: UUID = Field(default_factory=uuid4)
    session_id: str | None = Field(default=None)
    timestamp: datetime = Field(default_factory=datetime.now)
    message: str | None = Field(default=None)
    metadata: dict[str, Any] = Field(default_factory=dict)

    def __str__(self) -> str:
        return f"{self.__class__.__name__}: {self.message}"

    def to_json(self) -> str:
        return self.model_dump_json(indent=2)


class InformationEvent(BaseEvent):
    pass


class ErrorEvent(BaseEvent):
    pass


class StatusUpdatedEvent(BaseEvent):
    pass


class MessageEvent(BaseEvent):
    pass


class NoticeEvent(BaseEvent):
    pass


=== File: libraries/python/events/pyproject.toml ===
[project]
name = "events"
version = "0.1.0"
description = "MADE:Exploration Chat Events"
authors = [{name="MADE:Explorers"}]
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "pydantic>=2.6.1",
]

[tool.uv]
package = true

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[dependency-groups]
dev = [
    "pyright>=1.1.389",
]


=== File: libraries/python/semantic-workbench-api-model/.vscode/settings.json ===
{
  "editor.codeActionsOnSave": {
    "source.organizeImports": "explicit",
    "source.fixAll": "explicit"
  },
  "editor.formatOnSave": true,
  "files.eol": "\n",
  "files.trimTrailingWhitespace": true,
  "flake8.ignorePatterns": ["**/*.py"], // disable flake8 in favor of ruff
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
  },
  "[jsonc]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
  },
  "python.analysis.autoFormatStrings": true,
  "python.analysis.autoImportCompletions": true,
  "python.analysis.diagnosticMode": "workspace",
  "python.analysis.fixAll": ["source.unusedImports"],
  "python.analysis.inlayHints.functionReturnTypes": true,
  "python.analysis.typeCheckingMode": "standard",
  "python.defaultInterpreterPath": "${workspaceFolder}/.venv",
  "python.languageServer": "Pylance",
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.fixAll": "explicit",
      "source.unusedImports": "explicit",
      "source.organizeImports": "explicit",
      "source.formatDocument": "explicit"
    }
  },
  "ruff.nativeServer": "on",
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.codeActionsOnSave": {
      "source.organizeImports": "explicit",
      "source.fixAll": "explicit"
    }
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.codeActionsOnSave": {
      "source.organizeImports": "explicit",
      "source.fixAll": "explicit"
    }
  },
  "css.lint.validProperties": ["composes"],
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "eslint.lintTask.enable": true,
  "editor.formatOnPaste": true,
  "editor.formatOnType": true,
  "eslint.workingDirectories": [
    {
      "mode": "auto"
    }
  ],
  "javascript.updateImportsOnFileMove.enabled": "always",
  "search.exclude": {
    "**/node_modules": true,
    "**/bower_components": true,
    "**/build": true,
    "**/.venv": true
  },
  "typescript.updateImportsOnFileMove.enabled": "always",
  "eslint.enable": true,
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact"
  ],
  "files.associations": { "*.json": "jsonc" },
  "files.exclude": {
    "**/.git": true,
    "**/.svn": true,
    "**/.hg": true,
    "**/CVS": true,
    "**/.DS_Store": true,
    "**/Thumbs.db": true
  },
  "editor.bracketPairColorization.enabled": true,
  "editor.guides.bracketPairs": "active",
  "eslint.options": {
    "overrideConfigFile": ".eslintrc.cjs"
  },
  "better-comments.highlightPlainText": true,
  "better-comments.multilineComments": true,
  "better-comments.tags": [
    {
      "tag": "!",
      "color": "#FF2D00",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "?",
      "color": "#3498DB",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "//",
      "color": "#474747",
      "strikethrough": true,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "todo",
      "color": "#FF8C00",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "fixme",
      "color": "#FF2D00",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "*",
      "color": "#98C379",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    }
  ],
  // For use with optional extension: "streetsidesoftware.code-spell-checker"
  "cSpell.ignorePaths": [
    ".venv",
    "node_modules",
    "package-lock.json",
    "settings.json",
    "uv.lock"
  ],
  "cSpell.words": [
    "abcjs",
    "activedescendant",
    "addoption",
    "aiosqlite",
    "aiter",
    "appsettings",
    "arcname",
    "aread",
    "asgi",
    "assistantparticipant",
    "assistantserviceregistration",
    "asyncpg",
    "autoflush",
    "azurewebsites",
    "cachetools",
    "Codespace",
    "Codespaces",
    "cognitiveservices",
    "conversationmessage",
    "conversationrole",
    "conversationshare",
    "conversationshareredemption",
    "datetime",
    "datname",
    "dbaeumer",
    "dbapi",
    "dbtype",
    "deadcode",
    "decompile",
    "deepmerge",
    "devcontainer",
    "devcontainers",
    "devtunnel",
    "dotenv",
    "echosql",
    "endregion",
    "epivision",
    "esbenp",
    "fastapi",
    "fileversion",
    "fluentui",
    "getfixturevalue",
    "griffel",
    "hashkey",
    "httpx",
    "innerjoin",
    "inspectable",
    "isouter",
    "joinedload",
    "jsonable",
    "jsonlogger",
    "jungaretti",
    "jwks",
    "keyvault",
    "Langchain",
    "levelname",
    "levelno",
    "listbox",
    "msal",
    "nonchat",
    "norender",
    "Ofsteps",
    "ondelete",
    "openai",
    "pydantic",
    "pylance",
    "pyproject",
    "pythonjsonlogger",
    "quickstart",
    "raiseerr",
    "reactflow",
    "reduxjs",
    "rehype",
    "rjsf",
    "rootpath",
    "selectin",
    "semanticworkbench",
    "sessionmaker",
    "setenv",
    "sqlalchemy",
    "sqlmodel",
    "sqltypes",
    "stackoverflow",
    "starlette",
    "streamsaver",
    "subprocessor",
    "tabster",
    "tamasfe",
    "tiktoken",
    "tracebacks",
    "Typeahead",
    "upscaled",
    "usecwd",
    "userparticipant",
    "uvicorn",
    "virtualenvs",
    "webservice",
    "westus",
    "winget",
    "workbenchservice",
    "workflowdefinition",
    "workflowrun",
    "workflowuserparticipant"
  ]
}


=== File: libraries/python/semantic-workbench-api-model/Makefile ===
repo_root = $(shell git rev-parse --show-toplevel)
include $(repo_root)/tools/makefiles/python.mk


=== File: libraries/python/semantic-workbench-api-model/README.md ===
# Semantic Workbench Models and Clients (API)

- If you want to talk from an assistant to the workbench: Workbench Client (assistant services provide these to you).
- If you want to talk from the workbench to an assistant: Assistant Client (the workbench service is the only user of this).


=== File: libraries/python/semantic-workbench-api-model/pyproject.toml ===
[project]
name = "semantic-workbench-api-model"
version = "0.1.0"
description = "Library of pydantic models for requests and responses to the semantic-workbench-service and semantic-workbench-assistant services."
authors = [{name="Semantic Workbench Team"}]
readme = "README.md"
requires-python = ">=3.11,<3.13"
dependencies = [
    "asgi-correlation-id>=4.3.1",
    "fastapi[standard]~=0.115.0",
    "hishel>=0.1.2",
]

[tool.uv]
package = true

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[dependency-groups]
dev = [
    "pyright>=1.1.389",
]


=== File: libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/__init__.py ===


=== File: libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/assistant_model.py ===
from __future__ import annotations

from typing import Any

from pydantic import BaseModel


class AssistantPutRequestModel(BaseModel):
    assistant_name: str
    template_id: str


class AssistantResponseModel(BaseModel):
    id: str


class StateDescriptionResponseModel(BaseModel):
    id: str
    display_name: str
    description: str
    enabled: bool = True


class StateDescriptionListResponseModel(BaseModel):
    states: list[StateDescriptionResponseModel]


class StateResponseModel(BaseModel):
    """
    This model is used by the Workbench to render the state in the UI.
    See: https://github.com/rjsf-team/react-jsonschema-form for
    the use of data, json_schema, and ui_schema.
    """

    id: str
    data: dict[str, Any]
    json_schema: dict[str, Any] | None
    ui_schema: dict[str, Any] | None


class StatePutRequestModel(BaseModel):
    data: dict[str, Any]


class ConfigResponseModel(BaseModel):
    config: dict[str, Any]
    errors: list[str] | None = None
    json_schema: dict[str, Any] | None
    ui_schema: dict[str, Any] | None


class ConfigPutRequestModel(BaseModel):
    config: dict[str, Any]


class AssistantTemplateModel(BaseModel):
    id: str
    name: str
    description: str
    config: ConfigResponseModel


class ServiceInfoModel(BaseModel):
    assistant_service_id: str
    name: str
    templates: list[AssistantTemplateModel]
    metadata: dict[str, Any] = {}


class ConversationPutRequestModel(BaseModel):
    id: str
    title: str


class ConversationResponseModel(BaseModel):
    id: str


class ConversationListResponseModel(BaseModel):
    conversations: list[ConversationResponseModel]


=== File: libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/assistant_service_client.py ===
# The workbench service is the only consumer of this.

from __future__ import annotations

import types
import urllib.parse
import uuid
from contextlib import asynccontextmanager
from typing import IO, Any, AsyncGenerator, AsyncIterator, Callable, Mapping, Self

import asgi_correlation_id
import httpx
from fastapi import HTTPException
from pydantic import BaseModel

from semantic_workbench_api_model.assistant_model import (
    AssistantPutRequestModel,
    ConfigPutRequestModel,
    ConfigResponseModel,
    ConversationPutRequestModel,
    ServiceInfoModel,
    StateDescriptionListResponseModel,
    StatePutRequestModel,
    StateResponseModel,
)
from semantic_workbench_api_model.workbench_model import ConversationEvent

HEADER_API_KEY = "X-API-Key"


# HTTPX transport factory can be overridden to return an ASGI transport for testing
def httpx_transport_factory() -> httpx.AsyncHTTPTransport:
    return httpx.AsyncHTTPTransport(retries=3)


class AuthParams(BaseModel):
    api_key: str

    def to_request_headers(self) -> Mapping[str, str]:
        return {HEADER_API_KEY: urllib.parse.quote(self.api_key)}

    @staticmethod
    def from_request_headers(headers: Mapping[str, str]) -> AuthParams:
        return AuthParams(api_key=headers.get(HEADER_API_KEY) or "")


class AssistantError(HTTPException):
    pass


class AssistantConnectionError(AssistantError):
    def __init__(
        self,
        error: httpx.RequestError | str,
        status_code: int = 424,
    ) -> None:
        match error:
            case str():
                super().__init__(
                    status_code=status_code,
                    detail=error,
                )
            case httpx.RequestError():
                super().__init__(
                    status_code=status_code,
                    detail=(
                        f"Failed to connect to assistant at url {error.request.url}; {error.__class__.__name__}:"
                        f" {error!s}"
                    ),
                )


class AssistantResponseError(AssistantError):
    def __init__(
        self,
        response: httpx.Response,
    ) -> None:
        super().__init__(
            status_code=response.status_code,
            detail=f"Assistant responded with error; response: {response.text}",
        )


class AssistantClient:
    def __init__(self, httpx_client_factory: Callable[[], httpx.AsyncClient]) -> None:
        self._client = httpx_client_factory()

    async def __aenter__(self) -> Self:
        self._client = await self._client.__aenter__()
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None = None,
        exc_value: BaseException | None = None,
        traceback: types.TracebackType | None = None,
    ) -> None:
        await self._client.__aexit__(exc_type, exc_value, traceback)

    async def aclose(self) -> None:
        await self._client.aclose()

    async def put_conversation(self, request: ConversationPutRequestModel, from_export: IO[bytes] | None) -> None:
        try:
            http_response = await self._client.put(
                f"/conversations/{request.id}",
                data={"conversation": request.model_dump_json()},
                files={"from_export": from_export} if from_export is not None else None,
            )
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

    async def delete_conversation(self, conversation_id: uuid.UUID) -> None:
        try:
            http_response = await self._client.delete(f"/conversations/{conversation_id}")
            if http_response.status_code == httpx.codes.NOT_FOUND:
                return

        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

    async def post_conversation_event(self, event: ConversationEvent) -> None:
        try:
            http_response = await self._client.post(
                f"/conversations/{event.conversation_id}/events",
                json=event.model_dump(mode="json"),
            )
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

    async def get_config(self) -> ConfigResponseModel:
        try:
            http_response = await self._client.get("/config")
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

        return ConfigResponseModel.model_validate(http_response.json())

    async def put_config(self, updated_config: ConfigPutRequestModel) -> ConfigResponseModel:
        try:
            http_response = await self._client.put("/config", json=updated_config.model_dump(mode="json"))
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

        return ConfigResponseModel.model_validate(http_response.json())

    @asynccontextmanager
    async def get_exported_data(self) -> AsyncGenerator[AsyncIterator[bytes], Any]:
        try:
            http_response = await self._client.send(self._client.build_request("GET", "/export-data"), stream=True)
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            # streamed responses must be "read" to get the body
            try:
                await http_response.aread()
                raise AssistantResponseError(http_response)
            finally:
                await http_response.aclose()

        try:
            yield http_response.aiter_bytes(1024)
        finally:
            await http_response.aclose()

    @asynccontextmanager
    async def get_exported_conversation_data(
        self,
        conversation_id: uuid.UUID,
    ) -> AsyncGenerator[AsyncIterator[bytes], Any]:
        try:
            http_response = await self._client.send(
                self._client.build_request("GET", f"/conversations/{conversation_id}/export-data"),
                stream=True,
            )
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            # streamed responses must be "read" to get the body
            try:
                await http_response.aread()
                raise AssistantResponseError(http_response)
            finally:
                await http_response.aclose()

        try:
            yield http_response.aiter_bytes(1024)
        finally:
            await http_response.aclose()

    async def get_state_descriptions(self, conversation_id: uuid.UUID) -> StateDescriptionListResponseModel:
        try:
            http_response = await self._client.get(f"/conversations/{conversation_id}/states")
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

        return StateDescriptionListResponseModel.model_validate(http_response.json())

    async def get_state(self, conversation_id: uuid.UUID, state_id: str) -> StateResponseModel:
        try:
            http_response = await self._client.get(f"/conversations/{conversation_id}/states/{state_id}")
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

        return StateResponseModel.model_validate(http_response.json())

    async def put_state(
        self,
        conversation_id: uuid.UUID,
        state_id: str,
        updated_state: StatePutRequestModel,
    ) -> StateResponseModel:
        try:
            http_response = await self._client.put(
                f"/conversations/{conversation_id}/states/{state_id}",
                json=updated_state.model_dump(mode="json"),
            )
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not http_response.is_success:
            raise AssistantResponseError(http_response)

        return StateResponseModel.model_validate(http_response.json())


class AssistantServiceClient:
    def __init__(
        self,
        httpx_client_factory: Callable[[], httpx.AsyncClient],
    ) -> None:
        self._client = httpx_client_factory()

    async def __aenter__(self) -> Self:
        self._client = await self._client.__aenter__()
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None = None,
        exc_value: BaseException | None = None,
        traceback: types.TracebackType | None = None,
    ) -> None:
        await self._client.__aexit__(exc_type, exc_value, traceback)

    async def aclose(self) -> None:
        await self._client.aclose()

    async def put_assistant(
        self,
        assistant_id: uuid.UUID,
        request: AssistantPutRequestModel,
        from_export: IO[bytes] | None,
    ) -> None:
        try:
            response = await self._client.put(
                f"/{assistant_id}",
                data={"assistant": request.model_dump_json()},
                files={"from_export": from_export} if from_export is not None else None,
            )
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not response.is_success:
            raise AssistantResponseError(response)

    async def delete_assistant(self, assistant_id: uuid.UUID) -> None:
        try:
            response = await self._client.delete(f"/{assistant_id}")
            if response.status_code == httpx.codes.NOT_FOUND:
                return

        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not response.is_success:
            raise AssistantResponseError(response)

    async def get_service_info(self) -> ServiceInfoModel:
        try:
            response = await self._client.get("/")
        except httpx.RequestError as e:
            raise AssistantConnectionError(e) from e

        if not response.is_success:
            raise AssistantResponseError(response)

        return ServiceInfoModel.model_validate(response.json())


class AssistantServiceClientBuilder:
    def __init__(
        self,
        base_url: str,
        api_key: str,
    ) -> None:
        self._base_url = base_url.strip("/")
        self._api_key = api_key

    def _client(self, *additional_paths: str) -> httpx.AsyncClient:
        return httpx.AsyncClient(
            transport=httpx_transport_factory(),
            base_url="/".join([self._base_url, *additional_paths]),
            timeout=httpx.Timeout(5.0, connect=10.0, read=60.0),
            headers={
                **AuthParams(api_key=self._api_key).to_request_headers(),
                asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                    asgi_correlation_id.correlation_id.get() or ""
                ),
            },
        )

    def for_service(self) -> AssistantServiceClient:
        return AssistantServiceClient(httpx_client_factory=self._client)

    def for_assistant(self, assistant_id: uuid.UUID) -> AssistantClient:
        return AssistantClient(
            httpx_client_factory=lambda: self._client(str(assistant_id)),
        )


=== File: libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_model.py ===
from __future__ import annotations

import datetime
import uuid
from enum import StrEnum
from typing import Annotated, Any, Literal

import asgi_correlation_id
from pydantic import BaseModel, Field, HttpUrl

from . import assistant_model


class User(BaseModel):
    id: str
    name: str
    image: str | None
    service_user: bool
    created_datetime: datetime.datetime


class UserList(BaseModel):
    users: list[User]


class AssistantServiceRegistration(BaseModel):
    assistant_service_id: str
    created_by_user_id: str
    created_by_user_name: str
    created_datetime: datetime.datetime
    name: str
    description: str
    include_in_listing: bool
    api_key_name: str

    assistant_service_url: str | None
    assistant_service_online: bool
    assistant_service_online_expiration_datetime: datetime.datetime | None

    api_key: Annotated[str | None, Field(repr=False)] = None


class AssistantServiceRegistrationList(BaseModel):
    assistant_service_registrations: list[AssistantServiceRegistration]


class AssistantServiceInfoList(BaseModel):
    assistant_service_infos: list[assistant_model.ServiceInfoModel]


class Assistant(BaseModel):
    id: uuid.UUID
    name: str
    image: str | None
    assistant_service_id: str
    assistant_service_online: bool
    template_id: str
    metadata: dict[str, Any]
    created_datetime: datetime.datetime


class AssistantList(BaseModel):
    assistants: list[Assistant]


class ParticipantRole(StrEnum):
    user = "user"
    assistant = "assistant"
    service = "service"


class ConversationPermission(StrEnum):
    read_write = "read_write"
    read = "read"


class ConversationParticipant(BaseModel):
    role: ParticipantRole
    id: str
    conversation_id: uuid.UUID
    name: str
    image: str | None
    status: str | None
    status_updated_timestamp: datetime.datetime
    active_participant: bool
    online: bool | None = None
    conversation_permission: ConversationPermission
    metadata: dict[str, Any]


class ConversationParticipantList(BaseModel):
    participants: list[ConversationParticipant]


class Conversation(BaseModel):
    id: uuid.UUID
    title: str
    owner_id: str
    imported_from_conversation_id: uuid.UUID | None
    metadata: dict[str, Any]
    created_datetime: datetime.datetime

    conversation_permission: ConversationPermission
    latest_message: ConversationMessage | None
    participants: list[ConversationParticipant]


class ConversationList(BaseModel):
    conversations: list[Conversation]


class ConversationShare(BaseModel):
    id: uuid.UUID
    owner_id: str
    label: str
    created_by_user: User
    conversation_id: uuid.UUID
    conversation_title: str
    conversation_permission: ConversationPermission
    is_redeemable: bool
    created_datetime: datetime.datetime
    metadata: dict[str, Any]


class ConversationShareList(BaseModel):
    conversation_shares: list[ConversationShare]


class ConversationShareRedemption(BaseModel):
    id: uuid.UUID
    conversation_share_id: uuid.UUID
    conversation_id: uuid.UUID
    redeemed_by_user: User
    conversation_permission: ConversationPermission
    new_participant: bool
    created_datetime: datetime.datetime


class ConversationShareRedemptionList(BaseModel):
    conversation_share_redemptions: list[ConversationShareRedemption]


class MessageSender(BaseModel):
    participant_role: ParticipantRole
    participant_id: str


class MessageType(StrEnum):
    chat = "chat"
    command = "command"
    command_response = "command-response"
    log = "log"
    note = "note"
    notice = "notice"


class ConversationMessage(BaseModel):
    id: uuid.UUID
    sender: MessageSender
    message_type: MessageType = MessageType.chat
    timestamp: datetime.datetime
    content_type: str
    content: str
    filenames: list[str]
    metadata: dict[str, Any]
    has_debug_data: bool

    @property
    def command_name(self) -> str:
        if self.message_type != MessageType.command:
            return ""

        return self.content.split(" ", 1)[0]

    @property
    def command_args(self) -> str:
        if self.message_type != MessageType.command:
            return ""

        return "".join(self.content.split(" ", 1)[1:])

    def mentions(self, assistant_id: str) -> bool:
        mentions = self.metadata.get("mentions")
        if not isinstance(mentions, list):
            return False
        return assistant_id in mentions


class ConversationMessageDebug(BaseModel):
    message_id: uuid.UUID
    debug_data: dict[str, Any]


class ConversationMessageList(BaseModel):
    messages: list[ConversationMessage]


class File(BaseModel):
    conversation_id: uuid.UUID
    created_datetime: datetime.datetime
    updated_datetime: datetime.datetime
    filename: str
    current_version: int
    content_type: str
    file_size: int
    participant_id: str
    participant_role: ParticipantRole
    metadata: dict[str, Any]


class FileList(BaseModel):
    files: list[File]


class FileVersion(BaseModel):
    version: int
    content_type: str
    file_size: int
    metadata: dict[str, Any]


class FileVersions(BaseModel):
    conversation_id: uuid.UUID
    created_datetime: datetime.datetime
    filename: str
    current_version: int
    versions: list[FileVersion]


class UpdateFile(BaseModel):
    metadata: dict[str, Any]


class ConversationImportResult(BaseModel):
    conversation_ids: list[uuid.UUID]
    assistant_ids: list[uuid.UUID]


class EditorData(BaseModel):
    position: dict[str, float]


class AssistantData(BaseModel):
    assistant_definition_id: str
    config_data: dict[str, Any]


class OutletPrompts(BaseModel):
    evaluate_transition: str
    context_transfer: str | None = None


class OutletData(BaseModel):
    id: str
    label: str
    prompts: OutletPrompts


class WorkflowState(BaseModel):
    id: str
    label: str
    conversation_definition_id: str
    force_new_conversation_instance: bool | None = None
    assistant_data_list: list[AssistantData]
    editor_data: EditorData
    outlets: list[OutletData]


class WorkflowTransition(BaseModel):
    id: str
    source_outlet_id: str
    target_state_id: str


class ConversationDefinition(BaseModel):
    id: str
    title: str


class AssistantDefinition(BaseModel):
    id: str
    name: str
    assistant_service_id: str


class WorkflowDefinition(BaseModel):
    id: uuid.UUID
    label: str
    start_state_id: str
    states: list[WorkflowState]
    transitions: list[WorkflowTransition]
    conversation_definitions: list[ConversationDefinition]
    assistant_definitions: list[AssistantDefinition]
    context_transfer_instruction: str


class WorkflowDefinitionList(BaseModel):
    workflow_definitions: list[WorkflowDefinition]


class NewWorkflowDefinition(BaseModel):
    label: str
    start_state_id: str
    states: list[WorkflowState]
    transitions: list[WorkflowTransition]
    conversation_definitions: list[ConversationDefinition]
    assistant_definitions: list[AssistantDefinition]
    context_transfer_instruction: str


class UpdateWorkflowDefinition(NewWorkflowDefinition):
    pass


class WorkflowParticipant(BaseModel):
    id: str
    active_participant: bool


class UpdateWorkflowParticipant(BaseModel):
    """
    Update the workflow participant's active status.
    """

    active_participant: bool


class WorkflowConversationMapping(BaseModel):
    conversation_id: str
    conversation_definition_id: str


class WorkflowAssistantMapping(BaseModel):
    assistant_id: str
    assistant_definition_id: str


class WorkflowRun(BaseModel):
    id: uuid.UUID
    title: str
    workflow_definition_id: uuid.UUID
    current_state_id: str
    conversation_mappings: list[WorkflowConversationMapping]
    assistant_mappings: list[WorkflowAssistantMapping]
    metadata: dict[str, Any] | None = None


class WorkflowRunList(BaseModel):
    workflow_runs: list[WorkflowRun]


class NewWorkflowRun(BaseModel):
    workflow_definition_id: uuid.UUID
    title: str
    metadata: dict[str, Any] | None = None


class UpdateWorkflowRun(BaseModel):
    """
    Update the workflow run's title and/or metadata. Leave a field as None to not update it.
    """

    title: str | None = None
    metadata: dict[str, Any] | None = None


class UpdateWorkflowRunMappings(BaseModel):
    """
    Update the workflow run's conversation and/or assistant mappings. Leave a field as None to not update it.
    """

    conversation_mappings: list[WorkflowConversationMapping] | None = None
    assistant_mappings: list[WorkflowAssistantMapping] | None = None


class UpdateUser(BaseModel):
    """
    Update the user's name and/or image. Leave a field as None to not update it.
    """

    name: str | None = None
    image: str | None = None


class NewAssistantServiceRegistration(BaseModel):
    assistant_service_id: Annotated[
        str,
        Field(
            min_length=4,
            pattern=r"^[a-z0-9-\.]+$",
            description="lowercase, alphanumeric, hyphen and dot characters only",
        ),
    ]
    name: Annotated[str, Field(min_length=1)]
    description: str
    include_in_listing: bool = True


class UpdateAssistantServiceRegistration(BaseModel):
    name: str | None = None
    description: str | None = None
    include_in_listing: bool | None = None


class UpdateAssistantServiceRegistrationUrl(BaseModel):
    name: str
    description: str
    url: HttpUrl
    online_expires_in_seconds: float


class NewAssistant(BaseModel):
    assistant_service_id: str
    template_id: str = "default"
    name: str
    image: str | None = None
    metadata: dict[str, Any] = {}


class UpdateAssistant(BaseModel):
    """
    Update the assistant's name, image, and/or metadata. Leave a field as None to not update it.
    """

    name: str | None = None
    image: str | None = None
    metadata: dict[str, Any] = {}


class AssistantStateEvent(BaseModel):
    state_id: str
    event: Literal["created", "updated", "deleted", "focus"]
    state: assistant_model.StateResponseModel | None


class NewConversation(BaseModel):
    title: str = "New Conversation"
    metadata: dict[str, Any] = {}


class UpdateConversation(BaseModel):
    """
    Update the conversation's title and/or metadata. Leave a field as None to not update it.
    """

    title: str | None = None
    metadata: dict[str, Any] = {}


class NewConversationMessage(BaseModel):
    id: uuid.UUID | None = None
    sender: MessageSender | None = None
    content: str
    message_type: MessageType = MessageType.chat
    content_type: str = "text/plain"
    filenames: list[str] | None = None
    metadata: dict[str, Any] | None = None
    debug_data: dict[str, Any] | None = None


class NewConversationShare(BaseModel):
    conversation_id: uuid.UUID
    label: str
    conversation_permission: ConversationPermission
    metadata: dict[str, Any] = {}


class UpdateParticipant(BaseModel):
    """
    Update the participant's status and/or active status. Leave a field as None to not update it.
    """

    status: str | None = None
    active_participant: bool | None = None
    metadata: dict[str, Any] | None = None


class ConversationEventType(StrEnum):
    message_created = "message.created"
    message_deleted = "message.deleted"
    participant_created = "participant.created"
    participant_updated = "participant.updated"
    file_created = "file.created"
    file_updated = "file.updated"
    file_deleted = "file.deleted"
    assistant_state_created = "assistant.state.created"
    assistant_state_updated = "assistant.state.updated"
    assistant_state_deleted = "assistant.state.deleted"
    assistant_state_focus = "assistant.state.focus"
    conversation_created = "conversation.created"
    conversation_updated = "conversation.updated"
    conversation_deleted = "conversation.deleted"


class ConversationEvent(BaseModel):
    id: str = Field(default_factory=lambda: uuid.uuid4().hex)
    correlation_id: str = Field(default_factory=lambda: asgi_correlation_id.correlation_id.get() or "")
    conversation_id: uuid.UUID
    event: ConversationEventType
    timestamp: datetime.datetime = Field(default_factory=lambda: datetime.datetime.now(datetime.UTC))
    data: dict[str, Any] = {}


=== File: libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py ===
from __future__ import annotations

import io
import json
import urllib.parse
import uuid
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from typing import Any, AsyncGenerator, AsyncIterator, Iterable, Mapping

import asgi_correlation_id
import httpx

from . import assistant_model, workbench_model

HEADER_ASSISTANT_SERVICE_ID = "X-Assistant-Service-ID"
HEADER_ASSISTANT_ID = "X-Assistant-ID"
HEADER_API_KEY = "X-API-Key"


# HTTPX transport factory can be overridden to return an ASGI transport for testing
def httpx_transport_factory() -> httpx.AsyncHTTPTransport:
    return httpx.AsyncHTTPTransport(retries=3)


@dataclass
class AssistantServiceRequestHeaders:
    assistant_service_id: str
    api_key: str

    def to_headers(self) -> Mapping[str, str]:
        return {
            HEADER_ASSISTANT_SERVICE_ID: self.assistant_service_id,
            HEADER_API_KEY: self.api_key,
        }

    @staticmethod
    def from_headers(headers: Mapping[str, str]) -> AssistantServiceRequestHeaders:
        return AssistantServiceRequestHeaders(
            assistant_service_id=headers.get(HEADER_ASSISTANT_SERVICE_ID) or "",
            api_key=headers.get(HEADER_API_KEY) or "",
        )


@dataclass
class AssistantRequestHeaders:
    assistant_id: uuid.UUID | None

    def to_headers(self) -> Mapping[str, str]:
        return {HEADER_ASSISTANT_ID: str(self.assistant_id)}

    @staticmethod
    def from_headers(headers: Mapping[str, str]) -> AssistantRequestHeaders:
        assistant_id: uuid.UUID | None = None
        with suppress(ValueError):
            assistant_id = uuid.UUID(headers.get(HEADER_ASSISTANT_ID) or "")
        return AssistantRequestHeaders(
            assistant_id=assistant_id,
        )


@dataclass
class UserRequestHeaders:
    token: str

    def to_headers(self) -> Mapping[str, str]:
        return {"Authorization": f"Bearer {self.token}"}


class ConversationAPIClient:
    def __init__(
        self,
        conversation_id: str,
        httpx_client: httpx.AsyncClient,
        headers: httpx.Headers,
    ) -> None:
        self._conversation_id = conversation_id
        self._client = httpx_client
        self._headers = headers

    async def get_sse_session(self, event_source_url: str) -> AsyncIterator[dict]:
        async with self._client.stream("GET", event_source_url, headers=self._headers) as response:
            event = {}
            async for line in response.aiter_lines():
                if line == "":
                    # End of the event; process and yield it
                    if "data" in event:
                        # Concatenate multiline data
                        data = event["data"]
                        event["data"] = json.loads(data)
                    yield event
                    event = {}
                elif line.startswith(":"):
                    # Comment line; ignore
                    continue
                else:
                    # Parse the field
                    field, value = line.split(":", 1)
                    value = value.lstrip()  # Remove leading whitespace
                    if field == "data":
                        # Handle multiline data
                        event.setdefault("data", "")
                        event["data"] += value + "\n"
                    else:
                        event[field] = value
            # Handle the last event if the stream ends without a blank line
            if event:
                if "data" in event:
                    data = event["data"]
                    event["data"] = json.loads(data)
                yield event

    async def delete_conversation(self) -> None:
        http_response = await self._client.delete(f"/conversations/{self._conversation_id}", headers=self._headers)
        if http_response.status_code == httpx.codes.NOT_FOUND:
            return
        http_response.raise_for_status()

    async def duplicate_conversation(
        self, new_conversation: workbench_model.NewConversation
    ) -> workbench_model.ConversationImportResult:
        http_response = await self._client.post(
            f"/conversations/{self._conversation_id}",
            json=new_conversation.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.ConversationImportResult.model_validate(http_response.json())

    async def get_conversation(self) -> workbench_model.Conversation:
        http_response = await self._client.get(f"/conversations/{self._conversation_id}", headers=self._headers)
        http_response.raise_for_status()
        return workbench_model.Conversation.model_validate(http_response.json())

    async def update_conversation(self, metadata: dict[str, Any]) -> workbench_model.Conversation:
        http_response = await self._client.patch(
            f"/conversations/{self._conversation_id}",
            json=workbench_model.UpdateConversation(metadata=metadata).model_dump(
                mode="json", exclude_unset=True, exclude_defaults=True
            ),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.Conversation.model_validate(http_response.json())

    async def get_participant_me(self) -> workbench_model.ConversationParticipant:
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/participants/me", headers=self._headers
        )
        http_response.raise_for_status()
        return workbench_model.ConversationParticipant.model_validate(http_response.json())

    async def get_participant(self, participant_id: str) -> workbench_model.ConversationParticipant:
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/participants/{participant_id}",
            params={"include_inactive": True},
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.ConversationParticipant.model_validate(http_response.json())

    async def get_participants(self, *, include_inactive: bool = False) -> workbench_model.ConversationParticipantList:
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/participants",
            params={"include_inactive": include_inactive},
            headers=self._headers,
        )
        if http_response.status_code == httpx.codes.NOT_FOUND:
            return workbench_model.ConversationParticipantList(participants=[])

        http_response.raise_for_status()
        return workbench_model.ConversationParticipantList.model_validate(http_response.json())

    async def update_participant(
        self,
        participant_id: str,
        participant: workbench_model.UpdateParticipant,
    ) -> workbench_model.ConversationParticipant:
        http_response = await self._client.patch(
            f"/conversations/{self._conversation_id}/participants/{participant_id}",
            json=participant.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.ConversationParticipant.model_validate(http_response.json())

    async def update_participant_me(
        self,
        participant: workbench_model.UpdateParticipant,
    ) -> workbench_model.ConversationParticipant:
        return await self.update_participant(participant_id="me", participant=participant)

    async def get_message(
        self,
        message_id: uuid.UUID,
    ) -> workbench_model.ConversationMessage:
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/messages/{message_id}", headers=self._headers
        )
        http_response.raise_for_status()
        return workbench_model.ConversationMessage.model_validate(http_response.json())

    async def get_messages(
        self,
        before: uuid.UUID | None = None,
        after: uuid.UUID | None = None,
        message_types: Iterable[workbench_model.MessageType] = (workbench_model.MessageType.chat,),
        participant_ids: Iterable[str] | None = None,
        participant_role: workbench_model.ParticipantRole | None = None,
        limit: int | None = None,
    ) -> workbench_model.ConversationMessageList:
        params: dict[str, str | list[str]] = {}
        if message_types:
            params["message_type"] = [mt.value for mt in message_types]
        if participant_ids:
            params["participant_id"] = list(participant_ids)
        if participant_role:
            params["participant_role"] = participant_role.value
        if before:
            params["before"] = str(before)
        if after:
            params["after"] = str(after)
        if limit:
            params["limit"] = str(limit)

        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/messages", params=params, headers=self._headers
        )
        http_response.raise_for_status()
        return workbench_model.ConversationMessageList.model_validate(http_response.json())

    async def send_messages(
        self,
        *messages: workbench_model.NewConversationMessage,
    ) -> workbench_model.ConversationMessageList:
        messages_out = []
        for message in messages:
            http_response = await self._client.post(
                f"/conversations/{self._conversation_id}/messages",
                json=message.model_dump(mode="json", exclude_unset=True, exclude_defaults=True),
                headers=self._headers,
            )
            http_response.raise_for_status()
            message_out = workbench_model.ConversationMessage.model_validate(http_response.json())
            messages_out.append(message_out)

        return workbench_model.ConversationMessageList(messages=messages_out)

    async def send_conversation_state_event(
        self,
        assistant_id: str,
        state_event: workbench_model.AssistantStateEvent,
    ) -> None:
        http_response = await self._client.post(
            f"/assistants/{assistant_id}/states/events",
            params={"conversation_id": self._conversation_id},
            json=state_event.model_dump(mode="json", exclude_unset=True, exclude_defaults=True),
            headers=self._headers,
        )
        http_response.raise_for_status()

    async def write_file(
        self,
        filename: str,
        file_content: io.BytesIO,
        content_type: str = "application/octet-stream",
    ) -> workbench_model.File:
        http_response = await self._client.put(
            f"/conversations/{self._conversation_id}/files",
            files=[("files", (filename, file_content, content_type))],
            headers=self._headers,
        )
        http_response.raise_for_status()

        file_list = workbench_model.FileList.model_validate(http_response.json())
        return file_list.files[0]

    @asynccontextmanager
    async def read_file(
        self,
        filename: str,
        chunk_size: int | None = None,
    ) -> AsyncGenerator[AsyncIterator[bytes], Any]:
        request = self._client.build_request(
            "GET", f"/conversations/{self._conversation_id}/files/{filename}", headers=self._headers
        )
        http_response = await self._client.send(request, stream=True)
        http_response.raise_for_status()

        try:
            yield http_response.aiter_bytes(chunk_size)
        finally:
            await http_response.aclose()

    async def get_file(self, filename: str) -> workbench_model.File | None:
        params = {"prefix": filename}
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/files", params=params, headers=self._headers
        )
        http_response.raise_for_status()

        files_response = workbench_model.FileList.model_validate(http_response.json())
        if not files_response.files:
            return None

        for file in files_response.files:
            if file.filename != filename:
                continue

            return file

        return None

    async def get_files(self, prefix: str | None = None) -> workbench_model.FileList:
        params = {"prefix": prefix} if prefix else {}
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/files", params=params, headers=self._headers
        )
        http_response.raise_for_status()

        return workbench_model.FileList.model_validate(http_response.json())

    async def file_exists(self, filename: str) -> bool:
        http_response = await self._client.get(
            f"/conversations/{self._conversation_id}/files/{filename}/versions", headers=self._headers
        )
        match http_response.status_code:
            case 200:
                return True
            case 404:
                return False
        http_response.raise_for_status()

        return False

    async def delete_file(self, filename: str) -> None:
        http_response = await self._client.delete(
            f"/conversations/{self._conversation_id}/files/{filename}", headers=self._headers
        )
        if http_response.status_code == httpx.codes.NOT_FOUND:
            return
        http_response.raise_for_status()

    async def update_file(
        self,
        filename: str,
        metadata: dict[str, Any],
    ) -> workbench_model.FileVersions:
        http_response = await self._client.patch(
            f"/conversations/{self._conversation_id}/files/{filename}",
            json=workbench_model.UpdateFile(metadata=metadata).model_dump(
                mode="json", exclude_unset=True, exclude_defaults=True
            ),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.FileVersions.model_validate(http_response.json())


class ConversationsAPIClient:
    def __init__(
        self,
        httpx_client: httpx.AsyncClient,
        headers: httpx.Headers,
    ) -> None:
        self._client = httpx_client
        self._headers = headers

    async def list_conversations(self) -> workbench_model.ConversationList:
        http_response = await self._client.get("/conversations", headers=self._headers)
        http_response.raise_for_status()
        return workbench_model.ConversationList.model_validate(http_response.json())

    async def create_conversation(
        self,
        new_conversation: workbench_model.NewConversation,
    ) -> workbench_model.Conversation:
        http_response = await self._client.post(
            "/conversations",
            json=new_conversation.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.Conversation.model_validate(http_response.json())

    async def create_conversation_with_owner(
        self,
        new_conversation: workbench_model.NewConversation,
        owner_id: str,
    ) -> workbench_model.Conversation:
        http_response = await self._client.post(
            f"/conversations/{owner_id}",
            json=new_conversation.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.Conversation.model_validate(http_response.json())

    async def create_conversation_share_with_owner(
        self,
        new_conversation_share: workbench_model.NewConversationShare,
        owner_id: str,
    ) -> workbench_model.ConversationShare:
        http_response = await self._client.post(
            f"/conversation-shares/{owner_id}",
            json=new_conversation_share.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.ConversationShare.model_validate(http_response.json())

    async def delete_conversation(self, conversation_id: str) -> None:
        http_response = await self._client.delete(f"/conversations/{conversation_id}", headers=self._headers)
        if http_response.status_code == httpx.codes.NOT_FOUND:
            return
        http_response.raise_for_status()


class AssistantsAPIClient:
    def __init__(
        self,
        httpx_client: httpx.AsyncClient,
        headers: httpx.Headers,
    ) -> None:
        self._client = httpx_client
        self._headers = headers

    async def list_assistants(self) -> workbench_model.AssistantList:
        http_response = await self._client.get("/assistants", headers=self._headers)
        http_response.raise_for_status()
        return workbench_model.AssistantList.model_validate(http_response.json())

    async def create_assistant(self, new_assistant: workbench_model.NewAssistant) -> workbench_model.Assistant:
        http_response = await self._client.post(
            "/assistants",
            json=new_assistant.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.Assistant.model_validate(http_response.json())

    async def delete_assistant(self, assistant_id: str) -> None:
        http_response = await self._client.delete(f"/assistants/{assistant_id}", headers=self._headers)
        if http_response.status_code == httpx.codes.NOT_FOUND:
            return
        http_response.raise_for_status()


class AssistantAPIClient:
    def __init__(
        self,
        assistant_id: str,
        httpx_client: httpx.AsyncClient,
        headers: httpx.Headers,
    ) -> None:
        self._assistant_id = assistant_id
        self._client = httpx_client
        self._headers = headers

    async def get_assistant(self) -> workbench_model.Assistant:
        http_response = await self._client.get(f"/assistants/{self._assistant_id}", headers=self._headers)
        http_response.raise_for_status()
        return workbench_model.Assistant.model_validate(http_response.json())

    async def delete_assistant(self) -> None:
        http_response = await self._client.delete(f"/assistants/{self._assistant_id}", headers=self._headers)
        if http_response.status_code == httpx.codes.NOT_FOUND:
            return
        http_response.raise_for_status()

    async def get_config(self) -> assistant_model.ConfigResponseModel:
        http_response = await self._client.get(f"/assistants/{self._assistant_id}/config", headers=self._headers)
        http_response.raise_for_status()
        return assistant_model.ConfigResponseModel.model_validate(http_response.json())

    async def update_config(self, config: assistant_model.ConfigPutRequestModel) -> assistant_model.ConfigResponseModel:
        http_response = await self._client.put(
            f"/assistants/{self._assistant_id}/config",
            json=config.model_dump(exclude_defaults=True, exclude_unset=True, mode="json"),
            headers=self._headers,
        )
        http_response.raise_for_status()
        return assistant_model.ConfigResponseModel.model_validate(http_response.json())


class AssistantServiceAPIClient:
    def __init__(
        self,
        httpx_client: httpx.AsyncClient,
        headers: httpx.Headers,
    ) -> None:
        self._client = httpx_client
        self._headers = headers

    async def update_registration_url(
        self,
        assistant_service_id: str,
        update: workbench_model.UpdateAssistantServiceRegistrationUrl,
    ) -> None:
        http_response = await self._client.put(
            f"/assistant-service-registrations/{assistant_service_id}",
            json=update.model_dump(mode="json", exclude_unset=True, exclude_defaults=True),
            headers=self._headers,
        )
        http_response.raise_for_status()

    async def get_assistant_services(self, user_ids: list[str]) -> workbench_model.AssistantServiceInfoList:
        http_response = await self._client.get(
            "/assistant-services",
            params={"user_id": user_ids},
            headers=self._headers,
        )
        http_response.raise_for_status()
        return workbench_model.AssistantServiceInfoList.model_validate(http_response.json())


class WorkbenchServiceClientBuilder:
    """Builder for assistant-services to create clients to interact with the Workbench service."""

    def __init__(
        self,
        httpx_client: httpx.AsyncClient,
        assistant_service_id: str,
        api_key: str,
    ) -> None:
        self._client = httpx_client
        self._assistant_service_id = assistant_service_id
        self._api_key = api_key

    def for_service(self) -> AssistantServiceAPIClient:
        return AssistantServiceAPIClient(
            httpx_client=self._client,
            headers=httpx.Headers({
                asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                    asgi_correlation_id.correlation_id.get() or ""
                ),
                **AssistantServiceRequestHeaders(
                    assistant_service_id=self._assistant_service_id,
                    api_key=self._api_key,
                ).to_headers(),
            }),
        )

    def for_conversation(self, assistant_id: str, conversation_id: str) -> ConversationAPIClient:
        return ConversationAPIClient(
            conversation_id=conversation_id,
            httpx_client=self._client,
            headers=httpx.Headers(
                {
                    asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                        asgi_correlation_id.correlation_id.get() or ""
                    ),
                    **AssistantServiceRequestHeaders(
                        assistant_service_id=self._assistant_service_id,
                        api_key=self._api_key,
                    ).to_headers(),
                    **AssistantRequestHeaders(
                        assistant_id=uuid.UUID(assistant_id),
                    ).to_headers(),
                },
            ),
        )

    def for_conversations(self, assistant_id: str | None = None) -> ConversationsAPIClient:
        if assistant_id is None:
            return ConversationsAPIClient(
                httpx_client=self._client,
                headers=httpx.Headers(
                    {
                        asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                            asgi_correlation_id.correlation_id.get() or ""
                        ),
                        **AssistantServiceRequestHeaders(
                            assistant_service_id=self._assistant_service_id,
                            api_key=self._api_key,
                        ).to_headers(),
                    },
                ),
            )

        return ConversationsAPIClient(
            httpx_client=self._client,
            headers=httpx.Headers(
                {
                    asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                        asgi_correlation_id.correlation_id.get() or ""
                    ),
                    **AssistantServiceRequestHeaders(
                        assistant_service_id=self._assistant_service_id,
                        api_key=self._api_key,
                    ).to_headers(),
                    **AssistantRequestHeaders(assistant_id=uuid.UUID(assistant_id)).to_headers(),
                },
            ),
        )


class WorkbenchServiceUserClientBuilder:
    """Builder for users to create clients to interact with the Workbench service."""

    def __init__(
        self,
        base_url: str,
        headers: UserRequestHeaders,
    ) -> None:
        self._base_url = base_url
        self._headers = headers

    def _client(self) -> httpx.AsyncClient:
        client = httpx.AsyncClient(transport=httpx_transport_factory())
        client.base_url = self._base_url
        client.timeout.connect = 10
        client.timeout.read = 60
        return client

    def for_assistants(self) -> AssistantsAPIClient:
        return AssistantsAPIClient(
            httpx_client=self._client(),
            headers=httpx.Headers({
                **self._headers.to_headers(),
                asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                    asgi_correlation_id.correlation_id.get() or ""
                ),
            }),
        )

    def for_assistant(self, assistant_id: str) -> AssistantAPIClient:
        return AssistantAPIClient(
            assistant_id=assistant_id,
            httpx_client=self._client(),
            headers=httpx.Headers({
                **self._headers.to_headers(),
                asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                    asgi_correlation_id.correlation_id.get() or ""
                ),
            }),
        )

    def for_conversations(self) -> ConversationsAPIClient:
        return ConversationsAPIClient(
            httpx_client=self._client(),
            headers=httpx.Headers({
                **self._headers.to_headers(),
                asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                    asgi_correlation_id.correlation_id.get() or ""
                ),
            }),
        )

    def for_conversation(self, conversation_id: str) -> ConversationAPIClient:
        return ConversationAPIClient(
            conversation_id=conversation_id,
            httpx_client=self._client(),
            headers=httpx.Headers({
                **self._headers.to_headers(),
                asgi_correlation_id.CorrelationIdMiddleware.header_name: urllib.parse.quote(
                    asgi_correlation_id.correlation_id.get() or ""
                ),
            }),
        )


=== File: libraries/python/semantic-workbench-assistant/.vscode/launch.json ===
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "debugpy",
      "request": "launch",
      "name": "canonical-assistant",
      "cwd": "${workspaceFolder}",
      "module": "semantic_workbench_assistant.start",
      "args": ["semantic_workbench_assistant.canonical:app"],
      "consoleTitle": "canonical-assistant"
    }
  ]
}


=== File: libraries/python/semantic-workbench-assistant/.vscode/settings.json ===
{
  "editor.bracketPairColorization.enabled": true,
  "editor.codeActionsOnSave": {
    "source.organizeImports": "explicit",
    "source.fixAll": "explicit"
  },
  "editor.guides.bracketPairs": "active",
  "editor.formatOnPaste": true,
  "editor.formatOnType": true,
  "editor.formatOnSave": true,
  "files.eol": "\n",
  "files.exclude": {
    "**/.git": true,
    "**/.svn": true,
    "**/.hg": true,
    "**/CVS": true,
    "**/.DS_Store": true,
    "**/Thumbs.db": true
  },
  "files.trimTrailingWhitespace": true,
  "flake8.ignorePatterns": ["**/*.py"], // disable flake8 in favor of ruff
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
  },
  "[jsonc]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
  },
  "python.analysis.autoFormatStrings": true,
  "python.analysis.autoImportCompletions": true,
  "python.analysis.diagnosticMode": "workspace",
  "python.analysis.fixAll": ["source.unusedImports"],
  "python.analysis.inlayHints.functionReturnTypes": true,
  "python.analysis.typeCheckingMode": "standard",
  "python.defaultInterpreterPath": "${workspaceFolder}/.venv",
  "python.testing.pytestEnabled": true,
  "python.testing.cwd": "${workspaceFolder}",
  "python.testing.pytestArgs": [],
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.fixAll": "explicit",
      "source.unusedImports": "explicit",
      "source.organizeImports": "explicit",
      "source.formatDocument": "explicit"
    }
  },
  "ruff.nativeServer": "on",
  "search.exclude": {
    "**/.venv": true,
    "**/.data": true,
    "**/__pycache__": true
  },
  "better-comments.highlightPlainText": true,
  "better-comments.multilineComments": true,
  "better-comments.tags": [
    {
      "tag": "!",
      "color": "#FF2D00",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "?",
      "color": "#3498DB",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "//",
      "color": "#474747",
      "strikethrough": true,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "todo",
      "color": "#FF8C00",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "fixme",
      "color": "#FF2D00",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    },
    {
      "tag": "*",
      "color": "#98C379",
      "strikethrough": false,
      "underline": false,
      "backgroundColor": "transparent",
      "bold": false,
      "italic": false
    }
  ],
  // For use with optional extension: "streetsidesoftware.code-spell-checker"
  "cSpell.ignorePaths": [
    ".venv",
    "node_modules",
    "package-lock.json",
    "settings.json",
    "uv.lock"
  ],
  "cSpell.words": [
    "abcjs",
    "activedescendant",
    "addoption",
    "aiosqlite",
    "aiter",
    "appsettings",
    "arcname",
    "aread",
    "asgi",
    "assistantparticipant",
    "assistantserviceregistration",
    "asyncpg",
    "autoflush",
    "azurewebsites",
    "cachetools",
    "Codespace",
    "Codespaces",
    "cognitiveservices",
    "conversationmessage",
    "conversationrole",
    "conversationshare",
    "conversationshareredemption",
    "datetime",
    "datname",
    "dbaeumer",
    "dbapi",
    "dbtype",
    "deadcode",
    "decompile",
    "deepmerge",
    "devcontainer",
    "devcontainers",
    "devtunnel",
    "dotenv",
    "echosql",
    "endregion",
    "epivision",
    "esbenp",
    "fastapi",
    "fileversion",
    "fluentui",
    "getfixturevalue",
    "griffel",
    "hashkey",
    "httpx",
    "innerjoin",
    "inspectable",
    "isouter",
    "joinedload",
    "jsonable",
    "jsonlogger",
    "jungaretti",
    "jwks",
    "keyvault",
    "Langchain",
    "levelname",
    "levelno",
    "listbox",
    "msal",
    "nonchat",
    "norender",
    "Ofsteps",
    "ondelete",
    "openai",
    "pydantic",
    "pylance",
    "pyproject",
    "pythonjsonlogger",
    "quickstart",
    "raiseerr",
    "reactflow",
    "reduxjs",
    "rehype",
    "rjsf",
    "rootpath",
    "selectin",
    "semanticworkbench",
    "sessionmaker",
    "setenv",
    "sqlalchemy",
    "sqlmodel",
    "sqltypes",
    "stackoverflow",
    "starlette",
    "streamsaver",
    "subprocessor",
    "tabster",
    "tamasfe",
    "tiktoken",
    "tracebacks",
    "Typeahead",
    "upscaled",
    "usecwd",
    "userparticipant",
    "uvicorn",
    "virtualenvs",
    "webservice",
    "westus",
    "winget",
    "workbenchservice",
    "workflowdefinition",
    "workflowrun",
    "workflowuserparticipant"
  ]
}


=== File: libraries/python/semantic-workbench-assistant/Makefile ===
repo_root = $(shell git rev-parse --show-toplevel)
include $(repo_root)/tools/makefiles/python.mk


=== File: libraries/python/semantic-workbench-assistant/README.md ===
# Semantic Workbench Assistant

Base class and utilities for creating an assistant within the Semantic Workbench.

## Assistant Templates

The Semantic Workbench supports assistant templates, allowing assistant services to provide multiple types of assistants with different configurations. Each template includes a unique ID, name, description, and default configuration. When users create a new assistant, they select from available templates rather than configuring an assistant from scratch.

To implement templates for your assistant service, define them in your service configuration and return them as part of your service info response.

## Start canonical assistant

The repository contains a canonical assistant without any AI features, that can be used as starting point to create custom agents.

To start the canonical assistant:

```sh
cd workbench-service
start-assistant semantic_workbench_assistant.canonical:app
```


=== File: libraries/python/semantic-workbench-assistant/pyproject.toml ===
[project]
name = "semantic-workbench-assistant"
version = "0.1.0"
description = "Library for facilitating the implementation of FastAPI-based Semantic Workbench assistants."
authors = [{ name = "Semantic Workbench Team" }]
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "asgi-correlation-id>=4.3.1",
    "backoff>=2.2.1",
    "fastapi[standard]~=0.115.0",
    "pydantic-settings>=2.2.0",
    "python-json-logger>=2.0.7",
    "rich>=13.7.0",
    "deepmerge>=2.0",
    "semantic-workbench-api-model>=0.1.0",
]

[dependency-groups]
dev = [
    "asgi-lifespan>=2.1.0",
    "pyright>=1.1.389",
    "pytest>=7.4.3",
    "pytest-asyncio>=0.23.5.post1",
    "pytest-httpx>=0.30.0",
]

[tool.uv.sources]
semantic-workbench-api-model = { path = "../semantic-workbench-api-model", editable = true }

[project.scripts]
start-semantic-workbench-assistant = "semantic_workbench_assistant.start:main"
start-assistant = "semantic_workbench_assistant.start:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.pytest.ini_options]
addopts = "-vv"
log_cli = true
log_cli_level = "WARNING"
log_cli_format = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/__init__.py ===
from . import settings

settings = settings.Settings()


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/__init__.py ===
from .assistant import AssistantApp
from .config import BaseModelAssistantConfig
from .content_safety import (
    AlwaysWarnContentSafetyEvaluator,
    ContentSafety,
    ContentSafetyEvaluation,
    ContentSafetyEvaluationResult,
    ContentSafetyEvaluator,
)
from .context import AssistantContext, ConversationContext, storage_directory_for_context
from .error import BadRequestError, ConflictError, NotFoundError
from .export_import import FileStorageAssistantDataExporter, FileStorageConversationDataExporter
from .protocol import (
    AssistantTemplate,
    AssistantAppProtocol,
    AssistantCapability,
    AssistantConfigDataModel,
    AssistantConfigProvider,
    AssistantConversationInspectorStateDataModel,
    AssistantConversationInspectorStateProvider,
)

__all__ = [
    "AlwaysWarnContentSafetyEvaluator",
    "AssistantApp",
    "AssistantAppProtocol",
    "AssistantCapability",
    "AssistantConfigProvider",
    "AssistantConfigDataModel",
    "AssistantContext",
    "AssistantConversationInspectorStateDataModel",
    "AssistantConversationInspectorStateProvider",
    "AssistantTemplate",
    "BaseModelAssistantConfig",
    "ConversationContext",
    "ContentSafety",
    "ContentSafetyEvaluation",
    "ContentSafetyEvaluationResult",
    "ContentSafetyEvaluator",
    "FileStorageAssistantDataExporter",
    "FileStorageConversationDataExporter",
    "BadRequestError",
    "NotFoundError",
    "ConflictError",
    "storage_directory_for_context",
]


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/assistant.py ===
from typing import (
    Any,
    Iterable,
    Mapping,
)

import deepmerge
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict

from semantic_workbench_assistant.assistant_app.config import BaseModelAssistantConfig
from semantic_workbench_assistant.assistant_service import create_app

from .content_safety import AlwaysWarnContentSafetyEvaluator, ContentSafety
from .export_import import FileStorageAssistantDataExporter, FileStorageConversationDataExporter
from .protocol import (
    AssistantCapability,
    AssistantConfigProvider,
    AssistantConversationInspectorStateProvider,
    AssistantDataExporter,
    AssistantTemplate,
    ContentInterceptor,
    ConversationDataExporter,
    Events,
)
from .service import AssistantService


class EmptyConfigModel(BaseModel):
    model_config = ConfigDict(title="This assistant has no configuration")


class AssistantApp:
    def __init__(
        self,
        assistant_service_id: str,
        assistant_service_name: str,
        assistant_service_description: str,
        assistant_service_metadata: dict[str, Any] = {},
        capabilities: set[AssistantCapability] = set(),
        config_provider: AssistantConfigProvider = BaseModelAssistantConfig(EmptyConfigModel).provider,
        data_exporter: AssistantDataExporter = FileStorageAssistantDataExporter(),
        conversation_data_exporter: ConversationDataExporter = FileStorageConversationDataExporter(),
        inspector_state_providers: Mapping[str, AssistantConversationInspectorStateProvider] | None = None,
        content_interceptor: ContentInterceptor | None = ContentSafety(AlwaysWarnContentSafetyEvaluator.factory),
        additional_templates: Iterable[AssistantTemplate] = [],
    ) -> None:
        self.assistant_service_id = assistant_service_id
        self.assistant_service_name = assistant_service_name
        self.assistant_service_description = assistant_service_description
        self._assistant_service_metadata = assistant_service_metadata
        self._capabilities = capabilities

        self.config_provider = config_provider
        self.data_exporter = data_exporter
        self.templates = {
            "default": AssistantTemplate(
                id="default",
                name=assistant_service_name,
                description=assistant_service_description,
            ),
        }
        if additional_templates:
            for template in additional_templates:
                if template.id in self.templates:
                    raise ValueError(f"Template {template.id} already exists")
                self.templates[template.id] = template
        self.conversation_data_exporter = conversation_data_exporter
        self.inspector_state_providers = dict(inspector_state_providers or {})
        self.content_interceptor = content_interceptor

        self.events = Events()

    @property
    def assistant_service_metadata(self) -> dict[str, Any]:
        return deepmerge.always_merger.merge(
            self._assistant_service_metadata,
            {"capabilities": {capability: True for capability in self._capabilities}},
        )

    def add_inspector_state_provider(
        self,
        state_id: str,
        provider: AssistantConversationInspectorStateProvider,
    ) -> None:
        if state_id in self.inspector_state_providers:
            raise ValueError(f"Inspector state provider with id {state_id} already exists")
        self.inspector_state_providers[state_id] = provider

    def add_capability(self, capability: AssistantCapability) -> None:
        self._capabilities.add(capability)

    def fastapi_app(self) -> FastAPI:
        return create_app(
            lambda lifespan: AssistantService(
                assistant_app=self,
                register_lifespan_handler=lifespan.register_handler,
            )
        )


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/config.py ===
import logging
import pathlib
from typing import Any, Generic, TypeVar

from pydantic import (
    BaseModel,
    ValidationError,
)

from ..config import (
    ConfigSecretStrJsonSerializationMode,
    config_secret_str_serialization_context,
    get_ui_schema,
    replace_config_secret_str_masked_values,
)
from ..storage import read_model, write_model
from .context import AssistantContext, storage_directory_for_context
from .error import BadRequestError
from .protocol import (
    AssistantConfigDataModel,
    AssistantConfigProvider,
)

logger = logging.getLogger(__name__)

ConfigModelT = TypeVar("ConfigModelT", bound=BaseModel)


class BaseModelAssistantConfig(Generic[ConfigModelT]):
    """
    Assistant-config implementation that uses a BaseModel for default config.
    """

    def __init__(
        self, default_cls: type[ConfigModelT], additional_templates: dict[str, type[ConfigModelT]] = {}
    ) -> None:
        self._templates = {
            "default": default_cls,
        }

        if not additional_templates:
            return

        for template_id, template_cls in additional_templates.items():
            if template_id in self._templates:
                raise ValueError(f"Template {template_id} already exists")
            self._templates[template_id] = template_cls

    async def get(self, assistant_context: AssistantContext) -> ConfigModelT:
        path = self._private_path_for(assistant_context)

        if not path.exists():
            # if the config file hasn't been written yet, check the export/import path
            path = self._export_import_path_for(assistant_context)

        config = None
        try:
            config = read_model(path, self._templates[assistant_context._template_id])
        except ValidationError as e:
            logger.warning("exception reading config; path: %s", path, exc_info=e)

        return config or self._templates[assistant_context._template_id].model_construct()

    @property
    def provider(self) -> AssistantConfigProvider:
        class _ConfigProvider:
            def __init__(self, provider: BaseModelAssistantConfig[ConfigModelT]) -> None:
                self._provider = provider

            async def get(self, assistant_context: AssistantContext) -> AssistantConfigDataModel:
                config = await self._provider.get(assistant_context)
                errors = []
                try:
                    self._provider._templates[assistant_context._template_id].model_validate(config.model_dump())
                except ValidationError as e:
                    for error in e.errors(include_url=False):
                        errors.append(str(error))

                return self._provider._config_data_model_for(config, errors)

            async def set(self, assistant_context: AssistantContext, config: dict[str, Any]) -> None:
                try:
                    updated_config = self._provider._templates[assistant_context._template_id].model_validate(config)
                except ValidationError as e:
                    raise BadRequestError(str(e))

                # replace masked secret values with original values
                original_config = await self._provider.get(assistant_context)
                updated_config = replace_config_secret_str_masked_values(updated_config, original_config)

                await self._provider._set(assistant_context, updated_config)

            def default_for(self, template_id: str) -> AssistantConfigDataModel:
                # return the default config for the given assistant type
                config = self._provider._templates[template_id].model_construct()
                return self._provider._config_data_model_for(config)

        return _ConfigProvider(self)

    def _private_path_for(self, assistant_context: AssistantContext) -> pathlib.Path:
        # store assistant config, including secrets, in a separate partition that is never exported
        return storage_directory_for_context(assistant_context, partition="private") / "config.json"

    def _export_import_path_for(self, assistant_context: AssistantContext) -> pathlib.Path:
        # store a copy of the config for export in the standard partition
        return storage_directory_for_context(assistant_context) / "config.json"

    async def _set(self, assistant_context: AssistantContext, config: ConfigModelT) -> None:
        # save the config with secrets serialized with their actual values for the assistant
        write_model(
            self._private_path_for(assistant_context),
            config,
            serialization_context=config_secret_str_serialization_context(
                ConfigSecretStrJsonSerializationMode.serialize_value
            ),
        )
        # save a copy of the config for export, with secret fields set to empty strings
        write_model(
            self._export_import_path_for(assistant_context),
            config,
            serialization_context=config_secret_str_serialization_context(
                ConfigSecretStrJsonSerializationMode.serialize_as_empty
            ),
        )

    ui_schema_cache: dict[type, dict[str, Any]] = {}

    def cache_ui_schema(self, config: ConfigModelT) -> dict[str, Any]:
        """
        Get the UI schema for the given config model.
        This method caches the UI schema to avoid re-generating it for the same config model.
        """
        if type(config) not in self.ui_schema_cache:
            self.ui_schema_cache[type(config)] = get_ui_schema(type(config))
        return self.ui_schema_cache[type(config)]

    json_schema_cache: dict[type, dict[str, Any]] = {}

    def cache_json_schema(self, config: ConfigModelT) -> dict[str, Any]:
        """
        Get the JSON schema for the given config model.
        This method caches the JSON schema to avoid re-generating it for the same config model.
        """
        if type(config) not in self.json_schema_cache:
            self.json_schema_cache[type(config)] = config.model_json_schema()
        return self.json_schema_cache[type(config)]

    def _config_data_model_for(self, config: ConfigModelT, errors: list[str] | None = None) -> AssistantConfigDataModel:
        return AssistantConfigDataModel(
            config=config.model_dump(mode="json"),
            errors=errors,
            json_schema=self.cache_json_schema(config),
            ui_schema=self.cache_ui_schema(config),
        )


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/content_safety.py ===
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
from enum import StrEnum
from typing import Any, Awaitable, Callable, Protocol

import deepmerge
from pydantic import BaseModel
from semantic_workbench_api_model.workbench_model import (
    ConversationEvent,
    ConversationEventType,
    MessageType,
    NewConversationMessage,
)

from semantic_workbench_assistant.assistant_app.context import ConversationContext
from semantic_workbench_assistant.assistant_app.protocol import ContentInterceptor

logger = logging.getLogger(__name__)


class ContentSafetyEvaluationResult(StrEnum):
    """
    An enumeration of content safety evaluation results.

    **Properties**
    - **Pass**: The content is safe.
    - **Warn**: The content is potentially unsafe.
    - **Fail**: The content is unsafe.
    """

    Pass = "pass"
    Warn = "warn"
    Fail = "fail"


class ContentSafetyEvaluation(BaseModel):
    """
    A model for content safety evaluation results.

    **Properties**
    - **result (ContentSafetyEvaluationResult)**
        - The result of the evaluation, one of the ContentSafetyEvaluationResult enum values.
    - **note (str | None)**
        - Commentary on the evaluation result, written in human-readable form to be used in UI.
    - **metadata (dict[str, Any] | None)**
        - Additional information about the evaluation, frequently passed along as debug information.
    """

    result: ContentSafetyEvaluationResult = ContentSafetyEvaluationResult.Fail
    note: str | None = None
    metadata: dict[str, Any] = {}


class ContentSafetyEvaluator(Protocol):
    """
    A protocol for content safety evaluators.

    These will be passed to a content safety interceptor to evaluate the safety of content or
    may be used directly by the assistant logic to evaluate content safety.

    **Methods:**
        - **evaluate(content: str | list[str]) -> ContentSafetyEvaluation**
            - Evaluate the content safety of a string or list of strings and return the result.
    """

    async def evaluate(self, content: str | list[str]) -> ContentSafetyEvaluation: ...


ContentEvaluatorFactory = Callable[[ConversationContext], Awaitable[ContentSafetyEvaluator]]


class AlwaysWarnContentSafetyEvaluator:
    """
    A content safety evaluator that always returns a warning

    Notes:
    - This is a placeholder evaluator that should be replaced with a real implementation.

    Methods:
        - evaluate(content: str | list[str]) -> ContentSafetyEvaluation:
            Evaluate the content safety of a string or list of strings and return a warning.
    """

    @staticmethod
    async def factory(context: ConversationContext) -> ContentSafetyEvaluator:
        """
        Factory method to create an instance of a ContentSafetyEvaluator.
        """
        return AlwaysWarnContentSafetyEvaluator()

    async def evaluate(self, content: str | list[str]) -> ContentSafetyEvaluation:
        """
        Evaluate the content safety of a string or list of strings.
        """
        return ContentSafetyEvaluation(
            result=ContentSafetyEvaluationResult.Warn,
            note="Content safety evaluation not implemented.",
            metadata={"note": "This is a placeholder evaluator that should be replaced with a real implementation."},
        )


class ContentSafety(ContentInterceptor):
    """
    A content safety interceptor that evaluates the safety of content. It is opinionated in that it
    will:

    - **Incoming conversation events**
        - Fail any event that contains unsafe content.
        - Add the evaluation result to the event data for easy access by other interceptors or the assistant logic.
        - Add interceptor data to the event data to avoid infinite loops.
    - **Outgoing conversation messages**
        - Replace all messages with a notice if any message contains unsafe content.
        - Add a warning to all messages that contain generated content.
        - Add the evaluation result to the debug metadata for visibility in the workbench UI debug views.
        - Add interceptor data to the message metadata to avoid infinite loops.

    **Notes**
    - Use this interceptor as an example or template for implementing content safety evaluation in an
        assistant if you want to introduce your own content safety evaluation logic or handling of
        evaluation results.
    """

    # use the class name to identify the metadata key
    @property
    def metadata_key(self) -> str:
        return "content_safety"

    def __init__(self, content_evaluator_factory: ContentEvaluatorFactory) -> None:
        self.content_evaluator_factory = content_evaluator_factory

    #
    # interceptor methods
    #

    async def intercept_incoming_event(
        self, context: ConversationContext, event: ConversationEvent
    ) -> ConversationEvent | None:
        """
        Evaluate the content safety of an incoming conversation event and return a
        new event with the evaluation result added to the data if the content is safe.

        If the content is not safe, the event will be removed and a notice message will
        be sent back to the conversation.
        """

        # avoid infinite loops by checking if the event was sent by the assistant
        if self._check_event_tag(event, context.assistant.id):
            # return the event without further processing
            return event

        # list of event types that should be evaluated
        if event.event not in [
            ConversationEventType.message_created,
            ConversationEventType.file_created,
            ConversationEventType.file_updated,
        ]:
            # skip evaluation for other event types
            return event

        # evaluate the content safety of the event data
        try:
            evaluator = await self.content_evaluator_factory(context)
            evaluation = await evaluator.evaluate(json.dumps(event.data))
        except Exception as e:
            # if there is an error, return a fail result with the error message
            logger.exception("Content safety evaluation failed.")
            evaluation = ContentSafetyEvaluation(
                result=ContentSafetyEvaluationResult.Fail,
                note=f"Content safety evaluation failed: {e}",
            )

        # create an evaluated event to return
        evaluated_event: ConversationEvent | None = None

        match evaluation.result:
            case ContentSafetyEvaluationResult.Pass | ContentSafetyEvaluationResult.Warn:
                # return the original event
                evaluated_event = event

            case ContentSafetyEvaluationResult.Fail:
                # send a notice back to the conversation that the content safety evaluation failed
                await context.send_messages([
                    self._tag_message(
                        NewConversationMessage(
                            content=evaluation.note or "Content safety evaluation failed.",
                            message_type=MessageType.notice,
                            metadata={
                                "generated_content": False,
                                "debug": {
                                    f"{self.metadata_key}": {
                                        "intercept_incoming_event": {
                                            "evaluation": evaluation.model_dump(),
                                            "event": event.model_dump(),
                                        },
                                    },
                                },
                            },
                        ),
                        context.assistant.id,
                    )
                ])

                # do not assign the updated event to prevent the event from being returned

        # update the results with the data from this interceptor
        if evaluated_event is not None:
            # tag the event with the assistant id to avoid infinite loops
            evaluated_event = self._tag_event(evaluated_event, context.assistant.id)

            # add the evaluation result to the event data so that it can be easily accessed
            # by the assistant logic as desired, such as attaching the evaluation result as
            # debug information on response messages
            deepmerge.always_merger.merge(
                evaluated_event.data,
                {
                    f"{self.metadata_key}": {
                        "intercept_incoming_event": {
                            "evaluation": evaluation.model_dump(),
                        },
                    },
                },
            )

        return evaluated_event

    async def intercept_outgoing_messages(
        self, context: ConversationContext, messages: list[NewConversationMessage]
    ) -> list[NewConversationMessage]:
        """
        Evaluate the content safety of outgoing conversation messages and return a list of
        new messages with warnings added to messages that contain generated content.

        If any message contains unsafe content, all messages will be replaced with a notice.
        """

        # check if any of the messages contain generated content
        if not any(
            message.metadata is not None and message.metadata.get("generated_content", True) for message in messages
        ):
            # skip evaluation if no generated content is found
            return messages

        # evaluate the content safety of the messages
        try:
            evaluator = await self.content_evaluator_factory(context)
            evaluation = await evaluator.evaluate([message.content for message in messages])
        except Exception as e:
            # if there is an error, return a fail result with the error message
            logger.exception("Content safety evaluation failed.")
            evaluation = ContentSafetyEvaluation(
                result=ContentSafetyEvaluationResult.Fail,
                note=f"Content safety evaluation failed: {e}",
            )

        # create a list of evaluated messages to return
        evaluated_messages: list[NewConversationMessage] = []

        match evaluation.result:
            case ContentSafetyEvaluationResult.Pass:
                # return the original messages
                evaluated_messages = messages

            case ContentSafetyEvaluationResult.Warn:
                # add a warning to each message
                evaluated_messages = [
                    NewConversationMessage(
                        **message.model_dump(exclude={"content"}),
                        content=f"{message.content}\n\n[Content safety evaluation warning: {evaluation.note}]",
                    )
                    for message in messages
                ]

            case ContentSafetyEvaluationResult.Fail:
                # replace messages with a single notice that the evaluation failed
                evaluated_messages = [
                    self._tag_message(
                        NewConversationMessage(
                            content=evaluation.note or "Content safety evaluation failed.",
                            message_type=MessageType.notice,
                            metadata={
                                "generated_content": False,
                            },
                        ),
                        context.assistant.id,
                    )
                ]

        # update the results with the data from this interceptor
        for message in evaluated_messages:
            # tag the message with the assistant id to avoid infinite loops
            message = self._tag_message(message, context.assistant.id)

            # add the evaluation result to the debug metadata so that it will
            # be visible in the workbench UI debug views
            deepmerge.always_merger.merge(
                message.metadata,
                {
                    "debug": {
                        f"{self.metadata_key}": {
                            "intercept_outgoing_messages": {
                                "evaluation": evaluation.model_dump(),
                            }
                        }
                    }
                },
            )

        return evaluated_messages

    #
    # helper methods
    #

    def _tag_event(self, event: ConversationEvent, assistant_id: str) -> ConversationEvent:
        """
        Tag an event with the assistant ID to avoid infinite loops.
        """
        deepmerge.always_merger.merge(
            event.data,
            {
                f"{self.metadata_key}": {
                    "assistant_id": assistant_id,
                },
            },
        )
        return event

    def _tag_message(self, message: NewConversationMessage, assistant_id: str) -> NewConversationMessage:
        """
        Tag a message with the assistant id to avoid infinite loops.
        """

        # add the metadata key to the message if it does not exist
        if message.metadata is None:
            message.metadata = {}

        # merge the interceptor key with source assistant id into the message metadata
        deepmerge.always_merger.merge(
            message.metadata,
            {
                f"{self.metadata_key}": {
                    "assistant_id": assistant_id,
                },
            },
        )
        return message

    def _check_event_tag(self, event: ConversationEvent, assistant_id: str) -> bool:
        """
        Check if the event is tagged with the assistant id.
        """

        # if event is a message_created event, check the message metadata
        if event.event == ConversationEventType.message_created:
            if (
                event.data.get("message", {})
                .get("metadata", {})
                .get(f"{self.metadata_key}", {})
                .get("assistant_id", None)
                == assistant_id
            ):
                # return True if the message is tagged with the assistant id
                # otherwise fall through to check the event data
                return True

        return event.data.get(f"{self.metadata_key}", {}).get("assistant_id", None) == assistant_id


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py ===
import asyncio
import io
import logging
import pathlib
import uuid
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, AsyncIterator

import httpx
import semantic_workbench_api_model
import semantic_workbench_api_model.workbench_service_client
from semantic_workbench_api_model import workbench_model

from .. import settings

logger = logging.getLogger(__name__)


@dataclass
class AssistantContext:
    id: str
    name: str

    _assistant_service_id: str
    _template_id: str = field(default="default")


class ConversationContext:
    def __init__(
        self,
        id: str,
        title: str,
        assistant: AssistantContext,
        httpx_client: httpx.AsyncClient,
    ) -> None:
        self.id = id
        self.title = title
        self.assistant = assistant

        self._httpx_client = httpx_client

        self._status_lock = asyncio.Lock()
        self._status_stack: list[str | None] = []
        self._prior_status: str | None = None

    def for_conversation(
        self,
        conversation_id: str,
    ) -> "ConversationContext":
        return ConversationContext(
            id=conversation_id,
            title="",
            assistant=self.assistant,
            httpx_client=self._httpx_client,
        )

    @property
    def _conversation_client(
        self,
    ) -> semantic_workbench_api_model.workbench_service_client.ConversationAPIClient:
        return semantic_workbench_api_model.workbench_service_client.WorkbenchServiceClientBuilder(
            assistant_service_id=self.assistant._assistant_service_id,
            api_key=settings.workbench_service_api_key,
            httpx_client=self._httpx_client,
        ).for_conversation(self.assistant.id, self.id)

    @property
    def _conversations_client(
        self,
    ) -> semantic_workbench_api_model.workbench_service_client.ConversationsAPIClient:
        return semantic_workbench_api_model.workbench_service_client.WorkbenchServiceClientBuilder(
            assistant_service_id=self.assistant._assistant_service_id,
            api_key=settings.workbench_service_api_key,
            httpx_client=self._httpx_client,
        ).for_conversations(self.assistant.id)

    @property
    def _assistant_service_client(
        self,
    ) -> semantic_workbench_api_model.workbench_service_client.AssistantServiceAPIClient:
        return semantic_workbench_api_model.workbench_service_client.WorkbenchServiceClientBuilder(
            assistant_service_id=self.assistant._assistant_service_id,
            api_key=settings.workbench_service_api_key,
            httpx_client=self._httpx_client,
        ).for_service()

    async def send_messages(
        self,
        messages: workbench_model.NewConversationMessage | list[workbench_model.NewConversationMessage],
    ) -> workbench_model.ConversationMessageList:
        if not isinstance(messages, list):
            messages = [messages]
        return await self._conversation_client.send_messages(*messages)

    async def update_participant_me(
        self, participant: workbench_model.UpdateParticipant
    ) -> workbench_model.ConversationParticipant:
        return await self._conversation_client.update_participant_me(participant)

    @asynccontextmanager
    async def set_status(self, status: str | None) -> AsyncGenerator[None, None]:
        """
        Context manager to update the participant status and reset it when done.

        Example:
        ```python
        async with conversation.set_status("processing ..."):
            await do_some_work()
        ```
        """
        async with self._status_lock:
            self._status_stack.append(self._prior_status)
            self._prior_status = status
        await self._conversation_client.update_participant_me(workbench_model.UpdateParticipant(status=status))
        try:
            yield
        finally:
            async with self._status_lock:
                revert_to_status = self._status_stack.pop()
            await self._conversation_client.update_participant_me(
                workbench_model.UpdateParticipant(status=revert_to_status)
            )

    async def get_conversation(self) -> workbench_model.Conversation:
        return await self._conversation_client.get_conversation()

    async def update_conversation(self, metadata: dict[str, Any]) -> workbench_model.Conversation:
        return await self._conversation_client.update_conversation(metadata)

    async def get_participants(self, include_inactive=False) -> workbench_model.ConversationParticipantList:
        return await self._conversation_client.get_participants(include_inactive=include_inactive)

    async def get_messages(
        self,
        before: uuid.UUID | None = None,
        after: uuid.UUID | None = None,
        message_types: list[workbench_model.MessageType] = [workbench_model.MessageType.chat],
        participant_ids: list[str] | None = None,
        participant_role: workbench_model.ParticipantRole | None = None,
        limit: int | None = None,
    ) -> workbench_model.ConversationMessageList:
        return await self._conversation_client.get_messages(
            before=before,
            after=after,
            message_types=message_types,
            participant_ids=participant_ids,
            participant_role=participant_role,
            limit=limit,
        )

    async def send_conversation_state_event(self, state_event: workbench_model.AssistantStateEvent) -> None:
        return await self._conversation_client.send_conversation_state_event(self.assistant.id, state_event)

    async def write_file(
        self,
        filename: str,
        file_content: io.BytesIO,
        content_type: str = "application/octet-stream",
    ) -> workbench_model.File:
        return await self._conversation_client.write_file(filename, file_content, content_type)

    @asynccontextmanager
    async def read_file(
        self, filename: str, chunk_size: int | None = None
    ) -> AsyncGenerator[AsyncIterator[bytes], Any]:
        async with self._conversation_client.read_file(filename, chunk_size=chunk_size) as stream:
            yield stream

    async def get_file(self, filename: str) -> workbench_model.File | None:
        return await self._conversation_client.get_file(filename=filename)

    async def list_files(self, prefix: str | None = None) -> workbench_model.FileList:
        return await self._conversation_client.get_files(prefix=prefix)

    async def file_exists(self, filename: str) -> bool:
        return await self._conversation_client.file_exists(filename)

    async def delete_file(self, filename: str) -> None:
        return await self._conversation_client.delete_file(filename)

    async def update_file(self, filename: str, metadata: dict[str, Any]) -> workbench_model.FileVersions:
        return await self._conversation_client.update_file(filename, metadata)

    async def get_assistant_services(self, user_ids: list[str] = []) -> workbench_model.AssistantServiceInfoList:
        return await self._assistant_service_client.get_assistant_services(user_ids=user_ids)

    @asynccontextmanager
    async def state_updated_event_after(self, state_id: str, focus_event: bool = False) -> AsyncIterator[None]:
        """
        Raise state "updated" event after the context manager block is executed, and optionally, a
        state "focus" event.

        Example:
        ```python
        # notify workbench that state has been updated
        async with conversation.state_updated_event_after("my_state_id"):
            await do_some_work()

        # notify workbench that state has been updated and set focus
        async with conversation.state_updated_event_after("my_state_id", focus_event=True):
            await do_some_work()
        ```
        """
        yield
        if focus_event:
            await self.send_conversation_state_event(
                workbench_model.AssistantStateEvent(state_id=state_id, event="focus", state=None)
            )
        await self.send_conversation_state_event(
            workbench_model.AssistantStateEvent(state_id=state_id, event="updated", state=None)
        )


def storage_directory_for_context(context: AssistantContext | ConversationContext, partition: str = "") -> pathlib.Path:
    match context:
        case AssistantContext():
            directory = context.id

        case ConversationContext():
            directory = f"{context.assistant.id}-{context.id}"

    if partition:
        directory = f"{directory}_{partition}"

    return pathlib.Path(settings.storage.root) / directory


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/error.py ===
class AssistantError(Exception):
    pass


class BadRequestError(AssistantError):
    pass


class ConflictError(BadRequestError):
    pass


class NotFoundError(BadRequestError):
    pass


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/export_import.py ===
import asyncio
import logging
import pathlib
import shutil
import tempfile
from contextlib import asynccontextmanager
from typing import (
    IO,
    AsyncIterator,
)

from .context import AssistantContext, ConversationContext, storage_directory_for_context
from .error import BadRequestError

logger = logging.getLogger(__name__)


@asynccontextmanager
async def zip_directory(directory: pathlib.Path) -> AsyncIterator[IO[bytes]]:
    # if the directory does not exist, create an empty temporary directory to zip
    empty_temp_dir = ""
    if not directory.exists():
        empty_temp_dir = tempfile.mkdtemp()
        directory = pathlib.Path(empty_temp_dir)

    try:
        # create a zip archive of the directory in a temporary directory
        with tempfile.TemporaryDirectory() as temp_dir:
            file_path = await asyncio.to_thread(
                shutil.make_archive,
                base_name=str(pathlib.Path(temp_dir) / "export"),
                format="zip",
                root_dir=directory,
                base_dir="",
                logger=logger,
                verbose=True,
            )

            with open(file_path, "rb") as f:
                yield f
    finally:
        if empty_temp_dir:
            await asyncio.to_thread(shutil.rmtree, empty_temp_dir, ignore_errors=True)


async def unzip_to_directory(stream: IO[bytes], directory: pathlib.Path) -> None:
    if directory.exists():
        await asyncio.to_thread(shutil.rmtree, directory)

    # write stream to temporary file
    with tempfile.NamedTemporaryFile(delete=False) as f:
        for chunk in stream:
            f.write(chunk)
        f.flush()

    # extract zip archive to directory
    try:
        await asyncio.to_thread(shutil.unpack_archive, filename=f.name, extract_dir=directory, format="zip")
    except shutil.ReadError as e:
        raise BadRequestError(str(e))
    finally:
        pathlib.Path(f.name).unlink(missing_ok=True)


class FileStorageAssistantDataExporter:
    """
    Supports assistants that store data (state) on the file system, enabling export and import as
    a zip archive of the assistant storage directory.
    """

    @asynccontextmanager
    async def export(self, context: AssistantContext) -> AsyncIterator[IO[bytes]]:
        async with zip_directory(storage_directory_for_context(context)) as stream:
            yield stream

    async def import_(self, context: AssistantContext, stream: IO[bytes]) -> None:
        await unzip_to_directory(stream, storage_directory_for_context(context))


class FileStorageConversationDataExporter:
    """
    Supports assistants that store data (state) on the file system, enabling export and import as
    a zip archive of the conversation storage directory.
    """

    @asynccontextmanager
    async def export(self, context: ConversationContext) -> AsyncIterator[IO[bytes]]:
        async with zip_directory(storage_directory_for_context(context)) as stream:
            yield stream

    async def import_(self, context: ConversationContext, stream: IO[bytes]) -> None:
        await unzip_to_directory(stream, storage_directory_for_context(context))


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/protocol.py ===
import asyncio
import datetime
import logging
from dataclasses import dataclass, field
from enum import StrEnum
from time import perf_counter
from typing import (
    IO,
    Any,
    AsyncContextManager,
    Awaitable,
    Callable,
    Generic,
    Literal,
    Mapping,
    Protocol,
    TypeVar,
    Union,
)

import typing_extensions
from semantic_workbench_api_model import workbench_model

from .context import AssistantContext, ConversationContext

logger = logging.getLogger(__name__)


@dataclass
class AssistantConversationInspectorStateDataModel:
    data: dict[str, Any]
    json_schema: dict[str, Any] | None = field(default=None)
    ui_schema: dict[str, Any] | None = field(default=None)


class ReadOnlyAssistantConversationInspectorStateProvider(Protocol):
    @property
    def display_name(self) -> str: ...
    @property
    def description(self) -> str: ...

    async def is_enabled(self, context: ConversationContext) -> bool: ...

    async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: ...


class WriteableAssistantConversationInspectorStateProvider(ReadOnlyAssistantConversationInspectorStateProvider):
    async def set(
        self,
        context: ConversationContext,
        data: dict[str, Any],
    ) -> None: ...


AssistantConversationInspectorStateProvider = typing_extensions.TypeAliasType(
    "AssistantConversationInspectorStateProvider",
    Union[
        ReadOnlyAssistantConversationInspectorStateProvider,
        WriteableAssistantConversationInspectorStateProvider,
    ],
)


class AssistantDataExporter(Protocol):
    """
    Protocol to support the export and import of assistant-managed state.
    """

    def export(self, context: AssistantContext) -> AsyncContextManager[IO[bytes]]: ...

    async def import_(self, context: AssistantContext, stream: IO[bytes]) -> None: ...


class ConversationDataExporter(Protocol):
    """
    Protocol to support the export and import of assistant-managed-conversation state.
    """

    def export(self, context: ConversationContext) -> AsyncContextManager[IO[bytes]]: ...

    async def import_(self, context: ConversationContext, stream: IO[bytes]) -> None: ...


@dataclass
class AssistantConfigDataModel:
    config: dict[str, Any]
    errors: list[str] | None = field(default=None)
    json_schema: dict[str, Any] | None = field(default=None)
    ui_schema: dict[str, Any] | None = field(default=None)


class AssistantConfigProvider(Protocol):
    async def get(self, assistant_context: AssistantContext) -> AssistantConfigDataModel: ...
    async def set(self, assistant_context: AssistantContext, config: dict[str, Any]) -> None: ...
    def default_for(self, template_id: str) -> AssistantConfigDataModel: ...


@dataclass
class AssistantTemplate:
    id: str
    name: str
    description: str


EventHandlerT = TypeVar("EventHandlerT")


IncludeEventsFromActors = Literal["all", "others", "this_assistant_service"]


class EventHandlerList(Generic[EventHandlerT], list[tuple[EventHandlerT, IncludeEventsFromActors]]):
    async def __call__(self, external_event: bool, *args, **kwargs):
        for handler, include in self:
            if external_event and include == "this_assistant_service":
                continue
            if not external_event and include == "others":
                continue

            handler_module = getattr(handler, "__module__", None)
            handler_name = getattr(handler, "__name__", None)
            start = perf_counter()
            try:
                if asyncio.iscoroutinefunction(handler):
                    await handler(*args, **kwargs)
                    continue

                if callable(handler):
                    handler(*args, **kwargs)
                    continue

            except Exception:
                logger.exception("error in event handler; name: %s.%s", handler_module, handler_name)
                return

            finally:
                end = perf_counter()
                logger.debug(
                    "event handler metrics; name: %s.%s, duration: %s",
                    handler_module,
                    handler_name,
                    datetime.timedelta(seconds=end - start),
                )

            raise TypeError(f"EventHandler {handler} is not a coroutine or callable")


class ObjectEventHandlers(Generic[EventHandlerT]):
    def __init__(self, on_created=True, on_updated=True, on_deleted=True) -> None:
        if on_created:
            self._on_created_handlers = EventHandlerList[EventHandlerT]()
            self.on_created = _create_decorator(self._on_created_handlers, "others")
            """event handler for created event; excluding events from this assistant service"""
            self.on_created_including_mine = _create_decorator(self._on_created_handlers, "all")
            """event handler for created event; including events from this assistant service"""

        if on_updated:
            self._on_updated_handlers = EventHandlerList[EventHandlerT]()
            self.on_updated = _create_decorator(self._on_updated_handlers, "others")
            """event handler for updated event; excluding events from this assistant service"""
            self.on_updated_including_mine = _create_decorator(self._on_updated_handlers, "all")
            """event handler for updated event; including events from this assistant service"""

        if on_deleted:
            self._on_deleted_handlers = EventHandlerList[EventHandlerT]()
            self.on_deleted = _create_decorator(self._on_deleted_handlers, "others")
            """event handler for deleted event; excluding events from this assistant service"""
            self.on_deleted_including_mine = _create_decorator(self._on_deleted_handlers, "all")
            """event handler for deleted event; including events from this assistant service"""


LifecycleEventHandler = Callable[[], Awaitable[None] | None]


class LifecycleEventHandlers:
    def __init__(self) -> None:
        self._on_service_start_handlers = EventHandlerList[LifecycleEventHandler]()
        self.on_service_start = _create_decorator(self._on_service_start_handlers, "all")

        self._on_service_shutdown_handlers = EventHandlerList[LifecycleEventHandler]()
        self.on_service_shutdown = _create_decorator(self._on_service_shutdown_handlers, "all")


def _create_decorator(
    handler_list: EventHandlerList[EventHandlerT], filter: IncludeEventsFromActors
) -> Callable[[EventHandlerT], EventHandlerT]:
    def _decorator(func: EventHandlerT) -> EventHandlerT:
        handler_list.append((func, filter))
        return func

    return _decorator


AssistantEventHandler = Callable[[AssistantContext], Awaitable[None] | None]

ConversationEventHandler = Callable[[ConversationContext], Awaitable[None] | None]

ConversationParticipantEventHandler = Callable[
    [ConversationContext, workbench_model.ConversationEvent, workbench_model.ConversationParticipant],
    Awaitable[None] | None,
]

ConversationMessageEventHandler = Callable[
    [ConversationContext, workbench_model.ConversationEvent, workbench_model.ConversationMessage],
    Awaitable[None] | None,
]

ConversationFileEventHandler = Callable[
    [
        ConversationContext,
        workbench_model.ConversationEvent,
        workbench_model.File,
    ],
    Awaitable[None] | None,
]

ServiceLifecycleEventHandler = Callable[[None], Awaitable[None] | None]


class MessageEvents(ObjectEventHandlers[ConversationMessageEventHandler]):
    def __init__(self) -> None:
        super().__init__(on_updated=False)

        self.chat = ObjectEventHandlers[ConversationMessageEventHandler](on_updated=False)
        self.log = ObjectEventHandlers[ConversationMessageEventHandler](on_updated=False)
        self.note = ObjectEventHandlers[ConversationMessageEventHandler](on_updated=False)
        self.notice = ObjectEventHandlers[ConversationMessageEventHandler](on_updated=False)
        self.command = ObjectEventHandlers[ConversationMessageEventHandler](on_updated=False)
        self.command_response = ObjectEventHandlers[ConversationMessageEventHandler](on_updated=False)
        # ensure we have an event handler for each message type
        for event_type in workbench_model.MessageType:
            assert getattr(self, str(event_type).replace("-", "_"))

    def __getitem__(self, key: workbench_model.MessageType) -> ObjectEventHandlers[ConversationMessageEventHandler]:
        match key:
            case workbench_model.MessageType.chat:
                return self.chat
            case workbench_model.MessageType.log:
                return self.log
            case workbench_model.MessageType.note:
                return self.note
            case workbench_model.MessageType.notice:
                return self.notice
            case workbench_model.MessageType.command:
                return self.command
            case workbench_model.MessageType.command_response:
                return self.command_response
            case _:
                raise KeyError(key)


class ConversationEvents(ObjectEventHandlers[ConversationEventHandler]):
    def __init__(self) -> None:
        super().__init__()

        self.participant = ObjectEventHandlers[ConversationParticipantEventHandler](on_deleted=False)
        self.file = ObjectEventHandlers[ConversationFileEventHandler]()
        self.message = MessageEvents()


class Events(LifecycleEventHandlers):
    def __init__(self) -> None:
        super().__init__()

        self.assistant = ObjectEventHandlers[AssistantEventHandler]()
        self.conversation = ConversationEvents()


class ContentInterceptor(Protocol):
    """
    Protocol to support the interception of incoming and outgoing messages.

    **Methods**
    - **intercept_incoming_event(context, event) -> ConversationEvent | None**
        - Intercept incoming events before they are processed by the assistant.
    - **intercept_outgoing_messages(context, messages) -> list[NewConversationMessage]**
        - Intercept outgoing messages before they are sent to the conversation.
    """

    async def intercept_incoming_event(
        self, context: ConversationContext, event: workbench_model.ConversationEvent
    ) -> workbench_model.ConversationEvent | None: ...

    async def intercept_outgoing_messages(
        self, context: ConversationContext, messages: list[workbench_model.NewConversationMessage]
    ) -> list[workbench_model.NewConversationMessage]: ...


class AssistantCapability(StrEnum):
    """Enum for the capabilities of the assistant."""

    supports_conversation_files = "supports_conversation_files"
    """Advertise support for awareness of files in the conversation."""

    supports_artifacts = "supports_artifacts"
    """Advertise support for artifacts in the conversation."""


class AssistantAppProtocol(Protocol):
    @property
    def events(self) -> Events: ...

    @property
    def assistant_service_id(self) -> str: ...

    @property
    def assistant_service_name(self) -> str: ...

    @property
    def assistant_service_description(self) -> str: ...

    @property
    def assistant_service_metadata(self) -> dict[str, Any]: ...

    @property
    def config_provider(self) -> AssistantConfigProvider: ...

    @property
    def templates(self) -> dict[str, AssistantTemplate]: ...

    @property
    def data_exporter(self) -> AssistantDataExporter: ...

    @property
    def conversation_data_exporter(self) -> ConversationDataExporter: ...

    @property
    def content_interceptor(self) -> ContentInterceptor | None: ...

    @property
    def inspector_state_providers(self) -> Mapping[str, AssistantConversationInspectorStateProvider]: ...

    def add_capability(self, capability: AssistantCapability) -> None: ...

    def add_inspector_state_provider(
        self,
        state_id: str,
        provider: AssistantConversationInspectorStateProvider,
    ) -> None: ...


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/service.py ===
import asyncio
import contextlib
import datetime
import functools
import logging
import pathlib
from contextlib import asynccontextmanager, contextmanager
from time import perf_counter
from typing import (
    IO,
    AsyncContextManager,
    AsyncIterator,
    Callable,
    Coroutine,
    TypeVar,
    cast,
)

import asgi_correlation_id
import httpx
import semantic_workbench_api_model
import semantic_workbench_api_model.workbench_service_client
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from semantic_workbench_api_model import assistant_model, workbench_model

from .. import settings
from ..assistant_service import FastAPIAssistantService
from ..storage import read_model, write_model
from .context import AssistantContext, ConversationContext
from .error import BadRequestError, ConflictError, NotFoundError
from .protocol import (
    AssistantAppProtocol,
    WriteableAssistantConversationInspectorStateProvider,
)

logger = logging.getLogger(__name__)


class _ConversationState(BaseModel):
    """
    Private model for conversation state for the AssistantService.
    """

    conversation_id: str
    title: str


class _AssistantState(BaseModel):
    """
    Private model for assistant state for the AssistantService.
    """

    assistant_id: str
    assistant_name: str

    template_id: str = "default"

    conversations: dict[str, _ConversationState] = {}


class _PersistedAssistantStates(BaseModel):
    """
    Private model for persisted assistant states for the AssistantService.
    """

    assistants: dict[str, _AssistantState] = {}


class _Event(BaseModel):
    assistant_id: str
    event: workbench_model.ConversationEvent


def translate_assistant_errors(func):
    @contextmanager
    def wrapping_logic():
        try:
            yield

        except ConflictError as e:
            raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))

        except NotFoundError as e:
            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))

        except BadRequestError as e:
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

        # all others are allowed through, likely resulting in 500s

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if not asyncio.iscoroutinefunction(func):
            with wrapping_logic():
                return func(*args, **kwargs)

        async def tmp():
            with wrapping_logic():
                return await func(*args, **kwargs)

        return tmp()

    return wrapper


ValueT = TypeVar("ValueT")


def require_found(value: ValueT | None, message: str | None = None) -> ValueT:
    if value is None:
        raise NotFoundError(message)
    return value


class AssistantService(FastAPIAssistantService):
    """
    Semantic workbench assistant-service that wraps an AssistantApp, handling API requests from the semantic-workbench
    service. It is responsible for the persistence of assistant and conversation instances and delegates all other
    responsibilities to the AssistantApp.
    """

    def __init__(
        self,
        assistant_app: AssistantAppProtocol,
        register_lifespan_handler: Callable[[Callable[[], AsyncContextManager[None]]], None],
    ) -> None:
        self.assistant_app = assistant_app

        super().__init__(
            service_id=self.assistant_app.assistant_service_id,
            service_name=self.assistant_app.assistant_service_name,
            service_description=self.assistant_app.assistant_service_description,
            register_lifespan_handler=register_lifespan_handler,
        )

        self._root_path = pathlib.Path(settings.storage.root)
        self._assistant_states_path = self._root_path / "assistant_states.json"
        self._event_queue_lock = asyncio.Lock()
        self._conversation_event_queues: dict[tuple[str, str], asyncio.Queue[_Event]] = {}
        self._conversation_event_tasks: set[asyncio.Task] = set()
        self._workbench_httpx_client = httpx.AsyncClient(
            transport=semantic_workbench_api_model.workbench_service_client.httpx_transport_factory(),
            timeout=httpx.Timeout(5.0, connect=10.0, read=60.0),
            base_url=str(settings.workbench_service_url),
        )
        register_lifespan_handler(self.lifespan)

    @asynccontextmanager
    async def lifespan(self) -> AsyncIterator[None]:
        await self.assistant_app.events._on_service_start_handlers(True)

        try:
            yield
        finally:
            await self._workbench_httpx_client.aclose()
            await self.assistant_app.events._on_service_shutdown_handlers(True)

            for task in self._conversation_event_tasks:
                task.cancel()

            results = []
            with contextlib.suppress(asyncio.CancelledError):
                results = await asyncio.gather(*self._conversation_event_tasks, return_exceptions=True)

            for result in results:
                if isinstance(result, Exception):
                    logging.exception("event handling task raised exception", exc_info=result)

    def read_assistant_states(self) -> _PersistedAssistantStates:
        states = None
        try:
            states = read_model(self._assistant_states_path, _PersistedAssistantStates)
        except FileNotFoundError:
            pass
        except ValidationError:
            logging.warning(
                "invalid assistant states, returning new state; path: %s",
                self._assistant_states_path,
                exc_info=True,
            )

        return states or _PersistedAssistantStates()

    def write_assistant_states(self, new_states: _PersistedAssistantStates) -> None:
        write_model(self._assistant_states_path, new_states)

    def _build_assistant_context(self, assistant_id: str, template_id: str, assistant_name: str) -> AssistantContext:
        return AssistantContext(
            _assistant_service_id=self.service_id,
            _template_id=template_id,
            id=assistant_id,
            name=assistant_name,
        )

    def get_assistant_context(self, assistant_id: str) -> AssistantContext | None:
        states = self.read_assistant_states()
        assistant_state = states.assistants.get(assistant_id)
        if assistant_state is None:
            return None
        return self._build_assistant_context(
            assistant_state.assistant_id,
            assistant_state.template_id,
            assistant_state.assistant_name,
        )

    def get_conversation_context(self, assistant_id: str, conversation_id: str) -> ConversationContext | None:
        states = self.read_assistant_states()
        assistant_state = states.assistants.get(assistant_id)
        if assistant_state is None:
            return None
        conversation_state = assistant_state.conversations.get(conversation_id)
        if conversation_state is None:
            return None

        assistant_context = self._build_assistant_context(
            assistant_id, assistant_state.template_id, assistant_state.assistant_name
        )
        context = ConversationContext(
            assistant=assistant_context,
            id=conversation_state.conversation_id,
            title=conversation_state.title,
            httpx_client=self._workbench_httpx_client,
        )

        content_interceptor = self.assistant_app.content_interceptor
        if content_interceptor is not None:
            original_send_messages = context.send_messages

            async def override(
                messages: workbench_model.NewConversationMessage | list[workbench_model.NewConversationMessage],
            ) -> workbench_model.ConversationMessageList:
                try:
                    if not isinstance(messages, list):
                        messages = [messages]
                    updated_messages = await content_interceptor.intercept_outgoing_messages(context, messages)
                except Exception:
                    logger.exception("error in content interceptor, swallowing messages")
                    return workbench_model.ConversationMessageList(messages=[])

                return await original_send_messages(updated_messages)

            context.send_messages = override

        return context

    @translate_assistant_errors
    async def get_service_info(self) -> assistant_model.ServiceInfoModel:
        templates = []
        for template in self.assistant_app.templates.values():
            default_config = self.assistant_app.config_provider.default_for(template.id)
            templates.append(
                assistant_model.AssistantTemplateModel(
                    id=template.id,
                    name=template.name,
                    description=template.description,
                    config=assistant_model.ConfigResponseModel(
                        config=default_config.config,
                        errors=[],
                        json_schema=default_config.json_schema,
                        ui_schema=default_config.ui_schema,
                    ),
                )
            )
        return assistant_model.ServiceInfoModel(
            assistant_service_id=self.service_id,
            name=self.service_name,
            templates=templates,
            metadata=self.assistant_app.assistant_service_metadata,
        )

    @translate_assistant_errors
    async def put_assistant(
        self,
        assistant_id: str,
        assistant: assistant_model.AssistantPutRequestModel,
        from_export: IO[bytes] | None = None,
    ) -> assistant_model.AssistantResponseModel:
        is_new = False
        states = self.read_assistant_states()

        assistant_state = states.assistants.get(assistant_id) or _AssistantState(
            assistant_id=assistant_id,
            assistant_name=assistant.assistant_name,
            template_id=assistant.template_id,
        )
        assistant_state.assistant_name = assistant.assistant_name

        is_new = not from_export and assistant_id not in states.assistants
        states.assistants[assistant_id] = assistant_state
        self.write_assistant_states(states)

        assistant_context = require_found(self.get_assistant_context(assistant_id))
        if is_new:
            await self.execute_as_task(
                self.assistant_app.events.assistant._on_created_handlers(True, assistant_context)
            )
        else:
            await self.execute_as_task(
                self.assistant_app.events.assistant._on_updated_handlers(True, assistant_context)
            )

        if from_export is not None:
            await self.assistant_app.data_exporter.import_(assistant_context, from_export)

        return await self.get_assistant(assistant_id)

    async def execute_as_task(self, coro: Coroutine) -> None:
        scheduled = datetime.datetime.now(datetime.UTC)

        async def wrapper():
            started = datetime.datetime.now(datetime.UTC)
            try:
                await coro
            finally:
                end = datetime.datetime.now(datetime.UTC)
                delay = started - scheduled
                elapsed = end - started
                logger.debug("scheduled task finished; delay: %s, elapsed: %s", delay, elapsed)

        task = asyncio.create_task(wrapper())
        self._conversation_event_tasks.add(task)
        task.add_done_callback(self._conversation_event_tasks.discard)

    @translate_assistant_errors
    async def export_assistant_data(self, assistant_id: str) -> StreamingResponse:
        assistant_context = require_found(self.get_assistant_context(assistant_id))

        async def iterate_stream() -> AsyncIterator[bytes]:
            async with self.assistant_app.data_exporter.export(assistant_context) as stream:
                for chunk in stream:
                    yield chunk

        return StreamingResponse(content=iterate_stream())

    @translate_assistant_errors
    async def get_assistant(self, assistant_id: str) -> assistant_model.AssistantResponseModel:
        assistant_context = require_found(self.get_assistant_context(assistant_id))
        return assistant_model.AssistantResponseModel(id=assistant_context.id)

    @translate_assistant_errors
    async def delete_assistant(self, assistant_id: str) -> None:
        assistant_context = self.get_assistant_context(assistant_id)
        if assistant_context is None:
            return

        states = self.read_assistant_states()
        assistant_state = states.assistants.get(assistant_id)

        if assistant_state is None:
            return

        # delete conversations
        for conversation_id in assistant_state.conversations:
            await self.delete_conversation(assistant_id, conversation_id)

        states = self.read_assistant_states()
        states.assistants.pop(assistant_id, None)
        self.write_assistant_states(states)

        await self.assistant_app.events.assistant._on_deleted_handlers(True, assistant_context)

    @translate_assistant_errors
    async def get_config(self, assistant_id: str) -> assistant_model.ConfigResponseModel:
        assistant_context = require_found(self.get_assistant_context(assistant_id))

        config = await self.assistant_app.config_provider.get(assistant_context)
        return assistant_model.ConfigResponseModel(
            config=config.config,
            errors=config.errors,
            json_schema=config.json_schema,
            ui_schema=config.ui_schema,
        )

    @translate_assistant_errors
    async def put_config(
        self, assistant_id: str, updated_config: assistant_model.ConfigPutRequestModel
    ) -> assistant_model.ConfigResponseModel:
        assistant_context = require_found(self.get_assistant_context(assistant_id))

        await self.assistant_app.config_provider.set(assistant_context, updated_config.config)
        return await self.get_config(assistant_id)

    @translate_assistant_errors
    async def put_conversation(
        self,
        assistant_id: str,
        conversation_id: str,
        conversation: assistant_model.ConversationPutRequestModel,
        from_export: IO[bytes] | None = None,
    ) -> assistant_model.ConversationResponseModel:
        states = self.read_assistant_states()
        assistant_state = require_found(states.assistants.get(assistant_id))

        conversation_state = assistant_state.conversations.get(conversation_id) or _ConversationState(
            conversation_id=conversation_id,
            title=conversation.title,
        )
        is_new = conversation_id not in assistant_state.conversations

        conversation_state.title = conversation.title

        assistant_state.conversations[conversation_id] = conversation_state
        self.write_assistant_states(states)

        conversation_context = require_found(self.get_conversation_context(assistant_id, conversation_id))

        if is_new:
            await self.execute_as_task(
                self.assistant_app.events.conversation._on_created_handlers(not from_export, conversation_context)
            )
        else:
            await self.execute_as_task(
                self.assistant_app.events.conversation._on_updated_handlers(True, conversation_context)
            )

        if from_export is not None:
            await self.assistant_app.conversation_data_exporter.import_(conversation_context, from_export)

        return assistant_model.ConversationResponseModel(id=conversation_context.id)

    @translate_assistant_errors
    async def export_conversation_data(self, assistant_id: str, conversation_id: str) -> StreamingResponse:
        conversation_context = require_found(self.get_conversation_context(assistant_id, conversation_id))

        async def iterate_stream() -> AsyncIterator[bytes]:
            async with self.assistant_app.conversation_data_exporter.export(conversation_context) as stream:
                for chunk in stream:
                    yield chunk

        return StreamingResponse(content=iterate_stream())

    @translate_assistant_errors
    async def get_conversation(
        self, assistant_id: str, conversation_id: str
    ) -> assistant_model.ConversationResponseModel:
        conversation_context = require_found(self.get_conversation_context(assistant_id, conversation_id))
        return assistant_model.ConversationResponseModel(id=conversation_context.id)

    @translate_assistant_errors
    async def delete_conversation(self, assistant_id: str, conversation_id: str) -> None:
        conversation_context = self.get_conversation_context(assistant_id, conversation_id)
        if conversation_context is None:
            return None

        states = self.read_assistant_states()
        assistant_state = require_found(states.assistants.get(assistant_id))
        if assistant_state.conversations.pop(conversation_id, None) is None:
            return
        self.write_assistant_states(states)

        await self.assistant_app.events.conversation._on_deleted_handlers(True, conversation_context)

    async def _get_or_create_queue(self, assistant_id: str, conversation_id: str) -> asyncio.Queue[_Event]:
        key = (assistant_id, conversation_id)
        queue = self._conversation_event_queues.get(key)
        if queue is not None:
            return queue

        async with self._event_queue_lock:
            queue = self._conversation_event_queues.get(key)
            if queue is not None:
                return queue

            queue = asyncio.Queue()
            self._conversation_event_queues[key] = queue
            task = asyncio.create_task(self._forward_events_from_queue(queue))
            self._conversation_event_tasks.add(task)
            task.add_done_callback(self._conversation_event_tasks.discard)
            return queue

    async def _forward_events_from_queue(self, queue: asyncio.Queue[_Event]) -> None:
        """
        De-queues events and makes the call to process_workbench_event.
        """
        while True:
            try:
                wrapper = None
                try:
                    async with asyncio.timeout(1):
                        wrapper = await queue.get()
                except asyncio.TimeoutError:
                    continue

                except RuntimeError as e:
                    logging.exception("exception in _forward_events_from_queue loop")
                    if e.args[0] == "Event loop is closed":
                        break

                queue.task_done()

                if wrapper is None:
                    continue

                assistant_id = wrapper.assistant_id
                event = wrapper.event

                asgi_correlation_id.correlation_id.set(event.correlation_id)

                conversation_context = self.get_conversation_context(
                    assistant_id=assistant_id,
                    conversation_id=str(event.conversation_id),
                )
                if conversation_context is None:
                    continue

                timestamp_now = datetime.datetime.now(datetime.UTC)

                start = perf_counter()
                await self._forward_event(conversation_context, event)
                end = perf_counter()

                logger.debug(
                    "forwarded event to event handler; assistant_id: %s, conversation_id: %s, event_id: %s, event: %s, time-since-event: %s, time-taken: %s",
                    assistant_id,
                    event.conversation_id,
                    event.id,
                    event.event,
                    timestamp_now - event.timestamp,
                    datetime.timedelta(seconds=end - start),
                )

            except Exception:
                logging.exception("exception in _forward_events_from_queue loop")

    @translate_assistant_errors
    async def post_conversation_event(
        self,
        assistant_id: str,
        conversation_id: str,
        event: workbench_model.ConversationEvent,
    ) -> None:
        """
        Receives events from semantic workbench and buffers them in a queue to avoid keeping
        the workbench waiting.
        """
        _ = require_found(self.get_conversation_context(assistant_id, conversation_id))

        queue = await self._get_or_create_queue(assistant_id=assistant_id, conversation_id=conversation_id)
        await queue.put(_Event(assistant_id=assistant_id, event=event))

    async def _forward_event(
        self,
        conversation_context: ConversationContext,
        event: workbench_model.ConversationEvent,
    ) -> None:
        updated_event = event

        content_interceptor = self.assistant_app.content_interceptor
        if content_interceptor is not None:
            try:
                updated_event = await content_interceptor.intercept_incoming_event(conversation_context, event)
            except Exception:
                logger.exception("error in content interceptor, dropping event")

            if updated_event is None:
                logger.info(
                    "event was dropped by content interceptor; event: %s, interceptor: %s",
                    event.event,
                    content_interceptor.__class__.__name__,
                )
                return

        match updated_event.event:
            case workbench_model.ConversationEventType.message_created:
                try:
                    message = workbench_model.ConversationMessage.model_validate(updated_event.data.get("message", {}))
                except ValidationError:
                    logging.exception("invalid message event data")
                    return

                event_originated_externally = message.sender.participant_id != conversation_context.assistant.id

                async with asyncio.TaskGroup() as tg:
                    tg.create_task(
                        self.assistant_app.events.conversation.message._on_created_handlers(
                            event_originated_externally,
                            conversation_context,
                            updated_event,
                            message,
                        )
                    )
                    tg.create_task(
                        self.assistant_app.events.conversation.message[message.message_type]._on_created_handlers(
                            event_originated_externally,
                            conversation_context,
                            updated_event,
                            message,
                        )
                    )

            case workbench_model.ConversationEventType.message_deleted:
                try:
                    message = workbench_model.ConversationMessage.model_validate(updated_event.data.get("message", {}))
                except ValidationError:
                    logging.exception("invalid message event data")
                    return

                event_originated_externally = message.sender.participant_id != conversation_context.assistant.id

                async with asyncio.TaskGroup() as tg:
                    tg.create_task(
                        self.assistant_app.events.conversation.message._on_deleted_handlers(
                            event_originated_externally,
                            conversation_context,
                            updated_event,
                            message,
                        )
                    )
                    tg.create_task(
                        self.assistant_app.events.conversation.message[message.message_type]._on_deleted_handlers(
                            event_originated_externally,
                            conversation_context,
                            updated_event,
                            message,
                        )
                    )

            case workbench_model.ConversationEventType.participant_created:
                try:
                    participant = workbench_model.ConversationParticipant.model_validate(
                        updated_event.data.get("participant", {})
                    )
                except ValidationError:
                    logging.exception("invalid participant event data")
                    return

                event_originated_externally = participant.id != conversation_context.assistant.id
                await self.assistant_app.events.conversation.participant._on_created_handlers(
                    event_originated_externally,
                    conversation_context,
                    updated_event,
                    participant,
                )

            case workbench_model.ConversationEventType.participant_updated:
                try:
                    participant = workbench_model.ConversationParticipant.model_validate(
                        updated_event.data.get("participant", {})
                    )
                except ValidationError:
                    logging.exception("invalid participant event data")
                    return

                event_originated_externally = participant.id != conversation_context.assistant.id
                await self.assistant_app.events.conversation.participant._on_updated_handlers(
                    event_originated_externally,
                    conversation_context,
                    updated_event,
                    participant,
                )

            case workbench_model.ConversationEventType.file_created:
                try:
                    file = workbench_model.File.model_validate(updated_event.data.get("file", {}))
                except ValidationError:
                    logging.exception("invalid file event data")
                    return

                event_originated_externally = file.participant_id != conversation_context.assistant.id
                await self.assistant_app.events.conversation.file._on_created_handlers(
                    event_originated_externally,
                    conversation_context,
                    updated_event,
                    file,
                )

            case workbench_model.ConversationEventType.file_updated:
                try:
                    file = workbench_model.File.model_validate(updated_event.data.get("file", {}))
                except ValidationError:
                    logging.exception("invalid file event data")
                    return

                event_originated_externally = file.participant_id != conversation_context.assistant.id
                await self.assistant_app.events.conversation.file._on_updated_handlers(
                    event_originated_externally,
                    conversation_context,
                    updated_event,
                    file,
                )

            case workbench_model.ConversationEventType.file_deleted:
                try:
                    file = workbench_model.File.model_validate(updated_event.data.get("file", {}))
                except ValidationError:
                    logging.exception("invalid file event data")
                    return

                event_originated_externally = file.participant_id != conversation_context.assistant.id
                await self.assistant_app.events.conversation.file._on_deleted_handlers(
                    event_originated_externally,
                    conversation_context,
                    updated_event,
                    file,
                )

    @translate_assistant_errors
    async def get_conversation_state_descriptions(
        self, assistant_id: str, conversation_id: str
    ) -> assistant_model.StateDescriptionListResponseModel:
        context = require_found(self.get_conversation_context(assistant_id, conversation_id))
        return assistant_model.StateDescriptionListResponseModel(
            states=[
                assistant_model.StateDescriptionResponseModel(
                    id=id,
                    display_name=provider.display_name,
                    description=provider.description,
                    enabled=await provider.is_enabled(context),
                )
                for id, provider in self.assistant_app.inspector_state_providers.items()
            ]
        )

    @translate_assistant_errors
    async def get_conversation_state(
        self, assistant_id: str, conversation_id: str, state_id: str
    ) -> assistant_model.StateResponseModel:
        conversation_context = require_found(self.get_conversation_context(assistant_id, conversation_id))

        provider = self.assistant_app.inspector_state_providers.get(state_id)
        if provider is None:
            raise NotFoundError(f"inspector {state_id} not found")

        data = await provider.get(conversation_context)
        return assistant_model.StateResponseModel(
            id=state_id,
            data=data.data,
            json_schema=data.json_schema,
            ui_schema=data.ui_schema,
        )

    @translate_assistant_errors
    async def put_conversation_state(
        self,
        assistant_id: str,
        conversation_id: str,
        state_id: str,
        updated_state: assistant_model.StatePutRequestModel,
    ) -> assistant_model.StateResponseModel:
        conversation_context = require_found(self.get_conversation_context(assistant_id, conversation_id))

        provider = self.assistant_app.inspector_state_providers.get(state_id)
        if provider is None:
            raise NotFoundError(f"inspector {state_id} not found")

        if getattr(provider, "set", None) is None:
            raise BadRequestError(f"inspector {state_id} is read-only")

        await cast(WriteableAssistantConversationInspectorStateProvider, provider).set(
            conversation_context, updated_state.data
        )

        return await self.get_conversation_state(assistant_id, conversation_id, state_id)


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_service.py ===
import asyncio
import logging
import random
from abc import ABC, abstractmethod
from contextlib import AsyncExitStack, asynccontextmanager
from typing import (
    IO,
    Annotated,
    AsyncContextManager,
    AsyncGenerator,
    AsyncIterator,
    Callable,
    NoReturn,
    Optional,
)

import asgi_correlation_id
import backoff
import backoff.types
import httpx
import semantic_workbench_api_model.workbench_service_client
from fastapi import (
    FastAPI,
    File,
    Form,
    HTTPException,
    Request,
    Response,
    UploadFile,
    status,
)
from fastapi.encoders import jsonable_encoder
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, HttpUrl, ValidationError
from semantic_workbench_api_model import (
    assistant_model,
    workbench_model,
    workbench_service_client,
)
from starlette.exceptions import HTTPException as StarletteHTTPException

from . import auth, settings
from .logging_config import log_request_middleware

logger = logging.getLogger(__name__)


def _backoff_success_handler(details: backoff.types.Details) -> None:
    if details["tries"] == 1:
        return
    logger.info(
        "Success after backoff %s(...); tries: %d, elapsed: %.1fs",
        details["target"].__name__,
        details["tries"],
        details["elapsed"],
    )


class FastAPIAssistantService(ABC):
    """
    Base class for implementations of assistant services using FastAPI.
    """

    def __init__(
        self,
        service_id: str,
        service_name: str,
        service_description: str,
        register_lifespan_handler: Callable[[Callable[[], AsyncContextManager[None]]], None],
    ) -> None:
        self._service_id = service_id
        self._service_name = service_name
        self._service_description = service_description
        self._workbench_httpx_client = httpx.AsyncClient(
            transport=semantic_workbench_api_model.workbench_service_client.httpx_transport_factory(),
            timeout=httpx.Timeout(5.0, connect=10.0, read=60.0),
            base_url=str(settings.workbench_service_url),
        )

        @asynccontextmanager
        async def lifespan() -> AsyncIterator[None]:
            logger.info(
                "connecting to semantic-workbench-service; workbench_service_url: %s, assistant_service_id: %s, callback_url: %s",
                settings.workbench_service_url,
                self.service_id,
                settings.callback_url,
            )

            service_client = self.workbench_client.for_service()
            # start periodic pings to workbench
            ping_task = asyncio.create_task(
                self._periodically_ping_semantic_workbench(service_client), name="ping-workbench"
            )

            try:
                yield

            finally:
                ping_task.cancel()
                try:
                    await ping_task
                except asyncio.CancelledError:
                    pass

        register_lifespan_handler(lifespan)

    async def _periodically_ping_semantic_workbench(
        self, client: workbench_service_client.AssistantServiceAPIClient
    ) -> NoReturn:
        while True:
            try:
                try:
                    await self._ping_semantic_workbench(client)
                except httpx.HTTPError:
                    logger.exception("ping to workbench failed")

                jitter = random.uniform(0, settings.workbench_service_ping_interval_seconds / 2.0)
                await asyncio.sleep(settings.workbench_service_ping_interval_seconds + jitter)

            except Exception:
                logger.exception("unexpected error in ping loop")

    @backoff.on_exception(
        backoff.expo,
        httpx.HTTPError,
        max_time=30,
        logger=logger,
        on_success=_backoff_success_handler,
    )
    async def _ping_semantic_workbench(self, client: workbench_service_client.AssistantServiceAPIClient) -> None:
        try:
            await client.update_registration_url(
                assistant_service_id=self.service_id,
                update=workbench_model.UpdateAssistantServiceRegistrationUrl(
                    name=self.service_name,
                    description=self.service_description,
                    url=HttpUrl(settings.callback_url),
                    online_expires_in_seconds=settings.workbench_service_ping_interval_seconds * 3.5,
                ),
            )

        except httpx.HTTPStatusError as e:
            # log additional information for common error cases
            match e.response.status_code:
                case 401:
                    logger.warning(
                        "authentication failed with semantic-workbench service, configured assistant_service_id and/or"
                        " workbench_service_api_key are incorrect; workbench_service_url: %s,"
                        " assistant_service_id: %s, callback_url: %s",
                        settings.workbench_service_url,
                        self.service_id,
                        settings.callback_url,
                    )
                case 404:
                    logger.warning(
                        "configured assistant_service_id does not exist in the semantic-workbench-service;"
                        " workbench_service_url: %s, assistant_service_id: %s, callback_url: %s",
                        settings.workbench_service_url,
                        self.service_id,
                        settings.callback_url,
                    )
            raise

    @property
    def service_id(self) -> str:
        return settings.assistant_service_id if settings.assistant_service_id is not None else self._service_id

    @property
    def service_name(self) -> str:
        return settings.assistant_service_name if settings.assistant_service_name is not None else self._service_name

    @property
    def service_description(self) -> str:
        return (
            settings.assistant_service_description
            if settings.assistant_service_description is not None
            else self._service_description
        )

    @property
    def workbench_client(self) -> workbench_service_client.WorkbenchServiceClientBuilder:
        return workbench_service_client.WorkbenchServiceClientBuilder(
            assistant_service_id=self.service_id,
            api_key=settings.workbench_service_api_key,
            httpx_client=self._workbench_httpx_client,
        )

    @abstractmethod
    async def get_service_info(self) -> assistant_model.ServiceInfoModel:
        pass

    @abstractmethod
    async def put_assistant(
        self,
        assistant_id: str,
        assistant: assistant_model.AssistantPutRequestModel,
        from_export: Optional[IO[bytes]] = None,
    ) -> assistant_model.AssistantResponseModel:
        pass

    @abstractmethod
    async def export_assistant_data(
        self, assistant_id: str
    ) -> StreamingResponse | FileResponse | JSONResponse | BaseModel:
        pass

    @abstractmethod
    async def get_assistant(self, assistant_id: str) -> assistant_model.AssistantResponseModel:
        pass

    @abstractmethod
    async def delete_assistant(self, assistant_id: str) -> None:
        pass

    @abstractmethod
    async def get_config(self, assistant_id: str) -> assistant_model.ConfigResponseModel:
        pass

    @abstractmethod
    async def put_config(
        self, assistant_id: str, updated_config: assistant_model.ConfigPutRequestModel
    ) -> assistant_model.ConfigResponseModel:
        pass

    @abstractmethod
    async def put_conversation(
        self,
        assistant_id: str,
        conversation_id: str,
        conversation: assistant_model.ConversationPutRequestModel,
        from_export: Optional[IO[bytes]] = None,
    ) -> assistant_model.ConversationResponseModel:
        pass

    @abstractmethod
    async def export_conversation_data(
        self,
        assistant_id: str,
        conversation_id: str,
    ) -> StreamingResponse | FileResponse | JSONResponse | BaseModel:
        pass

    @abstractmethod
    async def get_conversation(
        self, assistant_id: str, conversation_id: str
    ) -> assistant_model.ConversationResponseModel:
        pass

    @abstractmethod
    async def delete_conversation(self, assistant_id: str, conversation_id: str) -> None:
        pass

    @abstractmethod
    async def post_conversation_event(
        self,
        assistant_id: str,
        conversation_id: str,
        event: workbench_model.ConversationEvent,
    ) -> None:
        pass

    @abstractmethod
    async def get_conversation_state_descriptions(
        self, assistant_id: str, conversation_id: str
    ) -> assistant_model.StateDescriptionListResponseModel:
        pass

    @abstractmethod
    async def get_conversation_state(
        self, assistant_id: str, conversation_id: str, state_id: str
    ) -> assistant_model.StateResponseModel:
        pass

    @abstractmethod
    async def put_conversation_state(
        self,
        assistant_id: str,
        conversation_id: str,
        state_id: str,
        updated_state: assistant_model.StatePutRequestModel,
    ) -> assistant_model.StateResponseModel:
        pass


def _assistant_service_api(
    app: FastAPI,
    service: FastAPIAssistantService,
    enable_auth_middleware: bool = True,
):
    """
    Implements API for AssistantService, forwarding requests to AssistantService.
    """

    if enable_auth_middleware:
        app.add_middleware(
            middleware_class=auth.AuthMiddleware,
            exclude_methods={"OPTIONS"},
            exclude_paths=set(settings.anonymous_paths),
        )
    app.add_middleware(asgi_correlation_id.CorrelationIdMiddleware)
    app.middleware("http")(log_request_middleware())

    @app.exception_handler(StarletteHTTPException)
    async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException) -> Response:
        if 500 <= exc.status_code < 600:
            logger.exception(
                "exception in request handler; method: %s, path: %s", request.method, request.url.path, exc_info=exc
            )
        return await http_exception_handler(request, exc)

    @app.get("/", description="Get the description of the assistant service")
    async def get_service_description(response: Response) -> assistant_model.ServiceInfoModel:
        response.headers["Cache-Control"] = "max-age=600"
        return await service.get_service_info()

    @app.put(
        "/{assistant_id}",
        description=(
            "Connect an assistant to the workbench, optionally providing exported-data to restore the assistant"
        ),
    )
    async def put_assistant(
        assistant_id: str,
        assistant_json: Annotated[str, Form(alias="assistant")],
        from_export: Annotated[Optional[UploadFile], File(alias="from_export")] = None,
    ) -> assistant_model.AssistantResponseModel:
        try:
            assistant_request = assistant_model.AssistantPutRequestModel.model_validate_json(assistant_json)
        except ValidationError as e:
            raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=e.errors())

        if from_export:
            return await service.put_assistant(assistant_id, assistant_request, from_export.file)

        return await service.put_assistant(assistant_id, assistant_request)

    @app.get(
        "/{assistant_id}",
        description="Get an assistant",
    )
    async def get_assistant(assistant_id: str) -> assistant_model.AssistantResponseModel:
        return await service.get_assistant(assistant_id)

    @app.delete(
        "/{assistant_id}",
        description="Delete an assistant",
    )
    async def delete_assistant(assistant_id: str) -> None:
        return await service.delete_assistant(assistant_id)

    @app.get(
        "/{assistant_id}/export-data",
        description="Export all data for this assistant",
    )
    async def export_assistant_data(assistant_id: str) -> Response:
        response = await service.export_assistant_data(assistant_id)
        match response:
            case StreamingResponse() | FileResponse() | JSONResponse():
                return response
            case BaseModel():
                return JSONResponse(jsonable_encoder(response))
            case _:
                raise TypeError(f"Unexpected response type {type(response)}")

    @app.get(
        "/{assistant_id}/config",
        description="Get config for this assistant",
    )
    async def get_config(assistant_id: str) -> assistant_model.ConfigResponseModel:
        return await service.get_config(assistant_id)

    @app.put(
        "/{assistant_id}/config",
        description="Set config for this assistant",
    )
    async def put_config(
        assistant_id: str, updated_config: assistant_model.ConfigPutRequestModel
    ) -> assistant_model.ConfigResponseModel:
        return await service.put_config(assistant_id, updated_config=updated_config)

    @app.put(
        "/{assistant_id}/conversations/{conversation_id}",
        description=(
            "Join an assistant to a workbench conversation, optionally"
            " providing exported-data to restore the conversation"
        ),
    )
    async def put_conversation(
        assistant_id: str,
        conversation_id: str,
        conversation_json: Annotated[str, Form(alias="conversation")],
        from_export: Annotated[Optional[UploadFile], File(alias="from_export")] = None,
    ) -> assistant_model.ConversationResponseModel:
        try:
            conversation = assistant_model.ConversationPutRequestModel.model_validate_json(conversation_json)
        except ValidationError as e:
            raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=e.errors())

        if from_export:
            return await service.put_conversation(assistant_id, conversation_id, conversation, from_export.file)

        return await service.put_conversation(assistant_id, conversation_id, conversation)

    @app.get(
        "/{assistant_id}/conversations/{conversation_id}",
        description="Get the status of a conversation",
    )
    async def get_conversation(assistant_id: str, conversation_id: str) -> assistant_model.ConversationResponseModel:
        return await service.get_conversation(assistant_id, conversation_id)

    @app.delete(
        "/{assistant_id}/conversations/{conversation_id}",
        description="Delete a conversation",
    )
    async def delete_conversation(assistant_id: str, conversation_id: str) -> None:
        return await service.delete_conversation(assistant_id, conversation_id)

    @app.get(
        "/{assistant_id}/conversations/{conversation_id}/export-data",
        description="Export all data for a conversation",
    )
    async def export_conversation_data(assistant_id: str, conversation_id: str) -> Response:
        response = await service.export_conversation_data(assistant_id=assistant_id, conversation_id=conversation_id)
        match response:
            case StreamingResponse():
                return response
            case FileResponse():
                return response
            case JSONResponse():
                return response
            case BaseModel():
                return JSONResponse(jsonable_encoder(response))
            case _:
                raise TypeError(f"Unexpected response type {type(response)}")

    @app.post(
        "/{assistant_id}/conversations/{conversation_id}/events",
        description="Notify assistant of an event in the conversation",
        status_code=status.HTTP_204_NO_CONTENT,
    )
    async def post_conversation_event(
        assistant_id: str,
        conversation_id: str,
        event: workbench_model.ConversationEvent,
    ) -> None:
        return await service.post_conversation_event(assistant_id, conversation_id, event)

    @app.get(
        "/{assistant_id}/conversations/{conversation_id}/states",
        description="Get the descriptions of the states available for a conversation",
    )
    async def get_conversation_state_descriptions(
        assistant_id: str, conversation_id: str
    ) -> assistant_model.StateDescriptionListResponseModel:
        return await service.get_conversation_state_descriptions(assistant_id, conversation_id)

    @app.get(
        "/{assistant_id}/conversations/{conversation_id}/states/{state_id}",
        description="Get a specific state by id for a conversation",
    )
    async def get_conversation_state(
        assistant_id: str, conversation_id: str, state_id: str
    ) -> assistant_model.StateResponseModel:
        return await service.get_conversation_state(assistant_id, conversation_id, state_id)

    @app.put(
        "/{assistant_id}/conversations/{conversation_id}/states/{state_id}",
        description="Update a specific state by id for a conversation",
    )
    async def put_conversation_state(
        assistant_id: str,
        conversation_id: str,
        state_id: str,
        updated_state: assistant_model.StatePutRequestModel,
    ) -> assistant_model.StateResponseModel:
        return await service.put_conversation_state(assistant_id, conversation_id, state_id, updated_state)


logger = logging.getLogger(__name__)


class FastAPILifespan:
    def __init__(self) -> None:
        self._lifecycle_handlers: list[Callable[[], AsyncContextManager[None]]] = []

    def register_handler(self, handler: Callable[[], AsyncContextManager[None]]) -> None:
        self._lifecycle_handlers.append(handler)

    @asynccontextmanager
    async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]:
        async with AsyncExitStack() as stack:
            logger.debug("app lifespan starting up; title: %s, version: %s", app.title, app.version)

            for handler in self._lifecycle_handlers:
                await stack.enter_async_context(handler())

            logger.info("app lifespan started; title: %s, version: %s", app.title, app.version)

            try:
                yield
            finally:
                logger.debug("app lifespan shutting down; title: %s, version: %s", app.title, app.version)

        logger.info("app lifespan shut down; title: %s, version: %s", app.title, app.version)


def create_app(
    factory: Callable[[FastAPILifespan], FastAPIAssistantService],
    enable_auth_middleware: bool = True,
) -> FastAPI:
    """
    Create a FastAPI app for an AssistantService.
    """
    lifespan = FastAPILifespan()
    svc = factory(lifespan)
    app = FastAPI(
        lifespan=lifespan.lifespan,
        title=svc.service_name,
        description=svc.service_description,
        # extra is used to store metadata about the service
        assistant_service_id=svc.service_id,
    )
    _assistant_service_api(app, svc, enable_auth_middleware)
    return app


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/auth.py ===
import logging
import secrets

from fastapi import HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from semantic_workbench_api_model import assistant_service_client
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp

from . import settings

logger = logging.getLogger(__name__)


class AuthMiddleware(BaseHTTPMiddleware):
    def __init__(self, app: ASGIApp, exclude_methods: set[str] = set(), exclude_paths: set[str] = set()) -> None:
        super().__init__(app)
        self.exclude_methods = exclude_methods
        self.exclude_routes = exclude_paths

    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
        if request.method in self.exclude_methods:
            return await call_next(request)

        if request.url.path in self.exclude_routes:
            return await call_next(request)

        try:
            await _require_api_key(request)

        except HTTPException as exc:
            # if the authorization header is invalid, return the error response
            return JSONResponse(content={"detail": exc.detail}, status_code=exc.status_code)
        except Exception:
            logger.exception("error validating authorization header")
            # return a generic error response
            return Response(status_code=500)

        return await call_next(request)


async def _require_api_key(request: Request) -> None:
    invalid_credentials_error = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid credentials",
        headers={"WWW-Authenticate": "Basic"},
    )

    params = assistant_service_client.AuthParams.from_request_headers(request.headers)
    api_key = params.api_key
    if not api_key:
        if settings.workbench_service_api_key:
            raise invalid_credentials_error
        return

    password_bytes = api_key.encode("utf8")
    correct_password_bytes = settings.workbench_service_api_key.encode("utf8")
    is_correct_password = secrets.compare_digest(password_bytes, correct_password_bytes)

    if not is_correct_password:
        raise invalid_credentials_error


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/canonical.py ===
import argparse
import logging
from dataclasses import dataclass
from typing import Annotated, Any, Callable, Literal

from pydantic import BaseModel, ConfigDict, Field
from semantic_workbench_api_model import workbench_model

from . import assistant_app, command
from .config import UISchema

logger = logging.getLogger(__name__)


class ModelConfigModel(BaseModel):
    name: Annotated[
        Literal["gpt35", "gpt35turbo", "gpt4"],
        Field(title="GPT model", description="The GPT model to use"),
        UISchema(widget="radio"),
    ] = "gpt35turbo"


class PromptConfigModel(BaseModel):
    custom_prompt: Annotated[
        str,
        Field(title="Custom prompt", description="Custom prompt to use", max_length=1_000),
        UISchema(widget="textarea"),
    ] = ""
    temperature: Annotated[float, Field(title="Temperature", description="The temperature to use", ge=0, le=1.0)] = 0.7


class ConfigStateModel(BaseModel):
    model_config: ConfigDict = ConfigDict(extra="forbid", strict=True)  # type: ignore

    un_annotated_text: str = ""
    short_text: Annotated[
        str, Field(title="Short text setting", description="This is a short text setting", max_length=50)
    ] = ""
    long_text: Annotated[
        str,
        Field(title="Long text setting", description="This is a long text setting", max_length=1_000),
        UISchema(widget="textarea"),
    ] = ""
    setting_int: Annotated[int, Field(title="Int", description="This is an int setting", ge=0, le=1_000_000)] = 0
    model: Annotated[ModelConfigModel, Field(title="Model config section")] = ModelConfigModel()
    prompt: Annotated[PromptConfigModel, Field(title="Prompt config section")] = PromptConfigModel()


@dataclass
class Command:
    parser: command.CommandArgumentParser
    message_generator: Callable[[argparse.Namespace], str]

    def process_args(self, command_arg_string: str) -> str:
        try:
            parsed_args = self.parser.parse_args(command_arg_string)
        except argparse.ArgumentError as e:
            return e.message

        return self.message_generator(parsed_args)


reverse_parser = command.CommandArgumentParser(
    command="/reverse",
    description="Reverse a string",
)
reverse_parser.add_argument("string", type=str, help="the string to reverse", nargs="+")

commands = {
    reverse_parser.command: Command(parser=reverse_parser, message_generator=lambda args: " ".join(args.string)[::-1])
}


class SimpleStateInspector:
    display_name = "simple state"
    description = "Simple state inspector"

    def __init__(self) -> None:
        self._data = {
            "message": "simple state message",
        }

    async def is_enabled(self, context: assistant_app.ConversationContext) -> bool:
        return True

    async def get(
        self, context: assistant_app.ConversationContext
    ) -> assistant_app.AssistantConversationInspectorStateDataModel:
        return assistant_app.AssistantConversationInspectorStateDataModel(data=self._data)

    async def set(
        self,
        context: assistant_app.ConversationContext,
        data: dict[str, Any],
    ) -> None:
        self._data = data


canonical_app = assistant_app.AssistantApp(
    assistant_service_id="canonical-assistant.semantic-workbench",
    assistant_service_name="Canonical Assistant",
    assistant_service_description="Canonical implementation of a workbench assistant service.",
    config_provider=assistant_app.BaseModelAssistantConfig(ConfigStateModel).provider,
    inspector_state_providers={"simple_state": SimpleStateInspector()},
)


@canonical_app.events.conversation.message.chat.on_created
async def on_chat_message_created(
    conversation_context: assistant_app.ConversationContext,
    _: workbench_model.ConversationEvent,
    message: workbench_model.ConversationMessage,
) -> None:
    if message.sender.participant_role != "user":
        return

    await conversation_context.send_messages(workbench_model.NewConversationMessage(content=f"echo: {message.content}"))


@canonical_app.events.conversation.message.command.on_created
async def on_command_message_created(
    conversation_context: assistant_app.ConversationContext,
    _: workbench_model.ConversationEvent,
    message: workbench_model.ConversationMessage,
) -> None:
    if message.sender.participant_role != "user":
        return

    command = commands.get(message.command_name)
    if command is None:
        logger.debug("ignoring unknown command: %s", message.command_name)
        return

    command_response = command.process_args(message.command_args)
    await conversation_context.send_messages(
        workbench_model.NewConversationMessage(
            message_type=workbench_model.MessageType.command_response, content=command_response
        )
    )


app = canonical_app.fastapi_app()


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/command.py ===
import argparse
import shlex
from typing import NoReturn


class CommandArgumentParser(argparse.ArgumentParser):
    """
    argparse.ArgumentParser sub-class for parsing assistant commands.
    - Raises argparse.ArgumentError for all parsing failures instead of exiting the
      process.
    - Adds a --help option to show the help message.
    """

    def __init__(self, command: str, description: str, add_help=True):
        super().__init__(
            prog=command,
            description=description,
            exit_on_error=False,
            add_help=False,
            formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        )

        if add_help:
            self.add_argument("-h", "--help", action="help", help="show this help message")

    @property
    def command(self) -> str:
        return self.prog

    def error(self, message) -> NoReturn:
        self._error_message = message
        raise argparse.ArgumentError(None, message)

    def parse_args(self, arg_string: str) -> argparse.Namespace:  # type: ignore
        try:
            sys_args_like = shlex.split(arg_string)
        except ValueError as e:
            raise argparse.ArgumentError(None, f"Invalid command arguments: {e}")

        self._error_message = None
        try:
            result = super().parse_args(args=sys_args_like)
            if self._error_message:
                raise argparse.ArgumentError(None, self._error_message)
            return result

        except argparse.ArgumentError as e:
            message = f"{self.prog}: error: {e}\n\n{self.format_help()}"
            raise argparse.ArgumentError(None, message)

        except SystemExit:
            message = self.format_help()
            if self._error_message:
                message = f"{self.prog}: error: {self._error_message}\n\n{message}"
            raise argparse.ArgumentError(None, message)


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/config.py ===
import inspect
import os
import re
import types
from collections import ChainMap
from enum import StrEnum
from typing import Annotated, Any, Literal, Type, TypeVar, get_args, get_origin

import deepmerge
import dotenv
import typing_extensions
from pydantic import (
    BaseModel,
    PlainSerializer,
    SerializationInfo,
    WithJsonSchema,
)

ModelT = TypeVar("ModelT", bound=BaseModel)


def first_env_var(*env_vars: str, include_upper_and_lower: bool = True, include_dot_env: bool = True) -> str | None:
    """
    Get the first environment variable that is set.

    Args:
        include_upper_and_lower: if True, then the UPPER and lower case versions of the env vars will be checked.
        include_dot_env: if True, then the .env file will be checked for the env vars after the os.
    """
    if include_upper_and_lower:
        env_vars = (*env_vars, *[env_var.upper() for env_var in env_vars], *[env_var.lower() for env_var in env_vars])

    for env_var in env_vars:
        if env_var in os.environ:
            return os.environ[env_var]

    if not include_dot_env:
        return None

    dotenv_path = dotenv.find_dotenv(usecwd=True)
    if not dotenv_path:
        return None

    dot_env_values = dotenv.dotenv_values(dotenv_path)
    for env_var in env_vars:
        if env_var in dot_env_values:
            return dot_env_values[env_var]

    return None


class UISchema:
    """
    UISchema defines the uiSchema for a field on a Pydantic config model. The uiSchema
    directs the workbench app on how to render the field in the UI.
    This class is intended to be used as a type annotation. See the example.
    The full uiSchema for a model can be extracted by passing the model type to `get_ui_schema`.

    uiSchema reference:
    https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema/

    Example:
        ```
        class MyConfig(BaseModel):
            description: Annotated[str, UISchema(widget="textarea")]
            option: Annotated[Union[Literal["yes"], Literal["no"]], UISchema(widget="radio")]


        ui_schema = get_ui_schema(MyConfig)
        ```
    """

    def __init__(
        self,
        schema: dict[str, Any] | None = None,
        help: str | None = None,
        widget: Literal["textarea", "radio", "checkbox", "hidden"] | str | None = None,
        placeholder: str | None = None,
        hide_title: Literal[True] | None = None,
        hide_label: Literal[True] | None = None,
        enable_markdown_in_description: bool | None = None,
        readonly: bool | None = None,
        title: str | None = None,
        title_fields: list[str] | None = None,
        rows: int | None = None,
        items: "UISchema | None" = None,
        collapsible: bool | None = None,
        collapsed: bool | None = None,
    ) -> None:
        """
        Initialize a UISchema instance with the provided options.

        The schema parameter provides full control over the schema. The additional parameters are
        shortcuts for common options.

        Args:
            schema: An optional uiSchema dictionary. If the schema is provided, and any of the other
                parameters are also provided, they will be merged into the schema.
            help: An optional help text to display with the field in the UI.
            widget: The widget to use for the field in the UI. Useful if you want to use a different
                widget than the default for the field type.
            placeholder: The placeholder text to display in the field.
            hide_title: Whether to hide the title of the field in the UI.
            enable_markdown_in_description: Whether to enable markdown when rendering the field description.
            readonly: Whether the field should be read-only in the UI.
            title: Custom title to display for the field in the UI.
            title_fields: List of field names to use for generating a title in array items.
            rows: Number of rows to display for textarea widgets.
            items: UISchema to apply to array items.
            collapsible: Whether the field should be collapsible in the UI.
            collapsed: Whether the field should be initially collapsed in the UI.
        """
        # Initialize schema with provided value or empty dict
        self.schema = schema or {}

        # Get existing UI options or create empty dict
        ui_options: dict[str, Any] = self.schema.get("ui:options", {}).copy()

        # Process items schema
        items_schema = {}
        items_ui_options = {}

        if items:
            items_schema = items.schema.copy() if items.schema else {}
            items_ui_options = items.schema.get("ui:options", {}).copy()

        # Also check if there are existing items UI options in the schema
        if "items" in self.schema and "ui:options" in self.schema["items"]:
            items_ui_options.update(self.schema["items"]["ui:options"])

        # Build UI options dictionary with all provided parameters
        option_mappings = {
            "help": help,
            "widget": widget,
            "hideTitle": hide_title,
            "label": False if hide_label else None,
            "enableMarkdownInDescription": enable_markdown_in_description,
            "placeholder": placeholder,
            "readonly": readonly,
            "title": title,
            "collapsible": collapsible,
            "collapsed": collapsed,
            "titleFields": title_fields,
            "rows": rows,
        }

        # Update ui_options with non-None values
        for key, value in option_mappings.items():
            if value is not None:
                ui_options[key] = value

        # Update schema with ui_options if any exist
        if ui_options:
            self.schema["ui:options"] = ui_options

        # Handle items schema
        if items_schema:
            self.schema["items"] = items_schema

        # Add items UI options if they exist
        if items_ui_options:
            if "items" not in self.schema:
                self.schema["items"] = {}
            self.schema["items"]["ui:options"] = items_ui_options


def get_ui_schema(type_: Type[BaseModel]) -> dict[str, Any]:
    """Gets the unified UI schema for a Pydantic model, built from the UISchema type annotations."""
    try:
        annotations = _get_annotations_of_type(type_, UISchema)
    except TypeError:
        return {}

    ui_schema = {}
    for field_name, v in annotations.items():
        field_type, annotations = v

        field_ui_schema = {}
        for annotation in annotations:
            field_ui_schema.update(annotation.schema)

        field_types = [field_type]
        if isinstance(field_type, types.UnionType):
            field_types = field_type.__args__

        for field_type in field_types:
            origin = get_origin(field_type)
            if origin is not None and origin is list:
                list_item_schema = get_ui_schema(field_type.__args__[0])
                if list_item_schema:
                    field_ui_schema = deepmerge.always_merger.merge(field_ui_schema, {"items": list_item_schema})
                continue

            type_ui_schema = get_ui_schema(field_type)
            field_ui_schema = deepmerge.always_merger.merge(field_ui_schema, type_ui_schema)

        if field_ui_schema:
            ui_schema[field_name] = field_ui_schema

    return ui_schema


class ConfigSecretStrJsonSerializationMode(StrEnum):
    serialize_masked_value = "serialize_masked_value"
    serialize_as_empty = "serialize_as_empty"
    serialize_value = "serialize_value"


_CONFIG_SECRET_STR_SERIALIZATION_MODE_CONTEXT_KEY = "_config_secret_str_serialization_mode"


def config_secret_str_serialization_context(
    json_serialization_mode: ConfigSecretStrJsonSerializationMode, context: dict[str, Any] = {}
) -> dict[str, Any]:
    """Creates a context that can be used to control the serialization of ConfigSecretStr fields."""
    return {
        **context,
        _CONFIG_SECRET_STR_SERIALIZATION_MODE_CONTEXT_KEY: json_serialization_mode,
    }


def _config_secret_str_serialization_mode_from_context(
    context: dict[str, Any] | None,
) -> ConfigSecretStrJsonSerializationMode:
    """Gets the serialization mode for ConfigSecretStr fields from the context."""
    if context is None:
        return ConfigSecretStrJsonSerializationMode.serialize_masked_value

    return context.get(
        _CONFIG_SECRET_STR_SERIALIZATION_MODE_CONTEXT_KEY, ConfigSecretStrJsonSerializationMode.serialize_masked_value
    )


def _mask(value: str) -> str:
    return "*" * len(value)


def _config_secret_str_json_serializer(value: str, info: SerializationInfo) -> str:
    """JSON serializer for secret strings that masks the value unless explicitly requested."""
    if not value:
        return value

    json_serialization_mode = _config_secret_str_serialization_mode_from_context(info.context)

    match json_serialization_mode:
        case ConfigSecretStrJsonSerializationMode.serialize_as_empty:
            return ""

        case ConfigSecretStrJsonSerializationMode.serialize_value:
            return value

        case ConfigSecretStrJsonSerializationMode.serialize_masked_value:
            return _mask(value)


def replace_config_secret_str_masked_values(model_values: ModelT, original_model_values: ModelT) -> ModelT:
    updated_model_values = model_values.model_copy()
    for field_name, field_info in updated_model_values.model_fields.items():
        field_value = getattr(updated_model_values, field_name)
        if isinstance(field_value, BaseModel) and hasattr(original_model_values, field_name):
            updated_value = replace_config_secret_str_masked_values(
                field_value,
                getattr(original_model_values, field_name),
            )
            setattr(updated_model_values, field_name, updated_value)
            continue

        if field_info.annotation is ConfigSecretStr:
            if hasattr(original_model_values, field_name) and re.match(
                r"^[*]+$", getattr(updated_model_values, field_name)
            ):
                setattr(updated_model_values, field_name, getattr(original_model_values, field_name))
            continue

    return updated_model_values


ConfigSecretStr = typing_extensions.TypeAliasType(
    "ConfigSecretStr",
    Annotated[
        str,
        PlainSerializer(
            func=_config_secret_str_json_serializer,
            return_type=str,
            when_used="json-unless-none",
        ),
        WithJsonSchema({
            "type": "string",
            "writeOnly": True,
            "format": "password",
        }),
        UISchema(
            widget="password",
        ),
    ],
)
"""
    Type alias for string fields that contain secrets in Pydantic models used for assistant-app
    configuration. Fields with this type will be serialized as masked values in JSON, for example
    when returning the configuration to the client.
    Additionally, the JSON schema for the field is updated to indicate that the field is write-only
    and should be displayed as a password field in the UI.
"""


def _all_annotations(cls: Type) -> ChainMap:
    """Returns a dictionary-like ChainMap that includes annotations for all
    attributes defined in cls or inherited from superclasses."""
    if hasattr(cls, "__mro__"):
        return ChainMap(*(inspect.get_annotations(c) for c in cls.mro()))
    return ChainMap(inspect.get_annotations(cls))


_AnnotationTypeT = TypeVar("_AnnotationTypeT")


def _get_annotations_of_type(
    type_: Type, annotation_type: type[_AnnotationTypeT]
) -> dict[str, tuple[Type, list[_AnnotationTypeT]]]:
    if hasattr(type_, "__mro__"):
        annotations = _all_annotations(type_)
    else:
        annotations = inspect.get_annotations(type_)

    result = {}
    for ann_name, ann_type in annotations.items():
        if isinstance(ann_type, typing_extensions.TypeAliasType):
            # Unwrap the type alias
            ann_type = ann_type.__value__

        if get_origin(ann_type) is not Annotated:
            result[ann_name] = (ann_type, [])
            continue

        first_arg, *extra_args = get_args(ann_type)
        matching_annotations = [a for a in extra_args if isinstance(a, annotation_type)]
        result[ann_name] = (first_arg, matching_annotations)

    return result


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/logging_config.py ===
import logging
import logging.config
from time import perf_counter
from typing import Awaitable, Callable

import asgi_correlation_id
from fastapi import Request, Response
from pydantic_settings import BaseSettings
from pythonjsonlogger import json as jsonlogger


class LoggingSettings(BaseSettings):
    json_format: bool = False
    # The maximum length of the message field in the JSON log output.
    # Azure app services have a limit of 16,368 characters for the entire log entry.
    # Longer entries will be split into multiple log entries, making it impossible
    # to parse the JSON when reading logs.
    json_format_maximum_message_length: int = 15_000
    log_level: str = "INFO"


class CustomFormatter(logging.Formatter):
    def format(self, record):
        # The default formatter format (configured below) includes a "data"
        # field, which is not always present in the record. We add it here to
        # avoid a KeyError. If you want to add data to be printed out in the
        # logs, add it to the `extra`` dict in the `data`` parameter.
        #
        # For example: logger.info("This is a log message", extra={"data": {"key": "value"}})
        #
        # Note: The JSON Formatter automatically adds anything in the extra dict
        # to its formatted output.
        if "data" not in record.__dict__["args"]:
            record.data = ""
        else:
            record.data = record.__dict__["args"]["data"]
        return super().format(record)


class CustomJSONFormatter(jsonlogger.JsonFormatter):
    def __init__(self, *args, **kwargs):
        self.max_message_length = kwargs.pop("max_message_length", 15_000)
        super().__init__(*args, **kwargs)

    def process_log_record(self, log_record):
        """
        Truncate the message if it is too long to ensure that the downstream processors, such as log shipping
        and/or logging storage, do not chop it into multiple log entries.
        """
        if "message" not in log_record:
            return log_record

        message = log_record["message"]
        if len(message) <= self.max_message_length:
            return log_record

        log_record["message"] = (
            f"{message[: self.max_message_length // 2]}... truncated ...{message[-self.max_message_length // 2 :]}"
        )
        return log_record


def config(settings: LoggingSettings):
    log_level = settings.log_level.upper()
    # log_level_int = logging.getLevelNamesMapping()[log_level]

    handler = "rich"
    if settings.json_format:
        handler = "json"

    logging.config.dictConfig({
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "default": {
                "()": CustomFormatter,
                "format": "%(name)35s [%(correlation_id)s] %(message)s %(data)s",
                "datefmt": "[%X]",
            },
            "json": {
                "()": CustomJSONFormatter,
                "format": "%(name)s %(filename)s %(module)s %(lineno)s %(levelname)s %(correlation_id)s %(message)s",
                "timestamp": True,
                "max_message_length": settings.json_format_maximum_message_length,
            },
        },
        "handlers": {
            "rich": {
                "class": "rich.logging.RichHandler",
                "rich_tracebacks": True,
                "formatter": "default",
                "filters": ["asgi_correlation_id"],
            },
            "json": {
                "class": "logging.StreamHandler",
                "formatter": "json",
                "filters": ["asgi_correlation_id"],
            },
        },
        "loggers": {
            "azure.core.pipeline.policies.http_logging_policy": {
                "level": "WARNING",
            },
            "azure.identity": {
                "level": "WARNING",
            },
            "semantic_workbench_assistant": {
                "level": log_level,
            },
        },
        "root": {
            "handlers": [handler],
            "level": log_level,
        },
        "filters": {
            "asgi_correlation_id": {
                "()": asgi_correlation_id.CorrelationIdFilter,
                "uuid_length": 8,
                "default_value": "-",
            },
        },
    })


def log_request_middleware(
    logger: logging.Logger | None = None,
) -> Callable[[Request, Callable[[Request], Awaitable[Response]]], Awaitable[Response]]:
    access_logger = logger or logging.getLogger("access_log")

    async def middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
        """
        This middleware will log all requests and their processing time.
        E.g. log:
        0.0.0.0:1234 - "GET /ping HTTP/1.1" 200 OK 1.00ms 0b
        """
        url = f"{request.url.path}?{request.query_params}" if request.query_params else request.url.path
        start_time = perf_counter()
        response = await call_next(request)
        process_time = (perf_counter() - start_time) * 1000
        formatted_process_time = "{0:.2f}".format(process_time)
        host = getattr(getattr(request, "client", None), "host", None)
        port = getattr(getattr(request, "client", None), "port", None)
        http_version = f"HTTP/{request.scope.get('http_version', '1.1')}"
        content_length = response.headers.get("content-length", 0)
        access_logger.info(
            f'{host}:{port} - "{request.method} {url} {http_version}" {response.status_code} {formatted_process_time}ms {content_length}b',
        )
        return response

    return middleware


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/settings.py ===
from pydantic import Field, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict

from semantic_workbench_assistant.logging_config import LoggingSettings

from .storage import FileStorageSettings


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="assistant__", env_nested_delimiter="__", env_file=".env", extra="allow"
    )

    storage: FileStorageSettings = FileStorageSettings(root=".data/assistants")
    logging: LoggingSettings = LoggingSettings()

    workbench_service_url: HttpUrl = HttpUrl("http://127.0.0.1:3000")
    workbench_service_api_key: str = ""
    workbench_service_ping_interval_seconds: float = 30.0

    assistant_service_id: str | None = None
    assistant_service_name: str | None = None
    assistant_service_description: str | None = None

    assistant_service_url: HttpUrl | None = None

    host: str = "127.0.0.1"
    port: int = 0

    website_protocol: str = Field(alias="WEBSITE_PROTOCOL", default="https")
    website_port: int | None = Field(alias="WEBSITE_PORT", default=None)
    # this env var is set by the Azure App Service
    website_hostname: str = Field(alias="WEBSITE_HOSTNAME", default="")

    anonymous_paths: list[str] = ["/", "/docs", "/openapi.json"]

    @property
    def callback_url(self) -> str:
        # use the configured assistant service url if available
        if self.assistant_service_url:
            return str(self.assistant_service_url)

        # use config from Azure App Service if available
        if self.website_hostname:
            url = f"{self.website_protocol}://{self.website_hostname}"
            if self.website_port is None:
                return url
            return f"{url}:{self.website_port}"

        # finally, fallback to the host name/ip and port the app is running on
        url = f"http://{self.host}"
        if self.port is None:
            return url
        return f"{url}:{self.port}"


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/start.py ===
import argparse
import logging
import os
import socket
import sys
from contextlib import closing

import uvicorn

from . import logging_config, settings

logger = logging.getLogger(__name__)
logging.getLogger(sys.modules[__name__].__package__).setLevel(logging.DEBUG)


def main():
    logging_config.config(settings=settings.logging)

    parse_args = argparse.ArgumentParser(
        description="start a FastAPI assistant service", formatter_class=argparse.ArgumentDefaultsHelpFormatter
    )
    parse_args.add_argument(
        "--app",
        type=str,
        help="assistant app to start in format <module>:<attribute> (also supports ASSISTANT_APP env var)",
        default=os.getenv("ASSISTANT_APP") or "assistant:app",
    )
    parse_args.add_argument(
        "--port",
        dest="port",
        type=int,
        help="port to run service on; if not specified or 0, a random port will be selected",
        default=settings.port,
    )
    parse_args.add_argument("--host", dest="host", type=str, default=settings.host, help="host IP to run service on")
    parse_args.add_argument(
        "--assistant-service-id",
        dest="assistant_service_id",
        type=str,
        help="Override the assistant service ID",
        default=settings.assistant_service_id,
    )
    parse_args.add_argument(
        "--assistant-service-name",
        dest="assistant_service_name",
        type=str,
        help="Override the assistant service name",
        default=settings.assistant_service_name,
    )
    parse_args.add_argument(
        "--assistant-service-description",
        dest="assistant_service_description",
        type=str,
        help="Override the assistant service description",
        default=settings.assistant_service_description,
    )
    parse_args.add_argument(
        "--assistant-service-url",
        dest="assistant_service_url",
        type=str,
        help="Override the assistant service URL",
        default=settings.assistant_service_url,
    )
    parse_args.add_argument(
        "--workbench-service-url",
        dest="workbench_service_url",
        type=str,
        help="Override the workbench service URL",
        default=settings.workbench_service_url,
    )
    parse_args.add_argument(
        "--reload", dest="reload", nargs="?", action="store", type=str, default="false", help="enable auto-reload"
    )

    args = parse_args.parse_args()

    logger.info(f"Starting '{args.app}' assistant service ...")
    reload = args.reload != "false"
    if reload:
        logger.info("Enabling auto-reload ...")

    settings.host = args.host
    settings.port = args.port or find_free_port(settings.host)
    settings.assistant_service_id = args.assistant_service_id
    settings.assistant_service_name = args.assistant_service_name
    settings.assistant_service_description = args.assistant_service_description
    settings.assistant_service_url = args.assistant_service_url
    settings.workbench_service_url = args.workbench_service_url

    uvicorn.run(
        args.app,
        host=settings.host,
        port=settings.port,
        reload=reload,
        access_log=False,
        log_config={"version": 1, "disable_existing_loggers": False},
    )


def find_free_port(host: str):
    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
        s.bind((host, 0))
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        return s.getsockname()[1]


if __name__ == "__main__":
    main()


=== File: libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/storage.py ===
import logging
import os
import pathlib
from typing import Any, Iterator, TypeVar

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

logger = logging.getLogger(__name__)


class FileStorageSettings(BaseSettings):
    model_config = SettingsConfigDict(extra="allow")

    root: str = ".data/files"


def write_model(file_path: os.PathLike, value: BaseModel, serialization_context: dict[str, Any] | None = None) -> None:
    """Write a pydantic model to a file."""
    path = pathlib.Path(file_path)
    if not path.parent.exists():
        path.parent.mkdir(parents=True)

    data_json = value.model_dump_json(context=serialization_context)
    path.write_text(data_json, encoding="utf-8")


ModelT = TypeVar("ModelT", bound=BaseModel)


def read_model(file_path: os.PathLike | str, cls: type[ModelT], strict: bool | None = None) -> ModelT | None:
    """Read a pydantic model from a file."""
    path = pathlib.Path(file_path)

    try:
        data_json = path.read_text(encoding="utf-8")
    except (FileNotFoundError, ValueError):
        return None

    return cls.model_validate_json(data_json, strict=strict)


def read_models_in_dir(dir_path: os.PathLike, cls: type[ModelT]) -> Iterator[ModelT]:
    """Read pydantic models from all files in a directory."""
    path = pathlib.Path(dir_path)
    if not path.is_dir():
        return

    for file_path in path.iterdir():
        value = read_model(file_path, cls)
        if value is not None:
            yield value


=== File: libraries/python/semantic-workbench-assistant/tests/conftest.py ===
import tempfile
from typing import Iterator

import pytest
from semantic_workbench_assistant import settings, storage


@pytest.fixture
def storage_settings(request: pytest.FixtureRequest) -> Iterator[storage.FileStorageSettings]:
    storage_settings = settings.storage.model_copy()

    with tempfile.TemporaryDirectory() as temp_dir:
        storage_settings.root = temp_dir
        yield storage_settings


=== File: libraries/python/semantic-workbench-assistant/tests/test_assistant_app.py ===
import asyncio
import datetime
import io
import pathlib
import random
import shutil
import tempfile
import uuid
from contextlib import asynccontextmanager
from typing import IO, AsyncIterator
from unittest import mock

import httpx
import pytest
import semantic_workbench_api_model
import semantic_workbench_api_model.assistant_service_client
from asgi_lifespan import LifespanManager
from fastapi import HTTPException
from pydantic import BaseModel
from semantic_workbench_api_model import (
    assistant_model,
    assistant_service_client,
    workbench_model,
    workbench_service_client,
)
from semantic_workbench_assistant import settings, storage
from semantic_workbench_assistant.assistant_app import (
    AssistantApp,
    AssistantContext,
    AssistantConversationInspectorStateDataModel,
    BadRequestError,
    BaseModelAssistantConfig,
    ConflictError,
    ConversationContext,
    FileStorageConversationDataExporter,
    NotFoundError,
)
from semantic_workbench_assistant.assistant_app.context import storage_directory_for_context
from semantic_workbench_assistant.assistant_app.service import (
    translate_assistant_errors,
)
from semantic_workbench_assistant.config import (
    ConfigSecretStr,
)


class AllOKTransport(httpx.AsyncBaseTransport):
    """
    A mock transport that always returns a 200 OK response.
    """

    async def handle_async_request(self, request) -> httpx.Response:
        return httpx.Response(200)


async def test_assistant_with_event_handlers(
    monkeypatch: pytest.MonkeyPatch, storage_settings: storage.FileStorageSettings
) -> None:
    monkeypatch.setattr(settings, "storage", storage_settings)

    app = AssistantApp(
        assistant_service_id="assistant_id",
        assistant_service_name="service name",
        assistant_service_description="service description",
    )

    assistant_created_calls = 0
    conversation_created_calls = 0
    message_created_calls = 0
    message_created_all_calls = 0
    message_chat_created_calls = 0

    @app.events.assistant.on_created
    async def on_assistant_created(assistant_context: AssistantContext) -> None:
        nonlocal assistant_created_calls
        assistant_created_calls += 1

    @app.events.conversation.on_created
    async def on_conversation_created(conversation_context: ConversationContext) -> None:
        nonlocal conversation_created_calls
        conversation_created_calls += 1

    @app.events.conversation.message.on_created
    def on_message_created(
        conversation_context: ConversationContext,
        _: workbench_model.ConversationEvent,
        message: workbench_model.ConversationMessage,
    ) -> None:
        nonlocal message_created_calls
        message_created_calls += 1

    @app.events.conversation.message.on_created_including_mine
    def on_message_created_all(
        conversation_context: ConversationContext,
        _: workbench_model.ConversationEvent,
        message: workbench_model.ConversationMessage,
    ) -> None:
        nonlocal message_created_all_calls
        message_created_all_calls += 1

    @app.events.conversation.message.chat.on_created
    async def on_chat_message(
        conversation_context: ConversationContext,
        _: workbench_model.ConversationEvent,
        message: workbench_model.ConversationMessage,
    ) -> None:
        nonlocal message_chat_created_calls
        message_chat_created_calls += 1

    service = app.fastapi_app()

    monkeypatch.setattr(assistant_service_client, "httpx_transport_factory", lambda: httpx.ASGITransport(app=service))
    monkeypatch.setattr(workbench_service_client, "httpx_transport_factory", lambda: AllOKTransport())

    async with LifespanManager(service):
        assistant_id = uuid.uuid4()
        assistant_request = assistant_model.AssistantPutRequestModel(
            assistant_name="my assistant", template_id="default"
        )

        client_builder = assistant_service_client.AssistantServiceClientBuilder("https://fake", "")
        service_client = client_builder.for_service()
        instance_client = client_builder.for_assistant(assistant_id)

        await service_client.put_assistant(assistant_id=assistant_id, request=assistant_request, from_export=None)

        assert assistant_created_calls == 1

        conversation_id = uuid.uuid4()

        await instance_client.put_conversation(
            request=assistant_model.ConversationPutRequestModel(
                id=str(conversation_id),
                title="My conversation",
            ),
            from_export=None,
        )

        assert conversation_created_calls == 1

        # send a message of type "chat"
        message_id = uuid.uuid4()
        await instance_client.post_conversation_event(
            event=workbench_model.ConversationEvent(
                conversation_id=conversation_id,
                correlation_id="",
                event=workbench_model.ConversationEventType.message_created,
                data={
                    "message": workbench_model.ConversationMessage(
                        id=message_id,
                        sender=workbench_model.MessageSender(
                            participant_role=workbench_model.ParticipantRole.user, participant_id="user"
                        ),
                        message_type=workbench_model.MessageType.chat,
                        timestamp=datetime.datetime.now(),
                        content_type="text/plain",
                        content="Hello, world",
                        filenames=[],
                        metadata={},
                        has_debug_data=False,
                    ).model_dump(mode="json")
                },
            )
        )

        assert message_created_calls == 1
        assert message_created_all_calls == 1
        assert message_chat_created_calls == 1

        # send a message of type "notice"
        await instance_client.post_conversation_event(
            event=workbench_model.ConversationEvent(
                conversation_id=conversation_id,
                correlation_id="",
                event=workbench_model.ConversationEventType.message_created,
                data={
                    "message": workbench_model.ConversationMessage(
                        id=message_id,
                        sender=workbench_model.MessageSender(
                            participant_role=workbench_model.ParticipantRole.user, participant_id="user"
                        ),
                        message_type=workbench_model.MessageType.notice,
                        timestamp=datetime.datetime.now(),
                        content_type="text/plain",
                        content="Hello, world",
                        filenames=[],
                        metadata={},
                        has_debug_data=False,
                    ).model_dump(mode="json")
                },
            )
        )

        assert message_created_calls == 2
        assert message_created_all_calls == 2
        assert message_chat_created_calls == 1

        # send a message from this assistant
        await instance_client.post_conversation_event(
            event=workbench_model.ConversationEvent(
                conversation_id=conversation_id,
                correlation_id="",
                event=workbench_model.ConversationEventType.message_created,
                data={
                    "message": workbench_model.ConversationMessage(
                        id=message_id,
                        sender=workbench_model.MessageSender(
                            participant_role=workbench_model.ParticipantRole.assistant, participant_id=str(assistant_id)
                        ),
                        message_type=workbench_model.MessageType.chat,
                        timestamp=datetime.datetime.now(),
                        content_type="text/plain",
                        content="Hello, world",
                        filenames=[],
                        metadata={},
                        has_debug_data=False,
                    ).model_dump(mode="json")
                },
            )
        )

        # these should remain unchanged
        assert message_chat_created_calls == 1
        assert message_created_calls == 2

        # this should have been called
        assert message_created_all_calls == 3


async def test_assistant_with_inspector(
    monkeypatch: pytest.MonkeyPatch, storage_settings: storage.FileStorageSettings
) -> None:
    monkeypatch.setattr(settings, "storage", storage_settings)

    class TestInspectorImplementation:
        display_name = "Test"
        description = "Test inspector"

        async def is_enabled(self, context: ConversationContext) -> bool:
            return True

        async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel:
            return AssistantConversationInspectorStateDataModel(
                data={"test": "data"},
                json_schema={},
                ui_schema={},
            )

    app = AssistantApp(
        assistant_service_id="assistant_id",
        assistant_service_name="service name",
        assistant_service_description="service description",
        inspector_state_providers={"test": TestInspectorImplementation()},
    )

    service = app.fastapi_app()

    monkeypatch.setattr(assistant_service_client, "httpx_transport_factory", lambda: httpx.ASGITransport(app=service))
    monkeypatch.setattr(workbench_service_client, "httpx_transport_factory", lambda: AllOKTransport())

    async with LifespanManager(service):
        assistant_id = uuid.uuid4()
        conversation_id = uuid.uuid4()

        assistant_request = assistant_model.AssistantPutRequestModel(
            assistant_name="my assistant", template_id="default"
        )

        client_builder = assistant_service_client.AssistantServiceClientBuilder("https://fake", "")
        service_client = client_builder.for_service()
        instance_client = client_builder.for_assistant(assistant_id)

        await service_client.put_assistant(assistant_id=assistant_id, request=assistant_request, from_export=None)
        await instance_client.put_conversation(
            request=assistant_model.ConversationPutRequestModel(
                id=str(conversation_id),
                title="My conversation",
            ),
            from_export=None,
        )

        response = await instance_client.get_state_descriptions(conversation_id=conversation_id)
        assert response == assistant_model.StateDescriptionListResponseModel(
            states=[
                assistant_model.StateDescriptionResponseModel(
                    id="test",
                    display_name="Test",
                    description="Test inspector",
                    enabled=True,
                )
            ]
        )

        response = await instance_client.get_state(conversation_id=conversation_id, state_id="test")
        assert response == assistant_model.StateResponseModel(
            id="test",
            data={"test": "data"},
            json_schema={},
            ui_schema={},
        )


async def test_assistant_with_state_exporter(
    monkeypatch: pytest.MonkeyPatch, storage_settings: storage.FileStorageSettings
) -> None:
    monkeypatch.setattr(settings, "storage", storage_settings)

    class SimpleStateExporter:
        def __init__(self) -> None:
            self.data = bytearray()

        @asynccontextmanager
        async def export(self, conversation_context: ConversationContext) -> AsyncIterator[IO[bytes]]:
            yield io.BytesIO(self.data)

        async def import_(self, conversation_context: ConversationContext, stream: IO[bytes]) -> None:
            self.data = stream.read()

    state_exporter = SimpleStateExporter()
    # wrap the instance so we can check calls to it
    state_exporter_wrapper = mock.Mock(wraps=state_exporter)

    app = AssistantApp(
        assistant_service_id="assistant_id",
        assistant_service_name="service name",
        assistant_service_description="service description",
        conversation_data_exporter=state_exporter_wrapper,
    )

    service = app.fastapi_app()

    monkeypatch.setattr(assistant_service_client, "httpx_transport_factory", lambda: httpx.ASGITransport(app=service))
    monkeypatch.setattr(workbench_service_client, "httpx_transport_factory", lambda: AllOKTransport())

    async with LifespanManager(service):
        assistant_id = uuid.uuid4()
        assistant_request = assistant_model.AssistantPutRequestModel(
            assistant_name="my assistant", template_id="default"
        )

        client_builder = assistant_service_client.AssistantServiceClientBuilder("https://fake", "")
        service_client = client_builder.for_service()
        instance_client = client_builder.for_assistant(assistant_id)

        await service_client.put_assistant(assistant_id=assistant_id, request=assistant_request, from_export=None)

        conversation_id = uuid.uuid4()

        import_bytes = bytearray(random.getrandbits(8) for _ in range(10))

        await instance_client.put_conversation(
            request=assistant_model.ConversationPutRequestModel(
                id=str(conversation_id),
                title="My conversation",
            ),
            from_export=io.BytesIO(import_bytes),
        )

        assert state_exporter_wrapper.import_.called
        assert isinstance(state_exporter_wrapper.import_.call_args[0][0], ConversationContext)
        assert state_exporter_wrapper.import_.call_args[0][0].id == str(conversation_id)
        assert state_exporter_wrapper.import_.call_args[0][0].title == "My conversation"

        assert state_exporter.data == import_bytes

        bytes_out = bytearray()
        async with instance_client.get_exported_conversation_data(conversation_id=conversation_id) as stream:
            async for chunk in stream:
                bytes_out.extend(chunk)

        assert state_exporter_wrapper.export.called
        assert isinstance(state_exporter_wrapper.import_.call_args[0][0], ConversationContext)
        assert state_exporter_wrapper.import_.call_args[0][0].id == str(conversation_id)
        assert state_exporter_wrapper.import_.call_args[0][0].title == "My conversation"

        assert bytes_out == import_bytes


async def test_assistant_with_config_provider(
    monkeypatch: pytest.MonkeyPatch, storage_settings: storage.FileStorageSettings
) -> None:
    monkeypatch.setattr(settings, "storage", storage_settings)

    class TestConfigModel(BaseModel):
        test_key: str = "test_value"
        secret_field: ConfigSecretStr = "secret_default"

    config_provider = BaseModelAssistantConfig(TestConfigModel).provider
    # wrap the provider so we can check calls to it
    config_provider_wrapper = mock.Mock(wraps=config_provider)

    expected_ui_schema = {"secret_field": {"ui:options": {"widget": "password"}}}

    app = AssistantApp(
        assistant_service_id="assistant_id",
        assistant_service_name="service name",
        assistant_service_description="service description",
        config_provider=config_provider_wrapper,
    )

    service = app.fastapi_app()

    monkeypatch.setattr(assistant_service_client, "httpx_transport_factory", lambda: httpx.ASGITransport(app=service))
    monkeypatch.setattr(workbench_service_client, "httpx_transport_factory", lambda: AllOKTransport())

    async with LifespanManager(service):
        assistant_id = uuid.uuid4()
        assistant_request = assistant_model.AssistantPutRequestModel(
            assistant_name="my assistant", template_id="default"
        )

        client_builder = assistant_service_client.AssistantServiceClientBuilder("https://fake", "")
        service_client = client_builder.for_service()
        instance_client = client_builder.for_assistant(assistant_id)

        await service_client.put_assistant(assistant_id=assistant_id, request=assistant_request, from_export=None)

        response = await instance_client.get_config()
        assert response == assistant_model.ConfigResponseModel(
            config={"test_key": "test_value", "secret_field": len("secret_default") * "*"},
            errors=[],
            json_schema=TestConfigModel.model_json_schema(),
            ui_schema=expected_ui_schema,
        )
        assert config_provider_wrapper.get.called

        config_provider_wrapper.reset_mock()

        response = await instance_client.put_config(
            assistant_model.ConfigPutRequestModel(config={"test_key": "new_value", "secret_field": "new_secret"})
        )
        assert response == assistant_model.ConfigResponseModel(
            config={"test_key": "new_value", "secret_field": len("new_secret") * "*"},
            errors=[],
            json_schema=TestConfigModel.model_json_schema(),
            ui_schema=expected_ui_schema,
        )
        assert config_provider_wrapper.set.called
        assert config_provider_wrapper.set.call_args[0][1] == {
            "test_key": "new_value",
            "secret_field": "new_secret",
        }

        config_provider_wrapper.reset_mock()

        response = await instance_client.get_config()
        assert response == assistant_model.ConfigResponseModel(
            config={"test_key": "new_value", "secret_field": len("new_secret") * "*"},
            errors=[],
            json_schema=TestConfigModel.model_json_schema(),
            ui_schema=expected_ui_schema,
        )
        assert config_provider_wrapper.get.called

        # ensure that the secret field is serialized as an empty string in the export
        with tempfile.TemporaryDirectory() as temp_dir:
            temp_dir_path = pathlib.Path(temp_dir)
            export_file_path = temp_dir_path / "export.zip"
            with export_file_path.open("wb") as f:
                async with instance_client.get_exported_data() as response:
                    async for chunk in response:
                        f.write(chunk)

            extract_path = temp_dir_path / "extracted"
            await asyncio.to_thread(
                shutil.unpack_archive,
                filename=export_file_path,
                extract_dir=extract_path,
                format="zip",
            )

            config_path = extract_path / "config.json"
            assert config_path.exists()
            assert config_path.read_text() == '{"test_key":"new_value","secret_field":""}'

        config_provider_wrapper.reset_mock()

        response = await instance_client.put_config(
            assistant_model.ConfigPutRequestModel(config={"test_key": "new_value", "secret_field": ""})
        )
        assert response == assistant_model.ConfigResponseModel(
            config={"test_key": "new_value", "secret_field": ""},
            errors=[],
            json_schema=TestConfigModel.model_json_schema(),
            ui_schema=expected_ui_schema,
        )
        assert config_provider_wrapper.set.called
        assert config_provider_wrapper.set.call_args[0][1] == {
            "test_key": "new_value",
            "secret_field": "",
        }

        config_provider_wrapper.reset_mock()

        with pytest.raises(semantic_workbench_api_model.assistant_service_client.AssistantResponseError) as e:
            await instance_client.put_config(
                assistant_model.ConfigPutRequestModel(config={"test_key": {"invalid_value": 1}})
            )

        assert e.value.status_code == 400


async def test_file_system_storage_state_data_provider_to_empty_dir(
    storage_settings: storage.FileStorageSettings, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.setattr(settings, "storage", storage_settings)

    src_conversation_context = ConversationContext(
        id=str(uuid.uuid4()),
        title="My conversation",
        assistant=AssistantContext(
            _assistant_service_id="",
            _template_id="",
            id=str(uuid.uuid4()),
            name="my assistant",
        ),
        httpx_client=mock.ANY,
    )

    dest_conversation_context = ConversationContext(
        id=str(uuid.uuid4()),
        title="My conversation",
        assistant=AssistantContext(
            _assistant_service_id="",
            _template_id="",
            id=str(uuid.uuid4()),
            name="my assistant",
        ),
        httpx_client=mock.ANY,
    )

    src_dir_path = storage_directory_for_context(src_conversation_context)
    src_dir_path.mkdir(parents=True)

    (src_dir_path / "test.txt").write_text("Hello, world")

    sub_dir_path = src_dir_path / "subdir"

    sub_dir_path.mkdir()

    (sub_dir_path / "test.bin").write_bytes(bytes([1, 2, 3, 4]))

    data_provider = FileStorageConversationDataExporter()

    async with data_provider.export(src_conversation_context) as stream:
        await data_provider.import_(dest_conversation_context, stream)

    dest_dir_path = storage_directory_for_context(dest_conversation_context)

    assert (dest_dir_path / "test.txt").read_text() == "Hello, world"

    assert (dest_dir_path / "subdir" / "test.bin").read_bytes() == bytes([1, 2, 3, 4])


async def test_file_system_storage_state_data_provider_to_non_empty_dir(
    storage_settings: storage.FileStorageSettings, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(settings, "storage", storage_settings)

    src_conversation_context = ConversationContext(
        id=str(uuid.uuid4()),
        title="My conversation",
        assistant=AssistantContext(
            _assistant_service_id="",
            _template_id="",
            id=str(uuid.uuid4()),
            name="my assistant",
        ),
        httpx_client=mock.ANY,
    )

    dest_conversation_context = ConversationContext(
        id=str(uuid.uuid4()),
        title="My conversation",
        assistant=AssistantContext(
            _assistant_service_id="",
            _template_id="",
            id=str(uuid.uuid4()),
            name="my assistant",
        ),
        httpx_client=mock.ANY,
    )

    # set up contents of the non-empty destination directory
    dest_dir_path = storage_directory_for_context(dest_conversation_context)
    dest_dir_path.mkdir(parents=True)

    (dest_dir_path / "test.txt").write_text("this file will be overwritten")

    dest_sub_dir_path = dest_dir_path / "subdir-gets-deleted"

    dest_sub_dir_path.mkdir()

    (dest_sub_dir_path / "test.bin").write_bytes(bytes([1, 2, 3, 4]))

    # set up contents of the source directory

    src_dir_path = storage_directory_for_context(src_conversation_context)
    src_dir_path.mkdir(parents=True)

    (src_dir_path / "test.txt").write_text("Hello, world")

    sub_dir_path = src_dir_path / "subdir"

    sub_dir_path.mkdir()

    (sub_dir_path / "test.bin").write_bytes(bytes([1, 2, 3, 4]))

    # export and import

    data_provider = FileStorageConversationDataExporter()

    async with data_provider.export(src_conversation_context) as stream:
        await data_provider.import_(dest_conversation_context, stream)

    assert (dest_dir_path / "test.txt").read_text() == "Hello, world"

    assert (dest_dir_path / "subdir" / "test.bin").read_bytes() == bytes([1, 2, 3, 4])

    assert dest_sub_dir_path.exists() is False


class UnknownErrorForTest(Exception):
    pass


@pytest.mark.parametrize(
    "raise_exception,expected_exception,expected_status_code",
    [
        [UnknownErrorForTest(), UnknownErrorForTest, None],
        (NotFoundError(), HTTPException, 404),
        (ConflictError(), HTTPException, 409),
        (BadRequestError(), HTTPException, 400),
    ],
)
async def test_translate_assistant_errors(
    raise_exception: Exception, expected_exception: type[Exception], expected_status_code: int | None
) -> None:
    @translate_assistant_errors
    def raise_err_sync() -> None:
        raise raise_exception

    @translate_assistant_errors
    async def raise_err_async() -> None:
        raise raise_exception

    with pytest.raises(expected_exception) as exc_info:
        raise_err_sync()

    if isinstance(exc_info.value, HTTPException):
        assert exc_info.value.status_code == expected_status_code

    with pytest.raises(expected_exception) as exc_info:
        await raise_err_async()

    if isinstance(exc_info.value, HTTPException):
        assert exc_info.value.status_code == expected_status_code


=== File: libraries/python/semantic-workbench-assistant/tests/test_canonical.py ===
import uuid

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from semantic_workbench_api_model import assistant_model
from semantic_workbench_assistant import canonical, settings, storage


@pytest.fixture
def canonical_assistant_service(
    monkeypatch: pytest.MonkeyPatch, storage_settings: storage.FileStorageSettings
) -> FastAPI:
    monkeypatch.setattr(settings, "storage", storage_settings)
    return canonical.canonical_app.fastapi_app()


def test_service_init(canonical_assistant_service: FastAPI):
    with TestClient(app=canonical_assistant_service):
        pass


def test_create_assistant_put_config(canonical_assistant_service: FastAPI):
    with TestClient(app=canonical_assistant_service) as client:
        assistant_id = str(uuid.uuid4())
        assistant_definition = assistant_model.AssistantPutRequestModel(
            assistant_name="test-assistant", template_id="default"
        )
        response = client.put(f"/{assistant_id}", data={"assistant": assistant_definition.model_dump_json()})
        response.raise_for_status()

        response = client.get(f"/{assistant_id}/config")
        response.raise_for_status()

        original_config_state = assistant_model.ConfigResponseModel(**response.json())
        original_config = canonical.ConfigStateModel(**original_config_state.config)

        # check that the default config state is as expected so we can later assert on the
        # partially updated state
        assert original_config.model_dump(mode="json") == {
            "un_annotated_text": "",
            "short_text": "",
            "long_text": "",
            "setting_int": 0,
            "model": {"name": "gpt35turbo"},
            "prompt": {"custom_prompt": "", "temperature": 0.7},
        }

        config = assistant_model.ConfigPutRequestModel(
            config=canonical.ConfigStateModel(
                short_text="test short text - this should update",
                long_text="test long text - this should update",
                prompt=canonical.PromptConfigModel(
                    custom_prompt="test custom prompt - this should update", temperature=0.999999
                ),
            ).model_dump()
        )

        response = client.put(f"/{assistant_id}/config", json=config.model_dump(mode="json"))
        response.raise_for_status()

        updated_config_state = assistant_model.ConfigResponseModel(**response.json())
        updated_config = canonical.ConfigStateModel(**updated_config_state.config)

        assert updated_config.model_dump(mode="json") == {
            "un_annotated_text": "",
            "short_text": "test short text - this should update",
            "long_text": "test long text - this should update",
            "setting_int": 0,
            "model": {"name": "gpt35turbo"},
            "prompt": {"custom_prompt": "test custom prompt - this should update", "temperature": 0.999999},
        }


def test_create_assistant_put_invalid_config(canonical_assistant_service: FastAPI):
    with TestClient(app=canonical_assistant_service) as client:
        assistant_id = str(uuid.uuid4())
        assistant_definition = assistant_model.AssistantPutRequestModel(
            assistant_name="test-assistant", template_id="default"
        )

        response = client.put(f"/{assistant_id}", data={"assistant": assistant_definition.model_dump_json()})
        response.raise_for_status()

        response = client.get(f"/{assistant_id}/config")
        response.raise_for_status()

        original_config_state = assistant_model.ConfigResponseModel(**response.json())

        response = client.put(f"/{assistant_id}/config", json={"data": {"invalid_key": "data"}})
        assert response.status_code in [422, 400]

        response = client.get(f"/{assistant_id}/config")
        response.raise_for_status()

        after_config_state = assistant_model.ConfigResponseModel(**response.json())

        assert after_config_state == original_config_state


=== File: libraries/python/semantic-workbench-assistant/tests/test_config.py ===
import uuid
from typing import Annotated, Literal

import pytest
from pydantic import BaseModel
from semantic_workbench_assistant.config import (
    ConfigSecretStr,
    ConfigSecretStrJsonSerializationMode,
    UISchema,
    config_secret_str_serialization_context,
    get_ui_schema,
    replace_config_secret_str_masked_values,
)


@pytest.mark.parametrize(
    ("model_dump_mode", "serialization_mode", "secret_value", "expected_value"),
    [
        # python serialization should always return the actual value
        ("dict_python", None, "super-secret", "super-secret"),
        ("dict_python", None, "", ""),
        ("dict_python", ConfigSecretStrJsonSerializationMode.serialize_as_empty, "super-secret", "super-secret"),
        ("dict_python", ConfigSecretStrJsonSerializationMode.serialize_as_empty, "", ""),
        ("dict_python", ConfigSecretStrJsonSerializationMode.serialize_masked_value, "super-secret", "super-secret"),
        ("dict_python", ConfigSecretStrJsonSerializationMode.serialize_masked_value, "", ""),
        ("dict_python", ConfigSecretStrJsonSerializationMode.serialize_value, "super-secret", "super-secret"),
        ("dict_python", ConfigSecretStrJsonSerializationMode.serialize_value, "", ""),
        # json serialization should return the expected value based on the serialization mode
        ("dict_json", None, "super-secret", "************"),
        ("dict_json", None, "", ""),
        ("dict_json", ConfigSecretStrJsonSerializationMode.serialize_as_empty, "super-secret", ""),
        ("dict_json", ConfigSecretStrJsonSerializationMode.serialize_as_empty, "", ""),
        ("dict_json", ConfigSecretStrJsonSerializationMode.serialize_masked_value, "super-secret", "************"),
        ("dict_json", ConfigSecretStrJsonSerializationMode.serialize_masked_value, "", ""),
        ("dict_json", ConfigSecretStrJsonSerializationMode.serialize_value, "super-secret", "super-secret"),
        ("dict_json", ConfigSecretStrJsonSerializationMode.serialize_value, "", ""),
        ("str_json", None, "super-secret", "************"),
        ("str_json", None, "", ""),
        ("str_json", ConfigSecretStrJsonSerializationMode.serialize_as_empty, "super-secret", ""),
        ("str_json", ConfigSecretStrJsonSerializationMode.serialize_as_empty, "", ""),
        ("str_json", ConfigSecretStrJsonSerializationMode.serialize_masked_value, "super-secret", "************"),
        ("str_json", ConfigSecretStrJsonSerializationMode.serialize_masked_value, "", ""),
        ("str_json", ConfigSecretStrJsonSerializationMode.serialize_value, "super-secret", "super-secret"),
        ("str_json", ConfigSecretStrJsonSerializationMode.serialize_value, "", ""),
    ],
)
def test_config_secret_str_serialization(
    model_dump_mode: Literal["dict_python", "dict_json", "str_json"],
    serialization_mode: ConfigSecretStrJsonSerializationMode | None,
    secret_value: str,
    expected_value: str,
) -> None:
    class TestModel(BaseModel):
        secret: ConfigSecretStr

    model = TestModel(secret=secret_value)
    assert model.secret == secret_value

    match serialization_mode:
        case None:
            context = None
        case _:
            context = config_secret_str_serialization_context(serialization_mode)

    match model_dump_mode:
        case "dict_python":
            dump = model.model_dump(mode="python", context=context)
            assert dump["secret"] == expected_value
        case "dict_json":
            dump = model.model_dump(mode="json", context=context)
            assert dump["secret"] == expected_value
        case "str_json":
            dump = model.model_dump_json(context=context)
            assert dump == f'{{"secret":"{expected_value}"}}'


def test_config_secret_str_deserialization() -> None:
    class SubModel1(BaseModel):
        submodel1_secret: ConfigSecretStr

    class SubModel2(BaseModel):
        submodel2_secret: ConfigSecretStr

    class TestModel(BaseModel):
        secret: ConfigSecretStr
        sub_model: SubModel1 | SubModel2

    secret_value = uuid.uuid4().hex

    sub_model = SubModel2(submodel2_secret=secret_value)
    model = TestModel(secret=secret_value, sub_model=sub_model)
    assert model.secret == secret_value

    serialized_config = model.model_dump(mode="json")

    assert serialized_config["secret"] == "*" * len(secret_value)
    assert serialized_config["sub_model"]["submodel2_secret"] == "*" * len(secret_value)

    deserialized_config = TestModel.model_validate(serialized_config)

    masked_reverted = replace_config_secret_str_masked_values(deserialized_config, model)
    assert masked_reverted.secret == model.secret
    assert isinstance(masked_reverted.sub_model, SubModel2)
    assert masked_reverted.sub_model.submodel2_secret == sub_model.submodel2_secret

    deserialized_model = TestModel.model_validate(masked_reverted)

    assert deserialized_model.secret == secret_value


def test_config_secret_str_ui_schema() -> None:
    class TestModel(BaseModel):
        secret: ConfigSecretStr

    assert get_ui_schema(TestModel) == {"secret": {"ui:options": {"widget": "password"}}}


def test_annotations() -> None:
    class ChildModel(BaseModel):
        child_name: Annotated[str, UISchema({"ui:options": {"child_name": True}})] = "default-child-name"

    class OtherChildModel(BaseModel):
        other_child_name: Annotated[str, UISchema({"ui:options": {"other_child_name": True}})] = (
            "default-other-child-name"
        )

    class TestModel(BaseModel):
        name: Annotated[str, UISchema({"ui:options": {"name": True}})] = "default-name"
        child: Annotated[ChildModel, UISchema({})] = ChildModel()
        union_type: Annotated[ChildModel | OtherChildModel, UISchema({})] = OtherChildModel()
        un_annotated: str = "un_annotated"
        annotated_with_others: Annotated[str, {"foo": "bar"}] = ""
        literal: Literal["literal"]

    ui_schema = get_ui_schema(TestModel)

    assert ui_schema == {
        "name": {"ui:options": {"name": True}},
        "child": {"child_name": {"ui:options": {"child_name": True}}},
        "union_type": {
            "child_name": {"ui:options": {"child_name": True}},
            "other_child_name": {"ui:options": {"other_child_name": True}},
        },
    }


def test_list_items() -> None:
    class ChildModel(BaseModel):
        child_name: Annotated[str, UISchema({"ui:options": {"child_name": True}})] = "default-child-name"

    class TestModel(BaseModel):
        children: Annotated[
            list[ChildModel],
            UISchema(
                items=UISchema(rows=2),
                schema={
                    "items": {
                        "ui:options": {"items-key": "items-value"},
                    }
                },
            ),
        ] = [ChildModel()]

    ui_schema = get_ui_schema(TestModel)

    assert ui_schema == {
        "children": {
            "items": {
                "ui:options": {"items-key": "items-value", "rows": 2},
                "child_name": {"ui:options": {"child_name": True}},
            }
        },
    }


=== File: libraries/python/semantic-workbench-assistant/tests/test_storage.py ===
import tempfile
from pathlib import Path
from typing import Annotated

import pydantic
import pytest
from pydantic import AliasChoices, BaseModel, Field
from semantic_workbench_assistant import storage


def test_read_non_existing():
    class TestModel(BaseModel):
        pass

    result = storage.read_model("./x", TestModel)

    assert result is None


def test_write_read_model():
    class SubModel(BaseModel):
        sub_name: str

    class TestModel(BaseModel):
        name: str
        sub: SubModel

    with tempfile.TemporaryDirectory() as temp_dir:
        value = TestModel(name="test", sub=SubModel(sub_name="sub"))

        value_path = Path(temp_dir) / "model.json"
        storage.write_model(file_path=value_path, value=value)

        assert storage.read_model(value_path, TestModel) == value


def test_write_read_updated_model():
    class TestModel(BaseModel):
        name: str

    class TestModelBreaking(BaseModel):
        name_new: str

    class TestModelSupportsOldName(BaseModel):
        name_new: Annotated[
            str,
            Field(validation_alias=AliasChoices("name", "name_new")),
        ]

    with tempfile.TemporaryDirectory() as temp_dir:
        value = TestModel(name="test")

        value_path = Path(temp_dir) / "model.json"
        storage.write_model(file_path=value_path, value=value)

        with pytest.raises(pydantic.ValidationError):
            storage.read_model(value_path, TestModelBreaking)

        assert storage.read_model(value_path, TestModelSupportsOldName) == TestModelSupportsOldName(name_new="test")



```
Page 110/114FirstPrevNextLast