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
8 changes: 8 additions & 0 deletions src/supervaizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@
"supervaizer.contracts",
"SUPERVAIZER_V2_CONTRACT_VERSION",
),
"AGENT_CUSTOM_ACTION_PREFIX": (
"supervaizer.contracts",
"AGENT_CUSTOM_ACTION_PREFIX",
),
"AGENT_REFRESH_ACTION": ("supervaizer.contracts", "AGENT_REFRESH_ACTION"),
"AGENT_REFRESH_EFFECT": ("supervaizer.contracts", "AGENT_REFRESH_EFFECT"),
"WORKSPACE_BINDING_CREATE_ACTION": (
"supervaizer.contracts",
"WORKSPACE_BINDING_CREATE_ACTION",
Expand Down Expand Up @@ -123,6 +129,8 @@
"V2A2UISubmitDefinition": ("supervaizer.contracts", "V2A2UISubmitDefinition"),
"V2AgentCapabilities": ("supervaizer.contracts", "V2AgentCapabilities"),
"V2AgentIdentity": ("supervaizer.contracts", "V2AgentIdentity"),
"V2AgentMethod": ("supervaizer.contracts", "V2AgentMethod"),
"V2AgentMethods": ("supervaizer.contracts", "V2AgentMethods"),
"V2ArtifactRef": ("supervaizer.contracts", "V2ArtifactRef"),
"V2ArtifactTypeDefinition": (
"supervaizer.contracts",
Expand Down
58 changes: 57 additions & 1 deletion src/supervaizer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import json
import re
from enum import Enum
from importlib import import_module
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -36,7 +37,12 @@
from supervaizer.__version__ import VERSION
from supervaizer.case import CaseNodes
from supervaizer.common import ApiSuccess, SvBaseModel, log
from supervaizer.contracts import SupervaizerV2AgentRegistrationContract
from supervaizer.contracts import (
SupervaizerV2AgentRegistrationContract,
V2ActionRequest,
V2AgentMethod,
V2AgentMethods,
)
from supervaizer.data_resource import DataResource
from supervaizer.event import JobStartConfirmationEvent
from supervaizer.job import Job, JobContext, JobResponse
Expand Down Expand Up @@ -695,6 +701,11 @@ class AgentAbstract(SvBaseModel):
default=None,
description="Optional Supervaizer v2 registration contract for A2A/A2UI Studio integrations",
)
v2_methods: V2AgentMethods | None = Field(
default=None,
description="Optional agent-level Supervaizer v2 method declarations",
exclude=True,
)

model_config = cast(
ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True}
Expand Down Expand Up @@ -727,6 +738,7 @@ def __init__(
supervaizer_v2_registration: SupervaizerV2AgentRegistrationContract
| dict[str, Any]
| None = None,
v2_methods: V2AgentMethods | dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""
Expand Down Expand Up @@ -792,10 +804,12 @@ def __init__(
custom_routes=custom_routes,
data_resources=data_resources or [],
supervaizer_v2_registration=supervaizer_v2_registration,
v2_methods=v2_methods,
**kwargs,
)

self._validate_supervaizer_v2_identity()
self._apply_v2_method_capabilities()

seen_resource_names: set[str] = set()
for r in self.data_resources:
Expand All @@ -821,6 +835,17 @@ def _validate_supervaizer_v2_identity(self) -> None:
f"{declared_slug!r} != {self.slug!r}"
)

def _apply_v2_method_capabilities(self) -> None:
if self.supervaizer_v2_registration is None or self.v2_methods is None:
return
actions = [
*self.supervaizer_v2_registration.capabilities.actions,
*self.v2_methods.action_ids,
]
self.supervaizer_v2_registration.capabilities.actions = list(
dict.fromkeys(actions)
)

@property
def slug(self) -> str:
return slugify(self.name)
Expand Down Expand Up @@ -975,6 +1000,37 @@ def _declared_method_paths(self) -> set[str]:
methods.extend(self.methods.custom.values())
return {method.method for method in methods if method is not None}

@property
def v2_action_ids(self) -> list[str]:
if self.v2_methods is None:
return []
return self.v2_methods.action_ids

def v2_method_for_action(self, action: str) -> V2AgentMethod | None:
if self.v2_methods is None:
return None
return self.v2_methods.method_for_action(action)

def _declared_v2_method_paths(self) -> set[str]:
if self.v2_methods is None:
return set()
methods = [self.v2_methods.refresh, *self.v2_methods.custom.values()]
return {method.method for method in methods if method is not None}

def execute_v2_action_method(self, action: str, request: V2ActionRequest) -> Any:
agent_method = self.v2_method_for_action(action)
if agent_method is None:
raise ValueError(f"Agent v2 action is not declared on agent: {action}")
if agent_method.method not in self._declared_v2_method_paths():
raise ValueError(
f"Agent v2 method path is not declared on agent: {agent_method.method}"
)

module_name, func_name = agent_method.method.rsplit(".", 1)
module = import_module(module_name)
action_method = getattr(module, func_name)
return action_method(request=request, **agent_method.params)

def job_start(
self,
job: Job,
Expand Down
67 changes: 66 additions & 1 deletion src/supervaizer/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@

from __future__ import annotations

import re
from collections.abc import Iterable
from enum import StrEnum
from typing import Any, Literal

from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator

CONTROLLER_CONTRACT_VERSION = "1.0"
API_VERSION = "v1"
Expand All @@ -28,6 +29,10 @@
WORKSPACE_BINDING_OPTIONS_ACTION = "workspace_binding.options"
WORKSPACE_BINDING_CREATE_ACTION = "workspace_binding.create"
WORKSPACE_BINDING_CREATE_SURFACE = "workspace_binding.create"
AGENT_REFRESH_ACTION = "agent.refresh"
AGENT_REFRESH_EFFECT = "agent.refreshed"
AGENT_CUSTOM_ACTION_PREFIX = "agent.custom."
_AGENT_CUSTOM_METHOD_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")


class ContractModel(BaseModel):
Expand Down Expand Up @@ -310,6 +315,47 @@ class V2AgentCapabilities(ContractModel):
artifact_types: list[V2ArtifactTypeDefinition] = Field(default_factory=list)


class V2AgentMethod(ContractModel):
method: str
params: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
is_async: bool = False
timeout: int | None = 600


class V2AgentMethods(ContractModel):
refresh: V2AgentMethod | None = None
custom: dict[str, V2AgentMethod] = Field(default_factory=dict)

@field_validator("custom")
@classmethod
def validate_custom_method_names(
cls, value: dict[str, V2AgentMethod]
) -> dict[str, V2AgentMethod]:
for name in value:
if not _AGENT_CUSTOM_METHOD_KEY_RE.fullmatch(name):
raise ValueError(
"agent custom method keys may only contain letters, numbers, "
"underscores, and hyphens"
)
return value

@property
def action_ids(self) -> list[str]:
actions: list[str] = []
if self.refresh is not None:
actions.append(AGENT_REFRESH_ACTION)
actions.extend(f"{AGENT_CUSTOM_ACTION_PREFIX}{name}" for name in self.custom)
return actions

def method_for_action(self, action: str) -> V2AgentMethod | None:
if action == AGENT_REFRESH_ACTION:
return self.refresh
if action.startswith(AGENT_CUSTOM_ACTION_PREFIX):
return self.custom.get(action.removeprefix(AGENT_CUSTOM_ACTION_PREFIX))
return None


class V2JobSyncPolicy(ContractModel):
action: str = "job.sync"
supported_statuses: list[str] = Field(default_factory=list)
Expand Down Expand Up @@ -524,6 +570,7 @@ def build_v2_agent_registration(
datasets: Iterable[V2DatasetDefinition | dict[str, Any]] = (),
dashboards: Iterable[V2DashboardDefinition | dict[str, Any]] = (),
workspace_binding: V2WorkspaceBindingDefinition | dict[str, Any] | None = None,
agent_methods: V2AgentMethods | dict[str, Any] | None = None,
case_lanes: Iterable[V2CaseLaneDefinition | dict[str, Any]] = (),
artifact_types: Iterable[V2ArtifactTypeDefinition | dict[str, Any]] = (),
job_policy: V2JobPolicy | dict[str, Any] | None = None,
Expand All @@ -538,6 +585,7 @@ def build_v2_agent_registration(
dataset_definitions = _contract_list(datasets, V2DatasetDefinition)
dashboard_definitions = _contract_list(dashboards, V2DashboardDefinition)
workspace_binding_definition = _workspace_binding(workspace_binding)
agent_method_definitions = _agent_methods(agent_methods)
sync_policy = _job_policy(job_policy)

capability_surfaces = _unique_strings([
Expand All @@ -553,6 +601,7 @@ def build_v2_agent_registration(
*_dataset_action_ids(dataset_definitions),
*(_job_sync_actions(sync_policy)),
*_workspace_binding_action_ids(workspace_binding_definition),
*_agent_method_action_ids(agent_method_definitions),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid advertising v2 method actions without runtime handlers

build_v2_agent_registration(..., agent_methods=...) now injects those action IDs into capabilities.actions, but the method declarations are not carried into the runtime Agent instance, and handler registration later depends on Agent.v2_methods (agent.v2_action_ids) rather than registration capabilities. In the common case where a caller only passes the built registration into Agent(supervaizer_v2_registration=...), Studio will see actions like agent.refresh as supported, but action invocation fails with “Action handler not registered”, breaking advertised A2A flows.

Useful? React with 👍 / 👎.

])

return SupervaizerV2AgentRegistrationContract(
Expand Down Expand Up @@ -623,6 +672,16 @@ def _workspace_binding(
return V2WorkspaceBindingDefinition.model_validate(value)


def _agent_methods(
value: V2AgentMethods | dict[str, Any] | None,
) -> V2AgentMethods | None:
if value is None:
return None
if isinstance(value, V2AgentMethods):
return value
return V2AgentMethods.model_validate(value)


def _unique_strings(values: Iterable[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
Expand Down Expand Up @@ -672,6 +731,12 @@ def _job_sync_actions(job_policy: V2JobPolicy) -> list[str]:
return [job_policy.sync.action]


def _agent_method_action_ids(agent_methods: V2AgentMethods | None) -> list[str]:
if agent_methods is None:
return []
return agent_methods.action_ids


def _workspace_binding_action_ids(
workspace_binding: V2WorkspaceBindingDefinition | None,
) -> list[str]:
Expand Down
17 changes: 17 additions & 0 deletions src/supervaizer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@
SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS = 5.0


def _agent_v2_method_handler(agent: Agent, action: str) -> ActionHandler:
def handler(request: Any) -> Any:
return agent.execute_v2_action_method(action, request)

return handler


class ServerAbstract(SvBaseModel):
"""
API Server for the Supervaize Controller.
Expand Down Expand Up @@ -445,6 +452,7 @@ async def validation_exception_handler(

# Store server instance on app state before building routers
self.app.state.server = self # <-- MOVED earlier (was after route mount)
self._register_agent_v2_method_handlers()

# Activate API + A2A routes when supervisor account or local mode is set
if self.supervisor_account or local_mode:
Expand Down Expand Up @@ -659,6 +667,15 @@ def register_v2_action(
register_v2_action_handler(self, action, handler, agent_slug=agent_slug)
return handler

def _register_agent_v2_method_handlers(self) -> None:
for agent in self.agents:
for action in agent.v2_action_ids:
self.register_v2_action(
action,
_agent_v2_method_handler(agent, action),
agent_slug=agent.slug,
)

def v2_action(
self, action: str, *, agent_slug: str | None = None
) -> Callable[[ActionHandler], ActionHandler]:
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"job.start",
"job.stop",
"job.sync",
"campaigns.sync",
"agent.refresh",
"artifact.get",
"resource.campaigns.list",
"resource.campaigns.create",
Expand Down
68 changes: 67 additions & 1 deletion tests/test_a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from fastapi.testclient import TestClient

from supervaizer import Agent, Server
from supervaizer import (
AGENT_REFRESH_ACTION,
AGENT_REFRESH_EFFECT,
Agent,
Server,
V2AgentMethod,
V2AgentMethods,
build_v2_agent_registration,
)
from supervaizer.access import API_KEYS
from supervaizer.contracts import (
V2ActionRequest,
Expand Down Expand Up @@ -56,6 +64,13 @@
from supervaizer.workspace_authorization import WORKSPACE_AUTHORIZATION_HEADER


def _test_agent_refresh(request: V2ActionRequest) -> dict[str, object]:
return {
"status": "ok",
"effects": [{"type": AGENT_REFRESH_EFFECT, "request_id": request.request_id}],
}


def _a2a_write_headers(server: Server) -> dict[str, str]:
return {"X-API-Key": server.api_key or ""}

Expand Down Expand Up @@ -1622,6 +1637,57 @@ def preview_job_start(request: V2ActionRequest) -> dict[str, object]:
}


def test_server_registers_agent_v2_method_handlers() -> None:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
agent = Agent(
name="Agent Name",
version="1.0.0",
supervaizer_v2_registration=build_v2_agent_registration(
agent_id="agent-name",
agent_slug="agent-name",
display_name="Agent Name",
agent_card_url="/.well-known/agents/v1.0.0/agent-name_agent.json",
controller_url="/a2a",
a2ui_catalog_version="test.0",
),
v2_methods=V2AgentMethods(
refresh=V2AgentMethod(method="tests.test_a2a._test_agent_refresh")
),
)
server = Server(
agents=[agent],
private_key=private_key,
api_key="test-api-key",
admin_interface=False,
)
headers = _authorized_a2a_headers(
server,
agent_slug=agent.slug,
scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, AGENT_REFRESH_ACTION],
)
client = TestClient(server.app)

response = client.post(
"/a2a",
headers=headers,
json={
"jsonrpc": "2.0",
"id": "rpc-agent-refresh",
"method": SUPERVAIZER_ACTION_INVOKE_METHOD,
"params": _v2_action_payload(
action=AGENT_REFRESH_ACTION,
agent_slug=agent.slug,
),
},
)

assert response.status_code == 200
assert response.json()["result"] == {
"status": "ok",
"effects": [{"type": AGENT_REFRESH_EFFECT, "request_id": "request-1"}],
}


def test_server_v2_surface_decorator_registers_handler(server_fixture: Server) -> None:
agent_slug = server_fixture.agents[0].slug
headers = _authorized_a2a_headers(
Expand Down
Loading
Loading