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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
31 changes: 31 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions src/supervaizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,11 +75,12 @@
"CaseStartEvent",
"CaseUpdateEvent",
"create_error_response",
"EntityEvents",
"DataResource",
"DataResourceField",
"Editable",
"FieldType",
"EntityEvents",
"EntityLifecycle",
"EntityLifecycle",
"EntityStatus",
"EntityStatus",
"ErrorResponse",
"ErrorType",
Expand Down
11 changes: 9 additions & 2 deletions src/supervaizer/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/supervaizer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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})"

Expand Down Expand Up @@ -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"]:
Expand Down
9 changes: 8 additions & 1 deletion src/supervaizer/case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -393,6 +399,7 @@ def start(
name=name,
description=description,
status=EntityStatus.STOPPED,
metadata=metadata or {},
)
log.info(f"[Case created] {case.id}")

Expand Down
7 changes: 6 additions & 1 deletion src/supervaizer/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
183 changes: 183 additions & 0 deletions src/supervaizer/data_resource.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading