Skip to content
Open
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
2 changes: 2 additions & 0 deletions models/src/agent_control_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
PatchControlResponse,
RenderControlTemplateRequest,
RenderControlTemplateResponse,
RestoreControlVersionResponse,
StepKey,
ValidateControlDataRequest,
ValidateControlDataResponse,
Expand Down Expand Up @@ -182,6 +183,7 @@
"PatchControlResponse",
"RenderControlTemplateRequest",
"RenderControlTemplateResponse",
"RestoreControlVersionResponse",
"StepKey",
"ValidateControlDataRequest",
"ValidateControlDataResponse",
Expand Down
11 changes: 6 additions & 5 deletions models/src/agent_control_models/policy.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
from typing import Any

from .base import BaseModel
from .controls import ControlDefinition, UnrenderedTemplateControl


class Control(BaseModel):
"""A control with identity and configuration.

For rendered controls (raw or template-backed), ``control`` is a
``ControlDefinition``. For unrendered template controls, ``control``
is an ``UnrenderedTemplateControl``.
``control`` contains the canonical payload after server-side validation.
Forward-compatible stored fields are preserved so clients can round-trip
historical snapshots without data loss.
"""

id: int
name: str
control: ControlDefinition | UnrenderedTemplateControl
control: dict[str, Any]


class Policy(BaseModel):
Expand Down
39 changes: 33 additions & 6 deletions models/src/agent_control_models/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
TemplateControlInput,
TemplateDefinition,
TemplateValue,
UnrenderedTemplateControl,
)
from .policy import Control

Expand Down Expand Up @@ -311,10 +310,10 @@ class GetControlResponse(BaseModel):

id: int = Field(..., description="Control ID")
name: str = Field(..., description="Control name")
data: ControlDefinition | UnrenderedTemplateControl = Field(
data: dict[str, Any] = Field(
description=(
"Control configuration data. A ControlDefinition for raw/rendered "
"controls or an UnrenderedTemplateControl for unrendered templates."
"Canonical control payload after validation. Forward-compatible "
"fields are preserved for round-tripping."
),
)

Expand Down Expand Up @@ -344,8 +343,11 @@ class RemoveAgentControlResponse(BaseModel):


class GetControlDataResponse(BaseModel):
data: ControlDefinition | UnrenderedTemplateControl = Field(
description="Control data payload (rendered control or unrendered template)"
data: dict[str, Any] = Field(
description=(
"Canonical control payload after validation. Forward-compatible "
"fields are preserved for round-tripping."
)
)


Expand Down Expand Up @@ -549,6 +551,31 @@ class GetControlVersionResponse(BaseModel):
)


class RestoreControlVersionResponse(BaseModel):
"""Response for restoring a control to a historical version."""

success: bool = Field(..., description="Whether the restore request succeeded")
control_id: int = Field(..., description="Identifier of the restored control")
restored_from_version_num: int = Field(
..., description="Historical version number used as the restore source"
)
current_version_num: int = Field(
...,
description=(
"Current latest version number after restore. For no-op restores, "
"this is the existing latest version."
),
)
name: str = Field(..., description="Current control name after restore")
data: dict[str, Any] = Field(
...,
description=(
"Current canonical control payload after restore. "
"Forward-compatible fields are preserved for round-tripping."
),
)


class DeleteControlResponse(BaseModel):
"""Response for deleting a control."""

Expand Down
58 changes: 58 additions & 0 deletions sdks/python/src/agent_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async def handle_input(user_message: str) -> str:
EvaluatorResult,
EvaluatorSpec,
JSONObject,
RestoreControlVersionResponse,
Step,
StepSchema,
TemplateControlInput,
Expand Down Expand Up @@ -967,6 +968,59 @@ async def main():
)


async def list_control_versions(
control_id: int,
server_url: str | None = None,
api_key: str | None = None,
cursor: int | None = None,
limit: int = 20,
) -> dict[str, Any]:
"""List version-history summaries for a control."""
_final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'

async with AgentControlClient(base_url=_final_server_url, api_key=api_key) as client:
return await controls.list_control_versions(
client,
control_id=control_id,
cursor=cursor,
limit=limit,
)


async def get_control_version(
control_id: int,
version_num: int,
server_url: str | None = None,
api_key: str | None = None,
) -> dict[str, Any]:
"""Get a specific version snapshot for a control."""
_final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'

async with AgentControlClient(base_url=_final_server_url, api_key=api_key) as client:
return await controls.get_control_version(
client,
control_id=control_id,
version_num=version_num,
)


async def restore_control_version(
control_id: int,
version_num: int,
server_url: str | None = None,
api_key: str | None = None,
) -> dict[str, Any]:
"""Restore an active control to a historical version."""
_final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'

async with AgentControlClient(base_url=_final_server_url, api_key=api_key) as client:
return await controls.restore_control_version(
client,
control_id=control_id,
version_num=version_num,
)


async def create_control(
name: str,
data: dict[str, Any] | ControlDefinition | TemplateControlInput,
Expand Down Expand Up @@ -1341,6 +1395,9 @@ async def main():
"create_control",
"list_controls",
"get_control",
"list_control_versions",
"get_control_version",
"restore_control_version",
"delete_control",
"update_control",
"validate_control_data",
Expand Down Expand Up @@ -1409,4 +1466,5 @@ async def main():
"EvaluatorSpec",
"EvaluatorResult",
"TemplateValue",
"RestoreControlVersionResponse",
]
23 changes: 23 additions & 0 deletions sdks/python/src/agent_control/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,29 @@ async def get_control_version(
return cast(dict[str, Any], response.json())


async def restore_control_version(
client: AgentControlClient,
control_id: int,
version_num: int,
) -> dict[str, Any]:
"""
Restore an active control to a historical version.

Args:
client: AgentControlClient instance
control_id: ID of the control
version_num: Control version number to restore

Returns:
Dictionary containing the restored control state and current version number.
"""
response = await client.http_client.post(
f"/api/v1/controls/{control_id}/versions/{version_num}/restore"
)
response.raise_for_status()
return cast(dict[str, Any], response.json())


async def create_control(
client: AgentControlClient,
name: str,
Expand Down
85 changes: 85 additions & 0 deletions sdks/python/tests/test_controls_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
from agent_control_models import TemplateControlInput


class _AsyncClientContext:
def __init__(self, client: object) -> None:
self.client = client

async def __aenter__(self) -> object:
return self.client

async def __aexit__(self, *args: object) -> None:
return None


@pytest.mark.asyncio
async def test_list_controls_passes_template_backed_filter() -> None:
# Given: an SDK client stub and a template-backed list filter
Expand Down Expand Up @@ -104,6 +115,80 @@ async def test_get_control_version_calls_specific_version_endpoint() -> None:
client.http_client.get.assert_awaited_once_with("/api/v1/controls/123/versions/2")


@pytest.mark.asyncio
async def test_restore_control_version_calls_restore_endpoint() -> None:
# Given: an SDK client stub for restoring a version
response = Mock()
response.raise_for_status = Mock()
response.json = Mock(
return_value={
"success": True,
"control_id": 123,
"restored_from_version_num": 2,
"current_version_num": 4,
"name": "restored-control",
"data": {},
}
)
client = SimpleNamespace(http_client=SimpleNamespace(post=AsyncMock(return_value=response)))

# When: restoring a specific control version
result = await agent_control.controls.restore_control_version(
client,
control_id=123,
version_num=2,
)

# Then: the SDK calls the restore endpoint
client.http_client.post.assert_awaited_once_with("/api/v1/controls/123/versions/2/restore")
assert result["current_version_num"] == 4


@pytest.mark.asyncio
async def test_top_level_control_version_helpers_delegate_to_controls_module(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Given: top-level SDK helpers and a fake client context
client = object()
created_clients: list[tuple[str, str | None]] = []

def fake_client_factory(base_url: str, api_key: str | None = None) -> _AsyncClientContext:
created_clients.append((base_url, api_key))
return _AsyncClientContext(client)

list_versions = AsyncMock(return_value={"versions": []})
get_version = AsyncMock(return_value={"version_num": 2})
restore_version = AsyncMock(return_value={"success": True})
monkeypatch.setattr(agent_control, "AgentControlClient", fake_client_factory)
monkeypatch.setattr(agent_control.controls, "list_control_versions", list_versions)
monkeypatch.setattr(agent_control.controls, "get_control_version", get_version)
monkeypatch.setattr(agent_control.controls, "restore_control_version", restore_version)

# When: calling the public top-level version helpers
await agent_control.list_control_versions(
123,
server_url="http://server.test",
api_key="secret",
cursor=7,
limit=5,
)
await agent_control.get_control_version(123, 2, server_url="http://server.test")
await agent_control.restore_control_version(123, 2, server_url="http://server.test")

# Then: they are exported and delegate to the lower-level controls module
assert "list_control_versions" in agent_control.__all__
assert "get_control_version" in agent_control.__all__
assert "restore_control_version" in agent_control.__all__
assert created_clients == [
("http://server.test", "secret"),
("http://server.test", None),
("http://server.test", None),
]
list_versions.assert_awaited_once_with(client, control_id=123, cursor=7, limit=5)
get_version.assert_awaited_once_with(client, control_id=123, version_num=2)
restore_version.assert_awaited_once_with(client, control_id=123, version_num=2)


@pytest.mark.asyncio
async def test_render_control_template_calls_preview_endpoint() -> None:
# Given: an SDK client stub and template preview input
Expand Down
5 changes: 5 additions & 0 deletions sdks/typescript/overlays/method-names.overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ actions:
x-speakeasy-group: controls
x-speakeasy-name-override: getVersion

- target: $["paths"]["/api/v1/controls/{control_id}/versions/{version_num}/restore"]["post"]
update:
x-speakeasy-group: controls
x-speakeasy-name-override: restoreVersion

- target: $["paths"]["/api/v1/evaluation"]["post"]
update:
x-speakeasy-group: evaluation
Expand Down
2 changes: 1 addition & 1 deletion sdks/typescript/src/generated/funcs/controls-get-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { Result } from "../types/fp.js";
* db: Database session (injected)
*
* Returns:
* GetControlDataResponse with validated ControlDefinition
* GetControlDataResponse with canonical validated control data
*
* Raises:
* HTTPException 404: Control not found
Expand Down
2 changes: 1 addition & 1 deletion sdks/typescript/src/generated/funcs/controls-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { Result } from "../types/fp.js";
* db: Database session (injected)
*
* Returns:
* GetControlResponse with control id, name, and data
* GetControlResponse with control id, name, and canonical validated data
*
* Raises:
* HTTPException 404: Control not found
Expand Down
Loading
Loading