Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
5e4753d
feat(ui): Intuitive JSON editing for Controls
lan17 Mar 27, 2026
7368f3e
fixup
lan17 Mar 27, 2026
b490793
feat(ui): improve JSON editor UX for control editing
lan17 Mar 27, 2026
2188807
chore(sdk): regenerate TypeScript SDK for controls/schema endpoint
lan17 Mar 27, 2026
a7904e1
fix(ci): fix mypy type error and prettier formatting
lan17 Mar 27, 2026
c27d4e8
fix(ui): skip comma auto-fix while suggest widget is open
lan17 Mar 27, 2026
53315ea
fix(ui): prevent double comma from auto-fix + programmatic edit conflict
lan17 Mar 27, 2026
4da79ce
refactor(ui): consolidate JSON editor content listeners
lan17 Mar 27, 2026
e92dc0e
fix(ui): eliminate infinite comma auto-fix loop
lan17 Mar 27, 2026
f14f802
fix(ui): auto-reformat JSON after code action insertions
lan17 Mar 27, 2026
153d3b6
fix(ui): preserve undo stack — use executeEdits instead of setValue
lan17 Mar 27, 2026
d188e3b
fix(ui): suppress null suggestion + prevent mid-edit corruption
lan17 Mar 27, 2026
06bd6bb
fix(ui): stop auto-reformat from jumping cursor while typing
lan17 Mar 27, 2026
5dd4d17
fix(ui): prevent undo crash + allow arbitrary step_types
lan17 Mar 27, 2026
1ebe626
refactor(ui): simplify JSON editor view — extract helpers, static CSS…
lan17 Mar 27, 2026
2ce42fb
fix(ui): auto-reformat after code action edits (multi-line changes only)
lan17 Mar 27, 2026
e5fcf2c
fix(ui): guard shouldAutoTriggerSuggest against undefined line + auto…
lan17 Mar 28, 2026
7bfa18d
fix(ui): defer auto-reformat to queueMicrotask — editing inside onDid…
lan17 Mar 28, 2026
da75572
fix(ui): suppress "No suggestions" popup while typing in strings
lan17 Mar 28, 2026
5a2ac6d
fix(ui): only auto-trigger suggestions on navigation, not typing
lan17 Mar 28, 2026
c9475c2
fix(ui): eliminate all unwanted suggestion popups while typing
lan17 Mar 28, 2026
9ff4a20
fix(ui): restore trigger character suggestions + fix overflow
lan17 Mar 28, 2026
94a81dc
fix(ui): restore domain-field suggestions + fix widget positioning
lan17 Mar 28, 2026
176d177
fix(ui): eliminate null/junk suggestions from JSON editor
lan17 Mar 28, 2026
e87e267
fix(ui): remove schema from Monaco diagnostics to eliminate duplicate…
lan17 Mar 28, 2026
27b11b3
fix(ui): auto-trigger suggestions for all enum fields in evaluator co…
lan17 Mar 30, 2026
d833482
feat(ui): add custom hover provider for JSON field tooltips
lan17 Mar 30, 2026
b91f464
fix(ui): switch hint decorations to deltaDecorations API for Monaco 0…
lan17 Mar 30, 2026
ca5d340
fix(ui): restore domain-field suggestions + fix widget positioning
lan17 Mar 30, 2026
be868aa
fix(ui): filter $schema from property suggestions
lan17 Mar 30, 2026
291eb70
fix(ui): remove non-actionable steering_context hint decoration
lan17 Mar 30, 2026
8a89f02
test(ui): add CI tests for JSON editor suggestion quality
lan17 Mar 30, 2026
acf6424
fix(models): use field-level examples instead of json_schema_extra fo…
lan17 Mar 30, 2026
18fa6e7
feat(ui): add codemirror json editor (#162)
siddhant-galileo Apr 6, 2026
356406c
fix: harden json editor validation
lan17 Apr 6, 2026
2455546
fix: resolve ui ci lint failures
lan17 Apr 6, 2026
cfa9e95
test: cover read-time control validation
lan17 Apr 6, 2026
65a620b
fix(ui): resolve edit-control UX bugs found via Playwright testing
lan17 Apr 6, 2026
4c95738
fix(ui): add noValidate to edit-control form for visible error messages
lan17 Apr 6, 2026
7eebe0d
fix(ui): place cursor inside quotes after accepting string property s…
lan17 Apr 6, 2026
46154d3
fix(ui): suppress $schema suggestion and auto-trigger on quote typing
lan17 Apr 6, 2026
e246c29
feat(ui): improve JSON editor error visibility and enum completions
lan17 Apr 6, 2026
fb85c75
feat(ui): add Cmd+S shortcut, unsaved changes warning, and UX polish
lan17 Apr 6, 2026
db54d49
docs: update UX audit checklist with completed items
lan17 Apr 6, 2026
f53cfe7
style: fix prettier formatting
lan17 Apr 6, 2026
878b597
test: update step name placeholder to match new text
lan17 Apr 6, 2026
66dace9
style: fix prettier in step-name-input test
lan17 Apr 6, 2026
b9d3c08
Merge branch 'main' of github.com:agentcontrol/agent-control into lev…
lan17 Apr 6, 2026
a3b10cb
fix(ui): unsaved changes warning on dialog X button close
lan17 Apr 6, 2026
16d24c8
fix(ui): clear onCloseRef before discard to prevent re-enter loop
lan17 Apr 6, 2026
69b5cc4
fix(ui): reset form dirty state after syncing control values
lan17 Apr 6, 2026
f4d7864
fix(ui): use explicit isDirty flag instead of form.isDirty() for unsa…
lan17 Apr 6, 2026
3cf96ed
fix(ui): defer dirty tracking enable to after all initial effects settle
lan17 Apr 6, 2026
a984ed6
fix(ui): track dirty state via native form onChange instead of onValu…
lan17 Apr 6, 2026
c749f5e
fix(ui): only warn about unsaved changes for JSON mode edits
lan17 Apr 6, 2026
50e0f8b
fix(ui): clear onCloseRef in non-dirty path to prevent infinite loop
lan17 Apr 6, 2026
60d26dc
fix(ui): revert sync applyEdits — crashes Monaco worker, document red…
lan17 Apr 7, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ jobs:
# Use production build for faster startup
NODE_ENV: production

- name: Run component tests
working-directory: ./ui
run: pnpm run test:ct

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
Expand Down
13 changes: 13 additions & 0 deletions models/src/agent_control_models/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class ControlScope(BaseModel):
"Step types this control applies to (omit to apply to all types). "
"Built-in types are 'tool' and 'llm'."
),
examples=[["llm"], ["tool"], ["llm", "tool"]],
)
step_names: list[str] | None = Field(
default=None,
Expand Down Expand Up @@ -172,6 +173,7 @@ class EvaluatorSpec(BaseModel):

name: str = Field(
...,
min_length=1,
description="Evaluator name or agent-scoped reference (agent:evaluator)",
examples=["regex", "list", "my-agent:pii-detector"],
)
Expand All @@ -184,6 +186,17 @@ class EvaluatorSpec(BaseModel):
],
)

@field_validator("name", mode="before")
@classmethod
def validate_name(cls, value: str) -> str:
if not isinstance(value, str):
return value

normalized = value.strip()
if not normalized:
raise ValueError("Evaluator name cannot be empty or whitespace-only.")
return normalized

@model_validator(mode="after")
def validate_evaluator_config(self) -> Self:
"""Validate config against evaluator's schema if evaluator is registered.
Expand Down
10 changes: 10 additions & 0 deletions models/src/agent_control_models/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ class GetControlDataResponse(BaseModel):
data: ControlDefinition = Field(description="Control data payload")


class GetControlSchemaResponse(BaseModel):
model_config = {"populate_by_name": True}

schema_: dict[str, Any] = Field(
alias="schema",
serialization_alias="schema",
description="JSON Schema for a full ControlDefinition payload",
)


class SetControlDataRequest(BaseModel):
"""Request to update control configuration data."""
data: ControlDefinition = Field(
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 @@ -110,6 +110,11 @@ actions:
x-speakeasy-group: controls
x-speakeasy-name-override: create

- target: $["paths"]["/api/v1/controls/schema"]["get"]
update:
x-speakeasy-group: controls
x-speakeasy-name-override: getSchema

- target: $["paths"]["/api/v1/controls/validate"]["post"]
update:
x-speakeasy-group: controls
Expand Down
142 changes: 142 additions & 0 deletions sdks/typescript/src/generated/funcs/controls-get-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
*/

import { AgentControlSDKCore } from "../core.js";
import * as M from "../lib/matchers.js";
import { compactMap } from "../lib/primitives.js";
import { RequestOptions } from "../lib/sdks.js";
import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js";
import { pathToFunc } from "../lib/url.js";
import { AgentControlSDKError } from "../models/errors/agent-control-sdk-error.js";
import {
ConnectionError,
InvalidRequestError,
RequestAbortedError,
RequestTimeoutError,
UnexpectedClientError,
} from "../models/errors/http-client-errors.js";
import { ResponseValidationError } from "../models/errors/response-validation-error.js";
import { SDKValidationError } from "../models/errors/sdk-validation-error.js";
import * as models from "../models/index.js";
import { APICall, APIPromise } from "../types/async.js";
import { Result } from "../types/fp.js";

/**
* Get control definition JSON schema
*
* @remarks
* Return the canonical JSON schema for ControlDefinition.
*/
export function controlsGetSchema(
client: AgentControlSDKCore,
options?: RequestOptions,
): APIPromise<
Result<
models.GetControlSchemaResponse,
| AgentControlSDKError
| ResponseValidationError
| ConnectionError
| RequestAbortedError
| RequestTimeoutError
| InvalidRequestError
| UnexpectedClientError
| SDKValidationError
>
> {
return new APIPromise($do(
client,
options,
));
}

async function $do(
client: AgentControlSDKCore,
options?: RequestOptions,
): Promise<
[
Result<
models.GetControlSchemaResponse,
| AgentControlSDKError
| ResponseValidationError
| ConnectionError
| RequestAbortedError
| RequestTimeoutError
| InvalidRequestError
| UnexpectedClientError
| SDKValidationError
>,
APICall,
]
> {
const path = pathToFunc("/api/v1/controls/schema")();

const headers = new Headers(compactMap({
Accept: "application/json",
}));

const secConfig = await extractSecurity(client._options.apiKeyHeader);
const securityInput = secConfig == null ? {} : { apiKeyHeader: secConfig };
const requestSecurity = resolveGlobalSecurity(securityInput);

const context = {
options: client._options,
baseURL: options?.serverURL ?? client._baseURL ?? "",
operationID: "get_control_schema_api_v1_controls_schema_get",
oAuth2Scopes: null,

resolvedSecurity: requestSecurity,

securitySource: client._options.apiKeyHeader,
retryConfig: options?.retries
|| client._options.retryConfig
|| { strategy: "none" },
retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"],
};

const requestRes = client._createRequest(context, {
security: requestSecurity,
method: "GET",
baseURL: options?.serverURL,
path: path,
headers: headers,
userAgent: client._options.userAgent,
timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1,
}, options);
if (!requestRes.ok) {
return [requestRes, { status: "invalid" }];
}
const req = requestRes.value;

const doResult = await client._do(req, {
context,
errorCodes: ["4XX", "5XX"],
retryConfig: context.retryConfig,
retryCodes: context.retryCodes,
});
if (!doResult.ok) {
return [doResult, { status: "request-error", request: req }];
}
const response = doResult.value;

const [result] = await M.match<
models.GetControlSchemaResponse,
| AgentControlSDKError
| ResponseValidationError
| ConnectionError
| RequestAbortedError
| RequestTimeoutError
| InvalidRequestError
| UnexpectedClientError
| SDKValidationError
>(
M.json(200, models.GetControlSchemaResponse$inboundSchema),
M.fail("4XX"),
M.fail("5XX"),
)(response, req);
if (!result.ok) {
return [result, { status: "complete", request: req, response }];
}

return [result, { status: "complete", request: req, response }];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
*/

import * as z from "zod/v4-mini";
import { safeParse } from "../lib/schemas.js";
import { Result as SafeParseResult } from "../types/fp.js";
import { SDKValidationError } from "./errors/sdk-validation-error.js";

export type GetControlSchemaResponse = {
/**
* JSON Schema for a full ControlDefinition payload
*/
schema: { [k: string]: any };
};

/** @internal */
export const GetControlSchemaResponse$inboundSchema: z.ZodMiniType<
GetControlSchemaResponse,
unknown
> = z.object({
schema: z.record(z.string(), z.any()),
});

export function getControlSchemaResponseFromJSON(
jsonString: string,
): SafeParseResult<GetControlSchemaResponse, SDKValidationError> {
return safeParse(
jsonString,
(x) => GetControlSchemaResponse$inboundSchema.parse(JSON.parse(x)),
`Failed to parse 'GetControlSchemaResponse' from JSON`,
);
}
1 change: 1 addition & 0 deletions sdks/typescript/src/generated/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export * from "./get-agent-policies-response.js";
export * from "./get-agent-response.js";
export * from "./get-control-data-response.js";
export * from "./get-control-response.js";
export * from "./get-control-schema-response.js";
export * from "./get-policy-controls-response.js";
export * from "./get-policy-response.js";
export * from "./health-response.js";
Expand Down
16 changes: 16 additions & 0 deletions sdks/typescript/src/generated/sdk/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { controlsCreate } from "../funcs/controls-create.js";
import { controlsDelete } from "../funcs/controls-delete.js";
import { controlsGetData } from "../funcs/controls-get-data.js";
import { controlsGetSchema } from "../funcs/controls-get-schema.js";
import { controlsGet } from "../funcs/controls-get.js";
import { controlsList } from "../funcs/controls-list.js";
import { controlsUpdateData } from "../funcs/controls-update-data.js";
Expand Down Expand Up @@ -83,6 +84,21 @@ export class Controls extends ClientSDK {
));
}

/**
* Get control definition JSON schema
*
* @remarks
* Return the canonical JSON schema for ControlDefinition.
*/
async getSchema(
options?: RequestOptions,
): Promise<models.GetControlSchemaResponse> {
return unwrapAsync(controlsGetSchema(
this,
options,
));
}

/**
* Validate control configuration
*
Expand Down
5 changes: 4 additions & 1 deletion server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,8 @@ start-dependencies:
test:
$(TEST_DB_ENV) uv run --package agent-control-server pytest --cov=src --cov-report=xml:../coverage-server.xml -q

run: start-dependencies migrate
run: start-dependencies migrate run-server

run-server:
uv run --package agent-control-server uvicorn agent_control_server.main:app --reload --host $(AGENT_CONTROL_HOST) --port $(AGENT_CONTROL_PORT)

23 changes: 22 additions & 1 deletion server/src/agent_control_server/endpoints/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DeleteControlResponse,
GetControlDataResponse,
GetControlResponse,
GetControlSchemaResponse,
ListControlsResponse,
PaginationInfo,
PatchControlRequest,
Expand Down Expand Up @@ -99,7 +100,11 @@ def _serialize_control_definition(control_def: ControlDefinition) -> dict[str, o
async def _validate_control_definition(
control_def: ControlDefinition, db: AsyncSession
) -> None:
"""Validate evaluator config for a control definition."""
"""Validate evaluator config for definitions referencing known global evaluators.

Agent-scoped evaluators must exist on the referenced agent. Builtin and external
names that are not loaded in this process are accepted without config checks.
"""
available_evaluators = list_evaluators()
agent_data_by_name: dict[str, AgentData] = {}
for field_prefix, leaf in _iter_condition_leaves(control_def.condition):
Expand Down Expand Up @@ -211,6 +216,9 @@ async def _validate_control_definition(

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

try:
Expand Down Expand Up @@ -325,6 +333,19 @@ async def create_control(
return CreateControlResponse(control_id=control.id)


@router.get(
"/schema",
response_model=GetControlSchemaResponse,
summary="Get control definition JSON schema",
response_description="JSON schema for ControlDefinition",
)
async def get_control_schema() -> GetControlSchemaResponse:
"""Return the canonical JSON schema for ControlDefinition."""
return GetControlSchemaResponse(
schema=ControlDefinition.model_json_schema(by_alias=True)
)


@router.get(
"/{control_id}",
response_model=GetControlResponse,
Expand Down
17 changes: 15 additions & 2 deletions server/tests/test_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
from copy import deepcopy
from typing import Any

from agent_control_server.models import Control
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session

from agent_control_server.models import Control

from .conftest import engine


Expand Down Expand Up @@ -106,6 +105,20 @@ def test_get_control_data_initially_unconfigured(client: TestClient) -> None:
assert "invalid data" in response_data.get("detail", "").lower()


def test_get_control_schema_returns_control_definition_schema(client: TestClient) -> None:
resp = client.get("/api/v1/controls/schema")

assert resp.status_code == 200, resp.text
schema = resp.json()["schema"]
properties = schema["properties"]

assert schema["type"] == "object"
assert {"execution", "condition", "action"}.issubset(properties)
assert set(schema["required"]) >= {"execution", "condition", "action"}
assert "condition" in properties
assert "$defs" in schema


VALID_CONTROL_DATA = {
"description": "Test Control",
"enabled": True,
Expand Down
Loading
Loading