Skip to content

Commit 1728bf9

Browse files
feat(server): add control clone-and-bind endpoint (#229)
1 parent c0fd159 commit 1728bf9

41 files changed

Lines changed: 3417 additions & 31 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

models/src/agent_control_models/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@
8383
from .server import (
8484
AgentRef,
8585
AgentSummary,
86+
CloneAndBindControlRequest,
87+
CloneAndBindControlResponse,
88+
CloneAndBindTargetBinding,
8689
ConflictMode,
90+
ControlAttachments,
8791
ControlSummary,
8892
ControlVersionSummary,
8993
CreateControlBindingRequest,
@@ -107,9 +111,11 @@
107111
PatchControlBindingResponse,
108112
PatchControlRequest,
109113
PatchControlResponse,
114+
PolicyRef,
110115
RenderControlTemplateRequest,
111116
RenderControlTemplateResponse,
112117
StepKey,
118+
TargetAttachmentRef,
113119
UpsertControlBindingRequest,
114120
UpsertControlBindingResponse,
115121
ValidateControlDataRequest,
@@ -176,7 +182,11 @@
176182
# Server models
177183
"AgentRef",
178184
"AgentSummary",
185+
"CloneAndBindControlRequest",
186+
"CloneAndBindControlResponse",
187+
"CloneAndBindTargetBinding",
179188
"ConflictMode",
189+
"ControlAttachments",
180190
"ControlVersionSummary",
181191
"ControlSummary",
182192
"CreateControlBindingRequest",
@@ -200,9 +210,11 @@
200210
"PatchControlBindingResponse",
201211
"PatchControlRequest",
202212
"PatchControlResponse",
213+
"PolicyRef",
203214
"RenderControlTemplateRequest",
204215
"RenderControlTemplateResponse",
205216
"StepKey",
217+
"TargetAttachmentRef",
206218
"UpsertControlBindingRequest",
207219
"UpsertControlBindingResponse",
208220
"ValidateControlDataRequest",

models/src/agent_control_models/errors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class ErrorCode(StrEnum):
5454
AUTH_INVALID_KEY = "AUTH_INVALID_KEY"
5555
AUTH_INSUFFICIENT_PRIVILEGES = "AUTH_INSUFFICIENT_PRIVILEGES"
5656
AUTH_MISCONFIGURED = "AUTH_MISCONFIGURED"
57+
AUTH_UPSTREAM_REJECTED = "AUTH_UPSTREAM_REJECTED"
5758

5859
# Resource Not Found (2xx pattern)
5960
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND" # Generic fallback
@@ -363,6 +364,7 @@ def make_error_type(error_code: ErrorCode) -> str:
363364
ErrorCode.AUTH_INVALID_KEY: "Invalid API Key",
364365
ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES: "Insufficient Privileges",
365366
ErrorCode.AUTH_MISCONFIGURED: "Authentication Misconfigured",
367+
ErrorCode.AUTH_UPSTREAM_REJECTED: "Authorization Upstream Rejected Request",
366368
# Not found errors
367369
ErrorCode.RESOURCE_NOT_FOUND: "Resource Not Found",
368370
ErrorCode.AGENT_NOT_FOUND: "Agent Not Found",

models/src/agent_control_models/server.py

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .agent import Agent, StepSchema
1515
from .base import BaseModel
1616
from .controls import (
17+
ControlAction,
1718
ControlDefinition,
1819
TemplateControlInput,
1920
TemplateDefinition,
@@ -347,6 +348,9 @@ class GetControlResponse(BaseModel):
347348

348349
id: int = Field(..., description="Control ID")
349350
name: str = Field(..., description="Control name")
351+
cloned_from_control_id: int | None = Field(
352+
None, description="Source control ID when this control is a clone."
353+
)
350354
data: ControlDefinition | UnrenderedTemplateControl = Field(
351355
description=(
352356
"Control configuration data. A ControlDefinition for raw/rendered "
@@ -514,14 +518,60 @@ class AgentRef(BaseModel):
514518
agent_name: str = Field(..., description="Agent name")
515519

516520

521+
class PolicyRef(BaseModel):
522+
"""Reference to a policy attached to a control."""
523+
524+
policy_id: int = Field(..., description="Policy ID")
525+
526+
527+
class TargetAttachmentRef(BaseModel):
528+
"""Reference to a target binding attached to a control."""
529+
530+
binding_id: int = Field(..., description="Control binding ID")
531+
target_type: str = Field(..., description="Opaque target kind")
532+
target_id: str = Field(..., description="Opaque target identifier")
533+
enabled: bool = Field(..., description="Whether this target binding is enabled")
534+
535+
536+
class ControlAttachments(BaseModel):
537+
"""Attachments for a listed control."""
538+
539+
agents: list[AgentRef] = Field(
540+
default_factory=list,
541+
description="Direct agent associations for this control",
542+
)
543+
policies: list[PolicyRef] = Field(
544+
default_factory=list,
545+
description="Policy associations for this control",
546+
)
547+
targets: list[TargetAttachmentRef] = Field(
548+
default_factory=list,
549+
description="Target bindings for this control",
550+
)
551+
targets_total: int = Field(
552+
default=0,
553+
description="Total target bindings matching the attachment filters",
554+
)
555+
targets_truncated: bool = Field(
556+
default=False,
557+
description="Whether the target bindings list was capped",
558+
)
559+
560+
517561
class ControlSummary(BaseModel):
518562
"""Summary of a control for list responses."""
519563

520564
id: int = Field(..., description="Control ID")
521565
name: str = Field(..., description="Control name")
566+
cloned_from_control_id: int | None = Field(
567+
None, description="Source control ID when this control is a clone."
568+
)
522569
description: str | None = Field(None, description="Control description")
523570
enabled: bool = Field(True, description="Whether control is enabled")
524571
execution: str | None = Field(None, description="'server' or 'sdk'")
572+
action: ControlAction | None = Field(
573+
None, description="Action applied when the control matches."
574+
)
525575
step_types: list[str] | None = Field(None, description="Step types in scope")
526576
stages: list[str] | None = Field(None, description="Evaluation stages in scope")
527577
tags: list[str] = Field(default_factory=list, description="Control tags")
@@ -542,6 +592,13 @@ class ControlSummary(BaseModel):
542592
used_by_agents_count: int = Field(
543593
0, description="Number of unique agents using this control"
544594
)
595+
attachments: ControlAttachments | None = Field(
596+
None,
597+
description=(
598+
"Expanded attachment details. Present when list controls is called "
599+
"with include_attachments=true."
600+
),
601+
)
545602

546603

547604
class ListControlsResponse(BaseModel):
@@ -580,7 +637,7 @@ class GetControlVersionResponse(BaseModel):
580637
...,
581638
description=(
582639
"Raw persisted snapshot of the control state at this version, including "
583-
"metadata such as name, deleted_at, and cloned_control_id."
640+
"metadata such as name, deleted_at, and cloned_from_control_id."
584641
),
585642
)
586643

@@ -635,6 +692,50 @@ class PatchControlResponse(BaseModel):
635692
]
636693

637694

695+
class CloneAndBindTargetBinding(BaseModel):
696+
"""Target binding to create for a cloned control."""
697+
698+
model_config = ConfigDict(extra="forbid")
699+
700+
target_type: ControlBindingTargetField = Field(
701+
...,
702+
description="Opaque attachment kind (caller-defined; e.g. 'environment', 'session').",
703+
)
704+
target_id: ControlBindingTargetField = Field(
705+
..., description="Opaque external identifier within the target_type."
706+
)
707+
enabled: bool = Field(
708+
default=True,
709+
description="Whether the created binding is active.",
710+
)
711+
712+
713+
class CloneAndBindControlRequest(BaseModel):
714+
"""Request to clone a control and attach the clone to one target."""
715+
716+
model_config = ConfigDict(extra="forbid")
717+
718+
name: SlugName | None = Field(
719+
None,
720+
description=(
721+
"Optional unique name for the cloned control. If omitted, the server "
722+
"generates a name from the source control name."
723+
),
724+
)
725+
target_binding: CloneAndBindTargetBinding = Field(
726+
..., description="Target binding to create for the cloned control."
727+
)
728+
729+
730+
class CloneAndBindControlResponse(BaseModel):
731+
"""Response from cloning and binding a control."""
732+
733+
id: int = Field(..., description="Identifier of the cloned control.")
734+
name: str = Field(..., description="Name of the cloned control.")
735+
cloned_from_control_id: int = Field(..., description="Source control ID.")
736+
binding_id: int = Field(..., description="Identifier of the created binding.")
737+
738+
638739
class CreateControlBindingRequest(BaseModel):
639740
"""Request to attach a control to an opaque external target."""
640741

@@ -741,6 +842,21 @@ class UpsertControlBindingResponse(BaseModel):
741842
enabled: bool = Field(..., description="Current enabled value.")
742843

743844

845+
class PatchControlBindingByKeyRequest(BaseModel):
846+
"""Request to update an existing control binding by natural key."""
847+
848+
target_type: ControlBindingTargetField = Field(
849+
..., description="Opaque attachment kind."
850+
)
851+
target_id: ControlBindingTargetField = Field(
852+
..., description="Opaque external identifier within the target_type."
853+
)
854+
control_id: int = Field(
855+
..., gt=0, description="ID of the bound control."
856+
)
857+
enabled: bool = Field(..., description="New enabled value for the binding.")
858+
859+
744860
class DeleteControlBindingByKeyRequest(BaseModel):
745861
"""Request to detach a control binding by natural key (idempotent)."""
746862

@@ -759,4 +875,3 @@ class DeleteControlBindingByKeyResponse(BaseModel):
759875
"binding existed."
760876
),
761877
)
762-

sdks/python/src/agent_control/__init__.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async def handle_input(user_message: str) -> str:
7979
set_trace_context_provider,
8080
)
8181

82-
from . import agents, controls, evaluation, evaluators, policies
82+
from . import agents, control_bindings, controls, evaluation, evaluators, policies
8383
from ._control_registry import (
8484
StepSchemaDict,
8585
get_registered_steps,
@@ -1019,10 +1019,14 @@ async def list_controls(
10191019
name: str | None = None,
10201020
enabled: bool | None = None,
10211021
template_backed: bool | None = None,
1022+
cloned: bool | None = None,
10221023
step_type: str | None = None,
10231024
stage: Literal["pre", "post"] | None = None,
10241025
execution: Literal["server", "sdk"] | None = None,
10251026
tag: str | None = None,
1027+
include_attachments: bool = False,
1028+
attachment_target_type: str | None = None,
1029+
attachment_target_id: str | None = None,
10261030
) -> dict[str, Any]:
10271031
"""
10281032
List all controls from the server with optional filtering.
@@ -1035,10 +1039,14 @@ async def list_controls(
10351039
name: Optional filter by name (partial, case-insensitive)
10361040
enabled: Optional filter by enabled status
10371041
template_backed: Optional filter by whether the control is template-backed
1042+
cloned: Optional filter by whether the control was cloned from another control
10381043
step_type: Optional filter by step type (built-ins: 'tool', 'llm')
10391044
stage: Optional filter by stage ('pre' or 'post')
10401045
execution: Optional filter by execution ('server' or 'sdk')
10411046
tag: Optional filter by tag
1047+
include_attachments: Whether to include attachment details
1048+
attachment_target_type: Optional target binding type filter for attachments
1049+
attachment_target_id: Optional target binding ID filter for attachments
10421050
10431051
Returns:
10441052
Dictionary containing:
@@ -1079,10 +1087,14 @@ async def main():
10791087
name=name,
10801088
enabled=enabled,
10811089
template_backed=template_backed,
1090+
cloned=cloned,
10821091
step_type=step_type,
10831092
stage=stage,
10841093
execution=execution,
10851094
tag=tag,
1095+
include_attachments=include_attachments,
1096+
attachment_target_type=attachment_target_type,
1097+
attachment_target_id=attachment_target_id,
10861098
)
10871099

10881100

@@ -1147,6 +1159,49 @@ async def main():
11471159
return await controls.create_control(client, name, data=data)
11481160

11491161

1162+
async def clone_and_bind_control(
1163+
control_id: int,
1164+
*,
1165+
target_type: str,
1166+
target_id: str,
1167+
name: str | None = None,
1168+
enabled: bool = True,
1169+
server_url: str | None = None,
1170+
api_key: str | None = None,
1171+
api_key_header: str | None = None,
1172+
) -> dict[str, Any]:
1173+
"""
1174+
Clone an existing control and bind the clone to a target.
1175+
1176+
Args:
1177+
control_id: Source control ID to clone
1178+
target_type: Opaque attachment kind
1179+
target_id: Opaque external target identifier
1180+
name: Optional unique name for the cloned control
1181+
enabled: Whether the created binding is active
1182+
server_url: Optional server URL (defaults to AGENT_CONTROL_URL env var)
1183+
api_key: Optional API key for authentication (defaults to AGENT_CONTROL_API_KEY env var)
1184+
1185+
Returns:
1186+
Dictionary containing id, name, cloned_from_control_id, and binding_id.
1187+
"""
1188+
_final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'
1189+
1190+
async with _ad_hoc_client(
1191+
server_url=_final_server_url,
1192+
api_key=api_key,
1193+
api_key_header=api_key_header,
1194+
) as client:
1195+
return await controls.clone_and_bind_control(
1196+
client,
1197+
control_id,
1198+
target_type=target_type,
1199+
target_id=target_id,
1200+
name=name,
1201+
enabled=enabled,
1202+
)
1203+
1204+
11501205
async def validate_control_data(
11511206
data: dict[str, Any] | ControlDefinition | TemplateControlInput,
11521207
server_url: str | None = None,
@@ -1502,6 +1557,7 @@ async def main():
15021557
"add_agent_control",
15031558
"remove_agent_control",
15041559
# Control management
1560+
"clone_and_bind_control",
15051561
"create_control",
15061562
"list_controls",
15071563
"get_control",
@@ -1520,6 +1576,7 @@ async def main():
15201576
"agents",
15211577
"policies",
15221578
"controls",
1579+
"control_bindings",
15231580
"evaluation",
15241581
"evaluators",
15251582
# Policy-Control management

0 commit comments

Comments
 (0)