diff --git a/backend/src/apis/app_api/agents/__init__.py b/backend/src/apis/app_api/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/apis/app_api/agents/services/__init__.py b/backend/src/apis/app_api/agents/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/apis/app_api/agents/services/binding_validation.py b/backend/src/apis/app_api/agents/services/binding_validation.py new file mode 100644 index 00000000..8b585620 --- /dev/null +++ b/backend/src/apis/app_api/agents/services/binding_validation.py @@ -0,0 +1,125 @@ +"""Agent Designer Phase 1 — design-time binding + model validation (D4/D5). + +Composes the *existing* per-primitive access checks; invents no new RBAC (D4). Run at +write time against the **author** (design-time half of D5); Phase 3 re-resolves each +binding against the invoking user at run time. + +Phase 1 resolution scope: +- ``model`` → must exist + author passes ``ModelAccessService.can_access_model``. +- ``memory_space`` → feature-flagged; author needs viewer+ (read) / editor+ (readwrite). +- ``knowledge_base`` → **managed implicitly** (the KB is welded to the agent and its index + is not user-configurable), so it is NOT author-settable in Phase 1; the compat layer + synthesizes it on read. An explicit knowledge_base binding is rejected. +- ``tool`` / ``skill`` → **inert**: shape-checked only, stored verbatim, no catalog lookup + and no RBAC (that lands with the Phase 2 catalog / Phase 3 resolution). +""" + +import logging +from typing import List, Optional + +from apis.shared.assistants.models import KNOWN_BINDING_KINDS, AgentBinding, AgentModelConfig +from apis.shared.auth.models import User +from apis.shared.feature_flags import memory_spaces_enabled +from apis.shared.memory.service import MemorySpaceService +from apis.shared.models.managed_models import get_managed_model + +from apis.app_api.admin.services.model_access import ModelAccessService + +logger = logging.getLogger(__name__) + +_INERT_KINDS = ("tool", "skill") +_ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3} + + +class BindingValidationError(Exception): + """A binding/model failed design-time validation. + + ``status_code`` maps to the HTTP response the route should return: 403 for an + access denial (the author lacks the capability), 400 for a malformed request. + """ + + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + + +async def validate_agent_write( + user: User, + *, + bindings: Optional[List[AgentBinding]] = None, + model_settings: Optional[AgentModelConfig] = None, + model_access_service: Optional[ModelAccessService] = None, + memory_service: Optional[MemorySpaceService] = None, +) -> None: + """Validate an Agent write for ``user``; raise ``BindingValidationError`` on failure. + + Services are injectable for testing. Callers pass only the fields actually present + on the request — ``None`` means "not provided", so nothing is validated for it. + """ + if model_settings is not None: + await _validate_model(user, model_settings, model_access_service or ModelAccessService()) + + if bindings is not None: + mem = memory_service or MemorySpaceService() + for binding in bindings: + _validate_binding(user, binding, mem) + + +async def _validate_model(user: User, cfg: AgentModelConfig, svc: ModelAccessService) -> None: + model = await get_managed_model(cfg.model_id) + if model is None: + raise BindingValidationError(f"Model '{cfg.model_id}' is not available.", status_code=400) + if not await svc.can_access_model(user, model): + raise BindingValidationError( + f"You do not have access to model '{cfg.model_id}'.", status_code=403 + ) + + +def _validate_binding(user: User, binding: AgentBinding, mem: MemorySpaceService) -> None: + kind = binding.kind + if kind not in KNOWN_BINDING_KINDS: + raise BindingValidationError(f"Unsupported binding kind '{kind}'.", status_code=400) + + if kind == "knowledge_base": + raise BindingValidationError( + "knowledge_base bindings are managed automatically and cannot be set directly.", + status_code=400, + ) + + if kind in _INERT_KINDS: + # Inert in Phase 1: shape only, no RBAC, no catalog lookup. + if not binding.ref or not binding.ref.strip(): + raise BindingValidationError(f"{kind} binding requires a non-empty 'ref'.", status_code=400) + return + + if kind == "memory_space": + _validate_memory_space(user, binding, mem) + + +def _validate_memory_space(user: User, binding: AgentBinding, mem: MemorySpaceService) -> None: + if not memory_spaces_enabled(): + raise BindingValidationError("Memory Spaces are not enabled.", status_code=400) + + access = (binding.config or {}).get("access", "read") + if access not in ("read", "readwrite"): + raise BindingValidationError( + f"memory_space binding 'access' must be 'read' or 'readwrite', got '{access}'.", + status_code=400, + ) + + always_load = (binding.config or {}).get("alwaysLoad") + if always_load is not None and not ( + isinstance(always_load, list) and all(isinstance(x, str) for x in always_load) + ): + raise BindingValidationError("memory_space 'alwaysLoad' must be a list of strings.", status_code=400) + + space, role = mem.resolve_permission(binding.ref, user.user_id, user.email) + if space is None: + raise BindingValidationError(f"Memory space '{binding.ref}' not found.", status_code=400) + + required = "editor" if access == "readwrite" else "viewer" + if role is None or _ROLE_RANK[role] < _ROLE_RANK[required]: + raise BindingValidationError( + f"'{required}' access required on memory space '{binding.ref}'.", status_code=403 + ) diff --git a/backend/src/apis/app_api/assistants/routes.py b/backend/src/apis/app_api/assistants/routes.py index 1a1d1d7c..28966faa 100644 --- a/backend/src/apis/app_api/assistants/routes.py +++ b/backend/src/apis/app_api/assistants/routes.py @@ -12,6 +12,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse +from apis.app_api.agents.services.binding_validation import ( + BindingValidationError, + validate_agent_write, +) from apis.app_api.documents.services.document_service import list_assistant_documents from apis.inference_api.chat.routes import stream_conversational_message from apis.inference_api.chat.service import get_agent @@ -121,6 +125,15 @@ async def create_assistant_endpoint(request: CreateAssistantRequest, current_use logger.info(f"POST /assistants - User: {user_id}, Name: {request.name}") + # Design-time binding/model validation (D4/D5). Outside the try below so the + # 4xx it raises isn't swallowed into a 500 by the generic handler. + try: + await validate_agent_write( + current_user, bindings=request.bindings, model_settings=request.model_settings + ) + except BindingValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + try: # Create complete assistant # Note: vector_index_id is automatically set from S3_ASSISTANTS_VECTOR_STORE_INDEX_NAME env var @@ -134,6 +147,8 @@ async def create_assistant_endpoint(request: CreateAssistantRequest, current_use tags=request.tags, starters=request.starters, emoji=request.emoji, + bindings=request.bindings, + model_settings=request.model_settings, ) # Convert to response model (excludes owner_id for privacy) @@ -344,6 +359,14 @@ async def update_assistant_endpoint(assistant_id: str, request: UpdateAssistantR detail="Only the owner can change assistant visibility", ) + # Design-time binding/model validation (D4/D5), after the auth gate above. + try: + await validate_agent_write( + current_user, bindings=request.bindings, model_settings=request.model_settings + ) + except BindingValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + # Mutation functions are keyed on the assistant's real owner_id, not the requester updated_assistant = await update_assistant( assistant_id=assistant_id, @@ -357,6 +380,8 @@ async def update_assistant_endpoint(assistant_id: str, request: UpdateAssistantR emoji=request.emoji, status=request.status, image_url=request.image_url, + bindings=request.bindings, + model_settings=request.model_settings, ) if not updated_assistant: diff --git a/backend/src/apis/shared/assistants/compat.py b/backend/src/apis/shared/assistants/compat.py new file mode 100644 index 00000000..578446b9 --- /dev/null +++ b/backend/src/apis/shared/assistants/compat.py @@ -0,0 +1,72 @@ +"""Agent Designer Phase 1 — legacy-Assistant → Agent compat mapping (D2). + +The Agent contract evolves the ``rag-assistants`` store IN PLACE: there is no +parallel table and no migration. A legacy Assistant row (one with no ``bindings`` / +``modelConfig`` attributes) is projected into the Agent shape *on read* by the pure +functions here — nothing is ever backfilled. + +Key grounding facts (verified against the live schema): + +- **No per-assistant model exists today.** The model is resolved per invocation + (request → user default → system default). So an absent ``modelConfig`` maps to + ``None`` meaning "resolve exactly as today" — we do NOT fabricate a model id (R1). +- **The KB is not first-class yet (F4 deferred).** ``vector_index_id`` is a *shared* + index name, "not user-configurable". The only stable per-Assistant KB identity is + the assistant id itself (retrieval filters vectors by ``assistant_id``). So the + synthesized ``knowledge_base`` binding uses ``ref == assistant_id`` (R4). When F4 + lands, this ref becomes a real KB id with no shape change here. + +NOTE: bindings carry *refs only*, never bodies — the METADATA item is 400 KB-capped +and already holds the full instructions. Any future kind needing a per-binding payload +must go to a child row (``AST#{id}/BINDING#…``), not inline here. +""" + +from typing import List + +from apis.shared.assistants.models import AgentBinding, Assistant + + +def effective_bindings(assistant: Assistant) -> List[AgentBinding]: + """Return the Agent's bindings, synthesizing the legacy KB binding when absent. + + - Stored bindings present → returned verbatim (unknown ``kind`` values survive). + - Stored bindings absent → a single ``knowledge_base`` binding whose ``ref`` is + the assistant id (the KB's only stable identity today). + """ + if assistant.bindings is not None: + return assistant.bindings + + return [ + AgentBinding( + kind="knowledge_base", + ref=assistant.assistant_id, + config={"vectorIndexId": assistant.vector_index_id}, + ) + ] + + +def to_agent_view(assistant: Assistant) -> dict: + """Project an Assistant into the resolved Agent read-shape. + + Returns a plain dict (camelCase keys) — the HTTP response model is a route concern + (Phase 3). ``agentId`` aliases ``assistantId``; legacy ids remain valid. ``owner_id`` + is deliberately omitted (never returned to clients), matching ``AssistantResponse``. + """ + return { + "agentId": assistant.assistant_id, + "ownerName": assistant.owner_name, + "name": assistant.name, + "description": assistant.description, + "instructions": assistant.instructions, + "modelConfig": assistant.model_settings.model_dump(by_alias=True) if assistant.model_settings else None, + "bindings": [b.model_dump(by_alias=True) for b in effective_bindings(assistant)], + "visibility": assistant.visibility, + "tags": assistant.tags or [], + "starters": assistant.starters or [], + "emoji": assistant.emoji, + "imageUrl": assistant.image_url, + "usageCount": assistant.usage_count, + "status": assistant.status, + "createdAt": assistant.created_at, + "updatedAt": assistant.updated_at, + } diff --git a/backend/src/apis/shared/assistants/models.py b/backend/src/apis/shared/assistants/models.py index da55fe7c..5c2f7694 100644 --- a/backend/src/apis/shared/assistants/models.py +++ b/backend/src/apis/shared/assistants/models.py @@ -1,9 +1,50 @@ """Assistants API request/response models""" -from typing import List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field +# Agent Designer Phase 1 (D3): the uniform binding kinds. Requests validate against +# this set; storage tolerates unknown kinds on read so records written by newer code +# survive a read/write round trip through older code (forward + rollback compat). +KNOWN_BINDING_KINDS = ("knowledge_base", "tool", "skill", "memory_space") +BindingKind = Literal["knowledge_base", "tool", "skill", "memory_space"] + + +class AgentModelConfig(BaseModel): + """Governed single-select model for an Agent (D3). + + The model is NOT a binding — it is a required singleton on the Agent record. + Optional in storage/compat, though: a legacy Assistant has no stored model, and + an absent ``modelConfig`` means "resolve the model exactly as today" (request → + user default → system default). The Agent Designer UI enforces single-select at + write time (Phase 4). + """ + + model_config = ConfigDict(populate_by_name=True) + + model_id: str = Field(..., alias="modelId", description="Selected model identifier") + provider: Optional[str] = Field(None, description="Model provider (e.g. 'bedrock'); mirrors InvocationRequest.provider") + params: Optional[Dict[str, Any]] = Field( + None, description="Model parameters (temperature, maxTokens, …); floats stored via Decimal" + ) + + +class AgentBinding(BaseModel): + """A single primitive binding on an Agent (D3). + + ``kind`` is an open string on read (unknown kinds pass through untouched); the + request layer validates it against ``KNOWN_BINDING_KINDS``. Phase 1 resolves only + ``memory_space`` and ``knowledge_base``; ``tool`` and ``skill`` are accepted and + stored but inert (not resolved) until Phase 2/3. + """ + + model_config = ConfigDict(populate_by_name=True) + + kind: str = Field(..., description="Binding kind (see KNOWN_BINDING_KINDS)") + ref: str = Field(..., description="Primitive identifier this binding points at") + config: Dict[str, Any] = Field(default_factory=dict, description="Kind-specific configuration") + class Assistant(BaseModel): """Complete assistant model (internal use)""" @@ -32,6 +73,16 @@ class Assistant(BaseModel): status: Literal["DRAFT", "COMPLETE"] = Field(..., description="Assistant lifecycle status") image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to assistant avatar/image") + # Agent Designer Phase 1 (D3): additive, optional. Absent on every legacy row. + # NOTE (R3): the model field cannot be named ``model_config`` — pydantic reserves + # that for ConfigDict — so it is ``model_settings`` with the ``modelConfig`` alias. + model_settings: Optional[AgentModelConfig] = Field( + None, alias="modelConfig", description="Governed single-select model (D3); absent = resolve as today" + ) + bindings: Optional[List[AgentBinding]] = Field( + None, description="Uniform primitive bindings (D3); absent = synthesize legacy KB binding via compat" + ) + class CreateAssistantDraftRequest(BaseModel): """Request body for creating a draft assistant (minimal fields)""" @@ -54,6 +105,9 @@ class CreateAssistantRequest(BaseModel): starters: Optional[List[str]] = Field(default_factory=list, description="Conversation starter prompts") emoji: Optional[str] = Field(None, description="Single emoji character for assistant avatar") image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to assistant avatar/image") + # Agent Designer Phase 1 (D3): additive, optional. Validated by binding_validation. + model_settings: Optional[AgentModelConfig] = Field(None, alias="modelConfig", description="Governed single-select model") + bindings: Optional[List[AgentBinding]] = Field(None, description="Uniform primitive bindings") class UpdateAssistantRequest(BaseModel): @@ -70,6 +124,9 @@ class UpdateAssistantRequest(BaseModel): emoji: Optional[str] = Field(None, description="Single emoji character for assistant avatar") status: Optional[Literal["DRAFT", "COMPLETE"]] = Field(None, description="Lifecycle status") image_url: Optional[str] = Field(None, alias="imageUrl", description="URL to assistant avatar/image") + # Agent Designer Phase 1 (D3): additive, optional. Validated by binding_validation. + model_settings: Optional[AgentModelConfig] = Field(None, alias="modelConfig", description="Governed single-select model") + bindings: Optional[List[AgentBinding]] = Field(None, description="Uniform primitive bindings") class AssistantResponse(BaseModel): diff --git a/backend/src/apis/shared/assistants/serialization.py b/backend/src/apis/shared/assistants/serialization.py new file mode 100644 index 00000000..07984ce3 --- /dev/null +++ b/backend/src/apis/shared/assistants/serialization.py @@ -0,0 +1,40 @@ +"""DynamoDB-safe (de)serialization for Agent binding/model config payloads. + +DynamoDB rejects Python ``float`` on write and returns ``Decimal`` on read. The Agent +record's ``modelConfig.params`` (temperature, top_p, …) and binding ``config`` blobs are +free-form, so any float nested in them must round-trip through ``Decimal``. Mirrors the +established pattern in ``apis/shared/sessions/metadata.py`` — kept here so the assistants +service persistence path (Phase 1 PR-2) has a single, tested helper. +""" + +from decimal import Decimal +from typing import Any + + +def to_ddb_safe(obj: Any) -> Any: + """Recursively convert floats to ``Decimal`` for a DynamoDB write.""" + if isinstance(obj, bool): + # bool is a subclass of int — leave it alone (Decimal(str(True)) would raise). + return obj + if isinstance(obj, float): + return Decimal(str(obj)) + if isinstance(obj, dict): + return {k: to_ddb_safe(v) for k, v in obj.items()} + if isinstance(obj, list): + return [to_ddb_safe(v) for v in obj] + return obj + + +def from_ddb(obj: Any) -> Any: + """Recursively convert ``Decimal`` back to native numbers after a DynamoDB read. + + Integral decimals become ``int``; the rest become ``float`` — so ``maxTokens`` reads + back as ``4096`` (int), not ``4096.0``. + """ + if isinstance(obj, Decimal): + return int(obj) if obj == obj.to_integral_value() else float(obj) + if isinstance(obj, dict): + return {k: from_ddb(v) for k, v in obj.items()} + if isinstance(obj, list): + return [from_ddb(v) for v in obj] + return obj diff --git a/backend/src/apis/shared/assistants/service.py b/backend/src/apis/shared/assistants/service.py index f9319268..5288781c 100644 --- a/backend/src/apis/shared/assistants/service.py +++ b/backend/src/apis/shared/assistants/service.py @@ -14,7 +14,8 @@ from datetime import datetime, timedelta, timezone from typing import List, Optional, Tuple -from .models import Assistant +from .models import AgentBinding, AgentModelConfig, Assistant +from .serialization import from_ddb, to_ddb_safe logger = logging.getLogger(__name__) @@ -88,6 +89,8 @@ async def create_assistant( tags: Optional[List[str]] = None, starters: Optional[List[str]] = None, emoji: Optional[str] = None, + bindings: Optional[List[AgentBinding]] = None, + model_settings: Optional[AgentModelConfig] = None, ) -> Assistant: """ Create a complete assistant with all required fields @@ -129,6 +132,8 @@ async def create_assistant( created_at=now, updated_at=now, status="COMPLETE", + bindings=bindings, + model_settings=model_settings, ) # Store the assistant @@ -156,7 +161,9 @@ async def _create_assistant_cloud(assistant: Assistant, table_name: str) -> None dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(table_name) - item = assistant.model_dump(by_alias=True, exclude_none=True) + # to_ddb_safe: modelConfig.params / binding config may carry floats, which + # DynamoDB rejects — convert to Decimal on the whole item (strings pass through). + item = to_ddb_safe(assistant.model_dump(by_alias=True, exclude_none=True)) item["PK"] = f"AST#{assistant.assistant_id}" item["SK"] = "METADATA" @@ -385,7 +392,7 @@ async def _get_assistant_cloud(assistant_id: str, owner_id: str, table_name: str logger.warning(f"Access denied: assistant {assistant_id} not owned by user {owner_id}") return None - return Assistant.model_validate(item) + return Assistant.model_validate(from_ddb(item)) except ClientError as e: error_code = e.response.get("Error", {}).get("Code", "Unknown") @@ -427,7 +434,7 @@ async def _get_assistant_cloud_without_ownership_check(assistant_id: str, table_ return None item = response["Item"] - return Assistant.model_validate(item) + return Assistant.model_validate(from_ddb(item)) except ClientError as e: error_code = e.response.get("Error", {}).get("Code", "Unknown") @@ -457,6 +464,8 @@ async def update_assistant( emoji: Optional[str] = None, status: Optional[str] = None, image_url: Optional[str] = None, + bindings: Optional[List[AgentBinding]] = None, + model_settings: Optional[AgentModelConfig] = None, ) -> Optional[Assistant]: """ Update assistant fields (deep merge) @@ -505,6 +514,12 @@ async def update_assistant( updates["status"] = status if image_url is not None: updates["image_url"] = image_url + # Phase 1: an explicit bindings list (incl. []) replaces the set; absent = untouched. + # Clearing modelConfig to null via merge isn't supported yet (exclude_none) — R5. + if bindings is not None: + updates["bindings"] = bindings + if model_settings is not None: + updates["model_settings"] = model_settings # Always update the updated_at timestamp updates["updated_at"] = _get_current_timestamp() @@ -564,8 +579,9 @@ async def _update_assistant_cloud(assistant: Assistant, table_name: str) -> None update_parts.append("updatedAt = :updated_at") expression_attribute_values[":updated_at"] = assistant.updated_at - # Update all fields from assistant model (excluding immutable fields) - assistant_dict = assistant.model_dump(by_alias=True, exclude_none=True) + # Update all fields from assistant model (excluding immutable fields). + # to_ddb_safe converts any float in modelConfig.params / binding config to Decimal. + assistant_dict = to_ddb_safe(assistant.model_dump(by_alias=True, exclude_none=True)) # DynamoDB reserved keywords that need to be escaped reserved_keywords = {"status", "name", "data", "size", "type", "value"} @@ -797,7 +813,7 @@ async def _list_user_assistants_cloud( all_assistants = [] for item in owner_response.get("Items", []): try: - all_assistants.append(Assistant.model_validate(item)) + all_assistants.append(Assistant.model_validate(from_ddb(item))) except Exception as e: logger.warning(f"Failed to parse assistant item: {e}") continue diff --git a/backend/tests/apis/app_api/agents/test_binding_validation.py b/backend/tests/apis/app_api/agents/test_binding_validation.py new file mode 100644 index 00000000..348d3b97 --- /dev/null +++ b/backend/tests/apis/app_api/agents/test_binding_validation.py @@ -0,0 +1,191 @@ +"""Agent Designer Phase 1 — design-time binding/model validation (D4/D5). + +Composes existing per-primitive access checks; the primitive services are mocked so +these stay fast unit tests. Asserts the inert guarantee for tool/skill (no RBAC/catalog +call is made), the memory_space grant matrix, and the implicit-KB rejection. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from apis.app_api.agents.services.binding_validation import ( + BindingValidationError, + validate_agent_write, +) +from apis.shared.assistants.models import AgentBinding, AgentModelConfig +from apis.shared.auth.models import User + +MODULE = "apis.app_api.agents.services.binding_validation" + + +def _user() -> User: + return User(email="alice@x.edu", user_id="u1", name="Alice", roles=[]) + + +def _model_svc(allowed: bool) -> MagicMock: + svc = MagicMock() + svc.can_access_model = AsyncMock(return_value=allowed) + return svc + + +def _mem_svc(space, role) -> MagicMock: + svc = MagicMock() + svc.resolve_permission = MagicMock(return_value=(space, role)) + return svc + + +# --------------------------------------------------------------------------- model +class TestModelValidation: + @pytest.mark.asyncio + async def test_accessible_model_passes(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.get_managed_model", AsyncMock(return_value=SimpleNamespace(model_id="m1"))) + await validate_agent_write( + _user(), + model_settings=AgentModelConfig(model_id="m1"), + model_access_service=_model_svc(True), + ) + + @pytest.mark.asyncio + async def test_unknown_model_400(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.get_managed_model", AsyncMock(return_value=None)) + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), model_settings=AgentModelConfig(model_id="ghost"), model_access_service=_model_svc(True) + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_forbidden_model_403(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.get_managed_model", AsyncMock(return_value=SimpleNamespace(model_id="m1"))) + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), model_settings=AgentModelConfig(model_id="m1"), model_access_service=_model_svc(False) + ) + assert ei.value.status_code == 403 + + +# --------------------------------------------------------------------------- inert +class TestInertKinds: + @pytest.mark.asyncio + async def test_tool_and_skill_stored_without_rbac(self): + # The inert guarantee: no memory/model service is consulted for tool/skill. + mem = _mem_svc(space=None, role=None) + await validate_agent_write( + _user(), + bindings=[ + AgentBinding(kind="tool", ref="gateway_x", config={"enabledTools": []}), + AgentBinding(kind="skill", ref="skill_1"), + ], + memory_service=mem, + ) + mem.resolve_permission.assert_not_called() + + @pytest.mark.asyncio + async def test_inert_kind_requires_ref(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write(_user(), bindings=[AgentBinding(kind="tool", ref=" ")]) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_unknown_kind_rejected(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write(_user(), bindings=[AgentBinding(kind="bogus", ref="x")]) + assert ei.value.status_code == 400 + + +# --------------------------------------------------------------------------- KB +class TestKnowledgeBase: + @pytest.mark.asyncio + async def test_explicit_kb_binding_rejected(self): + # Phase 1: KB is managed implicitly (synthesized on read), not author-settable. + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write(_user(), bindings=[AgentBinding(kind="knowledge_base", ref="ast_1")]) + assert ei.value.status_code == 400 + + +# --------------------------------------------------------------------------- memory_space +class TestMemorySpace: + @pytest.fixture(autouse=True) + def _flag_on(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: True) + + @pytest.mark.asyncio + async def test_flag_off_400(self, monkeypatch): + monkeypatch.setattr(f"{MODULE}.memory_spaces_enabled", lambda: False) + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read"})], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_readwrite_requires_editor(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + assert ei.value.status_code == 403 + + @pytest.mark.asyncio + async def test_readwrite_editor_ok(self): + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + memory_service=_mem_svc(space=object(), role="editor"), + ) + + @pytest.mark.asyncio + async def test_read_viewer_ok(self): + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read"})], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + + @pytest.mark.asyncio + async def test_no_grant_403(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read"})], + memory_service=_mem_svc(space=object(), role=None), + ) + assert ei.value.status_code == 403 + + @pytest.mark.asyncio + async def test_missing_space_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="ghost", config={"access": "read"})], + memory_service=_mem_svc(space=None, role=None), + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_bad_access_value_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "admin"})], + memory_service=_mem_svc(space=object(), role="owner"), + ) + assert ei.value.status_code == 400 + + @pytest.mark.asyncio + async def test_bad_alwaysload_400(self): + with pytest.raises(BindingValidationError) as ei: + await validate_agent_write( + _user(), + bindings=[ + AgentBinding(kind="memory_space", ref="spc_1", config={"access": "read", "alwaysLoad": "nope"}) + ], + memory_service=_mem_svc(space=object(), role="viewer"), + ) + assert ei.value.status_code == 400 diff --git a/backend/tests/shared/test_agent_compat.py b/backend/tests/shared/test_agent_compat.py new file mode 100644 index 00000000..161fed0d --- /dev/null +++ b/backend/tests/shared/test_agent_compat.py @@ -0,0 +1,122 @@ +"""Agent Designer Phase 1 — compat mapping, serialization, and model-contract tests. + +Pure library tests (no boto3): the D2 read-side compat mapping, the D3 model/binding +shapes, the R3 pydantic naming landmine, and the Decimal round-trip helpers. +""" + +from decimal import Decimal + +from apis.shared.assistants.compat import effective_bindings, to_agent_view +from apis.shared.assistants.models import AgentBinding, AgentModelConfig, Assistant +from apis.shared.assistants.serialization import from_ddb, to_ddb_safe + + +def _legacy_assistant(**overrides) -> Assistant: + """A legacy Assistant row — no bindings, no modelConfig.""" + base = dict( + assistant_id="ast_123", + owner_id="u1", + owner_name="Alice", + name="Bot", + description="A bot", + instructions="You are helpful.", + vector_index_id="assistants-index", + visibility="PRIVATE", + created_at="2026-07-07T00:00:00Z", + updated_at="2026-07-07T00:00:00Z", + status="COMPLETE", + ) + base.update(overrides) + return Assistant(**base) + + +class TestCompatMapping: + def test_legacy_synthesizes_single_kb_binding_reffing_assistant_id(self): + a = _legacy_assistant() + bindings = effective_bindings(a) + assert len(bindings) == 1 + (kb,) = bindings + assert kb.kind == "knowledge_base" + # The KB's only stable identity today IS the assistant id (R4). + assert kb.ref == "ast_123" + assert kb.config == {"vectorIndexId": "assistants-index"} + + def test_legacy_modelconfig_is_none_not_fabricated(self): + # R1: absent model must map to None ("resolve as today"), never a fake id. + a = _legacy_assistant() + assert a.model_settings is None + assert to_agent_view(a)["modelConfig"] is None + + def test_stored_bindings_returned_verbatim(self): + stored = [AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})] + a = _legacy_assistant(bindings=stored) + assert effective_bindings(a) == stored + + def test_unknown_kind_survives_read(self): + # Forward/rollback compat: a kind written by newer code passes through. + a = _legacy_assistant(bindings=[AgentBinding(kind="future_kind", ref="x", config={})]) + assert effective_bindings(a)[0].kind == "future_kind" + + def test_empty_bindings_list_is_not_synthesized(self): + # An explicit empty list means "no bindings", distinct from absent (legacy). + a = _legacy_assistant(bindings=[]) + assert effective_bindings(a) == [] + + def test_agent_view_uses_agent_id_and_omits_owner_id(self): + view = to_agent_view(_legacy_assistant()) + assert view["agentId"] == "ast_123" + assert "ownerId" not in view and "owner_id" not in view + + +class TestModelContract: + def test_modelconfig_alias_roundtrip(self): + # R3: field is ``model_settings`` in Python, ``modelConfig`` on the wire. + a = _legacy_assistant( + model_settings=AgentModelConfig(model_id="us.anthropic.claude", params={"temperature": 0.7}) + ) + assert a.model_settings.model_id == "us.anthropic.claude" + dumped = a.model_dump(by_alias=True) + assert dumped["modelConfig"]["modelId"] == "us.anthropic.claude" + + def test_assistant_validates_modelconfig_from_wire_alias(self): + a = Assistant.model_validate( + { + "assistantId": "ast_9", + "ownerId": "u1", + "ownerName": "Alice", + "name": "B", + "description": "d", + "instructions": "i", + "vectorIndexId": "assistants-index", + "visibility": "PRIVATE", + "createdAt": "t", + "updatedAt": "t", + "status": "COMPLETE", + "modelConfig": {"modelId": "m1"}, + "bindings": [{"kind": "tool", "ref": "t1", "config": {}}], + } + ) + assert a.model_settings.model_id == "m1" + assert a.bindings[0].kind == "tool" + + def test_binding_config_defaults_to_empty_dict(self): + assert AgentBinding(kind="skill", ref="s1").config == {} + + +class TestSerialization: + def test_float_to_decimal_and_back(self): + params = {"temperature": 0.7, "topP": 1.0, "maxTokens": 4096, "stop": ["x"], "stream": True} + safe = to_ddb_safe(params) + assert isinstance(safe["temperature"], Decimal) + # Floats that happen to be integral still convert (they arrived as float). + assert isinstance(safe["topP"], Decimal) + # Native ints are already DynamoDB-safe — left untouched. + assert isinstance(safe["maxTokens"], int) + # bool must not be coerced to Decimal. + assert safe["stream"] is True + back = from_ddb(safe) + assert back["temperature"] == 0.7 and isinstance(back["temperature"], float) + # Integral decimals read back as int, not 1.0. + assert back["topP"] == 1 and isinstance(back["topP"], int) + assert back["maxTokens"] == 4096 and isinstance(back["maxTokens"], int) + assert back["stop"] == ["x"] diff --git a/backend/tests/shared/test_assistants_agent_fields.py b/backend/tests/shared/test_assistants_agent_fields.py new file mode 100644 index 00000000..e5c96cf7 --- /dev/null +++ b/backend/tests/shared/test_assistants_agent_fields.py @@ -0,0 +1,65 @@ +"""Agent Designer Phase 1 — bindings + modelConfig persistence round-trip (moto). + +Proves the D3 fields survive a real DynamoDB write/read: float params round-trip through +Decimal, bindings are preserved, and legacy rows still read back with no agent fields. +""" + +import pytest + +from apis.shared.assistants.models import AgentBinding, AgentModelConfig + + +class TestAgentFieldsPersistence: + @pytest.fixture(autouse=True) + def _set_env(self, monkeypatch): + monkeypatch.setenv("S3_ASSISTANTS_VECTOR_STORE_INDEX_NAME", "test-index") + + @pytest.mark.asyncio + async def test_create_with_bindings_and_modelconfig_roundtrips(self, assistants_table): + from apis.shared.assistants.service import create_assistant, get_assistant + + created = await create_assistant( + owner_id="u1", + owner_name="Alice", + name="Oliver", + description="Chief of Staff", + instructions="You are Oliver.", + model_settings=AgentModelConfig(model_id="m1", params={"temperature": 0.7, "maxTokens": 4096}), + bindings=[AgentBinding(kind="memory_space", ref="spc_1", config={"access": "readwrite"})], + ) + got = await get_assistant(created.assistant_id, "u1") + assert got is not None + # Float survived the Decimal round trip as a native float. + assert got.model_settings.model_id == "m1" + assert got.model_settings.params["temperature"] == 0.7 + assert isinstance(got.model_settings.params["temperature"], float) + assert got.model_settings.params["maxTokens"] == 4096 + assert len(got.bindings) == 1 + assert got.bindings[0].kind == "memory_space" + assert got.bindings[0].config == {"access": "readwrite"} + + @pytest.mark.asyncio + async def test_legacy_create_has_no_agent_fields(self, assistants_table): + from apis.shared.assistants.service import create_assistant, get_assistant + + created = await create_assistant( + owner_id="u1", owner_name="Alice", name="Plain", description="d", instructions="i" + ) + got = await get_assistant(created.assistant_id, "u1") + assert got.model_settings is None + assert got.bindings is None # absent → compat synthesizes KB on read + + @pytest.mark.asyncio + async def test_update_sets_bindings(self, assistants_table): + from apis.shared.assistants.service import create_assistant, get_assistant, update_assistant + + created = await create_assistant( + owner_id="u1", owner_name="Alice", name="Bot", description="d", instructions="i" + ) + await update_assistant( + assistant_id=created.assistant_id, + owner_id="u1", + bindings=[AgentBinding(kind="tool", ref="gateway_x", config={})], + ) + got = await get_assistant(created.assistant_id, "u1") + assert got.bindings is not None and got.bindings[0].kind == "tool"