Skip to content

Commit 8c23cef

Browse files
feat(ui, server): Intuitive JSON editing for Controls (#151)
## Summary - What changed and why. ## Scope - User-facing/API changes: - Internal changes: - Out of scope: ## Risk and Rollout - Risk level: low / medium / high - Rollback plan: ## Testing - [ ] Added or updated automated tests - [ ] Ran `make check` (or explained why not) - [ ] Manually verified behavior ## Checklist - [ ] Linked issue/spec (if applicable) - [ ] Updated docs/examples for user-facing changes - [ ] Included any required follow-up tasks --------- Co-authored-by: siddhant-galileo <siddhant@galileo.ai>
1 parent 5984a60 commit 8c23cef

58 files changed

Lines changed: 11865 additions & 120 deletions

Some content is hidden

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

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ jobs:
143143
# Use production build for faster startup
144144
NODE_ENV: production
145145

146+
- name: Run component tests
147+
working-directory: ./ui
148+
run: pnpm run test:ct
149+
146150
- name: Upload Playwright report
147151
uses: actions/upload-artifact@v4
148152
if: failure()

‎models/src/agent_control_models/controls.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class ControlScope(BaseModel):
7474
"Step types this control applies to (omit to apply to all types). "
7575
"Built-in types are 'tool' and 'llm'."
7676
),
77+
examples=[["llm"], ["tool"], ["llm", "tool"]],
7778
)
7879
step_names: list[str] | None = Field(
7980
default=None,
@@ -172,6 +173,7 @@ class EvaluatorSpec(BaseModel):
172173

173174
name: str = Field(
174175
...,
176+
min_length=1,
175177
description="Evaluator name or agent-scoped reference (agent:evaluator)",
176178
examples=["regex", "list", "my-agent:pii-detector"],
177179
)
@@ -184,6 +186,17 @@ class EvaluatorSpec(BaseModel):
184186
],
185187
)
186188

189+
@field_validator("name", mode="before")
190+
@classmethod
191+
def validate_name(cls, value: str) -> str:
192+
if not isinstance(value, str):
193+
return value
194+
195+
normalized = value.strip()
196+
if not normalized:
197+
raise ValueError("Evaluator name cannot be empty or whitespace-only.")
198+
return normalized
199+
187200
@model_validator(mode="after")
188201
def validate_evaluator_config(self) -> Self:
189202
"""Validate config against evaluator's schema if evaluator is registered.

‎models/src/agent_control_models/server.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,16 @@ class GetControlDataResponse(BaseModel):
293293
data: ControlDefinition = Field(description="Control data payload")
294294

295295

296+
class GetControlSchemaResponse(BaseModel):
297+
model_config = {"populate_by_name": True}
298+
299+
schema_: dict[str, Any] = Field(
300+
alias="schema",
301+
serialization_alias="schema",
302+
description="JSON Schema for a full ControlDefinition payload",
303+
)
304+
305+
296306
class SetControlDataRequest(BaseModel):
297307
"""Request to update control configuration data."""
298308
data: ControlDefinition = Field(

‎sdks/typescript/overlays/method-names.overlay.yaml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ actions:
110110
x-speakeasy-group: controls
111111
x-speakeasy-name-override: create
112112

113+
- target: $["paths"]["/api/v1/controls/schema"]["get"]
114+
update:
115+
x-speakeasy-group: controls
116+
x-speakeasy-name-override: getSchema
117+
113118
- target: $["paths"]["/api/v1/controls/validate"]["post"]
114119
update:
115120
x-speakeasy-group: controls
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
3+
*/
4+
5+
import { AgentControlSDKCore } from "../core.js";
6+
import * as M from "../lib/matchers.js";
7+
import { compactMap } from "../lib/primitives.js";
8+
import { RequestOptions } from "../lib/sdks.js";
9+
import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js";
10+
import { pathToFunc } from "../lib/url.js";
11+
import { AgentControlSDKError } from "../models/errors/agent-control-sdk-error.js";
12+
import {
13+
ConnectionError,
14+
InvalidRequestError,
15+
RequestAbortedError,
16+
RequestTimeoutError,
17+
UnexpectedClientError,
18+
} from "../models/errors/http-client-errors.js";
19+
import { ResponseValidationError } from "../models/errors/response-validation-error.js";
20+
import { SDKValidationError } from "../models/errors/sdk-validation-error.js";
21+
import * as models from "../models/index.js";
22+
import { APICall, APIPromise } from "../types/async.js";
23+
import { Result } from "../types/fp.js";
24+
25+
/**
26+
* Get control definition JSON schema
27+
*
28+
* @remarks
29+
* Return the canonical JSON schema for ControlDefinition.
30+
*/
31+
export function controlsGetSchema(
32+
client: AgentControlSDKCore,
33+
options?: RequestOptions,
34+
): APIPromise<
35+
Result<
36+
models.GetControlSchemaResponse,
37+
| AgentControlSDKError
38+
| ResponseValidationError
39+
| ConnectionError
40+
| RequestAbortedError
41+
| RequestTimeoutError
42+
| InvalidRequestError
43+
| UnexpectedClientError
44+
| SDKValidationError
45+
>
46+
> {
47+
return new APIPromise($do(
48+
client,
49+
options,
50+
));
51+
}
52+
53+
async function $do(
54+
client: AgentControlSDKCore,
55+
options?: RequestOptions,
56+
): Promise<
57+
[
58+
Result<
59+
models.GetControlSchemaResponse,
60+
| AgentControlSDKError
61+
| ResponseValidationError
62+
| ConnectionError
63+
| RequestAbortedError
64+
| RequestTimeoutError
65+
| InvalidRequestError
66+
| UnexpectedClientError
67+
| SDKValidationError
68+
>,
69+
APICall,
70+
]
71+
> {
72+
const path = pathToFunc("/api/v1/controls/schema")();
73+
74+
const headers = new Headers(compactMap({
75+
Accept: "application/json",
76+
}));
77+
78+
const secConfig = await extractSecurity(client._options.apiKeyHeader);
79+
const securityInput = secConfig == null ? {} : { apiKeyHeader: secConfig };
80+
const requestSecurity = resolveGlobalSecurity(securityInput);
81+
82+
const context = {
83+
options: client._options,
84+
baseURL: options?.serverURL ?? client._baseURL ?? "",
85+
operationID: "get_control_schema_api_v1_controls_schema_get",
86+
oAuth2Scopes: null,
87+
88+
resolvedSecurity: requestSecurity,
89+
90+
securitySource: client._options.apiKeyHeader,
91+
retryConfig: options?.retries
92+
|| client._options.retryConfig
93+
|| { strategy: "none" },
94+
retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"],
95+
};
96+
97+
const requestRes = client._createRequest(context, {
98+
security: requestSecurity,
99+
method: "GET",
100+
baseURL: options?.serverURL,
101+
path: path,
102+
headers: headers,
103+
userAgent: client._options.userAgent,
104+
timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1,
105+
}, options);
106+
if (!requestRes.ok) {
107+
return [requestRes, { status: "invalid" }];
108+
}
109+
const req = requestRes.value;
110+
111+
const doResult = await client._do(req, {
112+
context,
113+
errorCodes: ["4XX", "5XX"],
114+
retryConfig: context.retryConfig,
115+
retryCodes: context.retryCodes,
116+
});
117+
if (!doResult.ok) {
118+
return [doResult, { status: "request-error", request: req }];
119+
}
120+
const response = doResult.value;
121+
122+
const [result] = await M.match<
123+
models.GetControlSchemaResponse,
124+
| AgentControlSDKError
125+
| ResponseValidationError
126+
| ConnectionError
127+
| RequestAbortedError
128+
| RequestTimeoutError
129+
| InvalidRequestError
130+
| UnexpectedClientError
131+
| SDKValidationError
132+
>(
133+
M.json(200, models.GetControlSchemaResponse$inboundSchema),
134+
M.fail("4XX"),
135+
M.fail("5XX"),
136+
)(response, req);
137+
if (!result.ok) {
138+
return [result, { status: "complete", request: req, response }];
139+
}
140+
141+
return [result, { status: "complete", request: req, response }];
142+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
3+
*/
4+
5+
import * as z from "zod/v4-mini";
6+
import { safeParse } from "../lib/schemas.js";
7+
import { Result as SafeParseResult } from "../types/fp.js";
8+
import { SDKValidationError } from "./errors/sdk-validation-error.js";
9+
10+
export type GetControlSchemaResponse = {
11+
/**
12+
* JSON Schema for a full ControlDefinition payload
13+
*/
14+
schema: { [k: string]: any };
15+
};
16+
17+
/** @internal */
18+
export const GetControlSchemaResponse$inboundSchema: z.ZodMiniType<
19+
GetControlSchemaResponse,
20+
unknown
21+
> = z.object({
22+
schema: z.record(z.string(), z.any()),
23+
});
24+
25+
export function getControlSchemaResponseFromJSON(
26+
jsonString: string,
27+
): SafeParseResult<GetControlSchemaResponse, SDKValidationError> {
28+
return safeParse(
29+
jsonString,
30+
(x) => GetControlSchemaResponse$inboundSchema.parse(JSON.parse(x)),
31+
`Failed to parse 'GetControlSchemaResponse' from JSON`,
32+
);
33+
}

‎sdks/typescript/src/generated/models/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export * from "./get-agent-policies-response.js";
4545
export * from "./get-agent-response.js";
4646
export * from "./get-control-data-response.js";
4747
export * from "./get-control-response.js";
48+
export * from "./get-control-schema-response.js";
4849
export * from "./get-policy-controls-response.js";
4950
export * from "./get-policy-response.js";
5051
export * from "./health-response.js";

‎sdks/typescript/src/generated/sdk/controls.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { controlsCreate } from "../funcs/controls-create.js";
66
import { controlsDelete } from "../funcs/controls-delete.js";
77
import { controlsGetData } from "../funcs/controls-get-data.js";
8+
import { controlsGetSchema } from "../funcs/controls-get-schema.js";
89
import { controlsGet } from "../funcs/controls-get.js";
910
import { controlsList } from "../funcs/controls-list.js";
1011
import { controlsUpdateData } from "../funcs/controls-update-data.js";
@@ -83,6 +84,21 @@ export class Controls extends ClientSDK {
8384
));
8485
}
8586

87+
/**
88+
* Get control definition JSON schema
89+
*
90+
* @remarks
91+
* Return the canonical JSON schema for ControlDefinition.
92+
*/
93+
async getSchema(
94+
options?: RequestOptions,
95+
): Promise<models.GetControlSchemaResponse> {
96+
return unwrapAsync(controlsGetSchema(
97+
this,
98+
options,
99+
));
100+
}
101+
86102
/**
87103
* Validate control configuration
88104
*

‎server/Makefile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,8 @@ start-dependencies:
7171
test:
7272
$(TEST_DB_ENV) uv run --package agent-control-server pytest --cov=src --cov-report=xml:../coverage-server.xml -q
7373

74-
run: start-dependencies migrate
74+
run: start-dependencies migrate run-server
75+
76+
run-server:
7577
uv run --package agent-control-server uvicorn agent_control_server.main:app --reload --host $(AGENT_CONTROL_HOST) --port $(AGENT_CONTROL_PORT)
78+

‎server/src/agent_control_server/endpoints/controls.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
DeleteControlResponse,
1212
GetControlDataResponse,
1313
GetControlResponse,
14+
GetControlSchemaResponse,
1415
ListControlsResponse,
1516
PaginationInfo,
1617
PatchControlRequest,
@@ -99,7 +100,11 @@ def _serialize_control_definition(control_def: ControlDefinition) -> dict[str, o
99100
async def _validate_control_definition(
100101
control_def: ControlDefinition, db: AsyncSession
101102
) -> None:
102-
"""Validate evaluator config for a control definition."""
103+
"""Validate evaluator config for definitions referencing known global evaluators.
104+
105+
Agent-scoped evaluators must exist on the referenced agent. Builtin and external
106+
names that are not loaded in this process are accepted without config checks.
107+
"""
103108
available_evaluators = list_evaluators()
104109
agent_data_by_name: dict[str, AgentData] = {}
105110
for field_prefix, leaf in _iter_condition_leaves(control_def.condition):
@@ -211,6 +216,9 @@ async def _validate_control_definition(
211216

212217
evaluator_cls = available_evaluators.get(parsed.name)
213218
if evaluator_cls is None:
219+
# Global (builtin / external) evaluators may be absent from this runtime
220+
# (optional packages, forward compatibility). Store the definition without
221+
# config validation; evaluation will fail later if the evaluator is missing.
214222
continue
215223

216224
try:
@@ -325,6 +333,19 @@ async def create_control(
325333
return CreateControlResponse(control_id=control.id)
326334

327335

336+
@router.get(
337+
"/schema",
338+
response_model=GetControlSchemaResponse,
339+
summary="Get control definition JSON schema",
340+
response_description="JSON schema for ControlDefinition",
341+
)
342+
async def get_control_schema() -> GetControlSchemaResponse:
343+
"""Return the canonical JSON schema for ControlDefinition."""
344+
return GetControlSchemaResponse(
345+
schema=ControlDefinition.model_json_schema(by_alias=True)
346+
)
347+
348+
328349
@router.get(
329350
"/{control_id}",
330351
response_model=GetControlResponse,

0 commit comments

Comments
 (0)