Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Empty file.
125 changes: 125 additions & 0 deletions backend/src/apis/app_api/agents/services/binding_validation.py
Original file line number Diff line number Diff line change
@@ -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
)
25 changes: 25 additions & 0 deletions backend/src/apis/app_api/assistants/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions backend/src/apis/shared/assistants/compat.py
Original file line number Diff line number Diff line change
@@ -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,
}
59 changes: 58 additions & 1 deletion backend/src/apis/shared/assistants/models.py
Original file line number Diff line number Diff line change
@@ -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)"""
Expand Down Expand Up @@ -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)"""
Expand All @@ -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):
Expand All @@ -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):
Expand Down
40 changes: 40 additions & 0 deletions backend/src/apis/shared/assistants/serialization.py
Original file line number Diff line number Diff line change
@@ -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
Loading