From 0dcff07b08f42b6e1456dd42b370a03ef9e3e96a Mon Sep 17 00:00:00 2001 From: Blaine Perry Date: Thu, 18 Jun 2026 17:19:28 -0500 Subject: [PATCH] feat: Cosmos-backed agent memory + invisible per-message timestamps Move durable agent state into Azure Cosmos DB and make Cosmos the single source of truth. - cosmos_memory.py: shared async client, durable chat-history provider, and conversation-index repository (partition /user_id); emulator-aware client (well-known key + no endpoint discovery for localhost) - user_data.py: per-user Cosmos repositories for custom agents, built-in agent customizations, and user profiles (replaces browser localStorage) - User-profile memory tools (get/save) read/write Cosmos directly as async function tools bound to user_id; removed the per-session in-memory store and the frontend persistence round-trip - Prepend the user's local date/time to each user message for model/session temporal context; stripped from the wire form so it stays invisible in the UI - REST endpoints for custom agents and agent customizations; server-backed frontend hooks with optimistic updates; delete obsolete localStorage hooks - Terraform: serverless Cosmos account + containers, managed-identity data contributor role, and ENABLE_DEV_COSMOS_ACCESS dev-access toggle - Tests: emulator-backed integration tests + loop-independent in-memory doubles injected via monkeypatch (no production test seams) --- .env.example | 164 +++++++ .envexample | 72 ---- .github/agents/copilot-instructions.md | 4 +- .gitignore | 1 + .vscode/tasks.json | 18 +- agent_factory.py | 29 +- cosmos_memory.py | 402 ++++++++++++++++++ frontend/src/App.tsx | 5 +- frontend/src/api/client.ts | 111 ++++- .../hooks/useBuiltInAgentCustomizations.ts | 69 ++- frontend/src/hooks/useChat.ts | 48 +-- .../src/hooks/useConversationPersistence.ts | 72 ---- frontend/src/hooks/useConversationStore.ts | 133 +----- frontend/src/hooks/useCustomAgents.ts | 43 +- frontend/src/hooks/useSessionLifecycle.ts | 146 ++----- frontend/src/hooks/useUserProfile.ts | 12 - frontend/src/pages/ChatPage.tsx | 51 ++- frontend/src/types/api.ts | 21 - frontend/src/utils/storage.ts | 31 -- infra/main.tf | 44 +- infra/main.tfvars.json | 1 + infra/modules/app-service/main.tf | 20 +- infra/modules/app-service/variables.tf | 25 ++ infra/modules/cosmos/main.tf | 101 +++++ infra/modules/cosmos/outputs.tf | 24 ++ infra/modules/cosmos/variables.tf | 49 +++ infra/outputs.tf | 10 + infra/variables.tf | 7 + main.py | 222 +++++++++- pyproject.toml | 6 + .../capture_cosmos_chat_pane_screenshots.py | 193 +++++++++ scripts/run_emulator_tests.sh | 45 ++ scripts/start_cosmos_emulator.sh | 102 +++++ session_orchestration.py | 141 ++++-- skills_manager.py | 10 +- .../contracts/conversations-api.md | 155 +++++++ .../contracts/cosmos-memory-integration.md | 116 +++++ specs/011-cosmos-agent-memory/data-model.md | 158 +++++++ specs/011-cosmos-agent-memory/plan.md | 149 +++++++ specs/011-cosmos-agent-memory/quickstart.md | 137 ++++++ specs/011-cosmos-agent-memory/research.md | 200 +++++++++ specs/011-cosmos-agent-memory/spec.md | 183 ++++++++ specs/011-cosmos-agent-memory/tasks.md | 267 ++++++++++++ streaming.py | 43 ++ tests/_doubles.py | 118 +++++ tests/conftest.py | 59 +++ tests/test_api.py | 16 +- tests/test_conversations_api.py | 161 +++++++ tests/test_cosmos_memory.py | 322 ++++++++++++++ tests/test_session_orchestration.py | 58 +++ tests/test_skills.py | 8 +- tests/test_skills_manager.py | 3 +- tests/test_user_data.py | 111 +++++ tests/test_user_profile.py | 106 +++-- tests/test_validators.py | 5 +- tools.py | 47 +- user_data.py | 146 +++++++ uv.lock | 34 +- validators.py | 8 +- 59 files changed, 4305 insertions(+), 737 deletions(-) create mode 100644 .env.example delete mode 100644 .envexample create mode 100644 cosmos_memory.py delete mode 100644 frontend/src/hooks/useConversationPersistence.ts delete mode 100644 frontend/src/hooks/useUserProfile.ts delete mode 100644 frontend/src/utils/storage.ts create mode 100644 infra/modules/cosmos/main.tf create mode 100644 infra/modules/cosmos/outputs.tf create mode 100644 infra/modules/cosmos/variables.tf create mode 100644 scripts/capture_cosmos_chat_pane_screenshots.py create mode 100755 scripts/run_emulator_tests.sh create mode 100755 scripts/start_cosmos_emulator.sh create mode 100644 specs/011-cosmos-agent-memory/contracts/conversations-api.md create mode 100644 specs/011-cosmos-agent-memory/contracts/cosmos-memory-integration.md create mode 100644 specs/011-cosmos-agent-memory/data-model.md create mode 100644 specs/011-cosmos-agent-memory/plan.md create mode 100644 specs/011-cosmos-agent-memory/quickstart.md create mode 100644 specs/011-cosmos-agent-memory/research.md create mode 100644 specs/011-cosmos-agent-memory/spec.md create mode 100644 specs/011-cosmos-agent-memory/tasks.md create mode 100644 tests/_doubles.py create mode 100644 tests/test_conversations_api.py create mode 100644 tests/test_cosmos_memory.py create mode 100644 tests/test_user_data.py create mode 100644 user_data.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f7e201 --- /dev/null +++ b/.env.example @@ -0,0 +1,164 @@ +# ============================================================================= +# Web-Agents environment configuration (EXAMPLE) +# +# Copy this file to `.env` and fill in the values: +# cp .env.example .env +# +# This single root `.env` is shared by BOTH the Python backend (loaded via +# python-dotenv) and the Vite frontend (loaded with envDir: '..'). Never commit +# the real `.env` — it is gitignored. Replace every placeholder below; values +# shown are examples, not real credentials. +# +# Legend: [REQUIRED] must be set [OPTIONAL] has a safe default +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# Primary LLM — Azure OpenAI [REQUIRED] +# ----------------------------------------------------------------------------- +# The SDK auto-detects the provider from these variables. For Azure OpenAI set +# the four AZURE_OPENAI_* values below. AZURE_OPENAI_MODEL is the *deployment* +# name. Leave AZURE_OPENAI_API_KEY empty to use managed identity (e.g. in Azure). +AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.us/" +AZURE_OPENAI_MODEL="gpt-4o" +AZURE_OPENAI_API_KEY="your-azure-openai-api-key" +AZURE_OPENAI_API_VERSION="2025-01-01-preview" + +# --- Alternative: OpenAI-compatible provider (e.g. Ollama, OpenAI) ----------- +# Use these INSTEAD of the AZURE_OPENAI_* block above for a non-Azure endpoint. +# OPENAI_API_KEY="ollama" +# OPENAI_BASE_URL="http://localhost:11434/v1" +# OPENAI_MODEL="llama3" + +# --- LLM tuning -------------------------------------------------------------- +# LLM_TEMPERATURE — default sampling temperature, clamped to [0.0, 2.0]. +# LLM_TEMPERATURE=0.2 [OPTIONAL] + + +# ----------------------------------------------------------------------------- +# Secondary / summarizer LLM (failover + history compaction) [OPTIONAL] +# ----------------------------------------------------------------------------- +# A dedicated Azure OpenAI deployment used for summarization and as a fallback +# when the primary returns 429/throttling. If unset, the primary client is used. +# AZURE_OPENAI_SECONDARY_ENDPOINT="https://your-secondary.openai.azure.com/" +# AZURE_OPENAI_SECONDARY_MODEL="gpt-4o-mini" +# AZURE_OPENAI_SECONDARY_API_KEY="your-secondary-api-key" +# AZURE_OPENAI_SECONDARY_API_VERSION="2024-02-15-preview" + + +# ----------------------------------------------------------------------------- +# Azure Cosmos DB — durable per-user chat memory [REQUIRED] +# ----------------------------------------------------------------------------- +# The app FAILS TO START without AZURE_COSMOS_ENDPOINT (there is no in-memory +# fallback). Locally, run the emulator (see the next section). In Azure, point +# this at the deployed account and leave AZURE_COSMOS_KEY empty to use managed +# identity (RBAC: Cosmos DB Built-in Data Contributor). +AZURE_COSMOS_ENDPOINT="https://localhost:8081/" + +# AZURE_COSMOS_KEY — account key. Only for local/emulator or non-MI scenarios. +# For the local emulator you can leave this UNSET: the app automatically uses +# the emulator's public well-known key. For a real account, prefer managed +# identity (leave empty). +# AZURE_COSMOS_KEY="your-cosmos-account-key" + +# Container/database names (defaults shown). [OPTIONAL] +# AZURE_COSMOS_DATABASE_NAME="agent-memory" +# AZURE_COSMOS_CONTAINER_NAME="chat-history" +# AZURE_COSMOS_CONVERSATIONS_CONTAINER="conversations" + +# AZURE_AUTHORITY_HOST — needed by managed identity in Azure Government. +# AZURE_AUTHORITY_HOST="https://login.microsoftonline.us" [OPTIONAL] + + +# ----------------------------------------------------------------------------- +# Cosmos DB Emulator (local development only) [OPTIONAL] +# ----------------------------------------------------------------------------- +# When USE_COSMOS_EMULATOR is truthy (true/1/yes/on), the "Start Cosmos +# Emulator" task / script launches the emulator container before the backend. +USE_COSMOS_EMULATOR=true +# COSMOS_EMULATOR_IMAGE="mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest" +# AZURE_COSMOS_EMULATOR_PARTITION_COUNT=3 +# The classic emulator advertises its container IP (e.g. 172.17.0.2). If the +# host cannot reach it, set a host-reachable IP here or use the vnext-preview +# image. (The app also disables endpoint discovery for localhost.) +# AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE="" +# AZURE_COSMOS_EMULATOR_ENDPOINT="https://localhost:8081/" # used by emulator tests + + +# ----------------------------------------------------------------------------- +# Authentication — Microsoft Entra ID (Azure AD) [REQUIRED in prod] +# ----------------------------------------------------------------------------- +# Shared by backend (token validation) and frontend (MSAL sign-in). The +# OAUTH_AZURE_GOV_AD_* names take precedence; AZURE_AD_* are accepted fallbacks. +OAUTH_AZURE_GOV_AD_TENANT_ID="00000000-0000-0000-0000-000000000000" +OAUTH_AZURE_GOV_AD_CLIENT_ID="00000000-0000-0000-0000-000000000000" +# AZURE_AD_TENANT_ID="" # fallback if OAUTH_AZURE_GOV_AD_TENANT_ID is unset +# AZURE_AD_CLIENT_ID="" # fallback if OAUTH_AZURE_GOV_AD_CLIENT_ID is unset + +# Authority base URL. Gov default shown; commercial = https://login.microsoftonline.com +# AZURE_AD_AUTHORITY="https://login.microsoftonline.us" [OPTIONAL] + +# AZURE_AD_CLIENT_SECRET — only required for MCP servers using on-behalf-of +# (OBO) token exchange. Backend-only; never exposed to the frontend. +# AZURE_AD_CLIENT_SECRET="your-client-secret" [OPTIONAL] + +# Disable authentication entirely — LOCAL DEVELOPMENT ONLY. Never in production. +# AUTH_DISABLED=true [OPTIONAL] + + +# ----------------------------------------------------------------------------- +# Azure AI Search — retrieval / "search context" provider [OPTIONAL] +# ----------------------------------------------------------------------------- +# Enables the search context provider and SQL/search tools. If unset, search +# context is reported as unavailable. Leave SEARCH_API_KEY empty for managed identity. +# SEARCH_SERVICE_ENDPOINT="https://your-search-service.search.azure.us" +# SEARCH_INDEX_NAME="your-index-name" +# SEARCH_API_KEY="your-search-api-key" +# SEARCH_TOP_K=5 +# SEARCH_SEMANTIC_CONFIGURATION_NAME="your-semantic-config" + + +# ----------------------------------------------------------------------------- +# Azure SQL tool (optional database query tool) [OPTIONAL] +# ----------------------------------------------------------------------------- +# Use Authentication=ActiveDirectoryMsi (App Service) or ActiveDirectoryDefault +# / ActiveDirectoryInteractive (local). Requires ODBC Driver 18 for SQL Server. +# AZURE_SQL_CONNECTIONSTRING="Driver={ODBC Driver 18 for SQL Server};Server=tcp:your-server.database.usgovcloudapi.net,1433;Database=your-db;Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;Authentication=ActiveDirectoryDefault" + + +# ----------------------------------------------------------------------------- +# Branding / UI (defaults shown) [OPTIONAL] +# ----------------------------------------------------------------------------- +# APP_NAME="Web-Agents" +# APP_TAGLINE="AI Agent Framework" +# APP_LOGO="/Microsoft.png" +# CLASSIFICATION_BANNER="UNCLASSIFIED" + + +# ----------------------------------------------------------------------------- +# Limits & tuning (defaults shown) [OPTIONAL] +# ----------------------------------------------------------------------------- +# MAX_USER_INPUT_CHARS=8000 # max characters accepted per user message +# MAX_SESSIONS=5 # frontend: conversations kept in the left pane +# MAX_QUERY_RESULT_ROWS=100 # SQL tool: max rows returned +# MAX_QUERY_RESULT_CHARS=12000 # SQL tool: max chars in a result payload +# MAX_SQL_CELL_CHARS=1200 # SQL tool: max chars per cell +# MAX_LOG_QUERY_CHARS=500 # logging: truncate logged SQL +# MAX_LOG_TOOL_RESULT_CHARS=100 # logging: truncate logged tool results +# MAX_SEARCH_SNIPPET_CHARS=500 # search: max chars per snippet + + +# ----------------------------------------------------------------------------- +# Evaluation tracing [OPTIONAL] +# ----------------------------------------------------------------------------- +# Directory where per-day eval trace JSONL files are written. +# EVAL_TRACE_DIR="eval_traces" + + +# ----------------------------------------------------------------------------- +# Frontend-only overrides (Vite) [OPTIONAL] +# ----------------------------------------------------------------------------- +# At runtime the frontend fetches config from the backend (/api/auth/config), +# so the auth/branding values above are normally sufficient. This VITE_ var is +# the only one read directly from import.meta.env as a build-time fallback. +# VITE_AZURE_AD_AUTHORITY="https://login.microsoftonline.us" diff --git a/.envexample b/.envexample deleted file mode 100644 index b2cbc37..0000000 --- a/.envexample +++ /dev/null @@ -1,72 +0,0 @@ -# ── Primary LLM Configuration ────────────────────────────────────────────────── -# Provider is auto-detected from environment variables: -# 1. AZURE_OPENAI_ENDPOINT set → Azure OpenAI -# 2. OPENAI_API_KEY set → OpenAI-compatible (Ollama, local models, etc.) -# 3. Both set → Azure takes priority -# -# === Option A: Azure OpenAI (default for production) === -AZURE_OPENAI_ENDPOINT="https://{your-custom-endpoint}.openai.azure.us/" -AZURE_OPENAI_MODEL="your-deployment-name" -AZURE_OPENAI_API_KEY="your-api-key" -AZURE_OPENAI_API_VERSION="2025-01-01-preview" -# -# === Option B: OpenAI-compatible (Ollama, vLLM, LM Studio, etc.) === -# Uncomment these and comment out the AZURE_OPENAI_* vars above: -# OPENAI_BASE_URL="http://localhost:11434/v1" -# OPENAI_MODEL="llama3" -# OPENAI_API_KEY="ollama" - -# ── Secondary/Fallback LLM (used for conversation summarization) ────────────── -AZURE_OPENAI_SECONDARY_ENDPOINT="https://{your-secondary-endpoint}.openai.azure.us/" -AZURE_OPENAI_SECONDARY_MODEL="your-secondary-deployment-name" -AZURE_OPENAI_SECONDARY_API_KEY="your-secondary-api-key" -AZURE_OPENAI_SECONDARY_API_VERSION="2025-01-01-preview" - -# ── LLM Tuning (optional, defaults shown) ──────────────────────────────────── -# LLM_TEMPERATURE=0.2 - -# ── Azure SQL / Synapse ─────────────────────────────────────────────────────── -# For deployment as an app service, use Managed Identity authentication: -# AZURE_SQL_CONNECTIONSTRING="Driver={ODBC Driver 18 for SQL Server};Server=tcp:.database.usgovcloudapi.net,1433;Database=;Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;Authentication=ActiveDirectoryMsi" -# -# For app service with User Assigned Identity (include UID): -# AZURE_SQL_CONNECTIONSTRING="Driver={ODBC Driver 18 for SQL Server};Server=tcp:.database.usgovcloudapi.net,1433;Database=;Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;Authentication=ActiveDirectoryMsi;UID=" -# -# For local development, use ActiveDirectoryInteractive: -AZURE_SQL_CONNECTIONSTRING="Driver={ODBC Driver 18 for SQL Server};Server=tcp:.database.usgovcloudapi.net,1433;Database=;Encrypt=yes;Uid=;TrustServerCertificate=no;Connection Timeout=30;Authentication=ActiveDirectoryInteractive" - -# ── Azure AI Search ─────────────────────────────────────────────────────────── -SEARCH_SERVICE_ENDPOINT="https://{your-custom-endpoint}.search.azure.us" -SEARCH_INDEX_NAME="{your-index-name}" -SEARCH_API_KEY="{your-query-key}" - -# ── Authentication ──────────────────────────────────────────────────────────── -# Set AUTH_DISABLED=true for local development (bypasses Azure AD token validation) -AUTH_DISABLED="true" -# Azure AD / OAuth (required when AUTH_DISABLED is not true) -OAUTH_AZURE_GOV_AD_CLIENT_ID="your-client-id" -OAUTH_AZURE_GOV_AD_TENANT_ID="your-tenant-id" -# AZURE_AD_AUTHORITY="https://login.microsoftonline.us" - -# ── UI Branding (optional, defaults shown) ──────────────────────────────────── -# APP_NAME="Web-Agents" -# APP_TAGLINE="AI Agent Framework" -# CLASSIFICATION_BANNER="UNCLASSIFIED" - -# ── Configurable Limits (optional, defaults shown) ──────────────────────────── -# MAX_USER_INPUT_CHARS=8000 -# MAX_QUERY_RESULT_ROWS=100 -# MAX_QUERY_RESULT_CHARS=12000 -# MAX_SQL_CELL_CHARS=1200 -# MAX_LOG_QUERY_CHARS=500 -# MAX_LOG_TOOL_RESULT_CHARS=100 -# MAX_SEARCH_SNIPPET_CHARS=500 - -# ── Evaluation Pipeline (optional) ─────────────────────────────────────────── -# ENABLE_EVAL_TRACE_LOGGING=false -# EVAL_TRACE_OUTPUT_PATH=eval/traces/agent_runs.jsonl - -# ── PowerBI Report Deep-Link (optional) ────────────────────────────────────── -# Report definitions (IDs, page IDs, filters) live in config/powerbi/reports.txt. -# Set this env var to override the base URL in that file (e.g., per-environment workspace). -# POWERBI_REPORT_BASE_URL="https://app.powerbigov.us/groups/{workspace-id}/reports/" \ No newline at end of file diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 9426314..6ffe190 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -28,6 +28,8 @@ - `config/agents.yaml` (built-in), localStorage (custom agents) (009-agents-page-grouping) - TypeScript 5.9.x and React 19.x for frontend; Python 3.12.6/FastAPI backend unchanged + React, Vite, existing `useTheme` and `useAuth` hooks; no new dependencies (010-admin-settings-page) - Existing localStorage key `webagents_theme`; no backend storage changes (010-admin-settings-page) +- Python 3.12.6 (FastAPI backend); TypeScript 5.9.x + React 19.x (frontend) + `agent-framework-core`/`agent-framework-openai` (existing); NEW `agent-framework-azure-cosmos` (provides `CosmosHistoryProvider`, re-exported as `agent_framework.azure.CosmosHistoryProvider`); `azure-cosmos` (async SDK, pulled in by the provider); `azure-identity` (`DefaultAzureCredential`, already used) (011-cosmos-agent-memory) +- Azure Cosmos DB for NoSQL — one database, two containers: `chat-history` (messages, partition key `/session_id`, managed by `CosmosHistoryProvider`) and `conversations` (per-user index, partition key `/user_id`, managed by new backend code). Local dev/tests use a Cosmos key/emulator or an in-memory fallback (011-cosmos-agent-memory) ## Project Structure @@ -60,6 +62,6 @@ uv run pytest # Run tests - Package manager: uv only (never pip) ## Recent Changes +- 011-cosmos-agent-memory: Added Python 3.12.6 (FastAPI backend); TypeScript 5.9.x + React 19.x (frontend) + `agent-framework-core`/`agent-framework-openai` (existing); NEW `agent-framework-azure-cosmos` (provides `CosmosHistoryProvider`, re-exported as `agent_framework.azure.CosmosHistoryProvider`); `azure-cosmos` (async SDK, pulled in by the provider); `azure-identity` (`DefaultAzureCredential`, already used) - 010-admin-settings-page: Added TypeScript 5.9.x and React 19.x for frontend; Python 3.12.6/FastAPI backend unchanged + React, Vite, existing `useTheme` and `useAuth` hooks; no new dependencies - 009-agents-page-grouping: Added Python 3.12+ (backend), TypeScript (frontend) + FastAPI (backend), React (frontend), Vite (bundler) -- 008-agents-as-tools: Added Python 3.12+ (backend), TypeScript / React 18 (frontend) + FastAPI, `agent-framework-core` (`Agent.as_tool()`), `agent-framework-azure-ai-search`, OpenAIChatClient (Azure OpenAI Government endpoint), React, Vite diff --git a/.gitignore b/.gitignore index 3a352d0..b044899 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ venv/ .env .env* +!.env.example *.log .ruff* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index beb48e4..67e75f9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,6 +11,19 @@ "group": "build", "problemMatcher": [] }, + { + "label": "Start Cosmos Emulator", + "type": "shell", + "command": "bash scripts/start_cosmos_emulator.sh", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "silent", + "panel": "shared" + } + }, { "label": "Run Backend", "type": "shell", @@ -20,7 +33,10 @@ }, "isBackground": true, "problemMatcher": [], - "dependsOn": "Build Frontend" + "dependsOn": [ + "Build Frontend", + "Start Cosmos Emulator" + ] }, { "label": "Build & Run", diff --git a/agent_factory.py b/agent_factory.py index 13d0538..f3a730f 100644 --- a/agent_factory.py +++ b/agent_factory.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, Sequence -from agent_framework import CompactionProvider, InMemoryHistoryProvider, SkillsProvider +from agent_framework import CompactionProvider, SkillsProvider from agent_framework import Agent as RuntimeAgent from agent_framework._compaction import ( CharacterEstimatorTokenizer, @@ -28,6 +28,7 @@ ) from mcp_servers import get_search_context_provider from sub_agent_tools import derive_sub_agent_tool_surface, disambiguate_tool_names +from cosmos_memory import get_history_provider @dataclass(frozen=True) @@ -92,24 +93,14 @@ def _build_skills_provider( if not skill_names or not _SKILLS_DIR.is_dir(): return None - provider = SkillsProvider(skill_paths=_SKILLS_DIR) + from agent_framework import FileSkillsSource, FilteringSkillsSource - # Filter to only the requested skills - discovered = set(provider._skills.keys()) - requested = set(skill_names) - missing = requested - discovered - if missing: - logger.warning("Requested skills not found in %s: %s", _SKILLS_DIR, missing) - - # Remove skills that weren't requested - to_remove = discovered - requested - for name in to_remove: - del provider._skills[name] - - if not provider._skills: - return None - - return provider + selected = set(skill_names) + source = FilteringSkillsSource( + FileSkillsSource(_SKILLS_DIR), + predicate=lambda skill: skill.frontmatter.name in selected, + ) + return SkillsProvider(source) def _get_default_temperature() -> float: @@ -145,7 +136,7 @@ def _build_context_providers( ], ) - history = InMemoryHistoryProvider(skip_excluded=True) + history = get_history_provider() compaction = CompactionProvider( before_strategy=pipeline, after_strategy=pipeline, diff --git a/cosmos_memory.py b/cosmos_memory.py new file mode 100644 index 0000000..39f204b --- /dev/null +++ b/cosmos_memory.py @@ -0,0 +1,402 @@ +"""Azure Cosmos DB memory layer for agent chat history and conversation index. + +Provides two things: + +1. ``get_history_provider()`` — the Agent Framework ``HistoryProvider`` used by + ``agent_factory`` for durable per-session message memory. Returns a + ``CosmosHistoryProvider``; Cosmos is required (the emulator locally, a real + account when deployed) and there is no runtime fallback. + +2. ``get_conversation_repository()`` — a per-user conversation *index* (the data + that powers the left chat pane and enforces ownership), backed by a Cosmos + container partitioned by ``/user_id``. + +Tests run against the emulator; where they need a double they monkeypatch the +module-level ``_history_provider`` / ``_conversation_repo`` singletons (see +``tests/_doubles.py``). + +Credentials: a Cosmos account key (``AZURE_COSMOS_KEY``) is used for local / +emulator development only; production uses ``DefaultAzureCredential`` (managed +identity) and Azure Government endpoints. Keys are never logged. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Protocol +from urllib.parse import urlsplit + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +def cosmos_config_summary() -> dict[str, str]: + """Non-secret Cosmos settings for startup logging (never the account key).""" + endpoint = (os.getenv("AZURE_COSMOS_ENDPOINT") or "").strip() + host = urlsplit(endpoint).hostname or "" + has_key = bool((os.getenv("AZURE_COSMOS_KEY") or "").strip()) + return { + "endpoint": endpoint, + "database": (os.getenv("AZURE_COSMOS_DATABASE_NAME") or "agent-memory").strip(), + "messages_container": (os.getenv("AZURE_COSMOS_CONTAINER_NAME") or "chat-history").strip(), + "conversations_container": (os.getenv("AZURE_COSMOS_CONVERSATIONS_CONTAINER") or "conversations").strip(), + "auth": "key" if has_key or host in ("localhost", "127.0.0.1") else "managed-identity", + } + + +# Public, fixed master key the Azure Cosmos DB Emulator accepts (NOT a secret — +# Microsoft documents it). The emulator rejects AAD tokens, so the local client +# uses this key unless AZURE_COSMOS_KEY is set. +_EMULATOR_WELL_KNOWN_KEY = ( + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" +) + + +# --------------------------------------------------------------------------- +# Conversation index record +# --------------------------------------------------------------------------- + +@dataclass +class ConversationRecord: + """One conversation-index entry (partitioned by ``user_id`` in Cosmos).""" + + id: str + user_id: str + profile_id: str + profile_name: str + title: str = "" + created_at: str = "" + last_activity_at: str = "" + custom_agent_id: str | None = None + used_builtin_override: bool = False + base_profile_id: str | None = None + override_updated_at: str | None = None + + def to_doc(self) -> dict[str, Any]: + return { + "id": self.id, + "user_id": self.user_id, + "profile_id": self.profile_id, + "profile_name": self.profile_name, + "title": self.title, + "created_at": self.created_at, + "last_activity_at": self.last_activity_at, + "custom_agent_id": self.custom_agent_id, + "used_builtin_override": self.used_builtin_override, + "base_profile_id": self.base_profile_id, + "override_updated_at": self.override_updated_at, + "doc_type": "conversation", + "schema_version": 1, + } + + @classmethod + def from_doc(cls, doc: dict[str, Any]) -> "ConversationRecord": + return cls( + id=str(doc["id"]), + user_id=str(doc.get("user_id", "")), + profile_id=str(doc.get("profile_id", "")), + profile_name=str(doc.get("profile_name", "")), + title=str(doc.get("title", "")), + created_at=str(doc.get("created_at", "")), + last_activity_at=str(doc.get("last_activity_at", "")), + custom_agent_id=doc.get("custom_agent_id"), + used_builtin_override=bool(doc.get("used_builtin_override", False)), + base_profile_id=doc.get("base_profile_id"), + override_updated_at=doc.get("override_updated_at"), + ) + + def to_wire(self) -> dict[str, Any]: + """Shape consumed by the frontend ``ConversationIndexEntry``.""" + return { + "id": self.id, + "profileId": self.profile_id, + "profileName": self.profile_name, + "description": self.title, + "createdAt": self.created_at, + "lastActivityAt": self.last_activity_at, + "customAgentId": self.custom_agent_id, + "usedBuiltInOverride": self.used_builtin_override, + "baseProfileId": self.base_profile_id, + "overrideUpdatedAt": self.override_updated_at, + } + + +# --------------------------------------------------------------------------- +# Conversation index repository +# --------------------------------------------------------------------------- + +class ConversationIndexRepository(Protocol): + """Per-user conversation index. All methods are partition-scoped by user_id.""" + + async def create( + self, + user_id: str, + conversation_id: str, + profile_id: str, + profile_name: str, + *, + custom_agent_id: str | None = None, + used_builtin_override: bool = False, + base_profile_id: str | None = None, + override_updated_at: str | None = None, + ) -> ConversationRecord: ... + + async def list_for_user( + self, user_id: str, *, limit: int = 50, cursor: str | None = None + ) -> tuple[list[ConversationRecord], str | None]: ... + + async def get_owned(self, user_id: str, conversation_id: str) -> ConversationRecord | None: ... + + async def touch(self, user_id: str, conversation_id: str, *, title: str | None = None) -> None: ... + + async def delete(self, user_id: str, conversation_id: str) -> bool: ... + + +class CosmosConversationRepository: + """Cosmos-backed conversation index, partitioned by ``/user_id``.""" + + def __init__(self, client: Any, database_name: str, container_name: str) -> None: + self._client = client + self._database_name = database_name + self._container_name = container_name + self._container: Any = None + + async def _get_container(self) -> Any: + if self._container is None: + from azure.cosmos import PartitionKey + + database = await self._client.create_database_if_not_exists(self._database_name) + self._container = await database.create_container_if_not_exists( + id=self._container_name, + partition_key=PartitionKey(path="/user_id"), + ) + return self._container + + async def create( + self, + user_id: str, + conversation_id: str, + profile_id: str, + profile_name: str, + *, + custom_agent_id: str | None = None, + used_builtin_override: bool = False, + base_profile_id: str | None = None, + override_updated_at: str | None = None, + ) -> ConversationRecord: + now = datetime.now(timezone.utc).isoformat() + record = ConversationRecord( + id=conversation_id, + user_id=user_id, + profile_id=profile_id, + profile_name=profile_name, + title="", + created_at=now, + last_activity_at=now, + custom_agent_id=custom_agent_id, + used_builtin_override=used_builtin_override, + base_profile_id=base_profile_id, + override_updated_at=override_updated_at, + ) + container = await self._get_container() + await container.upsert_item(record.to_doc()) + return record + + async def list_for_user( + self, user_id: str, *, limit: int = 50, cursor: str | None = None + ) -> tuple[list[ConversationRecord], str | None]: + container = await self._get_container() + query = ( + "SELECT * FROM c WHERE c.user_id = @uid " + "ORDER BY c.last_activity_at DESC" + ) + parameters = [{"name": "@uid", "value": user_id}] + records: list[ConversationRecord] = [] + items = container.query_items( + query=query, + parameters=parameters, + partition_key=user_id, + ) + async for item in items: + records.append(ConversationRecord.from_doc(item)) + if len(records) >= max(0, limit): + break + return records, None + + async def get_owned(self, user_id: str, conversation_id: str) -> ConversationRecord | None: + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + container = await self._get_container() + try: + item = await container.read_item(item=conversation_id, partition_key=user_id) + except CosmosResourceNotFoundError: + return None + return ConversationRecord.from_doc(item) + + async def touch(self, user_id: str, conversation_id: str, *, title: str | None = None) -> None: + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + container = await self._get_container() + try: + item = await container.read_item(item=conversation_id, partition_key=user_id) + except CosmosResourceNotFoundError: + return + item["last_activity_at"] = datetime.now(timezone.utc).isoformat() + if title and not item.get("title"): + item["title"] = title + await container.upsert_item(item) + + async def delete(self, user_id: str, conversation_id: str) -> bool: + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + container = await self._get_container() + try: + await container.delete_item(item=conversation_id, partition_key=user_id) + return True + except CosmosResourceNotFoundError: + return False + + +# --------------------------------------------------------------------------- +# Shared Cosmos client + provider/repository singletons +# --------------------------------------------------------------------------- + +_cosmos_client: Any = None +_async_credential: Any = None +_history_provider: Any = None +_conversation_repo: Any = None + + +def _build_cosmos_client() -> Any: + """Create a single shared async CosmosClient (key for local, MI for prod).""" + from azure.cosmos.aio import CosmosClient + + endpoint = (os.getenv("AZURE_COSMOS_ENDPOINT") or "").strip() + key = (os.getenv("AZURE_COSMOS_KEY") or "").strip() or None + host = urlsplit(endpoint).hostname or "" + kwargs: dict[str, Any] = {} + if host in ("localhost", "127.0.0.1"): + # Emulator: self-signed cert, rejects AAD tokens (use its well-known key), + # and advertises its internal container IP — so pin the client to our + # endpoint by disabling endpoint discovery. + kwargs["connection_verify"] = False + kwargs["enable_endpoint_discovery"] = False + if not key: + key = _EMULATOR_WELL_KNOWN_KEY + + global _async_credential + if key: + credential: Any = key + else: + from azure.identity.aio import DefaultAzureCredential + + # DefaultAzureCredential honours AZURE_AUTHORITY_HOST for Azure Government. + _async_credential = DefaultAzureCredential() + credential = _async_credential + + logger.info( + "Cosmos client created (host=%s, auth=%s)", + host, + "key" if key else "managed-identity", + ) + return CosmosClient(url=endpoint, credential=credential, **kwargs) + + +def get_cosmos_client() -> Any: + """Return the lazily-created, shared async CosmosClient. + + Raises if Cosmos is unconfigured. Shared with the per-user repositories in + ``user_data`` so the whole app uses a single client / connection pool. + """ + global _cosmos_client + _require_cosmos() + if _cosmos_client is None: + _cosmos_client = _build_cosmos_client() + return _cosmos_client + + +def _require_cosmos() -> None: + """Raise unless Cosmos is configured. There is no in-memory fallback by design.""" + if not (os.getenv("AZURE_COSMOS_ENDPOINT") or "").strip(): + raise RuntimeError( + "Azure Cosmos DB is required but not configured (AZURE_COSMOS_ENDPOINT is unset). " + "Run the Cosmos emulator locally (USE_COSMOS_EMULATOR=true and " + "AZURE_COSMOS_ENDPOINT=https://localhost:8081/), or point AZURE_COSMOS_ENDPOINT at " + "the deployed Cosmos account. Chat memory has no non-durable fallback." + ) + + +def get_history_provider() -> Any: + """Return the cached Cosmos agent history provider. + + Requires Cosmos to be configured (emulator locally, real account when + deployed); raises otherwise. + """ + global _history_provider + if _history_provider is not None: + return _history_provider + + from agent_framework.azure import CosmosHistoryProvider + + db = (os.getenv("AZURE_COSMOS_DATABASE_NAME") or "agent-memory").strip() + container = (os.getenv("AZURE_COSMOS_CONTAINER_NAME") or "chat-history").strip() + _history_provider = CosmosHistoryProvider( + cosmos_client=get_cosmos_client(), + database_name=db, + container_name=container, + ) + logger.info( + "Durable Cosmos history provider enabled (db=%s, container=%s)", + db, + container, + ) + return _history_provider + + +def get_conversation_repository() -> Any: + """Return the cached Cosmos conversation-index repository. + + Requires Cosmos to be configured; raises otherwise. + """ + global _conversation_repo + if _conversation_repo is not None: + return _conversation_repo + + _conversation_repo = CosmosConversationRepository( + get_cosmos_client(), + (os.getenv("AZURE_COSMOS_DATABASE_NAME") or "agent-memory").strip(), + (os.getenv("AZURE_COSMOS_CONVERSATIONS_CONTAINER") or "conversations").strip(), + ) + return _conversation_repo + + +def require_cosmos_configured() -> None: + """Fail fast at startup unless Cosmos is configured (or a double was injected for tests).""" + if _history_provider is not None or _conversation_repo is not None: + return + _require_cosmos() + + +async def close_cosmos() -> None: + """Close the shared Cosmos client + credential (called on app shutdown).""" + global _cosmos_client, _async_credential, _history_provider, _conversation_repo + if _cosmos_client is not None: + try: + await _cosmos_client.close() + except Exception: # noqa: BLE001 — shutdown best-effort + logger.debug("Error closing Cosmos client", exc_info=True) + if _async_credential is not None: + try: + await _async_credential.close() + except Exception: # noqa: BLE001 — shutdown best-effort + logger.debug("Error closing Cosmos credential", exc_info=True) + _cosmos_client = None + _async_credential = None + _history_provider = None + _conversation_repo = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78da3b6..0ac626b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ function AppContent() { save: saveBuiltInOverride, remove: resetBuiltInOverride, } = useBuiltInAgentCustomizations() - const { loadIndex, deleteConversationsByCustomAgent } = useConversationStore() + const { deleteConversationsByCustomAgent } = useConversationStore() // Initialise history state so the back button can return here from admin. useEffect(() => { @@ -50,8 +50,7 @@ function AppContent() { const handleDeleteAgent = (id: string) => { removeCustomAgent(id) - deleteConversationsByCustomAgent(id) - loadIndex() + void deleteConversationsByCustomAgent(id) } if (isLoading) { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d7ffa0e..b45e2f2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,9 @@ import type { AgentProfile, + AgentCustomizationOverride, BuiltInAgentDefinition, + ChatMessage, + ConversationIndexEntry, CustomAgentDefinition, McpConnectionResult, McpServerEntry, @@ -127,12 +130,6 @@ export async function testMcpConnections(servers: McpServerEntry[]): Promise; - user_profile?: SessionUserProfilePayload; + conversation_id?: string; } export async function createSessionRequest( @@ -227,6 +223,99 @@ export async function fetchHistory(sessionId: string): Promise return resp.json(); } +// --- Conversations (durable per-user chat history, Cosmos-backed) --- + +export interface ConversationMessagesResponse { + id: string; + profileId: string; + profileName: string; + messages: ChatMessage[]; +} + +export async function listConversations(limit = 50): Promise { + const resp = await fetch(`${API_BASE}/conversations?limit=${limit}`, { + headers: getAuthHeaders(), + }); + assertNotUnauthorized(resp, 'Failed to load conversations'); + if (!resp.ok) await handleHttpError(resp, 'Failed to load conversations'); + const data = await resp.json(); + return (data.conversations ?? []) as ConversationIndexEntry[]; +} + +export async function getConversationMessages(id: string): Promise { + const resp = await fetch(`${API_BASE}/conversations/${encodeURIComponent(id)}/messages`, { + headers: getAuthHeaders(), + }); + assertNotUnauthorized(resp, 'Failed to load conversation'); + if (!resp.ok) await handleHttpError(resp, 'Failed to load conversation'); + return resp.json(); +} + +export async function deleteConversation(id: string): Promise { + const resp = await fetch(`${API_BASE}/conversations/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }); + assertNotUnauthorized(resp, 'Failed to delete conversation'); + if (!resp.ok && resp.status !== 404) await handleHttpError(resp, 'Failed to delete conversation'); +} + +// --- Custom agents, agent customizations, user profile (durable per-user, Cosmos) --- + +export async function listCustomAgents(): Promise { + const resp = await fetch(`${API_BASE}/custom-agents`, { headers: getAuthHeaders() }); + assertNotUnauthorized(resp, 'Failed to load custom agents'); + if (!resp.ok) await handleHttpError(resp, 'Failed to load custom agents'); + const data = await resp.json(); + return (data.agents ?? []) as CustomAgentDefinition[]; +} + +export async function saveCustomAgent(agent: CustomAgentDefinition): Promise { + const resp = await fetch(`${API_BASE}/custom-agents/${encodeURIComponent(agent.id)}`, { + method: 'PUT', + headers: jsonHeaders(), + body: JSON.stringify(agent), + }); + assertNotUnauthorized(resp, 'Failed to save custom agent'); + if (!resp.ok) await handleHttpError(resp, 'Failed to save custom agent'); +} + +export async function deleteCustomAgent(id: string): Promise { + const resp = await fetch(`${API_BASE}/custom-agents/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }); + assertNotUnauthorized(resp, 'Failed to delete custom agent'); + if (!resp.ok && resp.status !== 404) await handleHttpError(resp, 'Failed to delete custom agent'); +} + +export async function listAgentCustomizations(): Promise { + const resp = await fetch(`${API_BASE}/agent-customizations`, { headers: getAuthHeaders() }); + assertNotUnauthorized(resp, 'Failed to load agent customizations'); + if (!resp.ok) await handleHttpError(resp, 'Failed to load agent customizations'); + const data = await resp.json(); + return (data.overrides ?? []) as AgentCustomizationOverride[]; +} + +export async function saveAgentCustomization(override: AgentCustomizationOverride): Promise { + const resp = await fetch(`${API_BASE}/agent-customizations/${encodeURIComponent(override.baseProfileId)}`, { + method: 'PUT', + headers: jsonHeaders(), + body: JSON.stringify(override), + }); + assertNotUnauthorized(resp, 'Failed to save agent customization'); + if (!resp.ok) await handleHttpError(resp, 'Failed to save agent customization'); +} + +export async function deleteAgentCustomization(baseProfileId: string): Promise { + const resp = await fetch(`${API_BASE}/agent-customizations/${encodeURIComponent(baseProfileId)}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }); + assertNotUnauthorized(resp, 'Failed to delete agent customization'); + if (!resp.ok && resp.status !== 404) await handleHttpError(resp, 'Failed to delete agent customization'); +} + export interface SSECallback { onText?: (data: SSETextEvent) => void; onFunctionCall?: (data: SSEFunctionCallEvent) => void; @@ -244,10 +333,14 @@ export async function sendMessage( ): Promise { let body: BodyInit; const headers: Record = { ...getAuthHeaders() }; + // Send the user's local date/time so the backend can give the model temporal + // context. It is added to the model/session history only — never shown in the UI. + const clientTime = new Date().toString(); if (images && images.length > 0) { const formData = new FormData(); formData.append('content', content); + formData.append('client_time', clientTime); for (const img of images) { formData.append('images', img); } @@ -255,7 +348,7 @@ export async function sendMessage( // Let browser set Content-Type with boundary for multipart } else { headers['Content-Type'] = 'application/json'; - body = JSON.stringify({ content }); + body = JSON.stringify({ content, client_time: clientTime }); } const resp = await fetch( diff --git a/frontend/src/hooks/useBuiltInAgentCustomizations.ts b/frontend/src/hooks/useBuiltInAgentCustomizations.ts index 6b7cf13..a095234 100644 --- a/frontend/src/hooks/useBuiltInAgentCustomizations.ts +++ b/frontend/src/hooks/useBuiltInAgentCustomizations.ts @@ -1,57 +1,38 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { AgentCustomizationOverride } from '../types/api'; -import { readJson, writeJson } from '../utils/storage'; - -const STORAGE_KEY = 'webagents_builtin_agent_customizations'; - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === 'string'); -} - -function isOverride(value: unknown): value is AgentCustomizationOverride { - if (!value || typeof value !== 'object') return false; - const candidate = value as Record; - return ( - typeof candidate.id === 'string' && - typeof candidate.baseProfileId === 'string' && - typeof candidate.description === 'string' && - typeof candidate.systemPrompt === 'string' && - isStringArray(candidate.tools) && - isStringArray(candidate.skills) && - Array.isArray(candidate.mcpServers) && - typeof candidate.useSearchContext === 'boolean' && - typeof candidate.icon === 'string' && - Array.isArray(candidate.starters) && - candidate.source === 'builtin-override' && - typeof candidate.createdAt === 'string' && - typeof candidate.updatedAt === 'string' - ); -} - -function persistOverrides(overrides: AgentCustomizationOverride[]): void { - writeJson(STORAGE_KEY, overrides); -} - +import { + listAgentCustomizations, + saveAgentCustomization as saveAgentCustomizationApi, + deleteAgentCustomization as deleteAgentCustomizationApi, +} from '../api/client'; + +/** + * Server-backed built-in agent customizations (overrides). The authoritative + * store is Azure Cosmos DB (via the backend API); mutations update local state + * optimistically and persist in the background. + */ export function useBuiltInAgentCustomizations() { - const [overrides, setOverrides] = useState(() => - readJson(STORAGE_KEY, [], Array.isArray).filter(isOverride), - ); + const [overrides, setOverrides] = useState([]); + + useEffect(() => { + let cancelled = false; + void listAgentCustomizations() + .then((list) => { if (!cancelled) setOverrides(list); }) + .catch(() => { /* surfaced by the API layer */ }); + return () => { cancelled = true; }; + }, []); const save = useCallback((override: AgentCustomizationOverride) => { setOverrides((prev) => { const idx = prev.findIndex((item) => item.baseProfileId === override.baseProfileId); - const next = idx >= 0 ? prev.map((item, i) => (i === idx ? override : item)) : [...prev, override]; - persistOverrides(next); - return next; + return idx >= 0 ? prev.map((item, i) => (i === idx ? override : item)) : [...prev, override]; }); + void saveAgentCustomizationApi(override).catch(() => { /* surfaced by the API layer */ }); }, []); const remove = useCallback((baseProfileId: string) => { - setOverrides((prev) => { - const next = prev.filter((item) => item.baseProfileId !== baseProfileId); - persistOverrides(next); - return next; - }); + setOverrides((prev) => prev.filter((item) => item.baseProfileId !== baseProfileId)); + void deleteAgentCustomizationApi(baseProfileId).catch(() => { /* surfaced by the API layer */ }); }, []); const get = useCallback( diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts index 66836bc..81c1d29 100644 --- a/frontend/src/hooks/useChat.ts +++ b/frontend/src/hooks/useChat.ts @@ -3,8 +3,8 @@ import type { AgentProfile, ChatMessage, ChatSession, + ConversationIndexEntry, McpConnectionResult, - StoredConversation, ToolInvocation, UsageDetails, } from '../types/api'; @@ -13,8 +13,6 @@ import { AuthError, } from '../api/client'; import { emitToast } from './useToast'; -import { saveProfile } from './useUserProfile'; -import { useConversationPersistence } from './useConversationPersistence'; import { cleanupSession, emptyBuiltInOverride, emptyUsage, startChatSession } from './useSessionLifecycle'; interface ChatState { @@ -30,7 +28,7 @@ interface ChatState { error: string | null; conversationId: string | null; saveCounter: number; - startSession: (profile: AgentProfile, history?: StoredConversation) => Promise; + startSession: (profile: AgentProfile, resume?: ConversationIndexEntry) => Promise; endSession: () => Promise; send: (content: string, images?: File[]) => Promise; clearError: () => void; @@ -64,18 +62,14 @@ export function useChat(): ChatState { overrideUpdatedAt?: string; }>(emptyBuiltInOverride()); - const { persistLatestConversation, saveCurrentConversation } = useConversationPersistence({ - session, - messages, - createdAtRef, - conversationIdRef, - customAgentIdRef, - builtInOverrideRef, - }); + // History is persisted server-side (Cosmos); the client no longer saves + // conversations to browser storage. saveCurrentConversation is a no-op kept + // for call-site compatibility. + const saveCurrentConversation = useCallback(async () => {}, []); - const startSession = useCallback(async (profile: AgentProfile, history?: StoredConversation) => { + const startSession = useCallback(async (profile: AgentProfile, resume?: ConversationIndexEntry) => { try { - const next = await startChatSession(profile, history, session); + const next = await startChatSession(profile, resume, session); createdAtRef.current = next.createdAt; conversationIdRef.current = next.conversationId; customAgentIdRef.current = next.customAgentId; @@ -262,27 +256,11 @@ export function useChat(): ChatState { }, onDone: () => { setIsStreaming(false); - // Sync user profile if save_user_profile was called - const profileSave = toolsRef.current.find((t) => t.name === 'save_user_profile'); - if (profileSave && session) { - try { - const args = JSON.parse(profileSave.arguments); - saveProfile(session.profile_id, { - name: args.name ?? '', - preferences: args.preferences ?? '', - notes: args.notes ?? '', - updatedAt: new Date().toISOString(), - }); - } catch { /* best-effort */ } - } - // Auto-save conversation after response completes + // The backend already persisted this turn to Cosmos and updated the + // conversation index; bump the save counter so the sidebar reloads + // the server-sourced conversation list. if (session) { - setMessages((currentMessages) => { - persistLatestConversation(currentMessages) - .then(() => setSaveCounter((c) => c + 1)) - .catch(() => { /* persistence is best-effort */ }); - return currentMessages; - }); + setSaveCounter((c) => c + 1); } }, }, @@ -294,7 +272,7 @@ export function useChat(): ChatState { // Non-auth errors already emitted as toasts by client.ts setIsStreaming(false); } - }, [session, messages.length, persistLatestConversation]); + }, [session, messages.length]); const clearError = useCallback(() => setError(null), []); diff --git a/frontend/src/hooks/useConversationPersistence.ts b/frontend/src/hooks/useConversationPersistence.ts deleted file mode 100644 index 5ea8971..0000000 --- a/frontend/src/hooks/useConversationPersistence.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useCallback } from 'react'; -import type { MutableRefObject } from 'react'; -import type { ChatMessage, ChatSession, StoredConversation } from '../types/api'; -import { fetchHistory } from '../api/client'; -import { useConversationStore } from './useConversationStore'; -import type { BuiltInOverrideState } from './useSessionLifecycle'; - -interface ConversationPersistenceOptions { - session: ChatSession | null; - messages: ChatMessage[]; - createdAtRef: MutableRefObject; - conversationIdRef: MutableRefObject; - customAgentIdRef: MutableRefObject; - builtInOverrideRef: MutableRefObject; -} - -function conversationDescription(messages: ChatMessage[]): string { - const firstUserMessage = messages.find((message) => message.role === 'user'); - return firstUserMessage - ? firstUserMessage.content.slice(0, 60) + (firstUserMessage.content.length > 60 ? '...' : '') - : 'New conversation'; -} - -export function useConversationPersistence({ - session, - messages, - createdAtRef, - conversationIdRef, - customAgentIdRef, - builtInOverrideRef, -}: ConversationPersistenceOptions) { - const { saveConversation } = useConversationStore(); - - const persistConversationSnapshot = useCallback((currentMessages: ChatMessage[], sessionData: Record) => { - if (!session) return; - const now = new Date().toISOString(); - const conversation: StoredConversation = { - id: conversationIdRef.current || session.session_id, - profileId: session.profile_id, - profileName: session.profile_name, - description: conversationDescription(currentMessages), - createdAt: createdAtRef.current || now, - lastActivityAt: now, - sessionData, - ...(customAgentIdRef.current ? { customAgentId: customAgentIdRef.current } : {}), - ...(builtInOverrideRef.current.usedBuiltInOverride ? { - usedBuiltInOverride: true, - baseProfileId: builtInOverrideRef.current.baseProfileId, - overrideUpdatedAt: builtInOverrideRef.current.overrideUpdatedAt, - } : {}), - }; - saveConversation(conversation); - }, [builtInOverrideRef, conversationIdRef, createdAtRef, customAgentIdRef, saveConversation, session]); - - const saveCurrentConversation = useCallback(async () => { - if (!session) return; - try { - const historyResp = await fetchHistory(session.session_id); - persistConversationSnapshot(messages, historyResp.session_data); - } catch { - // Non-fatal: persistence is best-effort. - } - }, [messages, persistConversationSnapshot, session]); - - const persistLatestConversation = useCallback(async (currentMessages: ChatMessage[]) => { - if (!session) return; - const historyResp = await fetchHistory(session.session_id); - persistConversationSnapshot(currentMessages, historyResp.session_data); - }, [persistConversationSnapshot, session]); - - return { persistConversationSnapshot, persistLatestConversation, saveCurrentConversation } as const; -} \ No newline at end of file diff --git a/frontend/src/hooks/useConversationStore.ts b/frontend/src/hooks/useConversationStore.ts index a5a0f0f..d8d412e 100644 --- a/frontend/src/hooks/useConversationStore.ts +++ b/frontend/src/hooks/useConversationStore.ts @@ -1,123 +1,34 @@ import { useCallback } from 'react'; -import type { ConversationIndexEntry, StoredConversation } from '../types/api'; -import { readJson, removeStorageItem, tryWriteJson } from '../utils/storage'; - -declare const __MAX_SESSIONS__: string; - -const INDEX_KEY = 'webagents_conversation_index'; -const CONVERSATION_KEY_PREFIX = 'webagents_conversation_'; - -function getMaxSessions(): number { - return parseInt(__MAX_SESSIONS__, 10) || 5; -} - -function conversationKey(id: string): string { - return `${CONVERSATION_KEY_PREFIX}${id}`; -} - -function isConversationIndex(value: unknown): value is ConversationIndexEntry[] { - return Array.isArray(value) && value.every( - (entry) => entry && typeof entry.id === 'string' && typeof entry.description === 'string', - ); -} +import type { ConversationIndexEntry } from '../types/api'; +import { listConversations, deleteConversation as deleteConversationApi } from '../api/client'; +/** + * Server-backed conversation index. The authoritative store is Azure Cosmos DB + * (via the backend API) — conversations are no longer kept in browser storage. + */ export function useConversationStore() { - const loadIndex = useCallback((): ConversationIndexEntry[] => { - const entries = readJson(INDEX_KEY, [], isConversationIndex); - const max = getMaxSessions(); - if (entries.length > max) { - entries.sort((a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime()); - const evicted = entries.splice(max); - for (const entry of evicted) { - removeStorageItem(conversationKey(entry.id)); - } - tryWriteJson(INDEX_KEY, entries); + const loadIndex = useCallback(async (): Promise => { + try { + return await listConversations(); + } catch { + // Non-fatal: surfaced by the API layer; the pane shows an empty list. + return []; } - return entries; }, []); - const saveIndex = useCallback((index: ConversationIndexEntry[]) => { - tryWriteJson(INDEX_KEY, index, (error) => console.warn('Failed to save conversation index to localStorage:', error)); + const deleteConversation = useCallback(async (id: string): Promise => { + await deleteConversationApi(id); }, []); - const saveConversation = useCallback((conversation: StoredConversation) => { - const index = loadIndex(); - - // Update or add to index - const existing = index.findIndex((e) => e.id === conversation.id); - const entry: ConversationIndexEntry = { - id: conversation.id, - profileId: conversation.profileId, - profileName: conversation.profileName, - description: conversation.description, - createdAt: conversation.createdAt, - lastActivityAt: conversation.lastActivityAt, - ...(conversation.customAgentId ? { customAgentId: conversation.customAgentId } : {}), - ...(conversation.usedBuiltInOverride ? { - usedBuiltInOverride: true, - baseProfileId: conversation.baseProfileId, - overrideUpdatedAt: conversation.overrideUpdatedAt, - } : {}), - }; - - if (existing >= 0) { - index[existing] = entry; - } else { - index.unshift(entry); + const deleteConversationsByCustomAgent = useCallback(async (customAgentId: string): Promise => { + try { + const all = await listConversations(); + const targets = all.filter((entry) => entry.customAgentId === customAgentId); + await Promise.all(targets.map((entry) => deleteConversationApi(entry.id))); + } catch { + // Best-effort cleanup; individual deletes remain available to the user. } - - // Sort newest-first - index.sort((a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime()); - - // Evict oldest if over limit - const max = getMaxSessions(); - while (index.length > max) { - const evicted = index.pop(); - if (evicted) { - removeStorageItem(conversationKey(evicted.id)); - } - } - - saveIndex(index); - - // Save full conversation data - tryWriteJson( - conversationKey(conversation.id), - conversation, - (error) => console.warn('Failed to save conversation to localStorage:', error), - ); - }, [loadIndex, saveIndex]); - - const loadConversation = useCallback((id: string): StoredConversation | null => { - return readJson(conversationKey(id), null, (value): value is StoredConversation => ( - Boolean(value && typeof value === 'object' && typeof (value as StoredConversation).id === 'string' && (value as StoredConversation).sessionData) - )); }, []); - const deleteConversation = useCallback((id: string) => { - const index = loadIndex().filter((e) => e.id !== id); - saveIndex(index); - removeStorageItem(conversationKey(id)); - }, [loadIndex, saveIndex]); - - const deleteConversationsByCustomAgent = useCallback((customAgentId: string) => { - const index = loadIndex(); - const keep: ConversationIndexEntry[] = []; - for (const e of index) { - if (e.customAgentId === customAgentId) { - removeStorageItem(conversationKey(e.id)); - } else { - keep.push(e); - } - } - saveIndex(keep); - }, [loadIndex, saveIndex]); - - return { - loadIndex, - saveConversation, - loadConversation, - deleteConversation, - deleteConversationsByCustomAgent, - }; + return { loadIndex, deleteConversation, deleteConversationsByCustomAgent }; } diff --git a/frontend/src/hooks/useCustomAgents.ts b/frontend/src/hooks/useCustomAgents.ts index 884e5c4..92f247b 100644 --- a/frontend/src/hooks/useCustomAgents.ts +++ b/frontend/src/hooks/useCustomAgents.ts @@ -1,12 +1,10 @@ -import { useState, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import type { CustomAgentDefinition } from '../types/api'; -import { readJson, writeJson } from '../utils/storage'; - -const STORAGE_KEY = 'webagents_custom_agents'; - -function persistAgents(agents: CustomAgentDefinition[]): void { - writeJson(STORAGE_KEY, agents); -} +import { + listCustomAgents, + saveCustomAgent as saveCustomAgentApi, + deleteCustomAgent as deleteCustomAgentApi, +} from '../api/client'; /** Backward-compat shim: older entries stored without `agentsAsTools`. */ function normalizeAgent(agent: CustomAgentDefinition): CustomAgentDefinition { @@ -15,27 +13,34 @@ function normalizeAgent(agent: CustomAgentDefinition): CustomAgentDefinition { : { ...agent, agentsAsTools: [] }; } +/** + * Server-backed custom agents. The authoritative store is Azure Cosmos DB (via + * the backend API); mutations update local state optimistically and persist in + * the background. + */ export function useCustomAgents() { - const [agents, setAgents] = useState(() => - readJson(STORAGE_KEY, [], Array.isArray).map(normalizeAgent), - ); + const [agents, setAgents] = useState([]); + + useEffect(() => { + let cancelled = false; + void listCustomAgents() + .then((list) => { if (!cancelled) setAgents(list.map(normalizeAgent)); }) + .catch(() => { /* surfaced by the API layer */ }); + return () => { cancelled = true; }; + }, []); const save = useCallback((agent: CustomAgentDefinition) => { const normalized = normalizeAgent(agent); setAgents((prev) => { const idx = prev.findIndex((a) => a.id === normalized.id); - const next = idx >= 0 ? prev.map((a, i) => (i === idx ? normalized : a)) : [...prev, normalized]; - persistAgents(next); - return next; + return idx >= 0 ? prev.map((a, i) => (i === idx ? normalized : a)) : [...prev, normalized]; }); + void saveCustomAgentApi(normalized).catch(() => { /* surfaced by the API layer */ }); }, []); const remove = useCallback((id: string) => { - setAgents((prev) => { - const next = prev.filter((a) => a.id !== id); - persistAgents(next); - return next; - }); + setAgents((prev) => prev.filter((a) => a.id !== id)); + void deleteCustomAgentApi(id).catch(() => { /* surfaced by the API layer */ }); }, []); return { agents, save, remove } as const; diff --git a/frontend/src/hooks/useSessionLifecycle.ts b/frontend/src/hooks/useSessionLifecycle.ts index b30fe1f..ee1ad94 100644 --- a/frontend/src/hooks/useSessionLifecycle.ts +++ b/frontend/src/hooks/useSessionLifecycle.ts @@ -2,16 +2,12 @@ import type { AgentProfile, ChatMessage, ChatSession, + ConversationIndexEntry, McpConnectionResult, SessionCreateResponse, - UserMemoryProfile, - StoredConversation, - ToolInvocation, UsageDetails, } from '../types/api'; -import { createSessionRequest, deleteSession, type SessionRequestPayload } from '../api/client'; -import { convertFrameworkContentItems } from '../utils/content'; -import { loadProfile } from './useUserProfile'; +import { createSessionRequest, deleteSession, getConversationMessages, type SessionRequestPayload } from '../api/client'; export interface BuiltInOverrideState { usedBuiltInOverride: boolean; @@ -53,13 +49,12 @@ export async function cleanupSession(session: ChatSession | null): Promise export async function startChatSession( profile: AgentProfile, - history: StoredConversation | undefined, + resume: ConversationIndexEntry | undefined, previousSession: ChatSession | null, ): Promise { await cleanupSession(previousSession); - const userProfile = loadProfile(); - const payload = buildSessionRequest(profile, history, userProfile); + const payload = buildSessionRequest(profile, resume); const newSession: SessionCreateResponse = await createSessionRequest(payload); const customAgentId = profile.customAgent?.id ?? null; let builtInOverride: BuiltInOverrideState; @@ -69,11 +64,11 @@ export async function startChatSession( baseProfileId: profile.builtInOverride.baseProfileId, overrideUpdatedAt: profile.builtInOverride.updatedAt, }; - } else if (history?.usedBuiltInOverride) { + } else if (resume?.usedBuiltInOverride) { builtInOverride = { usedBuiltInOverride: true, - baseProfileId: history.baseProfileId ?? profile.id, - overrideUpdatedAt: history.overrideUpdatedAt, + baseProfileId: resume.baseProfileId ?? profile.id, + overrideUpdatedAt: resume.overrideUpdatedAt, }; } else { builtInOverride = emptyBuiltInOverride(); @@ -87,6 +82,18 @@ export async function startChatSession( }; } + // History now lives server-side (Cosmos). On resume, load the prior messages + // through the backend instead of from any client-held blob. + let restoredMessages: ChatMessage[] = []; + if (resume) { + try { + const loaded = await getConversationMessages(resume.id); + restoredMessages = loaded.messages ?? []; + } catch { + restoredMessages = []; + } + } + const { mcp_results, tools_loaded, skills_loaded, agents_loaded, search_context, ...session } = newSession; return { session, @@ -95,9 +102,9 @@ export async function startChatSession( skillsLoaded: skills_loaded ?? [], agentsLoaded: agents_loaded ?? [], searchContext: search_context ?? false, - restoredMessages: history ? extractMessagesFromSessionData(history.sessionData) : [], - conversationId: history?.id ?? newSession.session_id, - createdAt: history?.createdAt ?? new Date().toISOString(), + restoredMessages, + conversationId: resume?.id ?? newSession.session_id, + createdAt: resume?.createdAt ?? new Date().toISOString(), customAgentId, builtInOverride, }; @@ -105,12 +112,9 @@ export async function startChatSession( function buildSessionRequest( profile: AgentProfile, - history: StoredConversation | undefined, - userProfile: UserMemoryProfile | null, + resume: ConversationIndexEntry | undefined, ): SessionRequestPayload { - const userProfilePayload = userProfile - ? { user_profile: { name: userProfile.name, preferences: userProfile.preferences, notes: userProfile.notes } } - : {}; + const resumePayload = resume ? { conversation_id: resume.id } : {}; if (profile.customAgent) { return { @@ -126,8 +130,7 @@ function buildSessionRequest( ...(profile.customAgent.agentsAsTools && profile.customAgent.agentsAsTools.length > 0 ? { agentsAsTools: profile.customAgent.agentsAsTools.map((entry) => ({ agentRef: entry.agentRef })) } : {}), - ...(history?.sessionData ? { history: history.sessionData } : {}), - ...userProfilePayload, + ...resumePayload, }; } @@ -147,107 +150,12 @@ function buildSessionRequest( : {}), override_updated_at: profile.builtInOverride.updatedAt, }, - ...(history?.sessionData ? { history: history.sessionData } : {}), - ...userProfilePayload, + ...resumePayload, }; } return { profile_id: profile.id, - ...(history?.sessionData ? { history: history.sessionData } : {}), - ...userProfilePayload, + ...resumePayload, }; } - -export function extractMessagesFromSessionData(sessionData: Record): ChatMessage[] { - try { - const state = sessionData.state as Record | undefined; - const inMemoryProvider = state?.in_memory as Record | undefined; - const inMemory = inMemoryProvider?.messages as Array> | undefined; - if (!Array.isArray(inMemory)) return []; - - const result: ChatMessage[] = []; - for (const msg of inMemory) { - const role = msg.role as string; - const contents = msg.contents as Array> | undefined; - if (!Array.isArray(contents)) continue; - - if (role === 'tool') { - attachToolResults(result, contents); - continue; - } - - if (role !== 'user' && role !== 'assistant') continue; - result.push(toChatMessage(role, contents)); - } - return result; - } catch { - return []; - } -} - -function attachToolResults(messages: ChatMessage[], contents: Array>): void { - const lastAssistant = messages.length > 0 ? messages[messages.length - 1] : null; - if (lastAssistant?.role !== 'assistant' || !lastAssistant.tool_invocations) return; - - for (const content of contents) { - if (content.type !== 'function_result' && content.type !== 'mcp_server_tool_result') continue; - const callId = content.call_id as string; - const existing = lastAssistant.tool_invocations.find((tool) => tool.call_id === callId); - if (!existing) continue; - - const rawResult = content.type === 'mcp_server_tool_result' ? content.output : content.result; - existing.result = renderFrameworkToolResult(rawResult); - const converted = convertFrameworkContentItems(content.items as Array> | undefined); - if (converted.some((item) => item.type === 'image')) { - existing.content_items = converted; - } - } -} - -function renderFrameworkToolResult(rawResult: unknown): string { - if (Array.isArray(rawResult)) { - const textParts = rawResult - .filter((item: Record) => item.type === 'text') - .map((item: Record) => item.text as string || ''); - return textParts.length > 0 ? textParts.join('\n') : JSON.stringify(rawResult); - } - return typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult ?? ''); -} - -function toChatMessage(role: string, contents: Array>): ChatMessage { - let text = ''; - const toolInvocations: ToolInvocation[] = []; - const toolByCallId: Record = {}; - - for (const content of contents) { - const type = content.type as string; - if (type === 'text') { - text += content.text as string || ''; - } else if (type === 'function_call' || type === 'mcp_server_tool_call') { - const args = content.arguments; - const callId = (content.call_id as string) || ''; - const renderedArgs = typeof args === 'string' ? args : JSON.stringify(args ?? ''); - const existing = callId ? toolByCallId[callId] : undefined; - if (existing) { - if (renderedArgs) existing.arguments = (existing.arguments || '') + renderedArgs; - if (!existing.name) existing.name = (content.name as string) || (content.tool_name as string) || ''; - } else { - const invocation: ToolInvocation = { - call_id: callId, - name: (content.name as string) || (content.tool_name as string) || '', - arguments: renderedArgs, - result: '', - }; - toolInvocations.push(invocation); - if (callId) toolByCallId[callId] = invocation; - } - } - } - - return { - role: role as 'user' | 'assistant', - content: text, - tool_invocations: toolInvocations.length > 0 ? toolInvocations : undefined, - }; -} \ No newline at end of file diff --git a/frontend/src/hooks/useUserProfile.ts b/frontend/src/hooks/useUserProfile.ts deleted file mode 100644 index c2341e3..0000000 --- a/frontend/src/hooks/useUserProfile.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UserMemoryProfile } from '../types/api'; -import { readJson, writeJson } from '../utils/storage'; - -const PROFILE_KEY = 'webagents_user_profile'; - -export function loadProfile(_profileId?: string): UserMemoryProfile | null { - return readJson(PROFILE_KEY, null); -} - -export function saveProfile(_profileId: string, profile: UserMemoryProfile): void { - writeJson(PROFILE_KEY, profile); -} diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index fdae99f..bca4335 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -60,7 +60,7 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa saveCurrentConversation, } = useChat(); - const { loadIndex, saveConversation, loadConversation, deleteConversation } = useConversationStore(); + const { loadIndex, deleteConversation } = useConversationStore(); const { user, logout, classificationBanner } = useAuth(); const { setActiveAgentId } = useTheme(); const { appName, appTagline, appLogo } = getRuntimeConfigSnapshot(); @@ -109,13 +109,13 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa console.error('Failed to load profiles:', err); }) .finally(() => setLoadingProfiles(false)); - setConversationIndex(loadIndex()); + void loadIndex().then(setConversationIndex); }, [loadIndex]); // Refresh conversation index whenever a save completes useEffect(() => { if (saveCounter > 0) { - setConversationIndex(loadIndex()); + void loadIndex().then(setConversationIndex); } }, [saveCounter, loadIndex]); @@ -166,7 +166,7 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa await saveCurrentConversation(); await endSession(); setSelectedProfile(null); - setConversationIndex(loadIndex()); + void loadIndex().then(setConversationIndex); // Replace the chat-active history entry with a chat entry so the back // button doesn't try to re-enter a session that no longer exists. if ((window.history.state as { view?: string } | null)?.view === 'chat-active') { @@ -194,23 +194,19 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa }, [session, handleNewChat]); const handleSelectConversation = useCallback(async (id: string) => { - // Save the current conversation if one is active + // End the current session so the UI transitions to the spinner state. if (session) { - await saveCurrentConversation(); - // End the current session so the UI transitions to the spinner state await endSession(); } - const stored = loadConversation(id); - if (!stored) return; - - // Touch lastActivityAt so resumed conversation moves to the top - // and is protected from eviction as the "oldest" - stored.lastActivityAt = new Date().toISOString(); - saveConversation(stored); - setConversationIndex(loadIndex()); + const entry = conversationIndex.find((e) => e.id === id); + if (!entry) return; - const profile = allProfiles.find((p) => p.id === stored.profileId); + // Custom-agent chats are stored with profileId "custom"; resolve them by + // customAgentId so the full definition is re-inlined on resume. + const profile = entry.profileId === 'custom' && entry.customAgentId + ? allProfiles.find((p) => p.customAgent?.id === entry.customAgentId) + : allProfiles.find((p) => p.id === entry.profileId); setSelectedProfile(profile || null); setCreatingSession(true); setSpinnerTextIndex(0); @@ -222,12 +218,12 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa try { await startSession(profile || { - id: stored.profileId, - name: stored.profileName, - description: stored.description, + id: entry.profileId, + name: entry.profileName, + description: entry.description, icon: '/icons/custom.svg', starters: [], - }, stored); + }, entry); // Push a history entry so the back button returns to profile selection, // but only if we're not already in an active session (switching conversations // should not add an extra back step). @@ -237,11 +233,15 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa } finally { setCreatingSession(false); } - }, [session, allProfiles, startSession, endSession, saveCurrentConversation, loadConversation, saveConversation, loadIndex]); + }, [session, conversationIndex, allProfiles, startSession, endSession]); - const handleDeleteConversation = useCallback((id: string) => { - deleteConversation(id); - setConversationIndex(loadIndex()); + const handleDeleteConversation = useCallback(async (id: string) => { + try { + await deleteConversation(id); + } catch { + /* surfaced by the API layer */ + } + void loadIndex().then(setConversationIndex); }, [deleteConversation, loadIndex]); const toggleSidebar = useCallback(() => { @@ -319,9 +319,6 @@ export function ChatPage({ onOpenAdmin, customAgents, builtInOverrides }: ChatPa
- {renderSettingsMenu()}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 3315935..2691942 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -249,27 +249,6 @@ export interface ConversationIndexEntry { overrideUpdatedAt?: string; } -export interface StoredConversation { - id: string; - profileId: string; - profileName: string; - description: string; - createdAt: string; - lastActivityAt: string; - sessionData: Record; - customAgentId?: string; - usedBuiltInOverride?: boolean; - baseProfileId?: string; - overrideUpdatedAt?: string; -} - -export interface UserMemoryProfile { - name: string; - preferences: string; - notes: string; - updatedAt: string; -} - export interface SkillDefinition { name: string; description: string; diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts deleted file mode 100644 index 0209587..0000000 --- a/frontend/src/utils/storage.ts +++ /dev/null @@ -1,31 +0,0 @@ -export function readJson(key: string, fallback: T, validate?: (value: unknown) => value is T): T { - try { - const raw = localStorage.getItem(key); - if (!raw) return fallback; - const parsed = JSON.parse(raw) as unknown; - if (validate && !validate(parsed)) { - localStorage.removeItem(key); - return fallback; - } - return parsed as T; - } catch { - try { localStorage.removeItem(key); } catch { /* ignore */ } - return fallback; - } -} - -export function writeJson(key: string, value: T): void { - localStorage.setItem(key, JSON.stringify(value)); -} - -export function tryWriteJson(key: string, value: T, onError?: (error: unknown) => void): void { - try { - writeJson(key, value); - } catch (error) { - onError?.(error); - } -} - -export function removeStorageItem(key: string): void { - try { localStorage.removeItem(key); } catch { /* ignore */ } -} \ No newline at end of file diff --git a/infra/main.tf b/infra/main.tf index 1c5e3f9..e65ec45 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -10,10 +10,10 @@ locals { tags = { "azd-env-name" = var.environment_name } - + # Use existing resource group if name is provided, otherwise use the created one - use_existing = var.existing_resource_group_name != "" - resource_group_name = local.use_existing ? data.azurerm_resource_group.existing[0].name : azurerm_resource_group.rg[0].name + use_existing = var.existing_resource_group_name != "" + resource_group_name = local.use_existing ? data.azurerm_resource_group.existing[0].name : azurerm_resource_group.rg[0].name resource_group_location = local.use_existing ? data.azurerm_resource_group.existing[0].location : azurerm_resource_group.rg[0].location } @@ -36,6 +36,22 @@ module "managed_identity" { resource_group_name = local.resource_group_name } +# Azure Cosmos DB (durable agent memory + per-user chat history) +module "cosmos" { + source = "./modules/cosmos" + + account_name = lower("cosmos-${var.environment_name}") + location = local.resource_group_location + tags = local.tags + resource_group_name = local.resource_group_name + principal_id = module.managed_identity.managed_identity_principal_id + + # Optional: grant the deploying user (AZURE_PRINCIPAL_ID) Cosmos data-plane + # access so you can run the app locally against this live account. Toggle with + # the ENABLE_DEV_COSMOS_ACCESS deployment env var (empty/false = not granted). + dev_principal_id = lower(trimspace(var.enable_dev_cosmos_access)) == "true" ? var.principal_id : "" +} + # App Service Plan and App Service module "app_service" { source = "./modules/app-service" @@ -53,14 +69,20 @@ module "app_service" { resource_group_name = local.resource_group_name # Application environment variables - azure_openai_endpoint = var.azure_openai_endpoint - azure_openai_model = var.azure_openai_model - azure_openai_api_key = var.azure_openai_api_key - azure_openai_api_version = var.azure_openai_api_version - azure_sql_connectionstring = var.azure_sql_connectionstring - search_service_endpoint = var.search_service_endpoint - search_index_name = var.search_index_name - search_api_key = var.search_api_key + azure_openai_endpoint = var.azure_openai_endpoint + azure_openai_model = var.azure_openai_model + azure_openai_api_key = var.azure_openai_api_key + azure_openai_api_version = var.azure_openai_api_version + azure_sql_connectionstring = var.azure_sql_connectionstring + search_service_endpoint = var.search_service_endpoint + search_index_name = var.search_index_name + search_api_key = var.search_api_key + + # Azure Cosmos DB (durable agent memory) + azure_cosmos_endpoint = module.cosmos.endpoint + azure_cosmos_database_name = module.cosmos.database_name + azure_cosmos_container_name = module.cosmos.messages_container_name + azure_cosmos_conversations_container = module.cosmos.conversations_container_name # Entra ID / Azure AD authentication entra_tenant_id = var.entra_tenant_id diff --git a/infra/main.tfvars.json b/infra/main.tfvars.json index 20f9153..0da2e81 100644 --- a/infra/main.tfvars.json +++ b/infra/main.tfvars.json @@ -12,6 +12,7 @@ "search_service_endpoint": "${SEARCH_SERVICE_ENDPOINT}", "search_index_name": "${SEARCH_INDEX_NAME}", "search_api_key": "${SEARCH_API_KEY}", + "enable_dev_cosmos_access": "${ENABLE_DEV_COSMOS_ACCESS}", "entra_tenant_id": "${OAUTH_AZURE_GOV_AD_TENANT_ID}", "entra_client_id": "${OAUTH_AZURE_GOV_AD_CLIENT_ID}", "entra_client_secret": "${ENTRA_CLIENT_SECRET}", diff --git a/infra/modules/app-service/main.tf b/infra/modules/app-service/main.tf index a17dd07..27f4316 100644 --- a/infra/modules/app-service/main.tf +++ b/infra/modules/app-service/main.tf @@ -43,21 +43,27 @@ resource "azurerm_linux_web_app" "app_service" { SCM_DO_BUILD_DURING_DEPLOYMENT = "true" AZURE_CLIENT_ID = var.managed_identity_client_id WEBSITES_PORT = "8000" - + # Azure OpenAI settings - AZURE_OPENAI_ENDPOINT = var.azure_openai_endpoint - AZURE_OPENAI_MODEL = var.azure_openai_model - AZURE_OPENAI_API_KEY = var.azure_openai_api_key - AZURE_OPENAI_API_VERSION = var.azure_openai_api_version - + AZURE_OPENAI_ENDPOINT = var.azure_openai_endpoint + AZURE_OPENAI_MODEL = var.azure_openai_model + AZURE_OPENAI_API_KEY = var.azure_openai_api_key + AZURE_OPENAI_API_VERSION = var.azure_openai_api_version + # Azure SQL settings AZURE_SQL_CONNECTIONSTRING = var.azure_sql_connectionstring - + # Azure AI Search settings SEARCH_SERVICE_ENDPOINT = var.search_service_endpoint SEARCH_INDEX_NAME = var.search_index_name SEARCH_API_KEY = var.search_api_key + # Azure Cosmos DB (durable agent memory + per-user chat history) + AZURE_COSMOS_ENDPOINT = var.azure_cosmos_endpoint + AZURE_COSMOS_DATABASE_NAME = var.azure_cosmos_database_name + AZURE_COSMOS_CONTAINER_NAME = var.azure_cosmos_container_name + AZURE_COSMOS_CONVERSATIONS_CONTAINER = var.azure_cosmos_conversations_container + # Entra ID / Azure AD authentication OAUTH_AZURE_GOV_AD_TENANT_ID = var.entra_tenant_id OAUTH_AZURE_GOV_AD_CLIENT_ID = var.entra_client_id diff --git a/infra/modules/app-service/variables.tf b/infra/modules/app-service/variables.tf index cab8929..1e3a3a7 100644 --- a/infra/modules/app-service/variables.tf +++ b/infra/modules/app-service/variables.tf @@ -101,6 +101,31 @@ variable "search_api_key" { sensitive = true } +# Azure Cosmos DB (durable agent memory + per-user chat history) +variable "azure_cosmos_endpoint" { + description = "Cosmos DB account endpoint" + type = string + default = "" +} + +variable "azure_cosmos_database_name" { + description = "Cosmos DB database name" + type = string + default = "agent-memory" +} + +variable "azure_cosmos_container_name" { + description = "Cosmos DB messages container name" + type = string + default = "chat-history" +} + +variable "azure_cosmos_conversations_container" { + description = "Cosmos DB conversation index container name" + type = string + default = "conversations" +} + # Entra ID / Azure AD authentication variable "entra_tenant_id" { description = "Entra ID (Azure AD) tenant ID for authentication" diff --git a/infra/modules/cosmos/main.tf b/infra/modules/cosmos/main.tf new file mode 100644 index 0000000..cadfcd6 --- /dev/null +++ b/infra/modules/cosmos/main.tf @@ -0,0 +1,101 @@ +resource "azurerm_cosmosdb_account" "cosmos" { + name = var.account_name + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + offer_type = "Standard" + kind = "GlobalDocumentDB" + + # Serverless throughput — bills per request unit, ideal for spiky, + # user-driven interactive chat memory workloads (see research.md R5). + capabilities { + name = "EnableServerless" + } + + consistency_policy { + consistency_level = "Session" + } + + geo_location { + location = var.location + failover_priority = 0 + } + + # Production authenticates via managed identity (RBAC). Disable account-key + # (local) auth so no shared secret exists for the cloud account. + local_authentication_disabled = true +} + +resource "azurerm_cosmosdb_sql_database" "db" { + name = var.database_name + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name +} + +# Messages container — owned by the Agent Framework CosmosHistoryProvider. +resource "azurerm_cosmosdb_sql_container" "messages" { + name = var.messages_container_name + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + database_name = azurerm_cosmosdb_sql_database.db.name + partition_key_paths = ["/session_id"] +} + +# Per-user conversation index — powers the left chat pane and enforces ownership. +resource "azurerm_cosmosdb_sql_container" "conversations" { + name = var.conversations_container_name + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + database_name = azurerm_cosmosdb_sql_database.db.name + partition_key_paths = ["/user_id"] +} + +# Per-user custom agents (admin-built). Names match the backend defaults in user_data.py. +resource "azurerm_cosmosdb_sql_container" "custom_agents" { + name = "custom-agents" + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + database_name = azurerm_cosmosdb_sql_database.db.name + partition_key_paths = ["/user_id"] +} + +# Per-user built-in agent customizations (overrides). +resource "azurerm_cosmosdb_sql_container" "agent_customizations" { + name = "agent-customizations" + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + database_name = azurerm_cosmosdb_sql_database.db.name + partition_key_paths = ["/user_id"] +} + +# Per-user memory profile (one document per user). +resource "azurerm_cosmosdb_sql_container" "user_profiles" { + name = "user-profiles" + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + database_name = azurerm_cosmosdb_sql_database.db.name + partition_key_paths = ["/user_id"] +} + +# Grant the app's managed identity data-plane access via the built-in +# "Cosmos DB Built-in Data Contributor" role (id ...0002). +resource "azurerm_cosmosdb_sql_role_assignment" "data_contributor" { + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + role_definition_id = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" + principal_id = var.principal_id + scope = azurerm_cosmosdb_account.cosmos.id +} + +# Optional developer/user data-plane access for running the app locally against +# this live account (Cosmos data-plane RBAC is separate from control-plane RBAC). +# Gated by the ENABLE_DEV_COSMOS_ACCESS deployment env var; empty = not granted. +resource "azurerm_cosmosdb_sql_role_assignment" "dev_data_contributor" { + count = var.dev_principal_id != "" ? 1 : 0 + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.cosmos.name + role_definition_id = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" + principal_id = var.dev_principal_id + scope = azurerm_cosmosdb_account.cosmos.id +} diff --git a/infra/modules/cosmos/outputs.tf b/infra/modules/cosmos/outputs.tf new file mode 100644 index 0000000..e1f107f --- /dev/null +++ b/infra/modules/cosmos/outputs.tf @@ -0,0 +1,24 @@ +output "endpoint" { + description = "Cosmos DB account endpoint (e.g. https://.documents.azure.us:443/)." + value = azurerm_cosmosdb_account.cosmos.endpoint +} + +output "account_name" { + description = "Cosmos DB account name." + value = azurerm_cosmosdb_account.cosmos.name +} + +output "database_name" { + description = "Cosmos DB SQL database name." + value = azurerm_cosmosdb_sql_database.db.name +} + +output "messages_container_name" { + description = "Messages container name." + value = azurerm_cosmosdb_sql_container.messages.name +} + +output "conversations_container_name" { + description = "Conversation index container name." + value = azurerm_cosmosdb_sql_container.conversations.name +} diff --git a/infra/modules/cosmos/variables.tf b/infra/modules/cosmos/variables.tf new file mode 100644 index 0000000..2bb61ea --- /dev/null +++ b/infra/modules/cosmos/variables.tf @@ -0,0 +1,49 @@ +variable "account_name" { + description = "Cosmos DB account name (globally unique, lowercase letters/numbers/hyphens, 3-44 chars)." + type = string +} + +variable "location" { + description = "Azure region for the Cosmos DB account." + type = string +} + +variable "resource_group_name" { + description = "Resource group that holds the Cosmos DB account." + type = string +} + +variable "tags" { + description = "Tags applied to the Cosmos DB account." + type = map(string) + default = {} +} + +variable "database_name" { + description = "Cosmos DB SQL database name." + type = string + default = "agent-memory" +} + +variable "messages_container_name" { + description = "Container for chat messages (partition key /session_id)." + type = string + default = "chat-history" +} + +variable "conversations_container_name" { + description = "Container for the per-user conversation index (partition key /user_id)." + type = string + default = "conversations" +} + +variable "principal_id" { + description = "Principal ID of the app's managed identity, granted data-plane access." + type = string +} + +variable "dev_principal_id" { + description = "Optional developer/user principal ID granted Cosmos data-plane access for local development (empty = none granted)." + type = string + default = "" +} diff --git a/infra/outputs.tf b/infra/outputs.tf index a7a1085..bff4c7e 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -39,6 +39,16 @@ output "MANAGED_IDENTITY_CLIENT_ID" { value = module.managed_identity.managed_identity_client_id } +output "AZURE_COSMOS_ENDPOINT" { + description = "The Cosmos DB account endpoint" + value = module.cosmos.endpoint +} + +output "AZURE_COSMOS_ACCOUNT_NAME" { + description = "The Cosmos DB account name" + value = module.cosmos.account_name +} + output "MANAGED_IDENTITY_PRINCIPAL_ID" { description = "The principal ID of the managed identity" value = module.managed_identity.managed_identity_principal_id diff --git a/infra/variables.tf b/infra/variables.tf index 3bf3c27..f6a6c60 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -90,6 +90,13 @@ variable "search_api_key" { sensitive = true } +# Local development access to the live Cosmos account +variable "enable_dev_cosmos_access" { + description = "When 'true', grant the deploying user (AZURE_PRINCIPAL_ID) the Cosmos data-plane 'Built-in Data Contributor' role so you can run the app locally against the live account. Leave 'false'/empty for normal deployments." + type = string + default = "false" +} + # Entra ID / Azure AD authentication variable "entra_tenant_id" { description = "Entra ID (Azure AD) tenant ID for authentication" diff --git a/main.py b/main.py index 191d9af..8111f6b 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,11 @@ from mcp_servers import parse_mcp_server_configs, connect_mcp_servers, cleanup_mcp_servers, get_search_service_config from prompt_config import get_profile_display_name, load_agents_yaml from session_orchestration import SessionContext, create_chat_session +from cosmos_memory import close_cosmos, cosmos_config_summary, get_conversation_repository, get_history_provider, require_cosmos_configured +from user_data import ( + get_agent_customizations_repository, + get_custom_agents_repository, +) from skills_manager import SkillManager from streaming import ( USAGE_INPUT_KEY, @@ -38,11 +43,13 @@ is_context_length_error, is_retryable_error, merge_usage, + messages_to_wire, sse_event, stream_agent_response, usage_value, + with_user_time, ) -from tools import UserProfileStore +from tools import build_user_profile_tools from validators import ( ALLOWED_IMAGE_MIMES, MAX_IMAGE_SIZE_BYTES, @@ -74,7 +81,7 @@ class SessionData: "session_id", "user_id", "profile_id", "profile_name", "agent", "agent_session", "tools", "usage", "eval_trace_logger", "prompt_manifest", "prompt_logical_profile", - "created_at", "context_usage", "user_profile_store", + "created_at", "context_usage", "mcp_tools", "used_profile_override", "override_updated_at", ) @@ -110,7 +117,6 @@ def __init__( "max_context_chars": 0, "last_context_chars": 0, } - self.user_profile_store = None self.mcp_tools: list[Any] = [] self.used_profile_override = False self.override_updated_at: str | None = None @@ -154,20 +160,19 @@ def _build_tool_instances( tool_names: set[str], *, session_id: str, - user_profile_data: dict[str, str] | None = None, -) -> tuple[list[Any], UserProfileStore | None]: + user_id: str | None = None, +) -> list[Any]: """Instantiate the selected backend tools for a session or tool inventory call.""" function_tools: list[Any] = [] - user_profile_store = None - if {"get_user_profile", "save_user_profile"} & tool_names: - user_profile_store = UserProfileStore(user_profile_data if isinstance(user_profile_data, dict) else None) - if "get_user_profile" in tool_names: - function_tools.append(user_profile_store.get_user_profile) - if "save_user_profile" in tool_names: - function_tools.append(user_profile_store.save_user_profile) + profile_tool_names = {"get_user_profile", "save_user_profile"} & tool_names + if profile_tool_names: + profile_tools = build_user_profile_tools(user_id or "") + for name in ("get_user_profile", "save_user_profile"): + if name in profile_tool_names: + function_tools.append(profile_tools[name]) - return function_tools, user_profile_store + return function_tools def _build_user_profile_context(user_profile_data: dict[str, str] | None) -> str: @@ -203,12 +208,25 @@ def _get_skills_dir() -> Path: @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan handler — runs on startup and shutdown.""" + # Fail fast: Cosmos is required (emulator locally, real account when deployed). + # There is no non-durable in-memory fallback. + require_cosmos_configured() + cfg = cosmos_config_summary() + logger.info( + "Cosmos memory enabled — endpoint=%s database=%s messages=%s conversations=%s auth=%s", + cfg["endpoint"], + cfg["database"], + cfg["messages_container"], + cfg["conversations_container"], + cfg["auth"], + ) yield - # Shutdown: clean up sessions + # Shutdown: clean up sessions and Cosmos resources session_count = len(_sessions) _sessions.clear() + await close_cosmos() logger.info("Cleaned up %d sessions on shutdown", session_count) @@ -298,7 +316,7 @@ async def get_tools(user: AuthenticatedUser = Depends(get_current_user)): available_names: set[str] = set() for tool_name in sorted(tool_names): try: - tool_objects, _ = _build_tool_instances({tool_name}, session_id="discovery") + tool_objects = _build_tool_instances({tool_name}, session_id="discovery") except HTTPException as e: unavailable_tools.append({"name": tool_name, "reason": e.detail}) continue @@ -422,7 +440,7 @@ async def test_mcp_connections( # GET /api/skills — list available skills for custom agent builder @app.get("/api/skills") async def get_skills(user: AuthenticatedUser = Depends(get_current_user)): - return {"skills": SkillManager(_get_skills_dir()).list_summaries()} + return {"skills": await SkillManager(_get_skills_dir()).list_summaries()} # --------------------------------------------------------------------------- @@ -688,12 +706,14 @@ async def send_message( content_type = request.headers.get("content-type", "") text_content = "" + client_time = "" image_files: list[UploadFile] = [] image_data_list: list[bytes] = [] if "multipart/form-data" in content_type: form = await request.form() text_content = str(form.get("content", "")) + client_time = str(form.get("client_time", "")) for item in form.getlist("images"): if hasattr(item, "read"): data = await item.read() @@ -702,6 +722,7 @@ async def send_message( else: body = await request.json() text_content = body.get("content", "") + client_time = str(body.get("client_time", "") or "") # Validate input length (T020) if len(text_content) > DEFAULT_MAX_USER_INPUT_CHARS: @@ -716,8 +737,12 @@ async def send_message( if error: raise HTTPException(status_code=400, detail=error) - # Build content objects - contents: list[Content] = [Content.from_text(text_content)] + # Build content objects. The user's local date/time is prepended to the text the + # model sees (and to the persisted session history) but is stripped from the wire + # form by messages_to_wire, so it stays invisible in the UI. + when = client_time.strip() or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + model_text = with_user_time(text_content, when) if text_content.strip() else text_content + contents: list[Content] = [Content.from_text(model_text)] for f, data in zip(image_files, image_data_list): mime = f.content_type or "image/jpeg" contents.append(Content.from_data(data=data, media_type=mime)) @@ -732,6 +757,17 @@ async def send_message( # Stream the response async def generate() -> AsyncGenerator[str, None]: + # Persist the conversation index (title on first turn + last activity) BEFORE + # streaming, so the sidebar's refresh on the "done" event reliably reflects the + # title. Doing it after streaming races the client's refresh, so the title would + # only appear after a manual page reload. + try: + conversations = get_conversation_repository() + candidate_title = " ".join((text_content or "").split())[:60] + await conversations.touch(session_data.user_id, session_id, title=candidate_title or None) + except Exception: + logger.warning("Failed to update conversation index for %s", session_id, exc_info=True) + try: async for event in stream_agent_response( session_data.agent, contents, session_data.agent_session, @@ -830,6 +866,156 @@ async def delete_session( return None +# --------------------------------------------------------------------------- +# Conversations — durable per-user chat history (Cosmos-backed) +# --------------------------------------------------------------------------- + +# GET /api/conversations — list the authenticated user's conversations (US2) +@app.get("/api/conversations") +async def list_conversations( + user: AuthenticatedUser = Depends(get_current_user), + limit: int = 50, + cursor: str | None = None, +): + repo = get_conversation_repository() + bounded = max(1, min(int(limit or 50), 200)) + try: + records, next_cursor = await repo.list_for_user(user.user_id, limit=bounded, cursor=cursor) + except Exception as e: + logger.error("Failed to list conversations: %s", e) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") + return {"conversations": [r.to_wire() for r in records], "nextCursor": next_cursor} + + +# GET /api/conversations/{id}/messages — messages for a resumed conversation (US3) +@app.get("/api/conversations/{conversation_id}/messages") +async def get_conversation_messages( + conversation_id: str, + user: AuthenticatedUser = Depends(get_current_user), +): + repo = get_conversation_repository() + try: + owned = await repo.get_owned(user.user_id, conversation_id) + except Exception as e: + logger.error("Conversation lookup failed: %s", e) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") + if owned is None: + raise HTTPException(status_code=404, detail="Conversation not found") + history_provider = get_history_provider() + try: + stored = await history_provider.get_messages(conversation_id) + except Exception as e: + logger.error("Failed to load messages for %s: %s", conversation_id, e) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") + return { + "id": owned.id, + "profileId": owned.profile_id, + "profileName": owned.profile_name, + "messages": messages_to_wire(stored or []), + } + + +# DELETE /api/conversations/{id} — delete index entry + stored messages (US5) +@app.delete("/api/conversations/{conversation_id}", status_code=204) +async def delete_conversation( + conversation_id: str, + user: AuthenticatedUser = Depends(get_current_user), +): + repo = get_conversation_repository() + try: + owned = await repo.get_owned(user.user_id, conversation_id) + except Exception as e: + logger.error("Conversation lookup failed: %s", e) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") + if owned is None: + raise HTTPException(status_code=404, detail="Conversation not found") + history_provider = get_history_provider() + try: + clear = getattr(history_provider, "clear", None) + if clear is not None: + await clear(conversation_id) + await repo.delete(user.user_id, conversation_id) + except Exception as e: + logger.error("Failed to delete conversation %s: %s", conversation_id, e) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") + # Drop any in-memory runtime session bound to this conversation + _sessions.pop(conversation_id, None) + return None + + +# --------------------------------------------------------------------------- +# Custom agents, agent customizations, user profile (durable per-user, Cosmos) +# --------------------------------------------------------------------------- + +_USER_DATA_UNAVAILABLE = "User data store is temporarily unavailable. Please try again." + + +@app.get("/api/custom-agents") +async def list_custom_agents(user: AuthenticatedUser = Depends(get_current_user)): + try: + agents = await get_custom_agents_repository().list_for_user(user.user_id) + except Exception as e: + logger.error("Failed to list custom agents: %s", e) + raise HTTPException(status_code=503, detail=_USER_DATA_UNAVAILABLE) + return {"agents": agents} + + +@app.put("/api/custom-agents/{agent_id}") +async def save_custom_agent(agent_id: str, request: Request, user: AuthenticatedUser = Depends(get_current_user)): + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Body must be a JSON object") + try: + saved = await get_custom_agents_repository().upsert(user.user_id, agent_id, body) + except Exception as e: + logger.error("Failed to save custom agent %s: %s", agent_id, e) + raise HTTPException(status_code=503, detail=_USER_DATA_UNAVAILABLE) + return saved + + +@app.delete("/api/custom-agents/{agent_id}", status_code=204) +async def delete_custom_agent(agent_id: str, user: AuthenticatedUser = Depends(get_current_user)): + try: + await get_custom_agents_repository().delete(user.user_id, agent_id) + except Exception as e: + logger.error("Failed to delete custom agent %s: %s", agent_id, e) + raise HTTPException(status_code=503, detail=_USER_DATA_UNAVAILABLE) + return None + + +@app.get("/api/agent-customizations") +async def list_agent_customizations(user: AuthenticatedUser = Depends(get_current_user)): + try: + overrides = await get_agent_customizations_repository().list_for_user(user.user_id) + except Exception as e: + logger.error("Failed to list agent customizations: %s", e) + raise HTTPException(status_code=503, detail=_USER_DATA_UNAVAILABLE) + return {"overrides": overrides} + + +@app.put("/api/agent-customizations/{base_profile_id}") +async def save_agent_customization(base_profile_id: str, request: Request, user: AuthenticatedUser = Depends(get_current_user)): + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Body must be a JSON object") + try: + saved = await get_agent_customizations_repository().upsert(user.user_id, base_profile_id, body) + except Exception as e: + logger.error("Failed to save agent customization %s: %s", base_profile_id, e) + raise HTTPException(status_code=503, detail=_USER_DATA_UNAVAILABLE) + return saved + + +@app.delete("/api/agent-customizations/{base_profile_id}", status_code=204) +async def delete_agent_customization(base_profile_id: str, user: AuthenticatedUser = Depends(get_current_user)): + try: + await get_agent_customizations_repository().delete(user.user_id, base_profile_id) + except Exception as e: + logger.error("Failed to delete agent customization %s: %s", base_profile_id, e) + raise HTTPException(status_code=503, detail=_USER_DATA_UNAVAILABLE) + return None + + # --------------------------------------------------------------------------- # T012 — Static file serving + SPA catch-all # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 1c17187..5d378bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pypdf>=6.0.0", "azure-search-documents>=11.7.0b2", "msal>=1.36.0", + "agent-framework-azure-cosmos>=1.0.0b260521", ] [dependency-groups] @@ -30,3 +31,8 @@ dev = [ [tool.uv] default-groups = [] prerelease = "allow" + +[tool.pytest.ini_options] +markers = [ + "emulator: integration tests that exercise the local Azure Cosmos DB Emulator (auto-skip when it is unreachable)", +] diff --git a/scripts/capture_cosmos_chat_pane_screenshots.py b/scripts/capture_cosmos_chat_pane_screenshots.py new file mode 100644 index 0000000..e84f6dd --- /dev/null +++ b/scripts/capture_cosmos_chat_pane_screenshots.py @@ -0,0 +1,193 @@ +"""Capture chat-pane visual-verification screenshots for the Cosmos memory feature. + +Mocks the backend API (including the new ``/api/conversations`` endpoints) so the +server-sourced conversation list renders deterministically without a live backend, +LLM, or Cosmos account. Saves PNGs under ``screenshots/011-cosmos-agent-memory/``. + +Usage: + uv run python scripts/capture_cosmos_chat_pane_screenshots.py --base-url http://localhost:8000 +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from playwright.sync_api import Page, sync_playwright + + +DEFAULT_PROFILE = { + "id": "visual-agent", + "name": "Visual Agent", + "description": "Screenshot verification profile", + "icon": "/favicon.png", + "starters": [ + {"label": "Summarize", "message": "Summarize the latest status."}, + {"label": "Plan", "message": "Make a concise plan."}, + ], +} + +CONVERSATIONS = [ + { + "id": "conv-1", + "profileId": "visual-agent", + "profileName": "Visual Agent", + "description": "Help me plan the offsite agenda", + "createdAt": "2026-06-18T14:03:11Z", + "lastActivityAt": "2026-06-18T14:25:02Z", + }, + { + "id": "conv-2", + "profileId": "visual-agent", + "profileName": "Visual Agent", + "description": "Draft a status update for the readiness review", + "createdAt": "2026-06-17T09:12:00Z", + "lastActivityAt": "2026-06-17T09:40:00Z", + }, + { + "id": "conv-3", + "profileId": "visual-agent", + "profileName": "Visual Agent", + "description": "Summarize yesterday's maintenance logs", + "createdAt": "2026-06-16T16:01:00Z", + "lastActivityAt": "2026-06-16T16:20:00Z", + }, +] + +RESUMED_MESSAGES = { + "id": "conv-1", + "profileId": "visual-agent", + "profileName": "Visual Agent", + "messages": [ + {"role": "user", "content": "Help me plan the offsite agenda"}, + {"role": "assistant", "content": "Here is a draft agenda: 1) Goals, 2) Roadmap, 3) Breakouts, 4) Wrap-up."}, + ], +} + + +def setup_mock_api(page: Page, *, empty_conversations: bool = False) -> None: + def handler(route) -> None: + request = route.request + url = request.url + method = request.method + path = url.split("?", 1)[0] + + if url.endswith("/api/auth/config"): + route.fulfill(json={ + "authDisabled": True, + "tenantId": "", + "clientId": "", + "authority": "https://login.microsoftonline.us", + "classificationBanner": "UNCLASSIFIED", + "appName": "Web-Agents", + "appTagline": "AI Agent Framework", + "appLogo": "/Microsoft.png", + }) + elif url.endswith("/api/profiles"): + route.fulfill(json={"profiles": [DEFAULT_PROFILE], "unavailable": []}) + elif url.endswith("/api/tools"): + route.fulfill(json={"tools": [], "unavailable": [], "search_context_available": True, "search_context_reason": None}) + elif url.endswith("/api/skills") and method == "GET": + route.fulfill(json={"skills": []}) + elif path.endswith("/messages") and "/api/conversations/" in path: + route.fulfill(json=RESUMED_MESSAGES) + elif path.endswith("/api/conversations") and method == "GET": + conversations = [] if empty_conversations else CONVERSATIONS + route.fulfill(json={"conversations": conversations, "nextCursor": None}) + elif "/api/conversations/" in path and method == "DELETE": + route.fulfill(status=204, body="") + elif "/api/profiles/" in url and url.endswith("/definition"): + route.fulfill(json={ + **DEFAULT_PROFILE, + "systemPrompt": "You are a visual verification agent.", + "tools": [], + "skills": [], + "mcpServers": [], + "useSearchContext": False, + "temperature": 0.2, + }) + elif url.endswith("/api/sessions") and method == "POST": + route.fulfill(json={ + "session_id": "conv-1", + "profile_id": "visual-agent", + "profile_name": "Visual Agent", + "tools_loaded": [], + "skills_loaded": [], + "search_context": False, + "mcp_results": [], + }) + elif "/api/sessions/" in url and url.endswith("/messages") and method == "POST": + route.fulfill( + status=200, + headers={"content-type": "text/event-stream"}, + body=( + 'event: text\ndata: {"content":"Durable memory response."}\n\n' + 'event: usage\ndata: {"input_token_count":4,"output_token_count":5,"total_token_count":9}\n\n' + 'event: done\ndata: {}\n\n' + ), + ) + elif "/api/sessions/" in url and method == "DELETE": + route.fulfill(status=204, body="") + else: + route.continue_() + + page.route("**/api/**", handler) + + +def accept_disclaimer(page: Page) -> None: + button = page.get_by_role("button", name="I UNDERSTAND AND AGREE") + if button.count() > 0: + button.click() + + +def capture(args: argparse.Namespace) -> None: + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, executable_path=args.chrome_path) + + # 1) Populated conversation list + profile selection. + page = browser.new_page(viewport={"width": args.width, "height": args.height}) + setup_mock_api(page) + page.goto(args.base_url, wait_until="networkidle") + accept_disclaimer(page) + page.get_by_text("SELECT YOUR AGENT").wait_for(timeout=8_000) + page.screenshot(path=str(output_dir / "011-conversations-list-populated.png"), full_page=True) + + # 2) Resume a conversation — prior messages render from the server mock. + page.locator(".sidebar-entry").first.click(timeout=8_000) + page.get_by_text("Here is a draft agenda", exact=False).wait_for(timeout=8_000) + page.screenshot(path=str(output_dir / "011-conversation-resumed.png"), full_page=True) + + page.close() + + # 3) Empty state — new user with no conversations. + page_empty = browser.new_page(viewport={"width": args.width, "height": args.height}) + setup_mock_api(page_empty, empty_conversations=True) + page_empty.goto(args.base_url, wait_until="networkidle") + accept_disclaimer(page_empty) + page_empty.get_by_text("SELECT YOUR AGENT").wait_for(timeout=8_000) + page_empty.screenshot(path=str(output_dir / "011-conversations-empty.png"), full_page=True) + + # 4) Empty-state at mobile width. + page_empty.set_viewport_size({"width": 360, "height": 640}) + page_empty.screenshot(path=str(output_dir / "011-conversations-empty-mobile.png"), full_page=True) + page_empty.close() + + browser.close() + print(f"Saved chat-pane screenshots to {output_dir}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Capture Cosmos chat-pane screenshots.") + parser.add_argument("--base-url", default="http://localhost:8000") + parser.add_argument("--output-dir", default="screenshots/011-cosmos-agent-memory") + parser.add_argument("--chrome-path", default="/usr/bin/google-chrome") + parser.add_argument("--width", type=int, default=1440) + parser.add_argument("--height", type=int, default=900) + return parser.parse_args() + + +if __name__ == "__main__": + capture(parse_args()) diff --git a/scripts/run_emulator_tests.sh b/scripts/run_emulator_tests.sh new file mode 100755 index 0000000..9d71a01 --- /dev/null +++ b/scripts/run_emulator_tests.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Run the Cosmos emulator-backed integration tests (`@pytest.mark.emulator`). +# +# Starts the Azure Cosmos DB Emulator in Docker if it is not already running, +# exports the well-known emulator endpoint/key (a public, fixed value — not a +# secret), then runs the emulator-marked test tier. Any extra args are passed +# through to pytest. +# +# Usage: +# scripts/run_emulator_tests.sh [extra pytest args] +set -euo pipefail + +ENDPOINT="${AZURE_COSMOS_EMULATOR_ENDPOINT:-https://localhost:8081/}" +KEY="${AZURE_COSMOS_KEY:-C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==}" +CONTAINER_NAME="azure-cosmos-emulator" +IMAGE="mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest" + +if command -v docker >/dev/null 2>&1; then + if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "Starting Azure Cosmos DB Emulator (${CONTAINER_NAME})..." + docker run -d --name "${CONTAINER_NAME}" \ + -p 8081:8081 -p 10250-10255:10250-10255 \ + "${IMAGE}" >/dev/null + echo "Waiting for the emulator to accept connections (up to ~2 min)..." + for _ in $(seq 1 60); do + if curl -ksf "${ENDPOINT}_explorer/index.html" >/dev/null 2>&1; then + break + fi + sleep 2 + done + fi +else + echo "docker not found; assuming an emulator is already reachable at ${ENDPOINT}" >&2 +fi + +export AZURE_COSMOS_ENDPOINT="${ENDPOINT}" +export AZURE_COSMOS_EMULATOR_ENDPOINT="${ENDPOINT}" +export AZURE_COSMOS_KEY="${KEY}" +export AZURE_COSMOS_DATABASE_NAME="${AZURE_COSMOS_DATABASE_NAME:-agent-memory-test}" +export AZURE_COSMOS_CONTAINER_NAME="${AZURE_COSMOS_CONTAINER_NAME:-chat-history-test}" +export AZURE_COSMOS_CONVERSATIONS_CONTAINER="${AZURE_COSMOS_CONVERSATIONS_CONTAINER:-conversations-test}" + +echo "Running emulator integration tests against ${ENDPOINT}..." +exec uv run pytest -m emulator -v "$@" diff --git a/scripts/start_cosmos_emulator.sh b/scripts/start_cosmos_emulator.sh new file mode 100755 index 0000000..9867a91 --- /dev/null +++ b/scripts/start_cosmos_emulator.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# Start the Azure Cosmos DB Emulator (Docker) for local development — but only +# when USE_COSMOS_EMULATOR is enabled. Wired into the VS Code "Build & Run" task +# (Ctrl+Shift+B) so the emulator comes up before the backend when requested. +# +# Enable by setting USE_COSMOS_EMULATOR to a truthy value (true/1/yes/on) in your +# shell or in the repo .env (the same .env the backend reads). When unset/false +# this script is a fast no-op so the default build stays quick. +# +# Honoured environment variables: +# USE_COSMOS_EMULATOR gate (true/1/yes/on enables; anything else skips) +# AZURE_COSMOS_ENDPOINT readiness URL (default https://localhost:8081/) +# COSMOS_EMULATOR_CONTAINER docker container name (default azure-cosmos-emulator) +# COSMOS_EMULATOR_IMAGE docker image (default mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest) +# ENV_FILE path to the .env to source (default /.env) +# AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE classic emulator IP to advertise +# AZURE_COSMOS_EMULATOR_PARTITION_COUNT partitions (default 3) +# +# Note: the classic Linux emulator advertises its container IP (e.g. 172.17.0.2) for data +# operations, which is often unreachable from the host — you'll see connection timeouts. +# If that happens, either set AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE to a host-reachable +# IP and use that same IP in AZURE_COSMOS_ENDPOINT, or use the newer image that works with +# localhost out of the box: +# COSMOS_EMULATOR_IMAGE=mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ENV_FILE:-${REPO_ROOT}/.env}" + +# Load .env (if present) so the flag + Cosmos settings can live alongside the +# backend's configuration. Best-effort; ignore parse errors. +if [ -f "${ENV_FILE}" ]; then + set -a + # shellcheck disable=SC1090 + . "${ENV_FILE}" 2>/dev/null || true + set +a +fi + +case "$(printf '%s' "${USE_COSMOS_EMULATOR:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) ;; + *) + echo "[cosmos-emulator] USE_COSMOS_EMULATOR not enabled — skipping emulator start." + exit 0 + ;; +esac + +ENDPOINT="${AZURE_COSMOS_ENDPOINT:-https://localhost:8081/}" +CONTAINER_NAME="${COSMOS_EMULATOR_CONTAINER:-azure-cosmos-emulator}" +IMAGE="${COSMOS_EMULATOR_IMAGE:-mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest}" + +# Pick a working Docker CLI: prefer the native Linux client, then fall back to +# the Windows Docker Desktop client (docker.exe). The fallback is handy in WSL +# when Docker Desktop is running but WSL integration isn't enabled for this +# distro. Either way, -p published ports are reachable from WSL at localhost. +DOCKER="" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + DOCKER="docker" +elif command -v docker.exe >/dev/null 2>&1 && docker.exe info >/dev/null 2>&1; then + DOCKER="docker.exe" + echo "[cosmos-emulator] Native WSL docker not reachable; using Docker Desktop via docker.exe." +fi + +if [ -z "${DOCKER}" ]; then + echo "[cosmos-emulator] WARNING: no reachable Docker daemon — skipping emulator start." + echo "[cosmos-emulator] Docker Desktop on Windows: start it AND enable WSL integration for this" + echo "[cosmos-emulator] distro (Settings > Resources > WSL Integration); or native Linux/WSL:" + echo "[cosmos-emulator] 'sudo service docker start'. The backend will still launch." + exit 0 +fi + +if "${DOCKER}" ps --format '{{.Names}}' | tr -d '\r' | grep -qx "${CONTAINER_NAME}"; then + echo "[cosmos-emulator] '${CONTAINER_NAME}' already running." + exit 0 +fi + +if "${DOCKER}" ps -a --format '{{.Names}}' | tr -d '\r' | grep -qx "${CONTAINER_NAME}"; then + echo "[cosmos-emulator] starting existing container '${CONTAINER_NAME}'..." + "${DOCKER}" start "${CONTAINER_NAME}" >/dev/null +else + echo "[cosmos-emulator] creating and starting '${CONTAINER_NAME}'..." + EMU_ENV=(-e "AZURE_COSMOS_EMULATOR_PARTITION_COUNT=${AZURE_COSMOS_EMULATOR_PARTITION_COUNT:-3}") + if [ -n "${AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE:-}" ]; then + EMU_ENV+=(-e "AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE=${AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE}") + fi + "${DOCKER}" run -d --name "${CONTAINER_NAME}" \ + -p 8081:8081 -p 10250-10255:10250-10255 \ + "${EMU_ENV[@]}" \ + "${IMAGE}" >/dev/null +fi + +echo "[cosmos-emulator] waiting for ${ENDPOINT} (up to ~2 min)..." +for _ in $(seq 1 60); do + if curl -ksf "${ENDPOINT%/}/_explorer/index.html" >/dev/null 2>&1; then + echo "[cosmos-emulator] ready." + exit 0 + fi + sleep 2 +done + +echo "[cosmos-emulator] did not become ready in time." >&2 +exit 1 diff --git a/session_orchestration.py b/session_orchestration.py index f0ab102..3aa1317 100644 --- a/session_orchestration.py +++ b/session_orchestration.py @@ -15,8 +15,10 @@ from pydantic import BaseModel from agent_factory import SubAgentResources, create_chat_runtime +from cosmos_memory import get_conversation_repository from eval_trace import EvalTraceLogger from mcp_servers import connect_mcp_servers, parse_mcp_server_configs +from user_data import get_user_profile_repository from prompt_config import ( SubAgentToolRef, _parse_sub_agent_tool_refs, @@ -37,6 +39,8 @@ validate_tool_names, ) +logger = logging.getLogger(__name__) + MAX_USER_INPUT_CHARS = int(os.getenv("MAX_USER_INPUT_CHARS", "8000")) _SECRET_QUERY_KEYS = {"api_key", "apikey", "code", "token", "access_token", "client_secret", "password"} @@ -80,7 +84,7 @@ class SessionContext: sessions: dict[str, Any] session_data_cls: type get_skills_dir: Callable[[], Path] - build_tool_instances: Callable[..., tuple[list[Any], Any]] + build_tool_instances: Callable[..., list[Any]] build_user_profile_context: Callable[[dict[str, str] | None], str] @@ -274,7 +278,7 @@ async def _resolve_builtin_sub_agent_resources( *, ctx: SessionContext, user_bearer_token: str | None, - user_profile_data: dict[str, str] | None, + user_id: str, session_id: str, logger: logging.Logger, ) -> tuple[dict[str, SubAgentResources], list[Any]]: @@ -299,10 +303,10 @@ async def _resolve_builtin_sub_agent_resources( continue tool_names_raw = entry.get("tools") or [] tool_names = {str(t) for t in tool_names_raw if isinstance(t, str)} - function_tools, _store = ctx.build_tool_instances( + function_tools = ctx.build_tool_instances( tool_names, session_id=f"{session_id}:sub:{profile_id}", - user_profile_data=user_profile_data, + user_id=user_id, ) try: mcp_configs = parse_mcp_server_configs(entry) @@ -325,16 +329,61 @@ async def _resolve_builtin_sub_agent_resources( return resources_by_profile, all_mcp_tools -def _restore_session_history(history: object, session_id: str, fallback_session: AgentSession, logger: logging.Logger) -> AgentSession: - if history and isinstance(history, dict): - try: - agent_session = AgentSession.from_dict(history) - agent_session._session_id = session_id - logger.info("Restored session history for session %s", session_id) - return agent_session - except Exception: - logger.warning("Failed to restore session history for %s, using fresh session", session_id) - return fallback_session +def _bind_session_id(session: AgentSession, session_id: str) -> AgentSession: + """Bind the runtime session to the conversation id (Cosmos partition key).""" + session._session_id = session_id + return session + + +async def _resolve_session_id(conversations: Any, user: Any, body: dict[str, Any]) -> tuple[str, bool]: + """Return ``(session_id, is_resume)``. + + When ``conversation_id`` is supplied the caller is resuming an existing + conversation: ownership is verified against the per-user index (404 if it is + not owned by the caller). Otherwise a new unguessable session id is generated + for a fresh conversation. + """ + conversation_id = body.get("conversation_id") + if not conversation_id: + return str(uuid.uuid4()), False + conversation_id = str(conversation_id) + try: + owned = await conversations.get_owned(user.user_id, conversation_id) + except Exception as exc: # noqa: BLE001 — surface store errors as retryable + logger.error("Conversation lookup failed for %s: %s", conversation_id, sanitize_mcp_result_error(str(exc))) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") from exc + if owned is None: + raise HTTPException(status_code=404, detail="Conversation not found") + return conversation_id, True + + +async def _create_conversation_index( + conversations: Any, + *, + user: Any, + session_id: str, + profile_id: str, + profile_name: str, + custom_agent_id: str | None = None, + used_builtin_override: bool = False, + base_profile_id: str | None = None, + override_updated_at: str | None = None, +) -> None: + """Write the per-user conversation index entry (FR-002/FR-004).""" + try: + await conversations.create( + user.user_id, + session_id, + profile_id, + profile_name, + custom_agent_id=custom_agent_id, + used_builtin_override=used_builtin_override, + base_profile_id=base_profile_id, + override_updated_at=override_updated_at, + ) + except Exception as exc: # noqa: BLE001 — surface store errors as retryable + logger.error("Failed to write conversation index for %s: %s", session_id, sanitize_mcp_result_error(str(exc))) + raise HTTPException(status_code=503, detail="Conversation store is temporarily unavailable. Please try again.") from exc def _parse_profile_override(raw_profile_override: object) -> ProfileOverrideRequest | None: @@ -364,7 +413,6 @@ def _store_session( profile_name: str, chat_runtime: Any, agent_session: AgentSession, - user_profile_store: Any, mcp_tools: list[Any], profile_override: "ProfileOverrideRequest | None" = None, ) -> None: @@ -380,7 +428,6 @@ def _store_session( prompt_manifest=chat_runtime.prompt_manifest, prompt_logical_profile=chat_runtime.prompt_logical_profile, ) - session_data.user_profile_store = user_profile_store session_data.mcp_tools = mcp_tools if profile_override is not None: session_data.used_profile_override = True @@ -439,7 +486,7 @@ async def _create_custom_chat_session( custom_search_context = bool(body.get("custom_search_context", False)) custom_temperature = validate_temperature(body.get("custom_temperature")) custom_tools = validate_tool_names(body.get("custom_tools", []), known_tool_names_from_profiles(profiles_data)) - custom_skills, dropped_skills = filter_known_skill_names(body.get("custom_skills", []), available_skill_names(ctx.get_skills_dir())) + custom_skills, dropped_skills = filter_known_skill_names(body.get("custom_skills", []), await available_skill_names(ctx.get_skills_dir())) if dropped_skills: logger.warning("Custom agent '%s' references unknown skills, dropping: %s", custom_name, dropped_skills) raw_mcp_servers = validate_http_mcp_servers(body.get("mcp_servers", []), override=False) @@ -449,22 +496,27 @@ async def _create_custom_chat_session( logger=logger, ) - session_id = str(uuid.uuid4()) + conversations = get_conversation_repository() + session_id, is_resume = await _resolve_session_id(conversations, user, body) custom_tool_set = set(custom_tools) try: - function_tools, user_profile_store = ctx.build_tool_instances( + function_tools = ctx.build_tool_instances( custom_tool_set, session_id=session_id, - user_profile_data=body.get("user_profile"), + user_id=user.user_id, ) mcp_configs = parse_mcp_server_configs({"mcp_servers": raw_mcp_servers}) mcp_tools, mcp_results = await connect_mcp_servers(mcp_configs, user_token=user_bearer_token) - profile_context = ctx.build_user_profile_context(body.get("user_profile")) if "get_user_profile" in custom_tool_set else "" + profile_context = ( + ctx.build_user_profile_context(await get_user_profile_repository().get(user.user_id, user.user_id)) + if "get_user_profile" in custom_tool_set + else "" + ) sub_agent_resources, sub_mcp_tools = await _resolve_builtin_sub_agent_resources( sub_agent_refs, ctx=ctx, user_bearer_token=user_bearer_token, - user_profile_data=body.get("user_profile"), + user_id=user.user_id, session_id=session_id, logger=logger, ) @@ -490,7 +542,7 @@ async def _create_custom_chat_session( logger.exception("Unexpected error creating session for custom agent '%s'", custom_name) raise HTTPException(status_code=500, detail=sanitize_mcp_result_error(str(exc))) from exc - agent_session = _restore_session_history(body.get("history"), session_id, chat_runtime.session, logger) + agent_session = _bind_session_id(chat_runtime.session, session_id) _store_session( ctx, session_id=session_id, @@ -499,9 +551,17 @@ async def _create_custom_chat_session( profile_name=custom_name, chat_runtime=chat_runtime, agent_session=agent_session, - user_profile_store=user_profile_store, mcp_tools=mcp_tools, ) + if not is_resume: + await _create_conversation_index( + conversations, + user=user, + session_id=session_id, + profile_id="custom", + profile_name=custom_name, + custom_agent_id=str(body.get("custom_id") or custom_name) or None, + ) logger.info("Created custom session %s for user %s agent=%s", session_id, user.user_id, custom_name) return { @@ -531,19 +591,20 @@ async def _create_profile_chat_session( profile_name = str(profile_entry.get("name", logical_profile)) profile_override = _parse_profile_override(body.get("profile_override")) profile_tool_names = list(profile_override.custom_tools) if profile_override is not None else (profile_entry.get("tools") or []) - session_id = str(uuid.uuid4()) + conversations = get_conversation_repository() + session_id, is_resume = await _resolve_session_id(conversations, user, body) try: - function_tools, user_profile_store = ctx.build_tool_instances( + function_tools = ctx.build_tool_instances( set(profile_tool_names), session_id=session_id, - user_profile_data=body.get("user_profile"), + user_id=user.user_id, ) if profile_override is not None: validate_tool_names(profile_override.custom_tools, known_tool_names_from_profiles(profiles_data)) if profile_override.custom_skills: - kept, dropped = filter_known_skill_names(profile_override.custom_skills, available_skill_names(ctx.get_skills_dir())) + kept, dropped = filter_known_skill_names(profile_override.custom_skills, await available_skill_names(ctx.get_skills_dir())) if dropped: logger.warning("Profile override for '%s' references unknown skills, dropping: %s", logical_profile, dropped) profile_override.custom_skills = kept @@ -554,7 +615,11 @@ async def _create_profile_chat_session( mcp_configs = parse_mcp_server_configs(profile_entry) mcp_tools, mcp_results = await connect_mcp_servers(mcp_configs, user_token=user_bearer_token) - profile_context = ctx.build_user_profile_context(body.get("user_profile")) if "get_user_profile" in profile_tool_names else "" + profile_context = ( + ctx.build_user_profile_context(await get_user_profile_repository().get(user.user_id, user.user_id)) + if "get_user_profile" in profile_tool_names + else "" + ) if profile_override is not None: override_sub_agent_payload = profile_override.agentsAsTools if profile_override.agentsAsTools is not None else profile_override.agents_as_tools @@ -567,7 +632,7 @@ async def _create_profile_chat_session( override_sub_agent_refs, ctx=ctx, user_bearer_token=user_bearer_token, - user_profile_data=body.get("user_profile"), + user_id=user.user_id, session_id=session_id, logger=logger, ) @@ -590,7 +655,7 @@ async def _create_profile_chat_session( profile_sub_agent_refs, ctx=ctx, user_bearer_token=user_bearer_token, - user_profile_data=body.get("user_profile"), + user_id=user.user_id, session_id=session_id, logger=logger, ) @@ -610,7 +675,7 @@ async def _create_profile_chat_session( logger.exception("Unexpected error creating session for profile '%s'", logical_profile) raise HTTPException(status_code=500, detail=sanitize_mcp_result_error(str(exc))) from exc - agent_session = _restore_session_history(body.get("history"), session_id, chat_runtime.session, logger) + agent_session = _bind_session_id(chat_runtime.session, session_id) _store_session( ctx, session_id=session_id, @@ -619,10 +684,20 @@ async def _create_profile_chat_session( profile_name=profile_name, chat_runtime=chat_runtime, agent_session=agent_session, - user_profile_store=user_profile_store, mcp_tools=mcp_tools, profile_override=profile_override, ) + if not is_resume: + await _create_conversation_index( + conversations, + user=user, + session_id=session_id, + profile_id=logical_profile, + profile_name=profile_name, + used_builtin_override=profile_override is not None, + base_profile_id=logical_profile if profile_override is not None else None, + override_updated_at=profile_override.override_updated_at if profile_override is not None else None, + ) logger.info("Created session %s for user %s profile %s", session_id, user.user_id, logical_profile) profile_skills = list(profile_override.custom_skills) if profile_override is not None else [ diff --git a/skills_manager.py b/skills_manager.py index f8b981c..c994e73 100644 --- a/skills_manager.py +++ b/skills_manager.py @@ -48,15 +48,15 @@ def parse(self, skill_dir: Path) -> dict: name = line[len("name:"):].strip() return {"name": name, "description": description, "content": content} - def list_summaries(self) -> list[dict[str, str]]: + async def list_summaries(self) -> list[dict[str, str]]: if not self.skills_dir.is_dir(): return [] - from agent_framework import SkillsProvider + from agent_framework import FileSkillsSource - provider = SkillsProvider(skill_paths=self.skills_dir) + skills = await FileSkillsSource(self.skills_dir).get_skills() return [ - {"name": skill.name, "description": skill.description} - for skill in provider._skills.values() + {"name": skill.frontmatter.name, "description": skill.frontmatter.description} + for skill in skills ] def get(self, name: str) -> dict: diff --git a/specs/011-cosmos-agent-memory/contracts/conversations-api.md b/specs/011-cosmos-agent-memory/contracts/conversations-api.md new file mode 100644 index 0000000..f8dda30 --- /dev/null +++ b/specs/011-cosmos-agent-memory/contracts/conversations-api.md @@ -0,0 +1,155 @@ +# Contract: Conversations API (REST) + +**Feature**: `011-cosmos-agent-memory` | **Date**: 2026-06-18 + +Backend REST endpoints that let the frontend read the authenticated user's real conversations and messages from Azure Cosmos DB, and that change session create/resume to load history server-side. All endpoints are served by the FastAPI backend (the only gateway to Cosmos — Principle VII) and require authentication via the existing `get_current_user` dependency (except where local-dev `AUTH_DISABLED=true` applies). + +**Conventions**: +- Base path: `/api`. +- Auth: `Authorization: Bearer ` → `AuthenticatedUser{ user_id, username }`. `user_id` (token `oid`) is the ownership key and is NEVER read from the request body. +- Ownership: every operation on a specific conversation first verifies the `conversations` index document's `user_id` equals the caller's `user_id`. Failures return `404 Not Found` (do not disclose existence to non-owners). +- Errors: JSON `{ "detail": "" }`. Cosmos throttling/transient failures surface as `503` with a retry hint; secrets never appear in messages. + +--- + +## 1. List my conversations + +``` +GET /api/conversations +``` + +Returns the authenticated user's conversations for the left chat pane, newest activity first. Single-partition query on `/user_id`. + +**Query parameters** (optional): +| Name | Type | Default | Notes | +|------|------|---------|-------| +| `limit` | int | 50 | Max items to return (bounded, e.g. 1–200). | +| `cursor` | string | — | Opaque continuation token for paging (Cosmos continuation), if more than `limit`. | + +**Response 200**: +```json +{ + "conversations": [ + { + "id": "f1e2d3c4-...", + "profileId": "chief-of-staff", + "profileName": "Chief of Staff", + "description": "Help me plan the offsite agenda", + "createdAt": "2026-06-18T14:03:11Z", + "lastActivityAt": "2026-06-18T14:25:02Z", + "customAgentId": null, + "usedBuiltInOverride": false, + "baseProfileId": null, + "overrideUpdatedAt": null + } + ], + "nextCursor": null +} +``` + +**Status codes**: `200` OK · `401` unauthenticated · `503` Cosmos unavailable/throttled. + +**Notes**: Returns only the caller's conversations. Cosmos is required — there is no in-memory fallback (the app fails fast at startup if Cosmos isn't configured; unit tests inject in-memory doubles). + +--- + +## 2. Get one conversation's messages (for resume display) + +``` +GET /api/conversations/{id}/messages +``` + +Returns the stored messages of a conversation the caller owns, mapped to the chat view's `ChatMessage[]` shape. Ownership is verified against the index before reading the `chat-history` container. + +**Path parameters**: `id` — conversation id (== session id). + +**Response 200**: +```json +{ + "id": "f1e2d3c4-...", + "profileId": "chief-of-staff", + "profileName": "Chief of Staff", + "messages": [ + { "role": "user", "content": "Help me plan the offsite agenda", "images": [] }, + { + "role": "assistant", + "content": "Here is a draft agenda...", + "tool_invocations": [ + { "call_id": "call-1", "name": "get_user_profile", "arguments": "{}", "result": "{...}" } + ], + "usage": { "input_token_count": 150, "output_token_count": 60, "total_token_count": 210 } + } + ] +} +``` + +**Status codes**: `200` OK · `401` unauthenticated · `404` not found or not owned · `503` Cosmos unavailable. + +**Notes**: Message mapping mirrors the streaming event shapes already produced by `streaming.py` (`text`, `function_call`, `function_result`, `usage`), so resumed conversations render identically to live ones. Tool-internal/excluded messages are not surfaced as separate chat bubbles. + +--- + +## 3. Delete a conversation + +``` +DELETE /api/conversations/{id} +``` + +Deletes a conversation the caller owns: clears its messages from `chat-history` (provider `clear(session_id)`) and removes its `conversations` index document. Idempotent for the owner. + +**Response**: `204 No Content`. + +**Status codes**: `204` deleted · `401` unauthenticated · `404` not found or not owned · `503` Cosmos unavailable (partial failure is reported and retryable; see FR-018). + +**Behavioral notes**: +- If the conversation being deleted is the active session, the frontend resets to a new/empty chat (edge case). +- Deletion order SHOULD clear messages first, then delete the index doc, so a retry after partial failure still resolves; the implementation must avoid an index entry that points at already-cleared messages without signaling the inconsistency. + +--- + +## 4. Changed: Create / resume a session + +The existing `POST /api/sessions` and `POST /api/sessions/{session_id}/messages` endpoints remain, with these contract changes: + +### 4a. `POST /api/sessions` — create or resume + +**New conversation** (no `conversation_id`): the backend generates a server-side `session_id` (UUID), creates the `conversations` index document bound to the caller's `user_id` and chosen profile, and returns the `session_id`. + +**Resume existing** (request includes `conversation_id`): the backend verifies ownership of that id, rebuilds the `AgentSession` with the same `session_id`, and the `CosmosHistoryProvider` loads prior history automatically. + +**Request body** (additions/removals relative to today): +| Field | Change | Notes | +|-------|--------|-------| +| `conversation_id` | NEW (optional) | When present → resume that conversation (ownership-checked). When absent → create new. | +| `history` | REMOVED | The client no longer supplies a serialized session/history blob; history lives in Cosmos. | +| `profile_id`, `custom_*`, `profile_override`, `mcp_servers`, `agents_as_tools`, `user_profile` | unchanged | Existing session-creation fields are preserved for new conversations. | + +**Response**: existing shape (`session_id`, `profile_id`, `profile_name`, `tools_loaded`, `skills_loaded`, `agents_loaded`, `search_context`, `mcp_results`), where `session_id` is the conversation id usable with the conversations endpoints. + +**Status codes**: `200`/`201` created or resumed · `401` unauthenticated · `404` resume id not found or not owned · `400` invalid profile/override · `503` Cosmos unavailable (when Cosmos is the configured store). + +### 4b. `POST /api/sessions/{session_id}/messages` — send a message (unchanged transport) + +- Transport, SSE event shapes (`text`, `function_call`, `function_result`, `usage`, `done`, `error`), text and multipart/image inputs are **unchanged** (FR-011). +- Side effect change: on stream completion (`done`), the backend (1) ensures the turn's messages are persisted to `chat-history` via the provider, (2) sets `title` from the first user message if not yet set, and (3) bumps `last_activity_at` on the index document. + +### 4c. `GET /api/sessions/{session_id}/history` — deprecated for persistence + +- This endpoint (previously used by the client to fetch the serializable session blob for localStorage) is no longer the persistence mechanism. It MAY be retained for diagnostics or removed; the frontend stops depending on it for saving conversations. The authoritative read path is `GET /api/conversations/{id}/messages`. + +### 4d. `DELETE /api/sessions/{session_id}` — runtime cleanup only + +- Continues to tear down the in-process runtime session. It does NOT delete persisted history (use `DELETE /api/conversations/{id}` for durable deletion). Ending a runtime session leaves the durable conversation intact for later resume. + +--- + +## Authorization matrix + +| Endpoint | Owner | Non-owner | Unauthenticated | +|----------|-------|-----------|-----------------| +| `GET /api/conversations` | own list only | own list only | `401` | +| `GET /api/conversations/{id}/messages` | `200` | `404` | `401` | +| `DELETE /api/conversations/{id}` | `204` | `404` | `401` | +| `POST /api/sessions` (resume) | `200` | `404` | `401` | + +All non-owner access to a specific conversation returns `404` (never `403`) to avoid disclosing existence (FR-008). diff --git a/specs/011-cosmos-agent-memory/contracts/cosmos-memory-integration.md b/specs/011-cosmos-agent-memory/contracts/cosmos-memory-integration.md new file mode 100644 index 0000000..7f222c5 --- /dev/null +++ b/specs/011-cosmos-agent-memory/contracts/cosmos-memory-integration.md @@ -0,0 +1,116 @@ +# Contract: Cosmos Memory Backend Integration + +**Feature**: `011-cosmos-agent-memory` | **Date**: 2026-06-18 + +Internal backend contract for wiring Azure Cosmos DB into the agent runtime and the conversation index. This is the integration surface implemented in a new `cosmos_memory.py` module plus edits to `agent_factory.py`, `session_orchestration.py`, and `main.py`. It is not a public HTTP API (that is `conversations-api.md`); it defines the Python-level seams so the integration stays seamless and minimal. + +## Configuration (environment) + +| Variable | Required (prod) | Purpose | +|----------|-----------------|---------| +| `AZURE_COSMOS_ENDPOINT` | yes | Cosmos account endpoint (`https://.documents.azure.us:443/`). Presence selects the Cosmos path; absence selects the local in-memory fallback. | +| `AZURE_COSMOS_DATABASE_NAME` | yes | Database name (e.g. `agent-memory`). | +| `AZURE_COSMOS_CONTAINER_NAME` | yes | Messages container name (e.g. `chat-history`). Consumed by `CosmosHistoryProvider`. | +| `AZURE_COSMOS_CONVERSATIONS_CONTAINER` | no (default `conversations`) | Per-user index container name. | +| `AZURE_COSMOS_KEY` | local only | Account key for local/emulator use. MUST be unset in production (Managed Identity + RBAC). | + +- Credential resolution mirrors the existing AI Search pattern: if `AZURE_COSMOS_KEY` is set, use it (local/dev); otherwise use `DefaultAzureCredential()` configured for Azure US Government, relying on the app's user-assigned managed identity. +- Secrets MUST never be logged; endpoint values may be logged, keys/credentials must not. + +## History provider factory + +A single cached factory builds the agent-facing history provider. + +```text +get_history_provider() -> HistoryProvider + if AZURE_COSMOS_ENDPOINT is configured: + return CosmosHistoryProvider( + endpoint=, + database_name=, + container_name=, + credential=, + # source_id defaults to "azure_cosmos_history" + ) + else: + return InMemoryHistoryProvider(skip_excluded=True) # local-dev / test fallback +``` + +**Contract guarantees**: +- The returned object is a `HistoryProvider` and therefore exposes `.source_id`. Callers MUST use `provider.source_id` for `CompactionProvider(history_source_id=...)` rather than hardcoding a string. +- The Cosmos client owns its own async lifecycle; the provider is reused (not recreated per request) consistent with Azure SDK guidance (single client instance). Provider/client cleanup hooks into the app/runtime shutdown path. +- The factory is import-safe: it does not connect at import time; the Cosmos container is resolved lazily on first use (the provider creates the container if missing as a safety net, though Terraform is the authoritative provisioner). + +## `_build_context_providers` change (`agent_factory.py`) + +The current pipeline (unchanged except for the history element): + +```text +history = get_history_provider() # was: InMemoryHistoryProvider(skip_excluded=True) +compaction = CompactionProvider( + before_strategy=pipeline, + after_strategy=pipeline, + tokenizer=tokenizer, + history_source_id=history.source_id, # works for both providers + ) +providers = [history, compaction] +# + optional AzureAISearchContextProvider, SkillsProvider (unchanged) +``` + +**Guarantees**: +- No change to the order or presence of compaction/search/skills providers. +- The swap is transparent to `create_chat_runtime`/`ChatRuntime`; the returned `agent` and `session` behave identically except history is now durable and session-keyed. +- `agent.create_session()` continues to produce an `AgentSession`; the backend assigns/overrides `session_id` to the conversation id so the provider partitions correctly. + +## Conversation index repository (`cosmos_memory.py`) + +Application-owned repository for the `conversations` container. Interface (async): + +```text +class ConversationIndexRepository: + async def create(user_id, conversation_id, profile_id, profile_name, *, custom_agent_id=None, + used_builtin_override=False, base_profile_id=None, override_updated_at=None) -> IndexEntry + async def list_for_user(user_id, *, limit=50, cursor=None) -> (list[IndexEntry], next_cursor) + async def get_owned(user_id, conversation_id) -> IndexEntry | None # ownership check (point-read) + async def touch(user_id, conversation_id, *, title=None) -> None # bump last_activity_at; set title if provided/unset + async def delete(user_id, conversation_id) -> bool # delete index doc (after messages cleared) +``` + +**Guarantees & rules**: +- All methods are partition-scoped by `user_id`; `user_id` always comes from the authenticated principal, never the request body. +- `get_owned` returns `None` for both "missing" and "owned by someone else" (callers translate to `404`). +- `create` generates the conversation id (UUID) server-side; it equals the messages `session_id`. +- `touch` is called on turn completion: sets `title` from the first user message when not yet set, and always updates `last_activity_at`. +- A **fallback implementation** (in-memory dict keyed by `user_id`) is used when Cosmos is not configured, exposing the same interface so route handlers are storage-agnostic. The fallback is process-local and non-durable (documented local-dev behavior). + +## Ownership-gated message access + +The only sanctioned way route handlers read or clear messages: + +```text +entry = await conversations_repo.get_owned(user.user_id, conversation_id) +if entry is None: + raise HTTPException(404) +messages = await history_provider.get_messages(conversation_id) # session_id == conversation_id +# or, for delete: +await history_provider.clear(conversation_id) +await conversations_repo.delete(user.user_id, conversation_id) +``` + +**Guarantee**: message-container access is always preceded by an index ownership check in the same request, giving a single authorization choke point (FR-008). + +## Session create/resume wiring (`session_orchestration.py`) + +- **Create**: generate `session_id` (UUID) → build runtime via `create_chat_runtime` → set `AgentSession.session_id = session_id` → `conversations_repo.create(user.user_id, session_id, profile_id, profile_name, ...)`. +- **Resume**: require `conversation_id` → `get_owned(user.user_id, conversation_id)` (else `404`) → build runtime → set `AgentSession.session_id = conversation_id`. No client-supplied `history` blob is read; the provider loads messages on first run. +- The previous `_restore_session_history(...from client dict...)` path is removed from the durable-storage flow; in the local fallback, in-memory session state may still be used but is non-authoritative. + +## Failure & resilience contract + +- Cosmos throttling/transient errors propagate as a retryable error to the route layer, which returns `503` with a retry hint (FR-018). The Azure Cosmos SDK's built-in retry handles most `429`s; the app adds a clear user-facing error rather than a stack trace. +- Diagnostics: on latency over threshold or unexpected status, capture Cosmos diagnostics for troubleshooting (without leaking credentials), consistent with Cosmos SDK best practices. +- Startup MUST NOT hard-fail when Cosmos is unconfigured locally; it selects the fallback. Startup MAY warn (once) that durable memory is disabled. + +## Testing contract + +- Unit/integration tests construct `ConversationIndexRepository` and the history provider against a **fake** Cosmos data layer (in-memory doubles implementing the same async methods), so `list/get/touch/delete`, ownership/isolation, resume, and the fallback path are covered with no live cloud account. +- A smoke test asserts `get_history_provider()` returns a `CosmosHistoryProvider` when `AZURE_COSMOS_ENDPOINT` is set (without connecting) and `InMemoryHistoryProvider` otherwise. diff --git a/specs/011-cosmos-agent-memory/data-model.md b/specs/011-cosmos-agent-memory/data-model.md new file mode 100644 index 0000000..1a827aa --- /dev/null +++ b/specs/011-cosmos-agent-memory/data-model.md @@ -0,0 +1,158 @@ +# Phase 1 Data Model: Azure Cosmos DB Agent Memory Layer + +**Feature**: `011-cosmos-agent-memory` | **Date**: 2026-06-18 | **Plan**: [plan.md](plan.md) + +This document defines the Azure Cosmos DB storage model, the document schemas, partition strategy, and the lifecycle/validation rules that satisfy the spec's functional requirements. Cosmos DB for NoSQL (SQL API) is used. + +## Storage topology + +```text +Cosmos DB account (NoSQL API, serverless, Azure Government: *.documents.azure.us) +└── database: agent-memory # AZURE_COSMOS_DATABASE_NAME + ├── container: chat-history # AZURE_COSMOS_CONTAINER_NAME — messages + │ partition key: /session_id + │ owner: agent_framework CosmosHistoryProvider + └── container: conversations # per-user conversation index + partition key: /user_id + owner: backend cosmos_memory.py (ConversationIndexRepository) +``` + +- **One database, two containers.** Each container has a single, distinct access pattern (see [research.md](research.md) R4). +- The `chat-history` container schema is owned by the Agent Framework provider; the application treats it as managed storage and never writes message documents by hand. The application reads it only through the provider (e.g., `get_messages`, `clear`). +- The `conversations` container is owned and shaped entirely by application code. + +--- + +## Entity 1 — Conversation Message (`chat-history` container) + +Represents a single persisted message in a conversation. **Managed by `CosmosHistoryProvider`** — schema shown for reference only; the application does not author these documents. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string (UUID) | Document id, generated per message by the provider. | +| `session_id` | string | **Partition key** (`/session_id`). Equals the conversation id. | +| `sort_key` | number | Monotonic ordering key (`time_ns()` base + index). Messages are returned `ORDER BY sort_key ASC`. | +| `source_id` | string | Provider source id (default `"azure_cosmos_history"`); used to scope queries. | +| `message` | object | Serialized Agent Framework `Message` (`Message.to_dict()`), including role and content (text, tool calls/results, etc.). | + +**Access patterns** (all single-partition by `session_id`): +- Load history for a turn: `SELECT c.message ... WHERE c.session_id=@sid AND c.source_id=@src ORDER BY c.sort_key ASC` (provider `get_messages`). +- Append turn messages: batched upsert (provider `save_messages`). +- Clear on delete: query ids then batched delete (provider `clear`). + +**Rules**: +- The application MUST NOT read this container except through the provider, and MUST verify conversation ownership (Entity 2) before invoking provider reads/clears for a given `session_id`. +- Message documents are append-only per turn; ordering relies on `sort_key`. + +--- + +## Entity 2 — Conversation Index Entry (`conversations` container) + +Represents one chat thread owned by a user. **Authored and managed by the backend** (`cosmos_memory.py`). This is the authoritative ownership record and the source for the left chat pane. + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `id` | string (UUID) | yes | Document id == conversation id == `session_id` (messages partition key). Unguessable (FR-009). | +| `user_id` | string | yes | **Partition key** (`/user_id`). The owner's stable identifier (auth token `oid`). | +| `profile_id` | string | yes | Agent/profile identifier used by this conversation (e.g., `chief-of-staff`, or `custom`). | +| `profile_name` | string | yes | Display name of the agent, for the list. | +| `title` | string | yes | Human-readable description derived from the first user message (truncated, e.g. ≤60 chars). Empty until the first message (see lifecycle). | +| `created_at` | string (ISO-8601 UTC) | yes | Creation timestamp. | +| `last_activity_at` | string (ISO-8601 UTC) | yes | Updated on each completed turn; the list orders by this descending. | +| `custom_agent_id` | string | no | Set when a custom agent was used, to reopen with the same agent. | +| `used_builtin_override` | boolean | no | True when a built-in agent was customized for this conversation. | +| `base_profile_id` | string | no | Original profile id when an override was applied. | +| `override_updated_at` | string (ISO-8601 UTC) | no | Timestamp of the applied override, mirrors existing session metadata. | +| `doc_type` | string | no | Constant marker (e.g. `"conversation"`) for forward-compatibility if the container is ever shared. | +| `schema_version` | number | no | Document schema version for future migrations (initially `1`). | + +**Access patterns** (all single-partition by `user_id`): +- List my conversations: `SELECT * FROM c WHERE c.user_id=@uid ORDER BY c.last_activity_at DESC` (optionally `OFFSET/LIMIT` for paging). +- Ownership check / fetch one: point-read by (`id`, partition `user_id`). +- Upsert on create and on activity: replace/upsert the document. +- Delete: point-delete by (`id`, partition `user_id`). + +**Validation rules**: +- `id` MUST be a server-generated UUID; never client-supplied (prevents enumeration and cross-user collisions). +- `user_id` MUST come from the validated auth token, never from the request body. +- `title` MUST be bounded in length and sanitized of control characters; derived from the first user message content only. +- `profile_id`/`profile_name` MUST reference a resolvable agent at resume time; if unresolved, resume surfaces a clear error rather than silently changing agents. +- All timestamps are UTC ISO-8601. + +--- + +## Relationships + +```text +AuthenticatedUser (user_id = oid) + │ owns 1..* + ▼ +Conversation Index Entry (conversations, partition /user_id) + │ id == session_id (1:1) + ▼ +Conversation Messages (chat-history, partition /session_id) 0..* +``` + +- **User → Conversation**: one-to-many; isolation is physical (per-user partition) and enforced logically (ownership check). +- **Conversation → Messages**: one-to-many; the conversation `id` is the messages' `session_id`. The index entry is the gatekeeper; messages are only accessed after the entry's `user_id` matches the caller. +- No cross-references are stored inside message documents back to the user; ownership is resolved exclusively through the index. + +--- + +## Conversation lifecycle (state transitions) + +```text + create session (new) + ─────────────────────────────────► CREATED + │ index doc written: id=session_id, user_id, + │ profile_*, created_at=last_activity_at=now, title="" + │ + first user message completes ▼ + ─────────────────────────────────► ACTIVE + │ title set from first user message; + │ messages persisted to chat-history; + │ last_activity_at bumped each turn + │ + resume (by conversation_id) │ ownership verified; AgentSession rebuilt with + ◄─────────────────────────────────────┘ same session_id; provider loads prior messages + │ + delete (by conversation_id) ▼ + ─────────────────────────────────► DELETED + ownership verified; provider.clear(session_id) + removes messages; index doc deleted +``` + +**Transition rules**: +- **CREATED → ACTIVE**: occurs when the first turn completes. If a conversation is created but never receives a message, it remains titleless; the list view MUST render it without error (edge case) or such empty entries MAY be pruned (implementation choice, must be consistent). +- **ACTIVE (resume)**: requires a successful ownership check; a failed check returns not-found/denied without revealing existence to non-owners. +- **→ DELETED**: requires ownership check; both containers are updated. Deletion of messages uses the provider's `clear(session_id)`; the index document is point-deleted. Partial failure MUST be surfaced and retryable (FR-018) and MUST NOT leave the index claiming a conversation whose messages were already cleared without signaling the inconsistency. + +--- + +## Consistency, throughput, and limits + +- **Throughput**: serverless (per-request RU). No provisioned floor. +- **Partition sizing**: each `session_id` partition (one conversation's messages) and each `user_id` partition (one user's index) is expected to stay far below the 20 GB logical-partition limit. Hierarchical partition keys are not required (see research.md R4). +- **Indexing**: default Cosmos indexing suffices for the point-reads and single-partition `ORDER BY last_activity_at` / `ORDER BY sort_key` queries used here; no composite-index tuning is required initially. If the conversations list query is flagged for an `ORDER BY` index, add a composite index `(user_id, last_activity_at DESC)` as a follow-up. +- **Throttling (429)**: the SDK's retry behavior plus an application-level clear error path (FR-018) handle transient throttling; turns must not be left partially persisted in an inconsistent way visible to the user. + +--- + +## Mapping to existing frontend types + +The new API responses map onto (revised) frontend types in `frontend/src/types/api.ts`: + +| Cosmos index field | Frontend `ConversationIndexEntry` field | +|--------------------|-----------------------------------------| +| `id` | `id` | +| `profile_id` | `profileId` | +| `profile_name` | `profileName` | +| `title` | `description` | +| `created_at` | `createdAt` | +| `last_activity_at` | `lastActivityAt` | +| `custom_agent_id` | `customAgentId` | +| `used_builtin_override` | `usedBuiltInOverride` | +| `base_profile_id` | `baseProfileId` | +| `override_updated_at` | `overrideUpdatedAt` | + +Per-conversation messages returned by `GET /api/conversations/{id}/messages` map to the existing `ChatMessage[]` shape (`role`, `content`, optional `tool_invocations`, `usage`, `images`) so the chat view renders resumed conversations unchanged. The previously client-stored `StoredConversation.sessionData` blob is **removed** from the client contract — history now lives in Cosmos and is loaded server-side. diff --git a/specs/011-cosmos-agent-memory/plan.md b/specs/011-cosmos-agent-memory/plan.md new file mode 100644 index 0000000..08f5c62 --- /dev/null +++ b/specs/011-cosmos-agent-memory/plan.md @@ -0,0 +1,149 @@ +# Implementation Plan: Azure Cosmos DB Agent Memory Layer + +**Branch**: `011-cosmos-agent-memory` | **Date**: 2026-06-18 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/011-cosmos-agent-memory/spec.md` + +## Summary + +Replace browser-stored conversation history with a server-side memory layer backed by Azure Cosmos DB (SQL/NoSQL API), integrated through Microsoft Agent Framework's `CosmosHistoryProvider`. Conversation messages are persisted per conversation (partition key `/session_id`) by the framework provider, giving agents durable per-user/per-agent memory that survives process restarts. A second Cosmos container holds a per-user conversation index (partition key `/user_id`) that powers the left chat pane and enforces ownership. New backend endpoints (`/api/conversations`) let the frontend read the user's real conversations and messages from Cosmos; the frontend stops using `localStorage` as the source of truth for history. Cosmos is provisioned via Terraform with Managed Identity (RBAC) in production and a key/emulator path for local development. + +The integration is intentionally "seamless": `CosmosHistoryProvider` subclasses the same `HistoryProvider` base as the current `InMemoryHistoryProvider` (it exposes the same `source_id` contract the existing `CompactionProvider` depends on), so it drops into the existing `agent_factory._build_context_providers` pipeline. The session identifier the backend already controls becomes the Cosmos partition key for messages. + +## Technical Context + +**Language/Version**: Python 3.12.6 (FastAPI backend); TypeScript 5.9.x + React 19.x (frontend) +**Primary Dependencies**: `agent-framework-core`/`agent-framework-openai` (existing); NEW `agent-framework-azure-cosmos` (provides `CosmosHistoryProvider`, re-exported as `agent_framework.azure.CosmosHistoryProvider`); `azure-cosmos` (async SDK, pulled in by the provider); `azure-identity` (`DefaultAzureCredential`, already used) +**Storage**: Azure Cosmos DB for NoSQL — one database, two containers: `chat-history` (messages, partition key `/session_id`, managed by `CosmosHistoryProvider`) and `conversations` (per-user index, partition key `/user_id`, managed by new backend code). Local dev/tests use a Cosmos key/emulator or an in-memory fallback +**Testing**: `uv run pytest` (backend) in two tiers — fast unit tests with in-memory Cosmos fakes (offline default), plus integration tests against the local Azure Cosmos DB Emulator (`emulator` marker; auto-skips when unreachable) covering the real provider, partition keys, ownership, resume, and delete; `npm run build` + Playwright visual verification (frontend chat pane) +**Target Platform**: Browser SPA served by FastAPI as a single Azure App Service deployment (Azure Government) +**Project Type**: Two-tier web application (Python backend at repo root, React frontend in `frontend/`) +**Performance Goals**: Conversation-list load and per-conversation message read perceptibly immediate (single-partition Cosmos queries); message-send latency dominated by the LLM call, not by history persistence; history persistence is incremental (append per turn), not full-blob rewrites +**Constraints**: Azure Government endpoints only (`*.documents.azure.us`); Managed Identity/RBAC in production (no keys); frontend never contacts Cosmos directly; no secrets in logs/responses/bundle; `uv` only for backend deps; existing chat/streaming/tool/image behavior must not regress; UI changes require Playwright screenshots at desktop/tablet/mobile +**Scale/Scope**: Multi-user; per-user conversation lists from a handful up to many dozens of conversations; per-conversation message counts in the typical chat range (well under the 20 GB logical-partition limit per conversation and per user index) + +**Resolved unknowns** (see [research.md](research.md) for rationale): throughput mode (serverless), credential strategy (Managed Identity prod / key local), local-dev fallback (in-memory provider when Cosmos unconfigured), title derivation (first user message), pre-existing browser conversation handling (ignored, no auto-migration), and Cosmos compaction interaction. No open `NEEDS CLARIFICATION` items remain. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Read-Only Data Access (SQL) | N/A | The read-only constraint governs the agent's Azure SQL tool access. This feature adds Cosmos as a memory store; it introduces no SQL data mutation and does not relax the SQL read-only rule. Cosmos writes are conversation-memory persistence, scoped to the authenticated user. | +| II. Single-File Agent Definitions | PASS | No agent profiles or tool docs move to YAML; the change is in the history-provider wiring and a new persistence module. `agents.yaml` is unchanged. | +| III. Security & Credential Hygiene | PASS (gated) | Managed Identity/RBAC in prod, key only for local; Azure Gov endpoints; ownership checks on every conversation operation; unguessable conversation ids; no secrets logged or sent to the frontend. Enforced by FR-008/009/013/014/017. | +| IV. Evaluation-Driven Quality | PASS | No prompt/model/parameter changes. Memory persistence can influence multi-turn outputs, so the eval pipeline remains the gate for any subsequent prompt/model change; this feature itself ships no agent-behavior config change. | +| V. Simplicity & Minimalism | PASS | Reuses the built-in `CosmosHistoryProvider` instead of a custom provider; adds the minimum (one dependency, one persistence module, one Terraform module, four endpoints). Two containers are justified by two distinct access patterns (per-session messages vs. per-user index) — see Complexity Tracking. | +| VI. Infrastructure as Code | PASS (gated) | Cosmos account/database/containers and the managed-identity data-plane role assignment are provisioned via a new Terraform module under `infra/`. No portal-created resources. Enforced by FR-015. | +| VII. Two-Tier API-First Architecture | PASS | Frontend reads conversations only through new backend REST endpoints; the backend is the sole gateway to Cosmos. No direct frontend-to-Azure calls. Contracts documented in `contracts/`. | + +**Gate result**: PASS — no violations requiring justification. Security and IaC principles are satisfied by explicit requirements and the Terraform module; verification occurs during implementation and visual/security review. + +## Project Structure + +### Documentation (this feature) + +```text +specs/011-cosmos-agent-memory/ +├── plan.md # This file +├── research.md # Phase 0 output — decisions & rationale +├── data-model.md # Phase 1 output — Cosmos containers & entities +├── quickstart.md # Phase 1 output — setup, local dev, verification +├── contracts/ +│ ├── conversations-api.md # New REST endpoints for the conversation index + messages +│ └── cosmos-memory-integration.md # Backend provider/repository integration contract +└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +# Backend (repository root) +cosmos_memory.py # NEW: CosmosHistoryProvider factory + conversation-index repository + # (list/get/upsert/delete index docs, ownership checks, fallback) +agent_factory.py # MODIFY: _build_context_providers swaps InMemoryHistoryProvider -> + # CosmosHistoryProvider when Cosmos is configured (in-memory fallback otherwise) +session_orchestration.py # MODIFY: on create, generate session_id + write conversation index doc bound to user_id; + # on resume, rebuild AgentSession with existing session_id (ownership-checked); + # drop reliance on client-supplied history blob +main.py # MODIFY: add /api/conversations endpoints (list/get-messages/delete); + # update message "done" handling to bump last_activity_at + title +auth.py # REUSE: AuthenticatedUser.user_id (oid) is the ownership/partition key (no change expected) + +tests/ +├── test_cosmos_memory.py # NEW: provider factory + index repository (faked Cosmos) + fallback +├── test_conversations_api.py # NEW: list/get/delete endpoints + per-user isolation/ownership +├── test_session_orchestration.py # MODIFY: resume-by-id, index doc creation, no-blob path +└── conftest.py # MODIFY: fixtures/fakes for Cosmos data path + +# Frontend (frontend/) +frontend/src/ +├── api/ +│ └── client.ts # MODIFY: add listConversations/getConversation(messages)/deleteConversation; +│ # resume by conversation_id instead of posting a history blob +├── hooks/ +│ ├── useConversationStore.ts # REPLACE: server-backed conversation index/messages (was localStorage) +│ ├── useConversationPersistence.ts# MODIFY: stop writing full conversations to localStorage; server persists +│ ├── useSessionLifecycle.ts # MODIFY: resume via conversation_id; load messages from API +│ └── useChat.ts # MODIFY: align save/resume flow to server-sourced history +├── components/ +│ └── Sidebar.tsx # MODIFY: render server conversations with loading/empty/error states +├── pages/ +│ └── ChatPage.tsx # MODIFY: fetch conversation list from API; select -> load from server +└── types/ + └── api.ts # MODIFY: conversation/message types aligned to new API responses + +# Infrastructure (infra/) +infra/ +├── main.tf # MODIFY: instantiate cosmos module; pass endpoint/db/container env to app-service +├── variables.tf # MODIFY: cosmos-related variables (name, throughput mode, db/container names) +├── outputs.tf # MODIFY: expose cosmos endpoint/account name as outputs +└── modules/ + ├── cosmos/ # NEW: Cosmos DB account (Gov), database, 2 containers, RBAC role assignment + │ ├── main.tf + │ ├── variables.tf + │ └── outputs.tf + └── app-service/ # MODIFY: accept + set AZURE_COSMOS_ENDPOINT/DATABASE_NAME/CONTAINER_NAME app settings + +screenshots/ +└── 011-cosmos-agent-memory/ # Visual verification captures (chat pane states) +``` + +**Structure Decision**: Two-tier web application. The backend gains one new module (`cosmos_memory.py`) plus targeted edits to agent/session/route wiring; the frontend replaces its localStorage persistence hooks with server-backed equivalents; infrastructure gains a Cosmos module and app-settings wiring. The Agent Framework `CosmosHistoryProvider` is used directly (no custom provider), keeping the surface minimal per Principle V. + +## Phase 0: Research + +Research completed in [research.md](research.md). All Technical Context unknowns are resolved with decisions and rationale (throughput mode, credential strategy, local fallback, two-container model, title derivation, ordering/partitioning, Azure Government configuration, compaction interaction, and pre-existing browser data handling). No open clarification items remain. + +## Phase 1: Design & Contracts + +Design artifacts completed: + +- [data-model.md](data-model.md) — Cosmos database/containers, document schemas, partition keys, ownership/indexing model, and how framework-managed message docs relate to the conversation index. +- [contracts/conversations-api.md](contracts/conversations-api.md) — new REST endpoints (`GET /api/conversations`, `GET /api/conversations/{id}/messages`, `DELETE /api/conversations/{id}`) and the changed session create/resume contract. +- [contracts/cosmos-memory-integration.md](contracts/cosmos-memory-integration.md) — backend provider/repository integration contract: how `CosmosHistoryProvider` is constructed and wired into `_build_context_providers`, the conversation-index repository interface, and the local-dev fallback. +- [quickstart.md](quickstart.md) — provisioning, environment variables, local-dev (key/emulator/fallback), and end-to-end verification steps including the Visual Verification Protocol. + +The agent context file is refreshed via `.specify/scripts/bash/update-agent-context.sh copilot` to record the new Cosmos technology in the active technologies list. + +## Constitution Check - Post-Design + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Read-Only Data Access (SQL) | N/A | Design adds Cosmos memory persistence only; SQL tool access remains read-only and unchanged. | +| II. Single-File Agent Definitions | PASS | Design keeps agent/tool definitions in code/`agents.yaml`; persistence lives in `cosmos_memory.py`. | +| III. Security & Credential Hygiene | PASS | Data model and contracts mandate ownership-scoped queries (partition by `user_id`, ownership check before message reads), Managed Identity in prod, Gov endpoints, and no secret exposure. | +| IV. Evaluation-Driven Quality | PASS | No prompt/model/parameter changes in the design. | +| V. Simplicity & Minimalism | PASS | Built-in provider reused; two containers justified by distinct access patterns; no speculative abstractions. | +| VI. Infrastructure as Code | PASS | Cosmos module + RBAC assignment + app settings all in Terraform; no manual provisioning. | +| VII. Two-Tier API-First Architecture | PASS | All Cosmos access is backend-only via documented REST contracts; frontend consumes the API. | + +**Gate result**: PASS — no violations. + +## Complexity Tracking + +No constitution violations require justification. One design choice merits a brief note: + +| Choice | Why Needed | Simpler Alternative Rejected Because | +|--------|------------|--------------------------------------| +| Two Cosmos containers (`chat-history` partitioned by `/session_id`, `conversations` partitioned by `/user_id`) | The framework provider owns message storage partitioned by session, but the left pane needs an ownership-scoped, per-user list with titles/timestamps that the provider does not maintain | A single container cannot serve both access patterns efficiently: per-user listing would require cross-partition scans of a session-partitioned container, and shoe-horning index metadata into message docs breaks the provider's managed schema. Two purpose-built containers keep each query single-partition and isolation simple. | diff --git a/specs/011-cosmos-agent-memory/quickstart.md b/specs/011-cosmos-agent-memory/quickstart.md new file mode 100644 index 0000000..ad18fa6 --- /dev/null +++ b/specs/011-cosmos-agent-memory/quickstart.md @@ -0,0 +1,137 @@ +# Quickstart: Azure Cosmos DB Agent Memory Layer + +**Feature**: `011-cosmos-agent-memory` | **Date**: 2026-06-18 + +How to configure, run, and verify the Cosmos-backed memory layer locally and in Azure (Government). Follow the project's package rules: backend deps via `uv` only; frontend via `npm`. + +## Prerequisites + +- Python 3.12.6, `uv`, Node toolchain (`npm`) — as today. +- For cloud: an Azure Government subscription and Terraform configured for the `usgovernment` cloud. +- For full-fidelity local Cosmos (optional): the Azure Cosmos DB Emulator (Docker) or a real account key. + +## 1. Add the backend dependency (uv) + +```bash +# from repo root +uv add agent-framework-azure-cosmos --prerelease=allow +uv sync +``` + +This makes `from agent_framework.azure import CosmosHistoryProvider` importable (it raises a clear error until the package is present) and pulls in the `azure-cosmos` async SDK. + +## 2. Choose a storage mode + +The backend selects its memory path from `AZURE_COSMOS_ENDPOINT`: + +| Mode | When | Effect | +|------|------|--------| +| Not configured | `AZURE_COSMOS_ENDPOINT` unset | Backend **fails fast at startup** — there is no in-memory fallback. | +| Cosmos emulator (key) | local dev/test | Durable, full-fidelity persistence locally using `AZURE_COSMOS_KEY`. **Required for local runs.** | +| Cosmos + Managed Identity | Azure deployment | Durable persistence via RBAC; no key. | + +### 2a. Cosmos is required (no in-memory fallback) + +The backend will not start unless Cosmos is configured. Locally, run the emulator (§2b) and set `AZURE_COSMOS_ENDPOINT`; when deployed, Terraform wires the real account. If `AZURE_COSMOS_ENDPOINT` is unset, startup fails fast with a clear error. + +### 2b. Local with the Azure Cosmos DB Emulator (durable local + integration tests) + +Start the emulator (Docker), then point the app and tests at it. The emulator uses a fixed, publicly documented key and a self-signed TLS cert, so async clients set `connection_verify=False` locally. + +```bash +# start the Linux Azure Cosmos DB Emulator (NoSQL API) +docker run -d --name cosmos-emulator -p 8081:8081 -p 10250-10255:10250-10255 \ + mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + +# well-known emulator endpoint + key (public; identical for every emulator instance) +export AZURE_COSMOS_ENDPOINT="https://localhost:8081/" +export AZURE_COSMOS_EMULATOR_ENDPOINT="https://localhost:8081/" +export AZURE_COSMOS_KEY="C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" +export AZURE_COSMOS_DATABASE_NAME="agent-memory" +export AZURE_COSMOS_CONTAINER_NAME="chat-history" +export AZURE_COSMOS_CONVERSATIONS_CONTAINER="conversations" +AUTH_DISABLED=true uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +The provider creates the messages container if missing; the backend ensures the `conversations` container exists. The `AZURE_COSMOS_KEY` above is Microsoft's public well-known emulator key (not a secret) — production never uses a key. For parity with production, prefer provisioning via Terraform (below). + +## 3. Provision Azure resources (Terraform) + +The `infra/modules/cosmos` module creates the account (NoSQL, serverless, Azure Gov), the database, both containers (partition keys `/session_id` and `/user_id`), and the **Cosmos DB Built-in Data Contributor** data-plane role assignment for the app's user-assigned managed identity. `main.tf` passes the endpoint/db/container names into the `app-service` module as app settings. + +```bash +cd infra +terraform init +terraform plan -var-file=main.tfvars.json +terraform apply -var-file=main.tfvars.json +``` + +Expected outputs (added in `outputs.tf`): the Cosmos account endpoint and name. The app service receives `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`, and `AZURE_COSMOS_CONTAINER_NAME` (no key). Production keeps account-key (local) auth disabled. + +> Reminder (terraform + azd): never feed Terraform outputs back as inputs for the same resource; keep user-set inputs (names, throughput mode) as separate variables. + +## 4. Build the frontend and run + +```bash +cd frontend && npm install && npm run build && cd .. +AUTH_DISABLED=true uv run uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +The left chat pane now loads conversations from `GET /api/conversations` instead of `localStorage`. + +## 5. End-to-end verification (maps to user stories) + +Run these against `http://localhost:8000` (use the cloud URL for a deployed check). With `AUTH_DISABLED=true`, a dev user identity is used; for isolation tests, run with auth enabled and two accounts. + +1. **Agent memory across restarts (US1, SC-001)** + - Start a chat; tell the agent a fact ("My project is codenamed Falcon"). + - Restart the backend process. + - Resume the same conversation; ask "What's my project codename?" → agent answers "Falcon". +2. **Left pane lists real chats (US2, SC-002)** + - Hold two or three conversations. + - Open the app in a second browser/profile signed in as the same user → the same list appears, newest first. + - Confirm via dev tools that the list comes from `/api/conversations` (network), not `localStorage`. +3. **Resume with full history (US3)** + - Click a past conversation → prior messages render; send a context-dependent follow-up → correct, context-aware reply; the new turn is appended. +4. **Per-user isolation (US4, SC-003)** — auth enabled, users A and B + - As A, create a conversation; note its `id`. + - As B, call `GET /api/conversations/{id}/messages`, `POST /api/sessions` (resume that id), and `DELETE /api/conversations/{id}` → each returns `404`; A's conversation is unchanged. + - `GET /api/conversations` as B never includes A's conversation. +5. **Delete (US5, SC-005)** + - Delete a conversation from the pane → it disappears from the list; `GET /api/conversations/{id}/messages` returns `404`. +6. **Cosmos required locally (US6, SC-006)** + - With no `AZURE_COSMOS_ENDPOINT`, the backend **fails fast** (no fallback). With the emulator configured it persists durably. `uv run pytest` passes using injected in-memory doubles. +7. **No secret leakage (SC-007)** + - Grep logs and the built bundle for any key/connection string → none present. + +## 6. Tests + +Two tiers — fast unit tests (in-memory fakes, always run) and integration tests against the local Cosmos emulator (real provider/containers): + +```bash +# unit tier — no emulator needed (emulator tests deselected by default) +uv run pytest -q + +# integration tier — requires the running Cosmos emulator (see 2b) +uv run pytest -m emulator -q +``` + +The unit tier covers provider selection (Cosmos required, else raises), conversation create/list/get/touch/delete, ownership/isolation, resume-by-id, and turn-completion metadata updates with injected in-memory doubles. The `emulator` tier re-runs the durable paths against the real emulator — verifying `/session_id` and `/user_id` partition behavior, real queries (list ordering, ownership reads), resume reload, and delete — and skips when the emulator data plane is unreachable. + +## 7. Visual verification (constitution — required for UI changes) + +The chat pane changes are UI-affecting, so capture and review screenshots after `npm run build`: + +```bash +# the API is mocked by the script, but the app requires Cosmos config to start +AUTH_DISABLED=true AZURE_COSMOS_ENDPOINT=https://localhost:8081/ AZURE_COSMOS_KEY=dummy uv run uvicorn main:app --host 0.0.0.0 --port 8000 & +# capture chat-pane states with Playwright (system Chrome at /usr/bin/google-chrome) +uv run python scripts/capture_cosmos_chat_pane_screenshots.py --base-url http://localhost:8000 +``` + +Capture and visually review: conversation list populated, empty state (new user), loading/error state, and a resumed conversation with prior messages. Save under `screenshots/011-cosmos-agent-memory/`. Re-run after any fix. Use text indicators (no emoji) in any new UI labels. + +## 8. Rollback / disable + +- There is no non-durable fallback: if Cosmos is unreachable the app surfaces retryable `503`s; if `AZURE_COSMOS_ENDPOINT` is unset the app fails to start. "Disabling" memory is not a supported mode — fix Cosmos instead. +- Cosmos data is unaffected by app restarts; deletion is only via `DELETE /api/conversations/{id}` or Terraform-managed teardown. diff --git a/specs/011-cosmos-agent-memory/research.md b/specs/011-cosmos-agent-memory/research.md new file mode 100644 index 0000000..0976da5 --- /dev/null +++ b/specs/011-cosmos-agent-memory/research.md @@ -0,0 +1,200 @@ +# Phase 0 Research: Azure Cosmos DB Agent Memory Layer + +**Feature**: `011-cosmos-agent-memory` | **Date**: 2026-06-18 + +This document records the technical decisions that resolve the Technical Context unknowns in [plan.md](plan.md). Each item follows Decision / Rationale / Alternatives considered. Findings are grounded in the installed Agent Framework source and the published `agent-framework-azure-cosmos` provider. + +--- + +## R1. History provider: use the built-in `CosmosHistoryProvider` + +**Decision**: Use `CosmosHistoryProvider` from the `agent-framework-azure-cosmos` package, imported as `from agent_framework.azure import CosmosHistoryProvider`. Add `agent-framework-azure-cosmos` to `pyproject.toml` and install with `uv`. + +**Rationale**: +- The installed `agent_framework.azure` namespace already lazily re-exports `CosmosHistoryProvider`; attempting to use it without the package raises a clear `ModuleNotFoundError` instructing installation of `agent-framework-azure-cosmos`. So the import path is stable and the only missing piece is the dependency. +- `CosmosHistoryProvider(HistoryProvider)` subclasses the exact same base class as the current `InMemoryHistoryProvider`. It implements `get_messages(session_id, *, state)` / `save_messages(session_id, messages, *, state)` plus `clear(session_id)` and `list_sessions()`. This makes it a near drop-in for the provider already used in `agent_factory._build_context_providers`. +- Using the maintained provider satisfies Principle V (Simplicity): no custom Cosmos provider to write or maintain. + +**Alternatives considered**: +- *Custom `ContextProvider` subclass writing to Cosmos*: rejected — duplicates maintained functionality, more code to audit, violates YAGNI. +- *Service-side thread storage (provider-managed conversation ids)*: rejected — the app already controls session ids and needs explicit ownership/index control for the per-user pane; service-side storage would not give the per-user index the left pane requires. + +--- + +## R2. Drop-in into the existing context-provider pipeline + +**Decision**: In `_build_context_providers`, construct a `CosmosHistoryProvider` (when Cosmos is configured) in place of `InMemoryHistoryProvider(skip_excluded=True)`, and continue to pass `history_source_id=history.source_id` to the existing `CompactionProvider`. + +**Rationale**: +- `CompactionProvider` only needs the history provider's `source_id` string. `CosmosHistoryProvider` exposes `source_id` (default `"azure_cosmos_history"`) via the shared `HistoryProvider`/`ContextProvider` base, so the compaction wiring is unchanged. +- The remaining providers (`CompactionProvider`, `AzureAISearchContextProvider`, `SkillsProvider`) are unaffected; only the history element changes. + +**Alternatives considered**: +- *Keep `InMemoryHistoryProvider` and separately mirror messages to Cosmos*: rejected — double bookkeeping, ordering/consistency risk, and the agent would still read volatile memory rather than the durable store. + +**Known risk / validation item**: `InMemoryHistoryProvider` is initialized with `skip_excluded=True` so it omits messages the `CompactionProvider` marked `_excluded` when re-loading context. `CosmosHistoryProvider.get_messages` does not implement `skip_excluded` filtering and stores whatever flows through `save_messages`. The compaction-plus-persistence interaction (whether exclusion markers should be persisted and re-applied on load) must be validated during implementation; the provider's `store_outputs`/`store_inputs`/`store_context_messages` flags and `load_messages` flag are the tuning points. This is an implementation validation task, not an open spec question. + +--- + +## R3. session_id is the Cosmos partition key; backend controls it + +**Decision**: Treat `conversation_id == AgentSession.session_id` as a server-generated unguessable UUID, and use it directly as the Cosmos messages partition key. On resume, rebuild the `AgentSession` with the same `session_id` so `CosmosHistoryProvider` auto-loads prior messages. + +**Rationale**: +- `CosmosHistoryProvider` partitions message documents on `/session_id` and uses the `session_id` argument (sourced from `AgentSession.session_id`) for all reads/writes. The backend already creates sessions and can set/restore `session_id` (current code does `agent_session._session_id = session_id`). +- This eliminates the current client→server "history blob" round-trip: history lives in Cosmos and is loaded by the provider keyed on the session id alone. +- Unguessable UUIDs satisfy FR-009 (non-enumerable keys). + +**Alternatives considered**: +- *Composite `user_id:conversation_uuid` session id to bake ownership into the partition key*: rejected as the primary mechanism — the provider hardcodes `/session_id` as the partition path, and ownership is more clearly and flexibly enforced via the per-user index (R4) than by string-parsing partition keys. (A composite id remains a possible defense-in-depth option but is not required.) + +--- + +## R4. Two-container model: messages + per-user conversation index + +**Decision**: Use one Cosmos database with two containers: +1. `chat-history` — message documents, partition key `/session_id`, owned/managed by `CosmosHistoryProvider`. +2. `conversations` — one index document per conversation, partition key `/user_id`, owned/managed by new backend code (`cosmos_memory.py`). + +**Rationale**: +- `CosmosHistoryProvider` stores only raw messages partitioned by session; it does not store per-user metadata (title, agent name, timestamps) and its `list_sessions()` is a cross-partition scan over all users — unsuitable for a per-user, isolated, efficient left-pane list. +- A `conversations` container partitioned by `/user_id` makes "list my conversations" a single-partition query, gives natural per-user isolation, and is the authoritative ownership record consulted before any message read/resume/delete. +- This directly maps to the existing frontend concepts (`ConversationIndexEntry` for the list, message history for the view), easing the frontend migration. + +**Alternatives considered**: +- *Single container for messages and index*: rejected — per-user listing would require cross-partition scans of a session-partitioned container; mixing index metadata into provider-managed message docs would break the provider's schema. (Recorded in plan Complexity Tracking.) +- *Hierarchical Partition Keys (HPK)*: considered per Cosmos guidance for >20 GB partitions. Rejected for now — a single conversation's messages and a single user's index both stay well under the 20 GB logical-partition limit, and the framework provider fixes `/session_id` as a non-hierarchical key. Simple partition keys keep queries single-partition without added complexity. Revisit only if a single user accumulates index volume approaching the limit. + +--- + +## R5. Throughput mode: serverless + +**Decision**: Provision the Cosmos account in **serverless** throughput mode. + +**Rationale**: +- Chat traffic is spiky and user-driven; serverless bills per-request-unit with no minimum provisioned RU, matching a variable, bursty interactive workload and minimizing idle cost. +- Operationally simpler than autoscale RU planning for an initial rollout (Principle V). + +**Alternatives considered**: +- *Provisioned autoscale*: better for sustained high throughput or when multi-region writes are needed; rejected for the initial scope as over-provisioning for an interactive chat memory store. Revisit if sustained load or SLA/throughput guarantees demand it. + +--- + +## R6. Credentials: Managed Identity (prod) / key (local); Azure Government endpoints + +**Decision**: In production, authenticate to Cosmos with `DefaultAzureCredential` (the app's user-assigned Managed Identity) and a data-plane RBAC role; permit an account key via `AZURE_COSMOS_KEY` only for local development. All Cosmos endpoints target Azure Government (`*.documents.azure.us`). + +**Rationale**: +- Constitution Principle III mandates Managed Identity in production and keys only for local dev, and requires Azure Government endpoints. `CosmosHistoryProvider` accepts either a credential object or a key string and reads `AZURE_COSMOS_*` env vars, supporting both paths cleanly. +- `DefaultAzureCredential` is already the project's pattern (used for AI Search). + +**Configuration notes for Azure Government**: +- The provider/SDK must resolve the Government login authority and Cosmos resource scope. The deployment configures `DefaultAzureCredential` for the US Government cloud (consistent with the existing `login.microsoftonline.us` authority usage) and uses the `.documents.azure.us` account endpoint. +- The data-plane role assignment uses the **Cosmos DB Built-in Data Contributor** SQL role (data-plane), not just control-plane RBAC, because message read/write are data-plane operations. + +**Alternatives considered**: +- *Account key in production*: rejected — violates Principle III. +- *Connection string env var*: rejected — keys/connection strings must not be the production path; managed identity avoids storing a secret at all. + +--- + +## R7. Local development & test strategy (no fallback) + +**Decision**: Azure Cosmos DB is **required** — there is **no non-durable in-memory fallback** in the app. Locally the app runs against the **Cosmos emulator** (`AZURE_COSMOS_ENDPOINT` points at it); when deployed it uses the real account; if `AZURE_COSMOS_ENDPOINT` is unset the backend **fails fast at startup**. Testing is two-tier: fast **unit tests** inject in-memory Cosmos doubles (always run, no dependency); **integration tests** run against the local emulator behind an `emulator` pytest marker (deselected by default; run with `-m emulator`), exercising the real `CosmosHistoryProvider`, real `/session_id` and `/user_id` partitions, and real queries. + +**Rationale**: +- Satisfies User Story 6 and the requirement that memory is always durable: a silent in-memory fallback could mask data loss, so it is removed. +- Aligns with the repo's Azure Cosmos DB guidance, which recommends the emulator for local development and testing. +- A single switch (presence of `AZURE_COSMOS_ENDPOINT`) selects emulator-vs-real; absence is a hard error, keeping behavior predictable. + +**Alternatives considered**: +- *In-memory app fallback when unconfigured*: rejected — hides the fact that chat history isn't durable; the app must fail fast instead. +- *Fakes only, no emulator*: rejected — fakes can't validate real partition-key behavior, query ordering, ownership reads, or batch delete; the emulator catches integration defects fakes would miss. +- *Mock the whole provider in app code*: rejected — fakes belong in tests (injected); app code always uses the real Cosmos provider. + +--- + +## R8. Conversation title and activity metadata + +**Decision**: Derive the conversation title/description from the first user message (truncated, consistent with current behavior). Update the index document's `last_activity_at` (and set the title on first message) as part of completing each turn. + +**Rationale**: +- Matches today's UX (the frontend currently derives a 60-char description from the first user message), so the visible behavior is unchanged while the storage moves server-side. +- Keeping title derivation server-side ensures the list is correct regardless of client. + +**Alternatives considered**: +- *LLM-generated titles*: rejected for scope — extra model calls and cost; out of scope (can be a later enhancement). +- *User-edited titles/rename*: out of scope (listed in spec Out of Scope). + +--- + +## R9. Handling pre-existing browser-stored conversations + +**Decision**: Do not auto-migrate existing `localStorage` conversations. The frontend stops using the localStorage conversation keys as the source of truth; their presence is ignored and must not cause errors. Auth token and theme remain in browser storage. + +**Rationale**: +- The prior store was capped (≈5) and per-browser; a clean cut to server-sourced history is simpler and avoids fragile migration logic (Principle V, FR-019). +- Ignoring legacy keys is safe and side-effect-free; a one-time import is explicitly out of scope. + +**Alternatives considered**: +- *One-time import of localStorage conversations into Cosmos*: rejected for scope/complexity; the limited, per-browser nature of the old data makes migration low value. + +--- + +## R10. Infrastructure as Code (Terraform) + +**Decision**: Add `infra/modules/cosmos` provisioning the Cosmos DB for NoSQL account (Azure Gov), the database, and the two containers with the correct partition keys, plus a data-plane SQL role assignment granting the app's user-assigned managed identity the **Cosmos DB Built-in Data Contributor** role. Wire `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`, and `AZURE_COSMOS_CONTAINER_NAME` into the `app-service` module app settings. Disable local (key) auth on the account in production. + +**Rationale**: +- Constitution Principle VI requires all Azure resources via Terraform with module groupings; the existing `infra/` already follows this pattern (managed-identity, app-service modules). +- Provisioning the containers in IaC (rather than relying on the provider's `create_container_if_not_exists`) makes partition keys and throughput explicit, reviewable, and reproducible. The provider's auto-create remains a safety net but is not the source of truth. + +**Alternatives considered**: +- *Let the provider auto-create database/containers at runtime*: rejected as the authoritative mechanism — partition-key/throughput choices belong in reviewable IaC; runtime auto-create also requires broader control-plane permissions for the app identity. +- *Account key in app settings*: rejected — managed identity + RBAC avoids storing a secret (Principle III). + +--- + +## R11. API surface and ownership enforcement + +**Decision**: Add `GET /api/conversations`, `GET /api/conversations/{id}/messages`, and `DELETE /api/conversations/{id}`, each scoped to the authenticated `user_id`. Session create/resume moves to a `conversation_id`-based contract (no client history blob). Every message read, resume, and delete verifies that the index document's `user_id` equals the caller's `user_id` before touching the messages container. + +**Rationale**: +- Centralizing ownership checks at the index (partitioned by `user_id`) gives a single, auditable authorization point (FR-008) and keeps the messages container access strictly gated. +- Reusing the existing `get_current_user` dependency keeps auth consistent with the rest of the API (Principle VII). + +**Alternatives considered**: +- *Trust the session/conversation id alone*: rejected — ids must be treated as capabilities only after ownership verification to prevent cross-user access even if an id leaks. + +--- + +## R12. Frontend migration approach + +**Decision**: Replace the localStorage-backed `useConversationStore` with a server-backed implementation calling the new endpoints; update `useConversationPersistence`/`useSessionLifecycle`/`useChat` so resume loads messages from the API and the client no longer persists full conversations or posts a history blob. `Sidebar`/`ChatPage` render the server list with loading/empty/error states. + +**Rationale**: +- Keeps the change surface localized to the persistence hooks and the components that consume them, preserving the existing chat/streaming UI (FR-011) while satisfying FR-007 (server-sourced history). + +**Alternatives considered**: +- *Dual-write to localStorage and server*: rejected — reintroduces the client as a source of truth, risks divergence, and contradicts the feature's goal. + +--- + +## Summary of resolved unknowns + +| Unknown (from Technical Context) | Resolution | +|----------------------------------|------------| +| History provider choice/import | Built-in `CosmosHistoryProvider` via `agent_framework.azure`; add `agent-framework-azure-cosmos` (R1) | +| Pipeline compatibility | Drop-in with unchanged `CompactionProvider` wiring; validate exclusion interaction (R2) | +| Partition key / id strategy | `session_id` (UUID) as messages partition key; backend-controlled (R3) | +| Per-user listing & isolation | Second `conversations` container partitioned by `/user_id` (R4) | +| Throughput mode | Serverless (R5) | +| Credentials & cloud | Managed Identity prod / key local; Azure Gov endpoints; Data Contributor role (R6) | +| Local/test execution | No app fallback (fail fast if unconfigured); unit tests inject in-memory doubles; emulator for integration, deselected by default (R7) | +| Title & activity metadata | Derived from first user message; server-updated `last_activity_at` (R8) | +| Legacy browser data | Ignored, no auto-migration (R9) | +| Infrastructure | Terraform `cosmos` module + RBAC + app settings (R10) | +| API & authorization | `/api/conversations` endpoints + ownership checks at the index (R11) | +| Frontend persistence | Server-backed hooks replace localStorage (R12) | + +No `NEEDS CLARIFICATION` items remain. Proceed to Phase 1 design. diff --git a/specs/011-cosmos-agent-memory/spec.md b/specs/011-cosmos-agent-memory/spec.md new file mode 100644 index 0000000..4336d00 --- /dev/null +++ b/specs/011-cosmos-agent-memory/spec.md @@ -0,0 +1,183 @@ +# Feature Specification: Azure Cosmos DB Agent Memory Layer + +**Feature Branch**: `011-cosmos-agent-memory` +**Created**: 2026-06-18 +**Status**: Draft +**Input**: User description: "I need to add cosmosdb to this as the agent's memory layer. take your time reading the docs for Agent Framework. this should be a seamless integration. the agents will need a per-user/per agent chat history. the chat pane on the left side will need to read the ACTUAL chats from Cosmos instead of browser storage." + +## Overview + +Today, conversation history lives in the browser's `localStorage`: the left chat pane lists at most a handful of conversations stored per-browser, and the agent's "memory" is a serialized session blob that the client round-trips back to the backend on every resume. This is fragile (cleared with browser cache), siloed per device, capped in count, and places conversation state in the client tier. + +This feature moves conversation memory to **Azure Cosmos DB** as the server-side source of truth, integrated through Microsoft Agent Framework's `CosmosHistoryProvider`. Each conversation gets **per-user and per-agent** persistent history. The left chat pane reads the user's **actual** conversations from Cosmos through the backend API instead of from browser storage. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Agent Remembers a Conversation Across Turns and Restarts (Priority: P1) + +As a user chatting with an agent, I want the agent to remember everything said earlier in the conversation — even after the server restarts or I return later — so the assistant maintains continuity without me re-supplying context. + +**Why this priority**: Persistent agent memory is the core of the feature. Without server-side history, every other capability (listing, resuming, isolation) has nothing durable to read. + +**Independent Test**: Start a conversation, tell the agent a fact, restart the backend process, send a follow-up that depends on the earlier fact, and confirm the agent recalls it. History is read back from Cosmos, not from any client-supplied blob. + +**Acceptance Scenarios**: + +1. **Given** a user has exchanged several turns with an agent, **When** they send another message, **Then** the agent's response reflects awareness of all prior turns in that conversation. +2. **Given** the backend process is restarted after a conversation, **When** the user resumes the same conversation and sends a message, **Then** the agent still has the full prior history available as context. +3. **Given** a message is sent, **When** the turn completes, **Then** the user and assistant messages for that turn are durably stored in Cosmos under that conversation. + +--- + +### User Story 2 - Left Chat Pane Lists My Real Conversations (Priority: P1) + +As an authenticated user, I want the left chat pane to show my actual past conversations retrieved from the server, so I see a complete, durable history that follows me across browsers and devices rather than a per-browser cache. + +**Why this priority**: The user explicitly requires the chat pane to read real chats from Cosmos instead of browser storage. This is the primary visible behavior change. + +**Independent Test**: Sign in, hold conversations, then open the app in a different browser/device (same account) and confirm the same conversation list appears, ordered by most recent activity, sourced from the backend API. + +**Acceptance Scenarios**: + +1. **Given** a signed-in user with prior conversations, **When** the chat pane loads, **Then** it displays their conversations retrieved from the backend (not from `localStorage`), each showing a title/description, the agent used, and last-activity time. +2. **Given** the same user signs in from a different browser or device, **When** the chat pane loads, **Then** it shows the same conversation list. +3. **Given** a brand-new user with no history, **When** the chat pane loads, **Then** it shows an empty state without errors. +4. **Given** the conversation list is loading or temporarily unavailable, **When** the pane renders, **Then** it shows a clear loading or error state instead of stale browser data. + +--- + +### User Story 3 - Resume a Past Conversation With Full History (Priority: P1) + +As a user, I want to click a conversation in the left pane and continue exactly where I left off, with the previous messages displayed and the agent retaining their context. + +**Why this priority**: Listing conversations is only useful if they can be reopened. Resume ties the index (US2) to the memory (US1). + +**Independent Test**: Select a past conversation, confirm its messages render in the chat view, send a new message that depends on earlier context, and confirm the agent responds with full awareness. + +**Acceptance Scenarios**: + +1. **Given** a user selects a past conversation, **When** it opens, **Then** the prior messages for that conversation are displayed in order, retrieved from the server. +2. **Given** a resumed conversation, **When** the user sends a new message, **Then** the agent has the prior history as context and the new turn is appended to the same stored conversation. +3. **Given** a resumed conversation that used a particular agent, **When** it reopens, **Then** it continues with that same agent identity. + +--- + +### User Story 4 - My Conversations Are Private to Me (Priority: P1) + +As an authenticated user, I want my conversation history to be accessible only to me, so no other user can list, read, resume, or delete my chats. + +**Why this priority**: This is a security-sensitive, multi-user, government-context application. Per-user isolation is non-negotiable and must be enforced server-side. + +**Independent Test**: As user A, create a conversation and note its identifier. As user B, attempt to fetch, resume, and delete that identifier via the API and confirm every attempt is denied. + +**Acceptance Scenarios**: + +1. **Given** a conversation owned by user A, **When** user B requests its messages, **Then** the request is denied and no content is returned. +2. **Given** a conversation owned by user A, **When** user B attempts to resume or delete it, **Then** the request is denied and the conversation is unchanged. +3. **Given** the conversation list endpoint, **When** any authenticated user calls it, **Then** only conversations owned by that user are returned. +4. **Given** an unauthenticated caller, **When** they call any conversation endpoint, **Then** access is rejected (outside local development). + +--- + +### User Story 5 - Delete a Conversation (Priority: P2) + +As a user, I want to delete a conversation so it is removed from my history and its stored messages no longer persist. + +**Why this priority**: Users need control over their own data; deletion is a standard expectation and a data-hygiene requirement, but it depends on listing/ownership existing first. + +**Independent Test**: Delete a conversation from the left pane, confirm it disappears from the list, and confirm a subsequent attempt to fetch its messages returns nothing. + +**Acceptance Scenarios**: + +1. **Given** a user deletes one of their conversations, **When** the chat pane refreshes, **Then** the conversation no longer appears in the list. +2. **Given** a deleted conversation, **When** anyone attempts to retrieve its messages, **Then** no messages are returned. +3. **Given** a user attempts to delete a conversation they do not own, **When** the request is processed, **Then** it is denied. + +--- + +### User Story 6 - Works Locally With the Cosmos Emulator (Priority: P3) + +As a developer, I want local development and tests to use the Azure Cosmos DB Emulator (not a cloud account), so iteration stays fast and cost-free while behaving exactly like production — with no silent non-durable fallback. + +**Why this priority**: Developer experience and testability matter, but the production behavior (P1) is what delivers user value. + +**Independent Test**: Run the Cosmos emulator locally and confirm the backend starts and persists durably; with no Cosmos configured at all, confirm the backend FAILS to start (no in-memory fallback). Run the unit suite and confirm conversation/memory behavior is validated with in-memory doubles, and the emulator integration suite passes against the running emulator. + +**Acceptance Scenarios**: + +1. **Given** the Cosmos emulator is running and `AZURE_COSMOS_ENDPOINT` points at it, **When** the backend starts, **Then** it starts successfully and chat history persists durably (same behavior as production). +2. **Given** no Cosmos is configured (`AZURE_COSMOS_ENDPOINT` unset), **When** the backend starts, **Then** it FAILS fast with a clear error — there is no non-durable in-memory fallback. +3. **Given** the automated unit suite, **When** it runs, **Then** conversation listing, resume, isolation, and deletion behaviors are covered using in-memory Cosmos doubles, with no live cloud dependency. +4. **Given** the local Azure Cosmos DB Emulator is running, **When** the `emulator`-marked integration suite runs against it, **Then** persistence, ownership, resume, and deletion are verified against the real provider and containers; **When** the emulator is not running, **Then** those tests are deselected/skipped and the default unit suite still passes. + +### Edge Cases + +- A conversation has no messages yet (created but the first message failed or was abandoned): the index entry must not show a broken title and must not error the list view. +- A very long first message: the derived conversation title must be truncated/wrapped without layout overflow in the chat pane. +- Cosmos is temporarily unavailable or rate-limited (429): message send and list operations must surface a clear, retryable error and must not corrupt or partially persist a turn. +- The authenticated user identifier is missing from the token: conversation endpoints must reject the request rather than fall back to a shared or empty partition. +- A conversation identifier that does not exist or is not owned by the caller: requests must be denied/return not-found without revealing whether the identifier exists for another user. +- Concurrent sends in the same conversation: stored message ordering must remain consistent and readable. +- Existing browser-stored conversations from before this feature: the system must not crash on their presence; their handling (ignored vs. one-time import) is a defined, documented behavior. +- Deleting a conversation while it is the active session: the active view must handle the removal gracefully (e.g., return to a new/empty chat). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST persist agent conversation messages (user and assistant turns, including tool interactions that are part of history) to Azure Cosmos DB as the server-side source of truth, via Agent Framework's Cosmos history provider. +- **FR-002**: Each conversation MUST be associated with the authenticated user who owns it and with the agent/profile used, enabling per-user and per-agent history. +- **FR-003**: The agent MUST load a conversation's prior history from Cosmos when continuing or resuming that conversation, so responses reflect full prior context without the client supplying the history. +- **FR-004**: The system MUST expose a backend API for the left chat pane to list the authenticated user's conversations, returning at minimum a conversation identifier, title/description, agent/profile name, and last-activity timestamp, ordered by most recent activity. +- **FR-005**: The system MUST expose a backend API to retrieve the messages of a single conversation owned by the authenticated user, for display when resuming. +- **FR-006**: The system MUST expose a backend API to delete a conversation owned by the authenticated user, removing both its index entry and its stored messages. +- **FR-007**: The frontend left chat pane MUST source its conversation list and message history from the backend API and MUST NOT rely on browser storage as the source of truth for conversation history. +- **FR-008**: The system MUST enforce ownership on every conversation read, resume, and delete operation, so a user can only access conversations they own; cross-user access MUST be denied. +- **FR-009**: Conversation identifiers used as storage keys MUST be unguessable (e.g., random identifiers) so they cannot be enumerated by other users. +- **FR-010**: The system MUST derive and store a human-readable conversation title/description (e.g., from the first user message) and MUST update the conversation's last-activity timestamp as the conversation progresses. +- **FR-011**: The system MUST continue to support sending messages, streaming responses, tool invocations, and image inputs exactly as today; adding Cosmos memory MUST NOT regress existing chat behavior. +- **FR-012**: The frontend MUST NOT communicate with Azure Cosmos DB directly; all Cosmos access MUST be proxied through the backend. +- **FR-013**: In production, backend access to Cosmos DB MUST authenticate via Azure Managed Identity (RBAC data-plane role); account keys MUST be permitted only for local development. +- **FR-014**: All Cosmos DB endpoints MUST target the Azure Government cloud in production deployments. +- **FR-015**: The Cosmos DB account, database, and containers MUST be provisioned through the project's Terraform infrastructure as code, including the data-plane role assignment for the application's managed identity. +- **FR-016**: The system MUST require Azure Cosmos DB and MUST NOT provide a non-durable in-memory fallback: locally it runs against the Cosmos emulator, when deployed it uses the real account, and if Cosmos is not configured the backend MUST fail fast at startup. Automated unit tests MAY substitute in-memory test doubles for the Cosmos data path so they run without a live account. +- **FR-017**: Cosmos credentials, connection strings, and keys MUST never appear in logs, API responses, or the frontend bundle. +- **FR-018**: When Cosmos operations fail (e.g., throttling or transient errors), the system MUST surface a clear, retryable error to the user and MUST avoid leaving a conversation turn partially persisted in an inconsistent state. +- **FR-019**: The behavior for conversations previously stored only in the browser MUST be explicitly defined (ignored or one-time imported) and MUST NOT cause errors when present. +- **FR-020**: New backend dependencies MUST be added via `uv` (the Cosmos history provider package), consistent with project package-management rules. + +### Key Entities *(include if feature involves data)* + +- **Conversation (Index Entry)**: Represents one chat thread owned by a user. Key attributes: conversation identifier (also the session identifier), owning user identifier, agent/profile identifier and display name, derived title/description, creation time, last-activity time, and any custom-agent/override descriptors needed to reopen with the same agent. Partitioned per user for efficient per-user listing and isolation. +- **Conversation Message**: Represents a single stored message within a conversation (role, content, and associated tool/usage metadata that constitutes history). Partitioned per conversation/session for efficient in-conversation reads and ordered by a sort key. Managed by the Agent Framework history provider. +- **Authenticated User**: The identity (stable user identifier from the auth token) that owns conversations and scopes all reads/writes. Already established by the existing authentication layer. +- **Session**: The runtime conversation context bound to a conversation identifier; on resume it is reconstructed with the same identifier so history loads from Cosmos. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After a backend restart, resuming a conversation and asking about an earlier detail yields a correct, context-aware answer in 100% of manual verification attempts (memory survives process restarts). +- **SC-002**: The same signed-in user sees an identical conversation list across at least two different browsers/devices, confirming server-sourced history (0% reliance on per-browser storage for the list). +- **SC-003**: In cross-user access tests, 100% of attempts by a non-owner to list, read, resume, or delete another user's conversation are denied. +- **SC-004**: Conversation count per user is no longer capped at the previous browser limit; a user can accumulate and retrieve well beyond the prior cap (e.g., dozens of conversations) and still see them all listed. +- **SC-005**: Deleting a conversation removes it from the list and makes its messages unretrievable in 100% of attempts. +- **SC-006**: The application starts and supports chat locally with no cloud Cosmos account configured; the offline unit suite passes without any Cosmos dependency, and the integration suite passes against the local Azure Cosmos DB Emulator (covering list, resume, isolation, delete) — both without a live cloud dependency. +- **SC-007**: No Cosmos key, connection string, or credential appears in any log line, API response, or the built frontend bundle (verified by inspection). +- **SC-008**: Frontend build/type-check completes successfully, and visual verification screenshots of the chat pane (loading, populated list, empty state, resumed conversation) show no clipping or overflow at desktop, tablet, and mobile widths. + +## Assumptions + +- The existing authentication layer provides a stable per-user identifier (object id) suitable for use as the ownership/partition key; multi-user isolation relies on it. +- One conversation maps to exactly one agent/profile; switching agents starts a new conversation (consistent with current behavior). +- The conversation title is derived from the first user message (consistent with current behavior), not separately user-edited, unless a rename capability is later prioritized. +- Cosmos DB SQL (NoSQL) API is used, matching the Agent Framework Cosmos history provider. +- Auth token storage (`auth_token`) and UI preferences (theme) may remain in browser storage; only conversation history is migrated to the server. + +## Out of Scope + +- Full-text or semantic search across conversation history (no vector/search index in this feature). +- Cross-agent shared memory or a global long-term memory store beyond per-conversation history. +- User-initiated conversation renaming, tagging, pinning, or folders (potential future enhancement). +- Workflow checkpoint storage (the Cosmos checkpoint storage capability is a separate concern, not required here). +- Bulk migration of large volumes of pre-existing browser conversations beyond the defined handling in FR-019. diff --git a/specs/011-cosmos-agent-memory/tasks.md b/specs/011-cosmos-agent-memory/tasks.md new file mode 100644 index 0000000..003842a --- /dev/null +++ b/specs/011-cosmos-agent-memory/tasks.md @@ -0,0 +1,267 @@ +# Tasks: Azure Cosmos DB Agent Memory Layer + +**Input**: Design documents from `/specs/011-cosmos-agent-memory/` +**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), [data-model.md](data-model.md), [contracts/](contracts/) + +**Tests**: Included, in two tiers. (1) Fast **unit tests** use in-memory Cosmos fakes and always run. (2) **Integration tests run against the local Azure Cosmos DB Emulator** (the durable-path verification — real `CosmosHistoryProvider`, real `/session_id` and `/user_id` partitions, real queries) behind an `emulator` pytest marker that auto-skips when the emulator is unreachable, so the default offline run stays green. This matches the repo's Azure Cosmos DB guidance (use the emulator for local dev/testing). + +**Organization**: Tasks are grouped by user story (from spec.md) to enable independent implementation and testing. Four P1 stories, one P2, one P3. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependency on an incomplete task) +- **[Story]**: US1–US6 (user-story phases only) +- Backend Python is at the repository root; frontend is in `frontend/`; infra is in `infra/` + +## Path Conventions (from plan.md Structure Decision) + +- Backend: repo-root modules (`main.py`, `agent_factory.py`, `session_orchestration.py`, NEW `cosmos_memory.py`), tests in `tests/` +- Frontend: `frontend/src/` (api, hooks, components, pages, types) +- Infra: `infra/` + `infra/modules/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Add the dependency and configuration surface the feature needs. + +- [ ] T001 Add `agent-framework-azure-cosmos` to `pyproject.toml` dependencies and install with `uv add agent-framework-azure-cosmos --prerelease=allow && uv sync` (never `pip`) +- [ ] T002 [P] Document the Cosmos environment variables (`AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`, `AZURE_COSMOS_CONTAINER_NAME`, `AZURE_COSMOS_CONVERSATIONS_CONTAINER`, `AZURE_COSMOS_KEY` for local) in `README.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The shared storage/factory layer every story depends on. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [ ] T003 Create `cosmos_memory.py` with Cosmos config resolution: read `AZURE_COSMOS_*` env vars and resolve `credential` = `AZURE_COSMOS_KEY` (local) or `DefaultAzureCredential()` configured for Azure US Government; mask secrets in any log line +- [ ] T004 Implement `get_history_provider()` in `cosmos_memory.py`: return a cached `CosmosHistoryProvider` (from `agent_framework.azure`) when `AZURE_COSMOS_ENDPOINT` is set, else `InMemoryHistoryProvider(skip_excluded=True)`; expose `.source_id` for downstream wiring +- [ ] T005 Define `ConversationIndexRepository` interface + `CosmosConversationIndexRepository` in `cosmos_memory.py` (`create`, `list_for_user`, `get_owned`, `touch`, `delete`), all partition-scoped by `/user_id` per [data-model.md](data-model.md) +- [ ] T006 Implement `InMemoryConversationIndexRepository` fallback in `cosmos_memory.py` exposing the same interface (process-local dict keyed by `user_id`) +- [ ] T007 Implement `get_conversation_repository()` selector + client/provider lifecycle (close on shutdown) in `cosmos_memory.py`, choosing Cosmos vs in-memory by config (depends on T003–T006) +- [ ] T008 [P] Update `agent_factory._build_context_providers` to call `get_history_provider()` and pass `history_source_id=history.source_id` to `CompactionProvider` (replace the `InMemoryHistoryProvider(skip_excluded=True)` literal) in `agent_factory.py` +- [ ] T009 Wire the conversation repository singleton into `main.py` (construct on startup, expose via `SessionContext`/FastAPI dependency, close on shutdown) (depends on T007) +- [ ] T010 [P] Add two test harnesses to `tests/conftest.py`: (a) in-memory Cosmos fakes (fake history provider + fake repository) for fast unit tests, and (b) a session-scoped **Azure Cosmos DB Emulator** fixture — async `CosmosClient` to `AZURE_COSMOS_EMULATOR_ENDPOINT` (default `https://localhost:8081`) using the well-known emulator key from env and `connection_verify=False` — exposed behind an `emulator` pytest marker that auto-skips when the endpoint is unreachable + +**Checkpoint**: Storage/factory layer ready — user stories can begin. + +--- + +## Phase 3: User Story 1 - Agent Remembers Across Turns and Restarts (Priority: P1) 🎯 MVP + +**Goal**: Durable per-conversation memory. Messages persist to Cosmos keyed by `session_id`; recreating a session with the same id reloads full history so the agent stays context-aware across turns and process restarts. + +**Independent Test**: Tell the agent a fact, restart the backend, resume the same conversation id, ask a dependent question → correct, context-aware answer; turn messages are present in the messages store. + +### Tests for User Story 1 + +- [ ] T011 [P] [US1] Unit test provider selection + repository `create`/`get_owned` (Cosmos-vs-fallback, no connect) in `tests/test_cosmos_memory.py` +- [ ] T012 [P] [US1] Emulator integration test (`@pytest.mark.emulator`): against the local Cosmos emulator, persist a turn via the real `CosmosHistoryProvider`, re-instantiate the provider (simulated restart), and assert prior history reloads by `conversation_id`, verifying the real `/session_id` partition round-trip, in `tests/test_session_orchestration.py` + +### Implementation for User Story 1 + +- [ ] T013 [US1] In `session_orchestration.py` create flow: generate a UUID `session_id`, set `AgentSession.session_id`, and call `repo.create(user.user_id, session_id, profile_id, profile_name, ...)` to write the index doc +- [ ] T014 [US1] In `session_orchestration.py` add resume: accept `conversation_id`, verify ownership via `repo.get_owned` (404 when `None`), rebuild `AgentSession` with that `session_id`, and remove the client `history`-blob restore from the durable path (depends on T013) +- [ ] T015 [US1] Update `POST /api/sessions` in `main.py` to accept optional `conversation_id` (resume) and stop requiring the `history` blob; return `session_id` as the conversation id (depends on T014) +- [ ] T016 [US1] On message stream completion (`done`) in `main.py`, call `repo.touch(user_id, conversation_id, title=)` to set the title and bump `last_activity_at` +- [ ] T017 [US1] Add Cosmos failure handling to session create/resume/turn in `main.py`/`session_orchestration.py`: surface a retryable `503`, never leave a turn partially persisted, never leak credentials (FR-018) + +**Checkpoint**: Agent memory is durable and survives restarts — MVP is functional and independently testable. + +--- + +## Phase 4: User Story 2 - Left Chat Pane Lists My Real Conversations (Priority: P1) + +**Goal**: The left pane shows the authenticated user's actual conversations from the backend (newest first), not browser storage. + +**Independent Test**: Hold conversations, open the app in a second browser/device as the same user → identical list sourced from `GET /api/conversations`; new user sees an empty state. + +### Tests for User Story 2 + +- [ ] T018 [P] [US2] API test (`emulator`-backed repository — real `/user_id` partition query): `GET /api/conversations` returns only the caller's conversations, ordered by `last_activity_at` desc, with paging, in `tests/test_conversations_api.py` + +### Implementation for User Story 2 + +- [ ] T019 [US2] Add `GET /api/conversations` in `main.py` (auth-scoped `repo.list_for_user`, `limit`/`cursor`) returning the wire shape from [contracts/conversations-api.md](contracts/conversations-api.md) +- [ ] T020 [P] [US2] Align `ConversationIndexEntry` to the API response and drop reliance on `StoredConversation.sessionData` in `frontend/src/types/api.ts` +- [ ] T021 [US2] Add `listConversations()` to `frontend/src/api/client.ts` calling `GET /api/conversations` (depends on T020) +- [ ] T022 [US2] Replace localStorage `loadIndex` with server `listConversations` (with loading/error state) in `frontend/src/hooks/useConversationStore.ts` (depends on T021) +- [ ] T023 [US2] Render server conversations with loading/empty/error states (handle empty title) in `frontend/src/components/Sidebar.tsx` and `frontend/src/pages/ChatPage.tsx` (depends on T022) + +**Checkpoint**: The pane lists real per-user conversations from Cosmos. + +--- + +## Phase 5: User Story 3 - Resume a Past Conversation With Full History (Priority: P1) + +**Goal**: Selecting a conversation renders its prior messages and continues with the agent's full context. + +**Independent Test**: Select a past conversation → prior messages render; send a context-dependent follow-up → correct reply; new turn appends to the same conversation. + +### Tests for User Story 3 + +- [ ] T024 [P] [US3] API test (`emulator`-backed — real message read from `chat-history`): `GET /api/conversations/{id}/messages` returns the owner's messages mapped to `ChatMessage[]`, and `404` for missing/non-owner, in `tests/test_conversations_api.py` + +### Implementation for User Story 3 + +- [ ] T025 [US3] Add a stored-`Message` → `ChatMessage` wire-shape mapping helper (role, content, `tool_invocations`, `usage`, `images`) reusing existing event shapes in `streaming.py` +- [ ] T026 [US3] Add `GET /api/conversations/{id}/messages` in `main.py`: ownership check, then `history_provider.get_messages(id)` mapped via T025 (depends on T025) +- [ ] T027 [P] [US3] Add `getConversationMessages(id)` to `frontend/src/api/client.ts` calling `GET /api/conversations/{id}/messages` +- [ ] T028 [US3] Resume via `conversation_id` and load messages from the API (replace localStorage `loadConversation` + `extractMessagesFromSessionData`) in `frontend/src/hooks/useSessionLifecycle.ts` and `frontend/src/hooks/useConversationPersistence.ts` (depends on T027) +- [ ] T029 [US3] Render resumed messages from the server and post `conversation_id` (no history blob) when resuming in `frontend/src/pages/ChatPage.tsx` and `frontend/src/hooks/useChat.ts` (depends on T028) + +**Checkpoint**: Conversations reopen with full history and continue seamlessly. + +--- + +## Phase 6: User Story 4 - My Conversations Are Private to Me (Priority: P1) + +**Goal**: Ownership is enforced on every conversation read/resume/delete; cross-user access is impossible. + +**Independent Test**: As user B, attempt to list/read/resume/delete user A's conversation id → every attempt denied (`404`); B's list never includes A's conversations. + +### Tests for User Story 4 + +- [ ] T030 [P] [US4] Cross-user isolation tests (`emulator`-backed — real per-`user_id` partition isolation): non-owner `GET messages`/resume/`DELETE` → `404`; `list` excludes other users, in `tests/test_conversations_api.py` + +### Implementation for User Story 4 + +- [ ] T031 [US4] Ensure `get_owned` returns `None` for both missing and foreign-owned docs and is the single ownership choke point used by all conversation paths in `cosmos_memory.py` +- [ ] T032 [US4] Verify every conversation/session-resume handler returns `404` (not `403`) on ownership failure, never discloses existence, and sources `user_id` only from the validated token in `main.py` +- [ ] T033 [US4] Confirm conversation ids are server-generated UUIDs (non-enumerable) on all create paths in `session_orchestration.py` + +**Checkpoint**: Per-user isolation verified end to end. + +--- + +## Phase 7: User Story 5 - Delete a Conversation (Priority: P2) + +**Goal**: A user can delete a conversation, removing its index entry and its stored messages. + +**Independent Test**: Delete from the pane → it disappears from the list and `GET /api/conversations/{id}/messages` returns `404`. + +### Tests for User Story 5 + +- [ ] T034 [P] [US5] API test (`emulator`-backed — assert messages are actually gone from `chat-history` after delete): `DELETE /api/conversations/{id}` clears messages + removes the index doc, returns `404` for non-owner, and is idempotent, in `tests/test_conversations_api.py` + +### Implementation for User Story 5 + +- [ ] T035 [US5] Add `DELETE /api/conversations/{id}` in `main.py`: ownership check → `history_provider.clear(id)` → `repo.delete(user_id, id)`, with retryable `503` on partial failure (FR-018) +- [ ] T036 [US5] Clarify that `DELETE /api/sessions/{id}` performs runtime cleanup only (does not delete durable history) in `main.py` +- [ ] T037 [US5] Add `deleteConversation(id)` to `frontend/src/api/client.ts` and wire the Sidebar delete control to call it, refresh the list, and reset to a new chat when the active conversation is deleted, in `frontend/src/api/client.ts` and `frontend/src/components/Sidebar.tsx` + +**Checkpoint**: Deletion works and is owner-scoped. + +--- + +## Phase 8: User Story 6 - Works Locally Without Cloud Cosmos (Priority: P3) + +**Goal**: The app runs locally and tests pass with no provisioned cloud Cosmos account. + +**Independent Test**: With no `AZURE_COSMOS_ENDPOINT`, the backend starts and chat works; `uv run pytest` passes offline. + +### Tests for User Story 6 + +- [ ] T038 [P] [US6] Test: full conversation flow (create/list/resume/delete) works against the in-memory fallback with no `AZURE_COSMOS_ENDPOINT`, in `tests/test_cosmos_memory.py` + +### Implementation for User Story 6 + +- [ ] T039 [US6] Emit a single startup warning when Cosmos is unconfigured (durable memory disabled) without hard-failing, in `cosmos_memory.py`/`main.py` +- [ ] T040 [US6] Confirm the default `uv run pytest` run passes fully offline (the `emulator` marker is deselected when the emulator is unreachable) with the fallback path exercised by CI-safe fakes, adjusting fixtures/markers in `tests/conftest.py` and `pyproject.toml` if needed + +**Checkpoint**: Local/dev/test workflow is cloud-free. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +**Purpose**: Production infrastructure, cleanup, security, and verification spanning stories. + +- [ ] T041 [P] Create `infra/modules/cosmos/{main.tf,variables.tf,outputs.tf}`: Cosmos DB for NoSQL account (Azure Government, serverless), database, and two containers (partition keys `/session_id` and `/user_id`), with local (key) auth disabled in production +- [ ] T042 Add the **Cosmos DB Built-in Data Contributor** data-plane SQL role assignment for the app's user-assigned managed identity in `infra/modules/cosmos/main.tf` (depends on T041) +- [ ] T043 Instantiate the cosmos module, pass endpoint/db/container into the app-service module, and expose account outputs in `infra/main.tf`, `infra/variables.tf`, `infra/outputs.tf` (depends on T041) +- [ ] T044 Accept and set `AZURE_COSMOS_ENDPOINT`/`AZURE_COSMOS_DATABASE_NAME`/`AZURE_COSMOS_CONTAINER_NAME` app settings (no key in production) in `infra/modules/app-service` (depends on T043) +- [ ] T045 [P] Remove dead localStorage conversation code (`webagents_conversation_*` keys, blob save) from `frontend/src/hooks/useConversationStore.ts` and `frontend/src/hooks/useConversationPersistence.ts` +- [ ] T046 [P] Create `scripts/capture_cosmos_chat_pane_screenshots.py` (Playwright, system Chrome, `--base-url` arg) for chat-pane visual verification +- [ ] T047 Run the Visual Verification Protocol: `cd frontend && npm run build`, capture chat-pane states (populated list, empty, loading/error, resumed) to `screenshots/011-cosmos-agent-memory/`, and review each (depends on T046 and the frontend stories) +- [ ] T048 [P] Security check: grep the built bundle (`frontend/dist/`) and runtime logs and confirm no Cosmos key/connection string appears there, in API responses, or in `cosmos_memory.py` log statements (SC-007) +- [ ] T049 Run [quickstart.md](quickstart.md) end-to-end validation (US1–US6 verification steps), including the Cosmos emulator integration suite (`uv run pytest -m emulator`) +- [ ] T050 [P] Register the `emulator` pytest marker in `pyproject.toml` and add `scripts/run_emulator_tests.sh` (starts/uses the Azure Cosmos DB Emulator container, exports `AZURE_COSMOS_EMULATOR_ENDPOINT`/`AZURE_COSMOS_KEY`, then runs `uv run pytest -m emulator`); document the emulator test workflow in `quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies — start immediately. +- **Foundational (Phase 2)**: depends on Setup — **blocks all user stories**. +- **User Stories (Phases 3–8)**: all depend on Foundational. The P1 stories are layered (US2/US3/US4/US5 read or gate the index/messages that US1 establishes), so the recommended order is US1 → US2 → US3 → US4 → US5 → US6. +- **Polish (Phase 9)**: depends on the targeted user stories being complete (infra can be built in parallel earlier, but verification tasks T047/T049 need the stories done). + +### User Story Dependencies + +- **US1 (P1)**: needs only Foundational. Establishes session-id control + index doc creation + resume + turn persistence (the memory engine). +- **US2 (P1)**: needs Foundational; reads the index US1 writes (list endpoint + pane). +- **US3 (P1)**: needs Foundational; resumes/reads messages (uses US1's session-id/ownership; adds the messages endpoint + frontend resume). +- **US4 (P1)**: hardens/verifies the ownership checks used by US1/US2/US3/US5. +- **US5 (P2)**: needs Foundational; deletes index + messages (uses ownership helper). +- **US6 (P3)**: validates the fallback path baked into Foundational. + +### Within Each User Story + +- Tests are written first and should fail before implementation. +- Backend storage/services before endpoints; types before API client before hooks before components. +- Story complete and independently testable before moving to the next priority. + +### Parallel Opportunities + +- Setup: T002 [P] alongside T001. +- Foundational: T010 [P] and T008 [P] can proceed alongside the `cosmos_memory.py` work (T003–T007 are sequential — same file). +- Tests (T011/T012, plus each story's [P] test) can be authored in parallel with each other where they are in different files. +- Frontend type task T020 [P] and API task T027 [P] can start before their dependent hook/component tasks. +- Polish: T041/T045/T046/T048/T050 [P] are independent files. + +--- + +## Parallel Example: User Story 1 + +```text +# Author the US1 tests together (different files): +Task: "Unit test provider selection + repo create/get in tests/test_cosmos_memory.py" # T011 +Task: "Emulator integration test memory-across-restart in tests/test_session_orchestration.py" # T012 + +# Then implement sequentially within session_orchestration.py / main.py (shared files): +T013 → T014 → T015 → T016 → T017 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 only) + +1. Phase 1 Setup → 2. Phase 2 Foundational (critical) → 3. Phase 3 US1. +4. **STOP and VALIDATE**: durable memory across restarts (SC-001). Demo the MVP. + +### Incremental Delivery + +1. Setup + Foundational → foundation ready. +2. US1 (memory) → US2 (list) → US3 (resume) → US4 (isolation) → US5 (delete) → US6 (local). +3. Each story is an independently testable increment; Polish (infra + visual + security + quickstart) finalizes for deployment. + +### Parallel Team Strategy + +After Foundational: one developer can carry US1→US3 (backend memory/endpoints) while another builds the frontend pane (US2/US3 frontend) and a third prepares the Terraform `cosmos` module (T041–T044) in parallel; converge on US4 isolation verification and Phase 9 validation. + +--- + +## Summary + +- **Total tasks**: 50 (Setup 2, Foundational 8, US1 7, US2 6, US3 6, US4 4, US5 4, US6 3, Polish 10) +- **MVP scope**: Phase 1 + Phase 2 + Phase 3 (US1) — durable agent memory across restarts +- **Tests**: included in two tiers — unit tests with in-memory fakes (offline) + integration tests against the local Azure Cosmos DB Emulator (`emulator` marker; auto-skips when unavailable) +- **Independent test criteria**: stated per story (Goal + Independent Test) +- **Key parallel opportunities**: cross-file foundational tasks (T008/T010), per-story test files, frontend types/client ahead of hooks/components, and the Terraform module alongside backend work diff --git a/streaming.py b/streaming.py index 0e46f70..d65ebfe 100644 --- a/streaming.py +++ b/streaming.py @@ -1,6 +1,7 @@ """Streaming, usage, and content conversion helpers for chat responses.""" import json +import re from typing import Any, AsyncGenerator, Optional from agent_framework import Agent as RuntimeAgent @@ -123,6 +124,48 @@ def render_tool_result(result: object) -> str: return str(result or "") +_USER_TIME_OPEN = "[Current date and time: " +_USER_TIME_CLOSE = "]\n\n" +_USER_TIME_RE = re.compile(r"^\[Current date and time: [^\]]*\]\n\n") + + +def with_user_time(text: str, when: str) -> str: + """Prepend a date/time marker to a user message for the model. + + The marker is stripped from the wire form by ``messages_to_wire`` so it stays + invisible in the UI while remaining in the model-visible session history. + """ + return f"{_USER_TIME_OPEN}{when}{_USER_TIME_CLOSE}{text}" + + +def strip_user_time(text: str) -> str: + """Remove the ``with_user_time`` marker so resumed history renders cleanly.""" + return _USER_TIME_RE.sub("", text, count=1) + + +def messages_to_wire(messages: list[Any]) -> list[dict[str, Any]]: + """Map stored agent_framework ``Message`` objects to the frontend ``ChatMessage[]`` shape. + + Mirrors the framework's conversation-persistence sample: read each message's + ``role`` and ``text``. Only user/assistant turns that carry text become chat + bubbles (tool-call/result messages have no display text). + """ + wire: list[dict[str, Any]] = [] + for msg in messages: + role = getattr(msg, "role", None) + role = getattr(role, "value", role) + if role not in ("user", "assistant"): + continue + text = getattr(msg, "text", "") or "" + if role == "user": + text = strip_user_time(text) + if not text: + continue + wire.append({"role": role, "content": text}) + return wire + + + async def stream_agent_response( agent: RuntimeAgent, contents: list[Content], diff --git a/tests/_doubles.py b/tests/_doubles.py new file mode 100644 index 0000000..c7765d3 --- /dev/null +++ b/tests/_doubles.py @@ -0,0 +1,118 @@ +"""In-memory test doubles for the Cosmos memory layer. + +These live in the test suite, not production code. The FastAPI app requires a +real Cosmos backend (the emulator locally); the real ``CosmosConversationRepository`` +is covered by the emulator-backed tests in ``test_cosmos_memory.py``. This +loop-independent in-memory index lets the broader API tests seed data with +``asyncio.run`` and then read it back through ``TestClient`` (a different event +loop) without a live emulator and without the async-client loop-binding problem. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from cosmos_memory import ConversationRecord + + +class InMemoryConversationRepository: + """Loop-independent in-memory conversation index (test double).""" + + def __init__(self) -> None: + self._by_user: dict[str, dict[str, ConversationRecord]] = {} + + async def create( + self, + user_id: str, + conversation_id: str, + profile_id: str, + profile_name: str, + *, + custom_agent_id: str | None = None, + used_builtin_override: bool = False, + base_profile_id: str | None = None, + override_updated_at: str | None = None, + ) -> ConversationRecord: + now = datetime.now(timezone.utc).isoformat() + record = ConversationRecord( + id=conversation_id, + user_id=user_id, + profile_id=profile_id, + profile_name=profile_name, + title="", + created_at=now, + last_activity_at=now, + custom_agent_id=custom_agent_id, + used_builtin_override=used_builtin_override, + base_profile_id=base_profile_id, + override_updated_at=override_updated_at, + ) + self._by_user.setdefault(user_id, {})[conversation_id] = record + return record + + async def list_for_user( + self, user_id: str, *, limit: int = 50, cursor: str | None = None + ) -> tuple[list[ConversationRecord], str | None]: + records = sorted( + self._by_user.get(user_id, {}).values(), + key=lambda r: r.last_activity_at, + reverse=True, + ) + return records[: max(0, limit)], None + + async def get_owned(self, user_id: str, conversation_id: str) -> ConversationRecord | None: + return self._by_user.get(user_id, {}).get(conversation_id) + + async def touch(self, user_id: str, conversation_id: str, *, title: str | None = None) -> None: + record = self._by_user.get(user_id, {}).get(conversation_id) + if record is None: + return + record.last_activity_at = datetime.now(timezone.utc).isoformat() + if title and not record.title: + record.title = title + + async def delete(self, user_id: str, conversation_id: str) -> bool: + user_convs = self._by_user.get(user_id, {}) + if conversation_id in user_convs: + del user_convs[conversation_id] + return True + return False + + +class InMemoryUserScopedRepository: + """Loop-independent in-memory per-user collection (test double for user_data).""" + + def __init__(self) -> None: + self._by_user: dict[str, dict[str, dict]] = {} + + async def list_for_user(self, user_id: str) -> list[dict]: + return list(self._by_user.get(user_id, {}).values()) + + async def get(self, user_id: str, item_id: str) -> dict | None: + return self._by_user.get(user_id, {}).get(item_id) + + async def upsert(self, user_id: str, item_id: str, data: dict) -> dict: + self._by_user.setdefault(user_id, {})[item_id] = data + return data + + async def delete(self, user_id: str, item_id: str) -> bool: + items = self._by_user.get(user_id, {}) + if item_id in items: + del items[item_id] + return True + return False + + +def clear_cosmos_singletons(monkeypatch) -> None: + """Clear cosmos_memory + user_data cached singletons for a test, via monkeypatch. + + Production has no reset/injection seams; tests reach the module-level + singletons through monkeypatch, which auto-reverts them at teardown. + """ + import cosmos_memory + import user_data + + for name in ("_cosmos_client", "_async_credential", "_history_provider", "_conversation_repo"): + monkeypatch.setattr(cosmos_memory, name, None) + for name in ("_custom_agents_repo", "_agent_customizations_repo", "_user_profile_repo"): + monkeypatch.setattr(user_data, name, None) diff --git a/tests/conftest.py b/tests/conftest.py index af6fa57..f9c6763 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,29 @@ os.environ.setdefault("AZURE_OPENAI_API_VERSION", "2024-02-15-preview") +@pytest.fixture(autouse=True) +def _cosmos_doubles(monkeypatch): + """Inject loop-independent in-memory Cosmos doubles for every test so the strict + 'Cosmos required' startup check passes and API tests can seed via asyncio.run and + read via TestClient (different event loops) without a live emulator. The REAL + Cosmos repository is covered by the emulator-backed tests in test_cosmos_memory.py. + + Uses monkeypatch (auto-reverts) so production carries no test-only injection seams.""" + import cosmos_memory + import user_data + from agent_framework import InMemoryHistoryProvider + from tests._doubles import InMemoryConversationRepository, InMemoryUserScopedRepository + + monkeypatch.setattr(cosmos_memory, "_cosmos_client", None) + monkeypatch.setattr(cosmos_memory, "_async_credential", None) + monkeypatch.setattr(cosmos_memory, "_history_provider", InMemoryHistoryProvider(skip_excluded=True)) + monkeypatch.setattr(cosmos_memory, "_conversation_repo", InMemoryConversationRepository()) + monkeypatch.setattr(user_data, "_custom_agents_repo", InMemoryUserScopedRepository()) + monkeypatch.setattr(user_data, "_agent_customizations_repo", InMemoryUserScopedRepository()) + monkeypatch.setattr(user_data, "_user_profile_repo", InMemoryUserScopedRepository()) + yield + + @pytest.fixture def client(): """FastAPI TestClient with clean in-memory sessions.""" @@ -33,6 +56,42 @@ def client(): _sessions.clear() +@pytest.fixture +def cosmos_emulator(monkeypatch): + """Point the app at the local Azure Cosmos DB Emulator; skip if unreachable. + + Used by ``@pytest.mark.emulator`` integration tests. The well-known emulator + key is a public, fixed value (not a secret). + """ + import socket + from urllib.parse import urlsplit + + from tests._doubles import clear_cosmos_singletons + + endpoint = os.environ.get("AZURE_COSMOS_EMULATOR_ENDPOINT", "https://localhost:8081/") + parsed = urlsplit(endpoint) + host = parsed.hostname or "localhost" + port = parsed.port or 8081 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.5) + try: + reachable = sock.connect_ex((host, port)) == 0 + finally: + sock.close() + if not reachable: + pytest.skip(f"Azure Cosmos DB Emulator not reachable at {endpoint}") + + well_known_key = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", endpoint) + monkeypatch.setenv("AZURE_COSMOS_KEY", os.environ.get("AZURE_COSMOS_KEY", well_known_key)) + monkeypatch.setenv("AZURE_COSMOS_DATABASE_NAME", os.environ.get("AZURE_COSMOS_DATABASE_NAME", "agent-memory-test")) + monkeypatch.setenv("AZURE_COSMOS_CONTAINER_NAME", os.environ.get("AZURE_COSMOS_CONTAINER_NAME", "chat-history-test")) + monkeypatch.setenv("AZURE_COSMOS_CONVERSATIONS_CONTAINER", os.environ.get("AZURE_COSMOS_CONVERSATIONS_CONTAINER", "conversations-test")) + # Clear the autouse in-memory doubles so the REAL Cosmos providers are built. + clear_cosmos_singletons(monkeypatch) + yield + + @pytest.fixture def skills_client(tmp_path, monkeypatch): """FastAPI TestClient with skills storage redirected to a temporary directory.""" diff --git a/tests/test_api.py b/tests/test_api.py index 56ade76..1d4e3b5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,11 +1,13 @@ """Tests for FastAPI API endpoints.""" +import asyncio import os from types import SimpleNamespace os.environ.setdefault("AUTH_DISABLED", "true") os.environ.setdefault("AZURE_SQL_CONNECTIONSTRING", "") +import user_data from main import _sessions from prompt_config import load_agents_yaml @@ -132,11 +134,16 @@ async def fake_connect_mcp_servers(configs, *, user_token=None): monkeypatch.setattr(session_orchestration, "create_chat_runtime", fake_create_chat_runtime) monkeypatch.setattr(session_orchestration, "connect_mcp_servers", fake_connect_mcp_servers) + asyncio.run( + user_data.get_user_profile_repository().upsert( + "dev-user", "dev-user", {"name": "Avery", "preferences": "brief", "notes": "pilot"} + ) + ) + resp = client.post( "/api/sessions", json={ "profile_id": "search", - "user_profile": {"name": "Avery", "preferences": "brief", "notes": "pilot"}, }, ) @@ -183,6 +190,12 @@ async def fake_connect_mcp_servers(configs, *, user_token=None): monkeypatch.setattr(session_orchestration, "create_chat_runtime", fake_create_chat_runtime) monkeypatch.setattr(session_orchestration, "connect_mcp_servers", fake_connect_mcp_servers) + asyncio.run( + user_data.get_user_profile_repository().upsert( + "dev-user", "dev-user", {"name": "Avery", "preferences": "brief", "notes": "pilot"} + ) + ) + resp = client.post( "/api/sessions", json={ @@ -194,7 +207,6 @@ async def fake_connect_mcp_servers(configs, *, user_token=None): "custom_temperature": 0.7, "custom_skills": [], "mcp_servers": [], - "user_profile": {"name": "Avery", "preferences": "brief", "notes": "pilot"}, }, ) diff --git a/tests/test_conversations_api.py b/tests/test_conversations_api.py new file mode 100644 index 0000000..bdacfec --- /dev/null +++ b/tests/test_conversations_api.py @@ -0,0 +1,161 @@ +"""Tests for the conversations REST API: listing, messages, deletion, isolation.""" + +import asyncio + +import cosmos_memory +from auth import AuthenticatedUser, get_current_user + + +def run(coro): + return asyncio.run(coro) + + +def _as_user(uid: str): + return lambda: AuthenticatedUser(user_id=uid, username=uid) + + +def _seed(uid: str, conv_id: str, profile_id: str = "search", profile_name: str = "Search Agent"): + repo = cosmos_memory.get_conversation_repository() + run(repo.create(uid, conv_id, profile_id, profile_name)) + + +class _FakeHistory: + """History provider returning canned messages for any session id.""" + + def __init__(self, messages): + self._messages = messages + + async def get_messages(self, session_id, *, state=None, **kwargs): + return list(self._messages) + + async def clear(self, session_id): + return None + + +# --------------------------------------------------------------------------- +# T018 — GET /api/conversations +# --------------------------------------------------------------------------- + +def test_list_returns_only_owner_conversations(client): + from main import app + + _seed("userA", "a1") + _seed("userA", "a2") + _seed("userB", "b1") + + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + resp = client.get("/api/conversations") + assert resp.status_code == 200 + ids = {c["id"] for c in resp.json()["conversations"]} + assert ids == {"a1", "a2"} + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_list_orders_by_last_activity_desc(client): + from main import app + + _seed("userA", "older") + _seed("userA", "newer") + run(cosmos_memory.get_conversation_repository().touch("userA", "newer", title="newer one")) + + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + resp = client.get("/api/conversations?limit=10") + assert resp.status_code == 200 + ids = [c["id"] for c in resp.json()["conversations"]] + assert ids[0] == "newer" + assert set(ids) == {"older", "newer"} + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# T024 — GET /api/conversations/{id}/messages +# --------------------------------------------------------------------------- + +def test_get_messages_returns_mapped_messages_for_owner(client, monkeypatch): + from main import app + from agent_framework._types import Content, Message + + monkeypatch.setattr( + cosmos_memory, + "_history_provider", + _FakeHistory([ + Message(role="user", contents=[Content.from_text("hi")]), + Message(role="assistant", contents=[Content.from_text("hello there")]), + ]), + ) + _seed("userA", "a1") + + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + resp = client.get("/api/conversations/a1/messages") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == "a1" + assert [m["role"] for m in data["messages"]] == ["user", "assistant"] + assert data["messages"][1]["content"] == "hello there" + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# T030 — per-user isolation across read / delete +# --------------------------------------------------------------------------- + +def test_messages_endpoint_denies_non_owner(client, monkeypatch): + from main import app + + monkeypatch.setattr(cosmos_memory, "_history_provider", _FakeHistory([])) + _seed("userA", "a1") + + app.dependency_overrides[get_current_user] = _as_user("userB") + try: + assert client.get("/api/conversations/a1/messages").status_code == 404 + assert client.get("/api/conversations/does-not-exist/messages").status_code == 404 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_list_excludes_other_users(client): + from main import app + + _seed("userA", "a1") + _seed("userB", "b1") + + app.dependency_overrides[get_current_user] = _as_user("userB") + try: + resp = client.get("/api/conversations") + ids = {c["id"] for c in resp.json()["conversations"]} + assert ids == {"b1"} + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# T034 — DELETE /api/conversations/{id} +# --------------------------------------------------------------------------- + +def test_delete_is_owner_scoped(client, monkeypatch): + from main import app + + monkeypatch.setattr(cosmos_memory, "_history_provider", _FakeHistory([])) + _seed("userA", "a1") + + # Non-owner cannot delete + app.dependency_overrides[get_current_user] = _as_user("userB") + try: + assert client.delete("/api/conversations/a1").status_code == 404 + finally: + app.dependency_overrides.pop(get_current_user, None) + + # Owner deletes; afterwards it is gone + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + assert client.delete("/api/conversations/a1").status_code == 204 + assert client.get("/api/conversations/a1/messages").status_code == 404 + assert client.delete("/api/conversations/a1").status_code == 404 + finally: + app.dependency_overrides.pop(get_current_user, None) diff --git a/tests/test_cosmos_memory.py b/tests/test_cosmos_memory.py new file mode 100644 index 0000000..46671e0 --- /dev/null +++ b/tests/test_cosmos_memory.py @@ -0,0 +1,322 @@ +"""Tests for the Cosmos memory layer: provider selection, conversation index, mapping.""" + +import asyncio +from uuid import uuid4 + +import pytest + +import cosmos_memory +from tests._doubles import clear_cosmos_singletons + + +# --------------------------------------------------------------------------- +# T011 — provider / repository selection +# --------------------------------------------------------------------------- + +def test_raises_when_cosmos_not_configured(monkeypatch): + monkeypatch.delenv("AZURE_COSMOS_ENDPOINT", raising=False) + clear_cosmos_singletons(monkeypatch) + with pytest.raises(RuntimeError): + cosmos_memory.get_history_provider() + with pytest.raises(RuntimeError): + cosmos_memory.get_conversation_repository() + with pytest.raises(RuntimeError): + cosmos_memory.require_cosmos_configured() + + +def test_cosmos_selected_with_endpoint(monkeypatch): + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://localhost:8081/") + # well-known public emulator key (valid base64; not a secret) + monkeypatch.setenv( + "AZURE_COSMOS_KEY", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + ) + monkeypatch.setenv("AZURE_COSMOS_DATABASE_NAME", "agent-memory") + monkeypatch.setenv("AZURE_COSMOS_CONTAINER_NAME", "chat-history") + clear_cosmos_singletons(monkeypatch) + from agent_framework.azure import CosmosHistoryProvider + + assert isinstance(cosmos_memory.get_history_provider(), CosmosHistoryProvider) + assert isinstance( + cosmos_memory.get_conversation_repository(), + cosmos_memory.CosmosConversationRepository, + ) + + +# --------------------------------------------------------------------------- +# Cosmos client credential selection (emulator key vs managed identity) +# --------------------------------------------------------------------------- + + +class _RecordingCosmosClient: + """Stand-in for azure.cosmos.aio.CosmosClient that records constructor args.""" + + def __init__(self, url, credential, **kwargs): + self.url = url + self.credential = credential + self.kwargs = kwargs + + +@pytest.fixture +def recording_cosmos_client(monkeypatch): + """Capture how _build_cosmos_client constructs the async CosmosClient.""" + import azure.cosmos.aio as cosmos_aio + + captured: dict[str, _RecordingCosmosClient] = {} + + def _factory(url, credential, **kwargs): + client = _RecordingCosmosClient(url, credential, **kwargs) + captured["client"] = client + return client + + monkeypatch.setattr(cosmos_aio, "CosmosClient", _factory) + return captured + + +def test_local_emulator_uses_well_known_key_when_no_key(monkeypatch, recording_cosmos_client): + """Regression: against the emulator with no AZURE_COSMOS_KEY we must use the + emulator's well-known master key. AAD/managed-identity tokens are rejected by + the emulator with 401 'token does not have a valid signature'.""" + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://localhost:8081/") + monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) + cosmos_memory._build_cosmos_client() + client = recording_cosmos_client["client"] + assert client.credential == cosmos_memory._EMULATOR_WELL_KNOWN_KEY + assert client.kwargs.get("connection_verify") is False + # The classic emulator advertises an unreachable container IP; discovery + # must be disabled so the SDK stays on the provided localhost endpoint. + assert client.kwargs.get("enable_endpoint_discovery") is False + + +def test_local_emulator_respects_explicit_key(monkeypatch, recording_cosmos_client): + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://127.0.0.1:8081/") + monkeypatch.setenv("AZURE_COSMOS_KEY", "explicit-test-key") + cosmos_memory._build_cosmos_client() + client = recording_cosmos_client["client"] + assert client.credential == "explicit-test-key" + + +def test_remote_endpoint_uses_managed_identity_when_no_key(monkeypatch, recording_cosmos_client): + """A real (non-local) endpoint with no key must use DefaultAzureCredential and + must NOT disable TLS verification or fall back to the emulator key.""" + import azure.identity.aio as identity_aio + + class _FakeCredential: + pass + + monkeypatch.setattr(identity_aio, "DefaultAzureCredential", _FakeCredential) + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://acct.documents.azure.us/") + monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) + cosmos_memory._build_cosmos_client() + client = recording_cosmos_client["client"] + assert isinstance(client.credential, _FakeCredential) + assert client.credential != cosmos_memory._EMULATOR_WELL_KNOWN_KEY + assert "connection_verify" not in client.kwargs + # Real (geo-replicated) accounts must keep endpoint discovery enabled. + assert "enable_endpoint_discovery" not in client.kwargs + + +# --------------------------------------------------------------------------- +# Conversation repository CRUD + isolation — REAL Cosmos via the emulator +# --------------------------------------------------------------------------- + +@pytest.mark.emulator +def test_repo_create_and_get_owned(cosmos_emulator): + async def scenario(): + repo = cosmos_memory.get_conversation_repository() + user = f"userA-{uuid4()}" + try: + record = await repo.create(user, "c1", "search", "Search Agent") + assert record.id == "c1" + assert record.user_id == user + got = await repo.get_owned(user, "c1") + assert got is not None + assert got.profile_name == "Search Agent" + finally: + await repo.delete(user, "c1") + await cosmos_memory.close_cosmos() + + asyncio.run(scenario()) + + +@pytest.mark.emulator +def test_repo_per_user_isolation(cosmos_emulator): + async def scenario(): + repo = cosmos_memory.get_conversation_repository() + user_a, user_b = f"userA-{uuid4()}", f"userB-{uuid4()}" + try: + await repo.create(user_a, "c1", "search", "Search Agent") + assert await repo.get_owned(user_b, "c1") is None + records, _ = await repo.list_for_user(user_b) + assert records == [] + finally: + await repo.delete(user_a, "c1") + await cosmos_memory.close_cosmos() + + asyncio.run(scenario()) + + +@pytest.mark.emulator +def test_repo_list_orders_by_last_activity_desc(cosmos_emulator): + async def scenario(): + repo = cosmos_memory.get_conversation_repository() + user = f"u-{uuid4()}" + try: + await repo.create(user, "c1", "search", "A") + await repo.create(user, "c2", "search", "B") + await repo.touch(user, "c1", title="hello world") # c1 becomes most recent + records, _ = await repo.list_for_user(user) + assert [r.id for r in records] == ["c1", "c2"] + assert records[0].title == "hello world" + finally: + await repo.delete(user, "c1") + await repo.delete(user, "c2") + await cosmos_memory.close_cosmos() + + asyncio.run(scenario()) + + +@pytest.mark.emulator +def test_repo_touch_sets_title_only_once(cosmos_emulator): + async def scenario(): + repo = cosmos_memory.get_conversation_repository() + user = f"u-{uuid4()}" + try: + await repo.create(user, "c1", "search", "A") + await repo.touch(user, "c1", title="first message") + await repo.touch(user, "c1", title="second message") + got = await repo.get_owned(user, "c1") + assert got.title == "first message" + finally: + await repo.delete(user, "c1") + await cosmos_memory.close_cosmos() + + asyncio.run(scenario()) + + +@pytest.mark.emulator +def test_repo_delete_is_owner_scoped_and_idempotent(cosmos_emulator): + async def scenario(): + repo = cosmos_memory.get_conversation_repository() + user, other = f"u-{uuid4()}", f"other-{uuid4()}" + try: + await repo.create(user, "c1", "search", "A") + assert await repo.delete(other, "c1") is False # not owner + assert await repo.get_owned(user, "c1") is not None + assert await repo.delete(user, "c1") is True + assert await repo.get_owned(user, "c1") is None + assert await repo.delete(user, "c1") is False # already gone + finally: + await cosmos_memory.close_cosmos() + + asyncio.run(scenario()) + + +def test_conversation_record_wire_shape(): + record = cosmos_memory.ConversationRecord( + id="c1", user_id="u", profile_id="search", profile_name="Search Agent", + title="Hello", created_at="t0", last_activity_at="t1", + ) + wire = record.to_wire() + assert wire == { + "id": "c1", + "profileId": "search", + "profileName": "Search Agent", + "description": "Hello", + "createdAt": "t0", + "lastActivityAt": "t1", + "customAgentId": None, + "usedBuiltInOverride": False, + "baseProfileId": None, + "overrideUpdatedAt": None, + } + # round-trips through the Cosmos document form + assert cosmos_memory.ConversationRecord.from_doc(record.to_doc()).id == "c1" + + +# --------------------------------------------------------------------------- +# Stored Message -> ChatMessage wire mapping +# --------------------------------------------------------------------------- + +def test_messages_to_wire_maps_user_and_assistant(): + from agent_framework._types import Content, Message + from streaming import messages_to_wire + + msgs = [ + Message(role="user", contents=[Content.from_text("hi there")]), + Message(role="assistant", contents=[Content.from_text("hello back")]), + ] + wire = messages_to_wire(msgs) + assert [m["role"] for m in wire] == ["user", "assistant"] + assert wire[0]["content"] == "hi there" + assert wire[1]["content"] == "hello back" + + +def test_messages_to_wire_strips_user_time_marker(): + from agent_framework._types import Content, Message + from streaming import messages_to_wire, with_user_time + + msgs = [ + Message(role="user", contents=[Content.from_text(with_user_time("hi there", "Wed Jun 18 2026 14:30:00 GMT-0700"))]), + Message(role="assistant", contents=[Content.from_text("hello back")]), + ] + wire = messages_to_wire(msgs) + # The timestamp marker is invisible in the wire form the UI renders. + assert wire[0]["content"] == "hi there" + assert wire[1]["content"] == "hello back" + + +# --------------------------------------------------------------------------- +# T012 — emulator integration (skips when the emulator is not running) +# --------------------------------------------------------------------------- + +@pytest.mark.emulator +def test_emulator_memory_round_trip(cosmos_emulator): + """Persist a turn via the real provider, simulate a restart, reload by id. + + The whole scenario runs inside a single event loop: the async Cosmos client + (an aiohttp session) is bound to the loop it is created on, so spreading the + steps across multiple ``asyncio.run`` calls would raise "Event loop is + closed". The production app uses one long-lived loop, matching this shape. + """ + import uuid as _uuid + from agent_framework._types import Content, Message + from agent_framework.azure import CosmosHistoryProvider + from azure.core.exceptions import AzureError + + user = "emu-user" + conv = str(_uuid.uuid4()) + + async def _scenario(): + try: + repo = cosmos_memory.get_conversation_repository() + provider = cosmos_memory.get_history_provider() + assert isinstance(provider, CosmosHistoryProvider) + + await repo.create(user, conv, "search", "Search Agent") + await provider.save_messages( + conv, [Message(role="user", contents=[Content.from_text("remember falcon")])] + ) + + # Simulated restart: drop + rebuild singletons (same loop), reload by id + await cosmos_memory.close_cosmos() + provider2 = cosmos_memory.get_history_provider() + loaded = await provider2.get_messages(conv) + assert any("falcon" in (getattr(m, "text", "") or "") for m in loaded) + + # ownership + cleanup + repo2 = cosmos_memory.get_conversation_repository() + assert await repo2.get_owned("other", conv) is None + await provider2.clear(conv) + await repo2.delete(user, conv) + finally: + await cosmos_memory.close_cosmos() + + try: + asyncio.run(_scenario()) + except (AzureError, OSError) as exc: + pytest.skip( + f"Cosmos emulator data plane not reachable ({type(exc).__name__}). " + "The classic emulator advertises its container IP; set " + "AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE or use the vnext-preview image." + ) diff --git a/tests/test_session_orchestration.py b/tests/test_session_orchestration.py index 10286cf..530044e 100644 --- a/tests/test_session_orchestration.py +++ b/tests/test_session_orchestration.py @@ -1,13 +1,17 @@ """Tests for session orchestration helpers.""" +import asyncio import logging import pytest from fastapi import HTTPException +import session_orchestration from prompt_config import BuiltinAgentRef, CustomAgentRef from session_orchestration import ( _build_validated_sub_agent_refs, + _create_conversation_index, + _resolve_session_id, sanitize_mcp_result_error, ) @@ -124,3 +128,57 @@ def test_build_validated_sub_agent_refs_flags_duplicate_targets(): ) codes = {e["code"] for e in exc_info.value.detail["errors"]} assert "duplicate_target" in codes + + +# --------------------------------------------------------------------------- +# Conversation-store error paths must surface a clean 503 (regression: the +# module-level `logger` was undefined, so the error branch raised NameError +# instead of HTTPException 503). +# --------------------------------------------------------------------------- + + +def _run(coro): + return asyncio.run(coro) + + +class _SimpleUser: + def __init__(self, user_id="user-1"): + self.user_id = user_id + + +class _BoomConversations: + """Conversation store whose every call fails, to exercise the error/log path.""" + + async def get_owned(self, user_id, conversation_id): + raise RuntimeError("store down") + + async def create(self, *args, **kwargs): + raise RuntimeError("store down") + + +def test_session_orchestration_defines_module_logger(): + assert isinstance(session_orchestration.logger, logging.Logger) + + +def test_resolve_session_id_maps_store_error_to_503(): + with pytest.raises(HTTPException) as exc_info: + _run( + _resolve_session_id( + _BoomConversations(), _SimpleUser(), {"conversation_id": "c1"} + ) + ) + assert exc_info.value.status_code == 503 + + +def test_create_conversation_index_maps_store_error_to_503(): + with pytest.raises(HTTPException) as exc_info: + _run( + _create_conversation_index( + _BoomConversations(), + user=_SimpleUser(), + session_id="c1", + profile_id="search", + profile_name="Search Agent", + ) + ) + assert exc_info.value.status_code == 503 diff --git a/tests/test_skills.py b/tests/test_skills.py index d742d61..9197a01 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -37,11 +37,14 @@ def test_returns_none_when_no_skills_requested(self): assert _build_skills_provider(None) is None assert _build_skills_provider([]) is None - def test_returns_none_when_no_matching_skills(self): + def test_returns_provider_when_skills_requested(self): + from agent_framework import SkillsProvider from agent_factory import _build_skills_provider + # The built-in FilteringSkillsSource advertises nothing for unknown names, + # but a provider is still returned whenever skills are requested. provider = _build_skills_provider(["nonexistent-skill"]) - assert provider is None + assert isinstance(provider, SkillsProvider) def test_returns_provider_with_table_usage_skill(self): from agent_framework import SkillsProvider @@ -50,4 +53,3 @@ def test_returns_provider_with_table_usage_skill(self): provider = _build_skills_provider(["table-usage"]) assert provider is not None assert isinstance(provider, SkillsProvider) - assert "table-usage" in provider._skills diff --git a/tests/test_skills_manager.py b/tests/test_skills_manager.py index b272980..198e40c 100644 --- a/tests/test_skills_manager.py +++ b/tests/test_skills_manager.py @@ -7,10 +7,11 @@ def test_list_and_get_skills(tmp_path, make_skill): + import asyncio make_skill(tmp_path, "alpha", "Alpha skill", "# Alpha\nBody") manager = SkillManager(tmp_path) - assert manager.list_summaries() == [{"name": "alpha", "description": "Alpha skill"}] + assert asyncio.run(manager.list_summaries()) == [{"name": "alpha", "description": "Alpha skill"}] assert manager.get("alpha") == {"name": "alpha", "description": "Alpha skill", "content": "# Alpha\nBody\n"} diff --git a/tests/test_user_data.py b/tests/test_user_data.py new file mode 100644 index 0000000..868a7c0 --- /dev/null +++ b/tests/test_user_data.py @@ -0,0 +1,111 @@ +"""Tests for the per-user data layer: custom agents, agent customizations, profile. + +API tests use the in-memory doubles (autouse fixture). The ``@pytest.mark.emulator`` +test exercises the real ``CosmosUserScopedRepository`` against the local emulator. +""" + +import asyncio +from uuid import uuid4 + +import pytest + +import cosmos_memory +import user_data +from auth import AuthenticatedUser, get_current_user + + +def _as_user(uid: str): + return lambda: AuthenticatedUser(user_id=uid, username=uid) + + +# --------------------------------------------------------------------------- +# API — custom agents +# --------------------------------------------------------------------------- + +def test_custom_agents_crud_and_isolation(client): + from main import app + + agent = {"id": "a1", "name": "Alpha", "systemPrompt": "be alpha"} + + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + assert client.get("/api/custom-agents").json()["agents"] == [] + assert client.put("/api/custom-agents/a1", json=agent).status_code == 200 + listed = client.get("/api/custom-agents").json()["agents"] + assert [a["id"] for a in listed] == ["a1"] + assert listed[0]["name"] == "Alpha" + finally: + app.dependency_overrides.pop(get_current_user, None) + + # Isolation: a different user sees nothing. + app.dependency_overrides[get_current_user] = _as_user("userB") + try: + assert client.get("/api/custom-agents").json()["agents"] == [] + finally: + app.dependency_overrides.pop(get_current_user, None) + + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + assert client.delete("/api/custom-agents/a1").status_code == 204 + assert client.get("/api/custom-agents").json()["agents"] == [] + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_agent_customizations_crud(client): + from main import app + + override = {"id": "o1", "baseProfileId": "search", "systemPrompt": "x", "source": "builtin-override"} + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + assert client.put("/api/agent-customizations/search", json=override).status_code == 200 + listed = client.get("/api/agent-customizations").json()["overrides"] + assert [o["baseProfileId"] for o in listed] == ["search"] + assert client.delete("/api/agent-customizations/search").status_code == 204 + assert client.get("/api/agent-customizations").json()["overrides"] == [] + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_save_rejects_non_object_body(client): + from main import app + + app.dependency_overrides[get_current_user] = _as_user("userA") + try: + assert client.put("/api/custom-agents/a1", json=["not", "an", "object"]).status_code == 400 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# Emulator — real CosmosUserScopedRepository CRUD + isolation +# --------------------------------------------------------------------------- + +@pytest.mark.emulator +def test_repo_crud_and_isolation(cosmos_emulator): + async def scenario(): + repo = user_data.get_custom_agents_repository() + user_a, user_b = f"userA-{uuid4()}", f"userB-{uuid4()}" + try: + await repo.upsert(user_a, "a1", {"id": "a1", "name": "Alpha"}) + await repo.upsert(user_a, "a2", {"id": "a2", "name": "Beta"}) + items = await repo.list_for_user(user_a) + assert {i["id"] for i in items} == {"a1", "a2"} + assert (await repo.get(user_a, "a1"))["name"] == "Alpha" + + # Per-user isolation. + assert await repo.get(user_b, "a1") is None + assert await repo.list_for_user(user_b) == [] + + # Update + owner-scoped, idempotent delete. + await repo.upsert(user_a, "a1", {"id": "a1", "name": "Alpha2"}) + assert (await repo.get(user_a, "a1"))["name"] == "Alpha2" + assert await repo.delete(user_b, "a1") is False + assert await repo.delete(user_a, "a1") is True + assert await repo.delete(user_a, "a1") is False + finally: + await repo.delete(user_a, "a1") + await repo.delete(user_a, "a2") + await cosmos_memory.close_cosmos() + + asyncio.run(scenario()) diff --git a/tests/test_user_profile.py b/tests/test_user_profile.py index 6382e05..0b66d88 100644 --- a/tests/test_user_profile.py +++ b/tests/test_user_profile.py @@ -1,17 +1,29 @@ -"""Tests for UserProfileStore tool methods.""" +"""Tests for the Cosmos-direct user-profile memory tools. -from tools import UserProfileStore +The ``_cosmos_doubles`` autouse fixture (conftest.py) backs ``get_user_profile_repository`` +with a loop-independent in-memory repository, so these exercise the real tool logic +without a live emulator. +""" + +import asyncio + +from tools import build_user_profile_tools class TestGetUserProfile: def test_returns_no_profile_message_when_empty(self): - store = UserProfileStore() - result = store.get_user_profile() + tools = build_user_profile_tools("user1") + result = asyncio.run(tools["get_user_profile"]()) assert "No user profile found" in result - def test_returns_json_when_profile_exists(self): - store = UserProfileStore({"name": "Alex", "preferences": "dark mode", "notes": "likes Python"}) - result = store.get_user_profile() + def test_returns_json_after_save(self): + tools = build_user_profile_tools("user1") + + async def scenario(): + await tools["save_user_profile"]("Alex", "dark mode", "likes Python") + return await tools["get_user_profile"]() + + result = asyncio.run(scenario()) assert '"name": "Alex"' in result assert '"preferences": "dark mode"' in result assert '"notes": "likes Python"' in result @@ -19,45 +31,75 @@ def test_returns_json_when_profile_exists(self): class TestSaveUserProfile: def test_saves_valid_profile(self): - store = UserProfileStore() - result = store.save_user_profile("Alex", "dark mode, concise answers", "enjoys hiking") - assert "saved successfully" in result - assert '"name": "Alex"' in result - get_result = store.get_user_profile() - assert '"preferences": "dark mode, concise answers"' in get_result + tools = build_user_profile_tools("user1") + + async def scenario(): + saved = await tools["save_user_profile"]("Alex", "dark mode, concise answers", "enjoys hiking") + got = await tools["get_user_profile"]() + return saved, got + + saved, got = asyncio.run(scenario()) + assert "saved successfully" in saved + assert '"name": "Alex"' in saved + assert '"preferences": "dark mode, concise answers"' in got def test_rejects_empty_name(self): - store = UserProfileStore() - result = store.save_user_profile("") + tools = build_user_profile_tools("user1") + result = asyncio.run(tools["save_user_profile"]("")) assert "Error" in result assert "name" in result def test_rejects_whitespace_name(self): - store = UserProfileStore() - result = store.save_user_profile(" ") + tools = build_user_profile_tools("user1") + result = asyncio.run(tools["save_user_profile"](" ")) assert "Error" in result assert "name" in result def test_defaults_preferences_and_notes_to_empty(self): - store = UserProfileStore() - result = store.save_user_profile("Alex") - assert "saved successfully" in result - get_result = store.get_user_profile() - assert '"preferences": ""' in get_result - assert '"notes": ""' in get_result + tools = build_user_profile_tools("user1") + + async def scenario(): + await tools["save_user_profile"]("Alex") + return await tools["get_user_profile"]() + + got = asyncio.run(scenario()) + assert '"preferences": ""' in got + assert '"notes": ""' in got def test_strips_whitespace(self): - store = UserProfileStore() - result = store.save_user_profile(" Alex ", " dark mode ", " notes here ") - assert "saved successfully" in result + tools = build_user_profile_tools("user1") + result = asyncio.run(tools["save_user_profile"](" Alex ", " dark mode ", " notes here ")) assert '"name": "Alex"' in result assert '"preferences": "dark mode"' in result assert '"notes": "notes here"' in result def test_overwrites_existing_profile(self): - store = UserProfileStore({"name": "Old", "preferences": "old pref", "notes": "old notes"}) - store.save_user_profile("New", "new pref", "new notes") - get_result = store.get_user_profile() - assert '"name": "New"' in get_result - assert '"preferences": "new pref"' in get_result - assert '"notes": "new notes"' in get_result + tools = build_user_profile_tools("user1") + + async def scenario(): + await tools["save_user_profile"]("Old", "old pref", "old notes") + await tools["save_user_profile"]("New", "new pref", "new notes") + return await tools["get_user_profile"]() + + got = asyncio.run(scenario()) + assert '"name": "New"' in got + assert '"preferences": "new pref"' in got + assert '"notes": "new notes"' in got + + def test_records_updated_at(self): + tools = build_user_profile_tools("user1") + + async def scenario(): + await tools["save_user_profile"]("Alex") + return await tools["get_user_profile"]() + + got = asyncio.run(scenario()) + assert '"updatedAt"' in got + + def test_profiles_are_isolated_per_user(self): + async def scenario(): + await build_user_profile_tools("user1")["save_user_profile"]("Alex") + return await build_user_profile_tools("user2")["get_user_profile"]() + + got = asyncio.run(scenario()) + assert "No user profile found" in got diff --git a/tests/test_validators.py b/tests/test_validators.py index 55caa75..68adff0 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -111,9 +111,10 @@ def test_validate_tool_and_skill_names(): def test_available_skill_names_reads_skill_provider(tmp_path, make_skill): + import asyncio make_skill(tmp_path, "alpha", "Alpha") - assert available_skill_names(tmp_path) == {"alpha"} - assert available_skill_names(tmp_path / "missing") == set() + assert asyncio.run(available_skill_names(tmp_path)) == {"alpha"} + assert asyncio.run(available_skill_names(tmp_path / "missing")) == set() def test_validate_http_mcp_servers_accepts_only_request_http_servers(): diff --git a/tools.py b/tools.py index 8d7ee28..58065d5 100644 --- a/tools.py +++ b/tools.py @@ -5,6 +5,7 @@ import struct import time from contextlib import closing +from datetime import datetime, timezone from typing import Any from pydantic import Field @@ -85,26 +86,28 @@ def _sanitize_cell_value(value: Any) -> Any: return _truncate_text(value, MAX_SQL_CELL_CHARS, "SQL CELL") return value -class UserProfileStore: - """Per-session in-memory store for user profile data. +def build_user_profile_tools(user_id: str) -> dict[str, Any]: + """Build the user-profile memory tools bound to a specific user. - The frontend sends the profile (from localStorage) when creating a session. - Tools exposed: get_user_profile, save_user_profile. + The returned async tools read and write the user's profile directly in + Azure Cosmos DB, so Cosmos is the single source of truth — there is no + per-session copy and no frontend round-trip to keep in sync. Returned + keyed by tool name so callers can expose ``get_user_profile`` and/or + ``save_user_profile`` independently. """ - - def __init__(self, profile: dict[str, str] | None = None): - self._profile: dict[str, str] | None = profile - - def get_user_profile(self) -> str: - """Return the stored user profile as a JSON string, or a message indicating no profile was found.""" - if self._profile: - return json.dumps(self._profile) - return ( - "No user profile found. Please ask the user for their name " - "and any preferences or interests they'd like you to remember." - ) - - def save_user_profile(self, name: str, preferences: str = "", notes: str = "") -> str: + from user_data import get_user_profile_repository + + async def get_user_profile() -> str: + """Return the user's saved profile (name, preferences, notes) as JSON, or a message if none is stored yet.""" + profile = await get_user_profile_repository().get(user_id, user_id) + if not profile: + return ( + "No user profile found. Please ask the user for their name " + "and any preferences or interests they'd like you to remember." + ) + return json.dumps(profile) + + async def save_user_profile(name: str, preferences: str = "", notes: str = "") -> str: """Save or update the user profile. Returns confirmation or a validation error. Args: @@ -114,9 +117,13 @@ def save_user_profile(self, name: str, preferences: str = "", notes: str = "") - """ if not name or not name.strip(): return "Error: name must not be empty." - self._profile = { + profile = { "name": name.strip(), "preferences": preferences.strip(), "notes": notes.strip(), + "updatedAt": datetime.now(timezone.utc).isoformat(), } - return f"User profile saved successfully: {json.dumps(self._profile)}" + await get_user_profile_repository().upsert(user_id, user_id, profile) + return f"User profile saved successfully: {json.dumps(profile)}" + + return {"get_user_profile": get_user_profile, "save_user_profile": save_user_profile} diff --git a/user_data.py b/user_data.py new file mode 100644 index 0000000..9c4d81d --- /dev/null +++ b/user_data.py @@ -0,0 +1,146 @@ +"""Per-user Cosmos repositories for custom agents, agent customizations, and the +user memory profile. + +Each datum is a small per-user collection partitioned by ``/user_id`` and stored +in its own container. The shared async ``CosmosClient`` is reused from +``cosmos_memory`` so the whole app uses one connection pool. Tests monkeypatch the +module-level singletons (``_custom_agents_repo`` etc.) with in-memory doubles. +""" + +from __future__ import annotations + +import logging +import os +from datetime import datetime, timezone +from typing import Any + +import cosmos_memory + +logger = logging.getLogger(__name__) + + +def _database_name() -> str: + return (os.getenv("AZURE_COSMOS_DATABASE_NAME") or "agent-memory").strip() + + +def _container_name(env_var: str, default: str) -> str: + return (os.getenv(env_var) or default).strip() + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class CosmosUserScopedRepository: + """Generic per-user document collection, partitioned by ``/user_id``. + + Each document wraps an arbitrary JSON payload:: + + {"id": item_id, "user_id": user_id, "data": , + "created_at": iso, "updated_at": iso} + + The repository returns the bare ``data`` payloads (the wire shapes the + frontend already uses); the wrapper fields are storage bookkeeping. + """ + + def __init__(self, container_name: str) -> None: + self._container_name = container_name + self._container: Any = None + + async def _get_container(self) -> Any: + if self._container is None: + from azure.cosmos import PartitionKey + + client = cosmos_memory.get_cosmos_client() + database = await client.create_database_if_not_exists(_database_name()) + self._container = await database.create_container_if_not_exists( + id=self._container_name, + partition_key=PartitionKey(path="/user_id"), + ) + return self._container + + async def list_for_user(self, user_id: str) -> list[dict[str, Any]]: + container = await self._get_container() + items = container.query_items( + query="SELECT * FROM c WHERE c.user_id = @uid ORDER BY c.created_at ASC", + parameters=[{"name": "@uid", "value": user_id}], + partition_key=user_id, + ) + return [item["data"] async for item in items if isinstance(item.get("data"), dict)] + + async def get(self, user_id: str, item_id: str) -> dict[str, Any] | None: + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + container = await self._get_container() + try: + doc = await container.read_item(item=item_id, partition_key=user_id) + except CosmosResourceNotFoundError: + return None + data = doc.get("data") + return data if isinstance(data, dict) else None + + async def upsert(self, user_id: str, item_id: str, data: dict[str, Any]) -> dict[str, Any]: + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + container = await self._get_container() + now = _utcnow_iso() + created_at = now + try: + existing = await container.read_item(item=item_id, partition_key=user_id) + created_at = existing.get("created_at", now) + except CosmosResourceNotFoundError: + pass + await container.upsert_item({ + "id": item_id, + "user_id": user_id, + "data": data, + "created_at": created_at, + "updated_at": now, + }) + return data + + async def delete(self, user_id: str, item_id: str) -> bool: + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + container = await self._get_container() + try: + await container.delete_item(item=item_id, partition_key=user_id) + return True + except CosmosResourceNotFoundError: + return False + + +# --------------------------------------------------------------------------- +# Cached singletons (tests monkeypatch these with in-memory doubles) +# --------------------------------------------------------------------------- + +_custom_agents_repo: Any = None +_agent_customizations_repo: Any = None +_user_profile_repo: Any = None + + +def get_custom_agents_repository() -> Any: + global _custom_agents_repo + if _custom_agents_repo is None: + _custom_agents_repo = CosmosUserScopedRepository( + _container_name("AZURE_COSMOS_CUSTOM_AGENTS_CONTAINER", "custom-agents") + ) + return _custom_agents_repo + + +def get_agent_customizations_repository() -> Any: + global _agent_customizations_repo + if _agent_customizations_repo is None: + _agent_customizations_repo = CosmosUserScopedRepository( + _container_name("AZURE_COSMOS_AGENT_CUSTOMIZATIONS_CONTAINER", "agent-customizations") + ) + return _agent_customizations_repo + + +def get_user_profile_repository() -> Any: + global _user_profile_repo + if _user_profile_repo is None: + _user_profile_repo = CosmosUserScopedRepository( + _container_name("AZURE_COSMOS_USER_PROFILES_CONTAINER", "user-profiles") + ) + return _user_profile_repo diff --git a/uv.lock b/uv.lock index 25c9073..3f75223 100644 --- a/uv.lock +++ b/uv.lock @@ -18,9 +18,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/7e/3476ba71284d7690ac99f991d2b307a7fd6133cd1509ab57cfa847002fd5/agent_framework_azure_ai_search-1.0.0b260409-py3-none-any.whl", hash = "sha256:78ad86ffbdd8557d3d94281cec67c7d4063dd8ef89ebda88d87d87eda56a669a", size = 11379, upload-time = "2026-04-10T03:26:28.869Z" }, ] +[[package]] +name = "agent-framework-azure-cosmos" +version = "1.0.0b260521" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "agent-framework-core" }, + { name = "azure-cosmos" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/16/97180499c1177010f0efd8a47d41bce7847bf729852402ef0b8b2edd18ba/agent_framework_azure_cosmos-1.0.0b260521.tar.gz", hash = "sha256:4cff0da0fafdd952f8517ec7961e6457263232392b27d07734a2914d9816ba34", size = 10977, upload-time = "2026-05-22T02:23:51.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/2d/cb1565112364ab30b747801f98dc62f541e26344cdd9e53d2d9bd9edba31/agent_framework_azure_cosmos-1.0.0b260521-py3-none-any.whl", hash = "sha256:f6e2b53539f5a62a5c8d47960aa950cb5d70ddb01c3e2bbefa6e67bb79804795", size = 11989, upload-time = "2026-05-22T02:24:26.541Z" }, +] + [[package]] name = "agent-framework-core" -version = "1.0.1" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -28,9 +41,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/3d/371e57a74ecd4fc551d458bd234d7591c052b467cac21e8805cb519a4187/agent_framework_core-1.0.1.tar.gz", hash = "sha256:6ace9fa8bee9d2e8556c28ff767d89b8e0a0a734246dcca4a196d0b0bc5cedb0", size = 285179, upload-time = "2026-04-10T03:29:28.193Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/38/5b4f1f69cfe62ecf0b66838a09d82678dfc0a90084e16b3f1c0f52aa53c3/agent_framework_core-1.9.0.tar.gz", hash = "sha256:09c5f18a43209f6db53dfe0da25d3dfbf1e5176a930a8ec3859091687d8f80a2", size = 449168, upload-time = "2026-06-18T09:42:48.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/11/a460d6656257c4302deb33724f29a52059bae193758ded7571fb576b26cb/agent_framework_core-1.0.1-py3-none-any.whl", hash = "sha256:8305fadb78adb9b625cda0ba8188bcb76ce01a2aa64eed937f8c9fb384043bc0", size = 323596, upload-time = "2026-04-10T03:31:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/1c0140cfbb134c51d335274948bcbdcee837a3bdad86bea92ef6b7235d13/agent_framework_core-1.9.0-py3-none-any.whl", hash = "sha256:aa33c588ec839dc7fc8e4768f4ff3d3cd44d4b38d7aa9733f5ee0a37ff4de956", size = 496431, upload-time = "2026-06-18T09:42:46.613Z" }, ] [[package]] @@ -240,6 +253,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, ] +[[package]] +name = "azure-cosmos" +version = "4.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/2a/0f2bba256e56626ba2cec97ab81dd002ff47ead1329767760b619afd927a/azure_cosmos-4.16.1.tar.gz", hash = "sha256:fa15d13702b470265a67e2dd9c0794021e6b776856dac6c223dcacc4d8e1d8d1", size = 2377651, upload-time = "2026-06-02T01:08:07.656Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b4/9c984ad33ca9e5c378ea472fc9aa38e719c245a4fa58a99d6dabbe0f9a17/azure_cosmos-4.16.1-py3-none-any.whl", hash = "sha256:43717215ec1433c1ea0f0e3d465f9182fb5afff3ae2331e595367122297872f1", size = 499044, upload-time = "2026-06-02T01:08:10.111Z" }, +] + [[package]] name = "azure-identity" version = "1.26.0b2" @@ -1653,6 +1679,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "agent-framework-azure-ai-search" }, + { name = "agent-framework-azure-cosmos" }, { name = "agent-framework-core" }, { name = "agent-framework-openai" }, { name = "azure-search-documents" }, @@ -1677,6 +1704,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-framework-azure-ai-search", specifier = ">=1.0.0b260311" }, + { name = "agent-framework-azure-cosmos", specifier = ">=1.0.0b260521" }, { name = "agent-framework-core", specifier = ">=1.0.0" }, { name = "agent-framework-openai", specifier = ">=1.0.1" }, { name = "azure-search-documents", specifier = ">=11.7.0b2" }, diff --git a/validators.py b/validators.py index 2922ff8..63274bc 100644 --- a/validators.py +++ b/validators.py @@ -95,13 +95,13 @@ def validate_tool_names(raw_tools: object, known_tools: set[str], field_name: st return list(raw_tools) -def available_skill_names(skills_dir: Path) -> set[str]: +async def available_skill_names(skills_dir: Path) -> set[str]: if not skills_dir.is_dir(): return set() - from agent_framework import SkillsProvider + from agent_framework import FileSkillsSource - provider = SkillsProvider(skill_paths=skills_dir) - return set(provider._skills.keys()) + skills = await FileSkillsSource(skills_dir).get_skills() + return {skill.frontmatter.name for skill in skills} def filter_known_skill_names(raw_skills: object, available_skills: set[str], field_name: str = "custom_skills") -> tuple[list[str], list[str]]: