diff --git a/AGENTS.md b/AGENTS.md index 99cc228..9d21f3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Reference specific personas when requesting work: ## Learned Workspace Facts +- Compliance for this repo expects explicit type annotations, including return types, on functions in new or modified Python files (including tests), for mypy-clean CI. - `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs. - In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a3ee4af..90d67c4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -21,6 +21,37 @@ All notable changes to this project will be documented in this file. ### Added +- **`DataResource` class** — Declares agent-owned CRUD endpoints exposed to Studio with `name`, `entity_type`, `description`, `operations` (list of CRUD operations), `importable` (bulk import support), and `deletable` flags. + +- **`DataResourceField` class** — Describes field schema with `name`, `type`, `description`, `editable`, and `visible` attributes for validated rendering in Studio forms. + +- **`FieldType` enum** — Validated field types: `STRING`, `INTEGER`, `BOOLEAN`, `DATE`, `DATETIME`, `TEXT`, `EMAIL`, `URL` for consistent data handling across agents and Studio. + +- **`Editable` enum** — Controls Studio form behaviour per field: `ALWAYS` (edit in all forms), `CREATE_ONLY` (edit only on creation), `NEVER` (read-only display). + +- **`metadata: dict` on `AbstractJob` and `CaseAbstractModel`** — Arbitrary metadata flows through `registration_info` to Studio, enabling agents to attach custom context and tracking data to jobs and cases. + +- **`data_resources: list[DataResource]` on `Agent`** — Included in `registration_info` for Studio to discover and render CRUD interfaces for agent-managed data. + +- **Auto-generated FastAPI CRUD routes** — For each declared `DataResource` operation, Supervaizer auto-mounts routes (GET, POST, PATCH, DELETE) at `/agents/{slug}/data/{resource}/...`. + +- **Bulk import route** — When `importable=True` on a `DataResource`, a `POST /data/{resource}/import/` route accepts CSV or JSON for batch creation, enabling Studio to load data in bulk. + +### Unit Tests Results + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 492 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | ~70s | + +## [0.13.3] 2026-04-14 + +### Added + - **`CaseNodeUpdate.upsert` and `Case.patch_step`** — Optional step update path for Studio: when `upsert` is true, the existing case step at the same index is updated instead of appending. `Case.patch_step(index, update)` sets `index` and `upsert` on the update, sends `send_update_case`, and replaces the matching entry in `Case.updates`. Serialized in `CaseNodeUpdate.registration_info` for the controller payload. - **Human answer with `casestep_index`** — `POST /jobs/{job_id}/cases/{case_id}/update`: if `request.answer` includes `casestep_index`, the controller calls `case.patch_step(int(casestep_index), update)` and runs `PersistentEntityLifecycle.handle_event(..., INPUT_RECEIVED)` instead of `receive_human_input`. Omit `casestep_index` for the previous append/receive-human-input behavior. diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 7244bfa..599fdbb 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -26,6 +26,12 @@ CaseNodes, ) from supervaizer.common import ApiError, ApiResult, ApiSuccess +from supervaizer.data_resource import ( + DataResource, + DataResourceField, + Editable, + FieldType, +) from supervaizer.event import ( AgentRegisterEvent, CaseStartEvent, @@ -69,11 +75,12 @@ "CaseStartEvent", "CaseUpdateEvent", "create_error_response", - "EntityEvents", + "DataResource", + "DataResourceField", + "Editable", + "FieldType", "EntityEvents", "EntityLifecycle", - "EntityLifecycle", - "EntityStatus", "EntityStatus", "ErrorResponse", "ErrorType", diff --git a/src/supervaizer/account_service.py b/src/supervaizer/account_service.py index fd10fbd..04bf0a7 100644 --- a/src/supervaizer/account_service.py +++ b/src/supervaizer/account_service.py @@ -14,7 +14,14 @@ import httpx -from supervaizer.common import ApiError, ApiResult, ApiSuccess, is_local_mode, log +from supervaizer.common import ( + ApiError, + ApiResult, + ApiSuccess, + SvBaseModel, + is_local_mode, + log, +) logger = logging.getLogger("httpx") # Enable httpx debug logging (optional - uncomment for transport-level debugging) @@ -65,7 +72,7 @@ def send_event( ) headers = account.api_headers - payload = event.payload + payload = SvBaseModel.serialize_value(event.payload) url_event = ( account.url_event.strip() ) # defensive: env vars often have trailing newline diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 0e57231..368b810 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -30,6 +30,7 @@ from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup from supervaizer.case import CaseNodes +from supervaizer.data_resource import DataResource if TYPE_CHECKING: from supervaizer.server import Server @@ -630,6 +631,11 @@ class AgentAbstract(SvBaseModel): description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]. Context includes workspace_id, workspace_slug, mission_id from the dynamic_choices request body.", exclude=True, ) + data_resources: list[DataResource] = Field( + default_factory=list, + description="Data resources this agent exposes for Studio CRUD access", + exclude=True, + ) model_config = cast( ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True} @@ -657,6 +663,7 @@ def __init__( max_execution_time: int = 60 * 60, # 1 hour (in seconds) custom_routes: Any | None = None, dynamic_choices_callback: Any | None = None, + data_resources: list["DataResource"] | None = None, **kwargs: Any, ) -> None: """ @@ -711,9 +718,19 @@ def __init__( max_execution_time=max_execution_time, custom_routes=custom_routes, dynamic_choices_callback=dynamic_choices_callback, + data_resources=data_resources or [], **kwargs, ) + seen_resource_names: set[str] = set() + for r in self.data_resources: + if r.name in seen_resource_names: + raise ValueError( + f"Duplicate DataResource name {r.name!r} on agent {self.name!r}; " + "each data resource must have a unique name per agent." + ) + seen_resource_names.add(r.name) + def __str__(self) -> str: return f"{self.name} ({self.id})" @@ -750,6 +767,7 @@ def registration_info(self) -> Dict[str, Any]: "server_encrypted_parameters": self.server_encrypted_parameters, "max_execution_time": self.max_execution_time, "instructions_path": self.instructions_path, + "data_resources": [r.registration_info for r in self.data_resources], } def update_agent_from_server(self, server: "Server") -> Optional["Agent"]: diff --git a/src/supervaizer/case.py b/src/supervaizer/case.py index ae3feb2..9d2302e 100644 --- a/src/supervaizer/case.py +++ b/src/supervaizer/case.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional import shortuuid -from pydantic import ConfigDict +from pydantic import ConfigDict, Field from supervaizer.common import SvBaseModel, log, singleton from supervaizer.lifecycle import EntityEvents, EntityStatus from supervaizer.storage import PersistentEntityLifecycle, StorageManager @@ -215,6 +215,10 @@ class CaseAbstractModel(SvBaseModel): total_cost: float = 0.0 final_delivery: Optional[Dict[str, Any]] = None finished_at: Optional[datetime] = None + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Agent-provided domain metadata (e.g. contact context)", + ) class Case(CaseAbstractModel): @@ -361,6 +365,7 @@ def registration_info(self) -> Dict[str, Any]: "updates": [update.registration_info for update in self.updates], "total_cost": self.total_cost, "final_delivery": self.final_delivery, + "metadata": SvBaseModel.serialize_value(self.metadata), } @classmethod @@ -371,6 +376,7 @@ def start( account: "Account", description: str, case_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> "Case": """ Start a new case @@ -393,6 +399,7 @@ def start( name=name, description=description, status=EntityStatus.STOPPED, + metadata=metadata or {}, ) log.info(f"[Case created] {case.id}") diff --git a/src/supervaizer/common.py b/src/supervaizer/common.py index 6ea236b..de5f6dc 100644 --- a/src/supervaizer/common.py +++ b/src/supervaizer/common.py @@ -35,7 +35,12 @@ class SvBaseModel(BaseModel): @staticmethod def serialize_value(value: Any) -> Any: - """Recursively serialize values, converting type objects and datetimes to strings.""" + """Recursively serialize values for JSON-compatible output. + + Converts type objects to their name and datetime values to ISO-8601 strings. + Dicts and lists are processed recursively. Used by ``to_dict``, job/case + ``registration_info`` metadata, and ``send_event`` HTTP bodies. + """ from datetime import datetime if isinstance(value, type): diff --git a/src/supervaizer/data_resource.py b/src/supervaizer/data_resource.py new file mode 100644 index 0000000..cb2e75e --- /dev/null +++ b/src/supervaizer/data_resource.py @@ -0,0 +1,183 @@ +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""Data Resource model for exposing agent-owned CRUD endpoints to Studio. + +Agents declare DataResource objects on their Agent instance. The SDK +auto-generates FastAPI CRUD routes for each declared resource, secured +with the same API key as all other agent routes. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Callable + +from pydantic import Field, model_validator + +from supervaizer.common import SvBaseModel + +# Used for URL path segments (/data/{name}/) and OpenAPI operation_id fragments. +_DATA_RESOURCE_NAME_PATTERN = r"^[a-z0-9][a-z0-9_-]*$" + + +class FieldType(StrEnum): + """Allowed field types for DataResourceField.""" + + STRING = "string" + INTEGER = "integer" + BOOLEAN = "boolean" + DATE = "date" + DATETIME = "datetime" + TEXT = "text" + EMAIL = "email" + URL = "url" + + +class Editable(StrEnum): + """Controls when Studio may edit a field.""" + + ALWAYS = "always" # Editable on create and update forms + CREATE_ONLY = "create_only" # Set on create; shown read-only on edit + NEVER = "never" # Agent-controlled; never shown in a form input + + +class DataResourceField(SvBaseModel): + """Describes a single field in a DataResource for Studio rendering.""" + + name: str = Field(description="Column/attribute name") + field_type: FieldType = Field( + default=FieldType.STRING, + description="One of: string, integer, boolean, date, datetime, text, email, url", + ) + label: str | None = Field( + default=None, description="Human-readable label; defaults to name.title()" + ) + required: bool = Field(default=False, description="Required on create form") + editable: Editable = Field(default=Editable.ALWAYS) + visible_on: list[str] = Field( + default_factory=lambda: ["list", "detail", "create", "edit"], + description="Views that render this field: list, detail, create, edit", + ) + description: str | None = Field( + default=None, description="Help text shown in Studio" + ) + related_resource: str | None = Field( + default=None, + description="Name of another DataResource this field FK-references", + ) + + @property + def display_label(self) -> str: + return self.label or self.name.replace("_", " ").title() + + +class DataResource(SvBaseModel): + """Declares a named data resource the agent exposes for Studio CRUD access. + + The agent provides callback functions for each operation. The SDK generates + the corresponding FastAPI routes automatically. + + Example:: + + contacts_resource = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField(name="email", field_type=FieldType.EMAIL, required=True), + ], + on_list=lambda: repo.list_all(), + on_get=lambda item_id: repo.get(item_id), + on_create=lambda data: repo.create(data), + on_update=lambda item_id, data: repo.update(item_id, data), + on_delete=lambda item_id: repo.delete(item_id), + ) + """ + + model_config = {"arbitrary_types_allowed": True} + + name: str = Field( + description=( + "URL-safe resource identifier, e.g. 'contacts'. " + "Lowercase letters, digits, underscores, and hyphens only; " + "must start with a letter or digit." + ), + pattern=_DATA_RESOURCE_NAME_PATTERN, + ) + display_name: str = Field(default="") + description: str = Field(default="") + fields: list[DataResourceField] = Field(default_factory=list) + read_only: bool = Field(default=False) + importable: bool = Field(default=False, description="Enables CSV bulk import route") + # Callbacks — excluded from model serialization + on_list: Callable[[], list[dict[str, Any]]] | None = Field( + default=None, exclude=True + ) + on_get: Callable[[str], dict[str, Any] | None] | None = Field( + default=None, exclude=True + ) + on_create: Callable[[dict[str, Any]], dict[str, Any]] | None = Field( + default=None, exclude=True + ) + on_update: Callable[[str, dict[str, Any]], dict[str, Any] | None] | None = Field( + default=None, exclude=True + ) + on_delete: Callable[[str], bool] | None = Field(default=None, exclude=True) + on_import: Callable[[list[dict[str, Any]]], dict[str, Any]] | None = Field( + default=None, exclude=True + ) + + @model_validator(mode="after") + def check_callbacks(self) -> "DataResource": + """Validate required callbacks. + + on_list is always required. + on_create is required for writable resources (read_only=False). + on_import is required when importable=True. + + on_get, on_update, and on_delete are optional — their presence is + reflected in the operations dict. A writable resource may support + create-only (no update/delete). + """ + if self.on_list is None: + raise ValueError(f"DataResource '{self.name}' must define on_list") + if not self.read_only and self.on_create is None: + raise ValueError( + f"Writable DataResource '{self.name}' must define on_create" + ) + if self.importable and self.on_import is None: + raise ValueError( + f"Importable DataResource '{self.name}' must define on_import" + ) + return self + + @property + def operations(self) -> dict[str, bool]: + return { + "list": self.on_list is not None, + "get": self.on_get is not None, + "create": self.on_create is not None and not self.read_only, + "update": self.on_update is not None and not self.read_only, + "delete": self.on_delete is not None and not self.read_only, + "import": self.importable and self.on_import is not None, + } + + @property + def display_name_resolved(self) -> str: + return self.display_name or self.name.replace("_", " ").title() + + @property + def registration_info(self) -> dict[str, Any]: + return { + "name": self.name, + "display_name": self.display_name_resolved, + "description": self.description, + "fields": [f.model_dump() for f in self.fields], + "read_only": self.read_only, + "importable": self.importable, + "operations": self.operations, + } diff --git a/src/supervaizer/data_routes.py b/src/supervaizer/data_routes.py new file mode 100644 index 0000000..40d81f2 --- /dev/null +++ b/src/supervaizer/data_routes.py @@ -0,0 +1,206 @@ +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""FastAPI route generation for DataResource CRUD endpoints. + +For each DataResource declared on an Agent, generates routes under: + GET /agents/{slug}/data/{resource_name}/ — list + GET /agents/{slug}/data/{resource_name}/{item_id} — detail + POST /agents/{slug}/data/{resource_name}/ — create + PUT /agents/{slug}/data/{resource_name}/{item_id} — update + DELETE /agents/{slug}/data/{resource_name}/{item_id} — delete + POST /agents/{slug}/data/{resource_name}/import/ — CSV bulk import + +All routes require the server API key (same auth as existing agent routes). +Uses factory functions per resource to avoid Python closure-in-loop capture bugs. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from fastapi import APIRouter, Body, HTTPException, Query, Security +from fastapi.responses import JSONResponse + +from supervaizer.common import log +from supervaizer.data_resource import DataResource + +if TYPE_CHECKING: + from supervaizer.agent import Agent + from supervaizer.server import Server + + +def create_agent_data_routes(server: "Server", agent: "Agent") -> APIRouter: + """Generate CRUD REST routes for all DataResources declared on an agent.""" + router = APIRouter(prefix=agent.path, tags=["Data Resources"]) + agent_slug = agent.slug + for resource in agent.data_resources: + _add_resource_routes(router, resource, server, agent_slug) + return router + + +def _data_resource_operation_id( + agent_slug: str, resource_name: str, action: str +) -> str: + """Build a globally unique OpenAPI operation_id (per app, all agents).""" + return f"{agent_slug}_{resource_name}_{action}" + + +def _add_resource_routes( + router: APIRouter, resource: DataResource, server: "Server", agent_slug: str +) -> None: + """Register all declared operation routes for one DataResource.""" + prefix = f"/data/{resource.name}" + + if resource.on_list is not None: + op_id = _data_resource_operation_id(agent_slug, resource.name, "list") + router.add_api_route( + f"{prefix}/", + _make_list_handler(resource, prefix), + methods=["GET"], + dependencies=[Security(server.verify_api_key)], + summary=f"List {resource.display_name_resolved}", + operation_id=op_id, + name=op_id, + ) + + if resource.on_get is not None: + op_id = _data_resource_operation_id(agent_slug, resource.name, "get") + router.add_api_route( + f"{prefix}/{{item_id}}", + _make_get_handler(resource, prefix), + methods=["GET"], + dependencies=[Security(server.verify_api_key)], + summary=f"Get {resource.display_name_resolved}", + operation_id=op_id, + name=op_id, + ) + + if resource.on_create is not None and not resource.read_only: + op_id = _data_resource_operation_id(agent_slug, resource.name, "create") + router.add_api_route( + f"{prefix}/", + _make_create_handler(resource, prefix), + methods=["POST"], + dependencies=[Security(server.verify_api_key)], + summary=f"Create {resource.display_name_resolved}", + operation_id=op_id, + name=op_id, + ) + + if resource.on_update is not None and not resource.read_only: + op_id = _data_resource_operation_id(agent_slug, resource.name, "update") + router.add_api_route( + f"{prefix}/{{item_id}}", + _make_update_handler(resource, prefix), + methods=["PUT"], + dependencies=[Security(server.verify_api_key)], + summary=f"Update {resource.display_name_resolved}", + operation_id=op_id, + name=op_id, + ) + + if resource.on_delete is not None and not resource.read_only: + op_id = _data_resource_operation_id(agent_slug, resource.name, "delete") + router.add_api_route( + f"{prefix}/{{item_id}}", + _make_delete_handler(resource, prefix), + methods=["DELETE"], + dependencies=[Security(server.verify_api_key)], + summary=f"Delete {resource.display_name_resolved}", + operation_id=op_id, + name=op_id, + ) + + if resource.importable and resource.on_import is not None: + op_id = _data_resource_operation_id(agent_slug, resource.name, "import") + router.add_api_route( + f"{prefix}/import/", + _make_import_handler(resource, prefix), + methods=["POST"], + dependencies=[Security(server.verify_api_key)], + summary=f"Import {resource.display_name_resolved} (bulk)", + operation_id=op_id, + name=op_id, + ) + + +def _make_list_handler(r: DataResource, prefix: str) -> Any: + async def _handler( + skip: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1000), + ) -> list[dict[str, Any]]: + log.info(f"📥 GET {prefix}/ [DataResource list: {r.name}]") + result = r.on_list() # type: ignore[misc] + return result[skip : skip + limit] + + return _handler + + +def _make_get_handler(r: DataResource, prefix: str) -> Any: + async def _handler(item_id: str) -> dict[str, Any]: + log.info(f"📥 GET {prefix}/{item_id} [DataResource get: {r.name}]") + result = r.on_get(item_id) # type: ignore[misc] + if result is None: + raise HTTPException( + status_code=404, detail=f"{r.name} '{item_id}' not found" + ) + return result + + return _handler + + +def _make_create_handler(r: DataResource, prefix: str) -> Any: + async def _handler(data: dict[str, Any] = Body(...)) -> JSONResponse: + log.info(f"📥 POST {prefix}/ [DataResource create: {r.name}]") + result = r.on_create(data) # type: ignore[misc] + if not isinstance(result, dict) or "id" not in result: + raise HTTPException( + status_code=500, + detail=f"on_create for '{r.name}' must return a dict with 'id'", + ) + return JSONResponse(content=result, status_code=201) + + return _handler + + +def _make_update_handler(r: DataResource, prefix: str) -> Any: + on_update = r.on_update + assert on_update is not None # route registered only when on_update is set + + async def _handler( + item_id: str, data: dict[str, Any] = Body(...) + ) -> dict[str, Any]: + log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {r.name}]") + result = on_update(item_id, data) + if result is None: + raise HTTPException( + status_code=404, detail=f"{r.name} '{item_id}' not found" + ) + return result + + return _handler + + +def _make_delete_handler(r: DataResource, prefix: str) -> Any: + async def _handler(item_id: str) -> JSONResponse: + log.info(f"📥 DELETE {prefix}/{item_id} [DataResource delete: {r.name}]") + success = r.on_delete(item_id) # type: ignore[misc] + if not success: + raise HTTPException( + status_code=404, detail=f"{r.name} '{item_id}' not found" + ) + return JSONResponse(content={"deleted": True}, status_code=200) + + return _handler + + +def _make_import_handler(r: DataResource, prefix: str) -> Any: + async def _handler(records: list[dict[str, Any]] = Body(...)) -> dict[str, Any]: + log.info(f"📥 POST {prefix}/import/ [DataResource import: {r.name}]") + return r.on_import(records) # type: ignore[misc] + + return _handler diff --git a/src/supervaizer/job.py b/src/supervaizer/job.py index 573d23e..eb29c52 100644 --- a/src/supervaizer/job.py +++ b/src/supervaizer/job.py @@ -10,7 +10,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional -from pydantic import field_validator +from pydantic import Field, field_validator from supervaizer.__version__ import VERSION from supervaizer.common import SvBaseModel, log, singleton @@ -256,6 +256,10 @@ class AbstractJob(SvBaseModel): created_at: datetime | None = None agent_parameters: list[dict[str, Any]] | None = None case_ids: list[str] = [] # Foreign key relationship to cases + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Agent-provided domain metadata (e.g. campaign context)", + ) class Job(AbstractJob): @@ -349,6 +353,7 @@ def registration_info(self) -> Dict[str, Any]: "finished_at": self.finished_at.isoformat() if self.finished_at else "", "created_at": self.created_at.isoformat() if self.created_at else "", "case_ids": self.case_ids, + "metadata": SvBaseModel.serialize_value(self.metadata), } @classmethod @@ -358,6 +363,7 @@ def new( agent_name: str, agent_parameters: Optional[list[dict[str, Any]]] = None, name: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, ) -> "Job": """Create a new job @@ -394,6 +400,7 @@ def new( job_context=job_context, status=EntityStatus.STOPPED, agent_parameters=agent_parameters, + metadata=metadata or {}, ) # Transition from STOPPED to IN_PROGRESS diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 684d27b..a175f96 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -35,6 +35,7 @@ AgentMethodParams, AgentResponse, ) +from supervaizer.data_routes import create_agent_data_routes from supervaizer.case import CaseNodeUpdate, Cases from supervaizer.common import SvBaseModel, log from supervaizer.job import Job, JobContext, JobResponse, Jobs @@ -387,6 +388,8 @@ def create_agents_routes(server: "Server") -> APIRouter: # Add custom method routes for each agent if agent.methods and agent.methods.custom: routers.include_router(create_agent_custom_routes(server, agent)) + if agent.data_resources: + routers.include_router(create_agent_data_routes(server, agent)) return routers diff --git a/tests/test_account_service.py b/tests/test_account_service.py index 18a645b..9be68ad 100644 --- a/tests/test_account_service.py +++ b/tests/test_account_service.py @@ -11,6 +11,7 @@ from supervaizer import Account, ApiSuccess from supervaizer.account_service import send_event +from supervaizer.common import SvBaseModel from supervaizer.event import Event from supervaizer.server import Server @@ -36,7 +37,7 @@ def test_send_event_success( mock_post.assert_called_once_with( account_fixture.url_event, headers=account_fixture.api_headers, - json=event_fixture.payload, + json=SvBaseModel.serialize_value(event_fixture.payload), ) assert isinstance(result, ApiSuccess) assert result.message == f"POST Event {event_fixture.type.name} sent" diff --git a/tests/test_agent.py b/tests/test_agent.py index e05035a..eabbfc6 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1089,3 +1089,62 @@ def test_agent_method_fields_definitions() -> None: assert defs[1]["description"] == "Enter your age" assert defs[1]["type"] == "int" assert defs[1]["choices"] is None + + +def test_agent_data_resources_default_empty() -> None: + """Agent.data_resources defaults to empty list.""" + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + ) + assert agent.data_resources == [] + + +def test_agent_rejects_duplicate_data_resource_names() -> None: + """Two DataResources with the same name on one agent are invalid.""" + from supervaizer.data_resource import DataResource + + dup = DataResource(name="items", fields=[], on_list=lambda: [], read_only=True) + with pytest.raises(ValueError, match="Duplicate DataResource name"): + Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + data_resources=[dup, dup], + ) + + +def test_agent_registration_info_includes_data_resources() -> None: + """Agent.registration_info includes data_resources when declared.""" + from supervaizer.data_resource import DataResource, DataResourceField, Editable + + contacts_resource = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField( + name="id", editable=Editable.NEVER, visible_on=["list", "detail"] + ), + DataResourceField(name="email", field_type="email", required=True), + ], + on_list=lambda: [], + on_create=lambda d: {**d, "id": "1"}, + ) + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + data_resources=[contacts_resource], + ) + info = agent.registration_info + assert "data_resources" in info + assert len(info["data_resources"]) == 1 + assert info["data_resources"][0]["name"] == "contacts" + assert info["data_resources"][0]["operations"]["create"] is True diff --git a/tests/test_case.py b/tests/test_case.py index bf61450..93c52dc 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -392,3 +392,42 @@ def test_case_patch_step_no_index_match_still_sends_but_keeps_registry_unchanged assert case_fixture.updates[0] is prior assert orphan.index == 99 assert orphan.upsert is True + + +def test_case_metadata_default_is_empty_dict(case_fixture: Case) -> None: + """CaseAbstractModel.metadata defaults to empty dict.""" + assert case_fixture.metadata == {} + + +def test_case_registration_info_includes_metadata(account_fixture: Account) -> None: + """Case.registration_info includes the metadata field.""" + from uuid import uuid4 + + case = Case( + id=str(uuid4()), + job_id="job-meta-test", + account=account_fixture, + status=EntityStatus.IN_PROGRESS, + name="Meta Test Case", + description="Test", + metadata={"contact_email": "a@b.com", "language": "en"}, + ) + info = case.registration_info + assert info["metadata"] == {"contact_email": "a@b.com", "language": "en"} + + +def test_case_start_accepts_metadata( + account_fixture: Account, + mocker: MockerFixture, +) -> None: + """Case.start() passes metadata through to the Case instance.""" + mocker.patch("supervaizer.account_service.send_event", return_value=None) + meta = {"contact_email": "test@example.com", "language": "fr"} + case = Case.start( + job_id="job-start-meta", + name="Test Contact", + account=account_fixture, + description="Test interview", + metadata=meta, + ) + assert case.metadata == meta diff --git a/tests/test_data_resource.py b/tests/test_data_resource.py new file mode 100644 index 0000000..1eaadf8 --- /dev/null +++ b/tests/test_data_resource.py @@ -0,0 +1,135 @@ +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +import pytest +from pydantic import ValidationError + +from supervaizer.data_resource import ( + DataResource, + DataResourceField, + Editable, + FieldType, +) + + +def test_field_defaults() -> None: + f = DataResourceField(name="email") + assert f.field_type == FieldType.STRING + assert f.editable == Editable.ALWAYS + assert f.visible_on == ["list", "detail", "create", "edit"] + assert f.required is False + + +def test_field_display_label_defaults_to_name_title() -> None: + f = DataResourceField(name="first_name") + assert f.display_label == "First Name" + + +def test_field_display_label_custom() -> None: + f = DataResourceField(name="first_name", label="Given Name") + assert f.display_label == "Given Name" + + +@pytest.mark.parametrize( + "bad_name", + [ + "", + "contacts/admin", + "{id}", + "bad name", + "CamelCase", + ], +) +def test_data_resource_name_must_be_url_safe(bad_name: str) -> None: + with pytest.raises(ValidationError): + DataResource(name=bad_name, fields=[], on_list=lambda: []) + + +def test_data_resource_requires_on_list() -> None: + with pytest.raises(ValueError, match="must define on_list"): + DataResource(name="contacts", fields=[]) + + +def test_data_resource_writable_requires_on_create() -> None: + with pytest.raises(ValueError, match="must define on_create"): + DataResource(name="contacts", fields=[], on_list=lambda: [], read_only=False) + + +def test_data_resource_read_only_no_on_create_required() -> None: + dr = DataResource(name="prompts", fields=[], on_list=lambda: [], read_only=True) + assert dr.operations["create"] is False + assert dr.operations["update"] is False + assert dr.operations["delete"] is False + assert dr.operations["list"] is True + + +def test_data_resource_importable_requires_on_import() -> None: + with pytest.raises(ValueError, match="must define on_import"): + DataResource( + name="contacts", + fields=[], + on_list=lambda: [], + on_create=lambda d: {**d, "id": "1"}, + importable=True, + ) + + +def test_data_resource_operations_full() -> None: + dr = DataResource( + name="contacts", + fields=[], + on_list=lambda: [], + on_get=lambda item_id: {"id": item_id}, + on_create=lambda d: {**d, "id": "new"}, + on_update=lambda item_id, d: {**d, "id": item_id}, + on_delete=lambda item_id: True, + ) + assert dr.operations == { + "list": True, + "get": True, + "create": True, + "update": True, + "delete": True, + "import": False, + } + + +def test_data_resource_registration_info() -> None: + dr = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField( + name="id", editable=Editable.NEVER, visible_on=["list", "detail"] + ), + DataResourceField(name="email", field_type=FieldType.EMAIL, required=True), + ], + on_list=lambda: [], + on_create=lambda d: {**d, "id": "1"}, + ) + info = dr.registration_info + assert info["name"] == "contacts" + assert info["display_name"] == "Contacts" + assert len(info["fields"]) == 2 + assert info["fields"][0]["name"] == "id" + assert info["fields"][0]["editable"] == "never" + assert info["fields"][1]["required"] is True + assert info["operations"]["create"] is True + assert info["operations"]["import"] is False + + +def test_data_resource_display_name_derived_from_name() -> None: + dr = DataResource( + name="contact_knowledge", fields=[], on_list=lambda: [], read_only=True + ) + assert dr.display_name_resolved == "Contact Knowledge" + + +def test_data_resource_callbacks_excluded_from_serialization() -> None: + dr = DataResource(name="prompts", fields=[], on_list=lambda: [], read_only=True) + dumped = dr.model_dump() + assert "on_list" not in dumped + assert "on_create" not in dumped diff --git a/tests/test_event.py b/tests/test_event.py index 1c71a6c..40334f9 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -4,6 +4,9 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. +import json +from datetime import datetime, timezone +from uuid import uuid4 from supervaizer import ( Account, @@ -20,7 +23,8 @@ Server, ServerRegisterEvent, ) -from supervaizer.job import Job +from supervaizer.job import Job, JobContext +from supervaizer.lifecycle import EntityStatus def test_event(event_fixture: Event) -> None: @@ -118,3 +122,33 @@ def test_job_finished_event(job_fixture: Job, account_fixture: Account) -> None: account=account_fixture, ) assert isinstance(job_finished_event, JobFinishedEvent) + + +def test_event_payload_json_encodable_with_metadata_datetime( + context_fixture: JobContext, + account_fixture: Account, +) -> None: + """Metadata with datetime/type must JSON-encode for httpx (event.details → payload).""" + dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc) + job = Job.new( + job_context=context_fixture, + agent_name="test-agent", + metadata={"scheduled_at": dt, "kind": str}, + ) + job_event = JobStartConfirmationEvent(job=job, account=account_fixture) + json.dumps(job_event.payload) + assert job_event.details["metadata"]["scheduled_at"] == dt.isoformat() + assert job_event.details["metadata"]["kind"] == "str" + + case = Case( + id=str(uuid4()), + job_id=context_fixture.job_id, + account=account_fixture, + status=EntityStatus.IN_PROGRESS, + name="n", + description="d", + metadata={"at": dt}, + ) + case_event = CaseStartEvent(case=case, account=account_fixture) + json.dumps(case_event.payload) + assert case_event.details["metadata"]["at"] == dt.isoformat() diff --git a/tests/test_job.py b/tests/test_job.py index a6620d5..f51f8c9 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -219,3 +219,28 @@ def test_get_job_include_persisted_not_found() -> None: mock_get.return_value = None # Simulate job not found in persistence result = Jobs().get_job(job_id, include_persisted=True) assert result is None + + +def test_job_metadata_default_is_empty_dict(context_fixture: JobContext) -> None: + """AbstractJob.metadata defaults to empty dict.""" + job = Job.new(job_context=context_fixture, agent_name="test-agent") + assert job.metadata == {} + + +def test_job_registration_info_includes_metadata(context_fixture: JobContext) -> None: + """Job.registration_info includes the metadata field.""" + meta = {"campaign_id": "c1", "phase": "interviews"} + job = Job.new(job_context=context_fixture, agent_name="test-agent", metadata=meta) + info = job.registration_info + assert info["metadata"] == {"campaign_id": "c1", "phase": "interviews"} + + +def test_job_new_accepts_metadata(context_fixture: JobContext) -> None: + """Job.new() passes metadata through to the Job instance.""" + meta = {"campaign_id": "c1", "scenario": "hr_screening"} + job = Job.new( + job_context=context_fixture, + agent_name="test-agent", + metadata=meta, + ) + assert job.metadata == meta diff --git a/tests/test_routes.py b/tests/test_routes.py index 305a0f5..2c7d7cd 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -6,9 +6,19 @@ from typing import Any +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient -from supervaizer import Agent, Job, Server +from supervaizer import ( + Account, + Agent, + AgentMethod, + AgentMethods, + Job, + Server, +) +from supervaizer.data_resource import DataResource +from supervaizer.parameter import ParametersSetup from supervaizer.routes import ( create_agents_routes, create_default_routes, @@ -283,3 +293,67 @@ def test_dynamic_choices_endpoint_no_callback( }, ) assert resp.status_code == 404 + + +def test_data_resource_openapi_operation_ids_unique_per_agent( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + """Same resource name on different agents must not share operationId (OpenAPI).""" + methods = AgentMethods( + job_start=agent_method_fixture, + job_stop=agent_method_fixture, + job_status=agent_method_fixture, + chat=None, + custom={"method1": agent_method_fixture}, + ) + dr_a = DataResource(name="items", fields=[], on_list=lambda: [], read_only=True) + dr_b = DataResource(name="items", fields=[], on_list=lambda: [], read_only=True) + agent_a = Agent( + name="First Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + data_resources=[dr_a], + ) + agent_b = Agent( + name="Second Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + data_resources=[dr_b], + ) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + server = Server( + scheme="http", + host="localhost", + port=8001, + environment="test", + mac_addr="E2-AC-ED-22-BF-B2", + debug=True, + agent_timeout=10, + private_key=private_key, + a2a_endpoints=False, + supervisor_account=account_fixture, + agents=[agent_a, agent_b], + api_key="test-api-key", + ) + client = TestClient(server.app) + schema = client.get("/openapi.json").json() + op_ids: list[str] = [] + for path_item in schema.get("paths", {}).values(): + for op in path_item.values(): + if isinstance(op, dict) and "operationId" in op: + op_ids.append(op["operationId"]) + list_ids = [oid for oid in op_ids if oid.endswith("_items_list")] + assert len(list_ids) == 2 + assert len(set(list_ids)) == 2 + assert f"{agent_a.slug}_items_list" in list_ids + assert f"{agent_b.slug}_items_list" in list_ids