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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,6 @@ agent-control/
├── engine/ # Evaluation engine (agent-control-engine)
├── models/ # Shared models (agent-control-models)
├── evaluators/ # Evaluator implementations (agent-control-evaluators)
├── ui/ # Next.js dashboard
└── examples/ # Usage examples
```

Expand Down
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 @@ -62,6 +62,7 @@
)
from .policy import Policy
from .server import (
AgentRef,
AgentSummary,
ControlSummary,
CreateEvaluatorConfigRequest,
Expand Down Expand Up @@ -129,6 +130,7 @@
"get_error_title",
"ERROR_TITLES",
# Server models
"AgentRef",
"AgentSummary",
"ControlSummary",
"CreateEvaluatorConfigRequest",
Expand Down
8 changes: 8 additions & 0 deletions models/src/agent_control_models/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,13 @@ class ListAgentsResponse(BaseModel):
# =============================================================================


class AgentRef(BaseModel):
"""Reference to an agent (for listing which agents use a control)."""

agent_id: str = Field(..., description="Agent UUID")
agent_name: str = Field(..., description="Agent name")


class ControlSummary(BaseModel):
"""Summary of a control for list responses."""

Expand All @@ -259,6 +266,7 @@ class ControlSummary(BaseModel):
step_types: list[str] | None = Field(None, description="Step types in scope")
stages: list[str] | None = Field(None, description="Evaluation stages in scope")
tags: list[str] = Field(default_factory=list, description="Control tags")
used_by_agent: AgentRef | None = Field(None, description="Agent using this control")


class ListControlsResponse(BaseModel):
Expand Down
16 changes: 14 additions & 2 deletions server/src/agent_control_server/endpoints/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ async def _validate_policy_controls_for_agent(
async def list_agents(
cursor: str | None = None,
limit: int = _DEFAULT_PAGINATION_LIMIT,
name: str | None = None,
db: AsyncSession = Depends(get_async_db),
) -> ListAgentsResponse:
"""
Expand All @@ -166,6 +167,7 @@ async def list_agents(
Args:
cursor: Optional cursor for pagination (UUID of last agent from previous page)
limit: Pagination limit (default 20, max 100)
name: Optional name filter (case-insensitive partial match)
db: Database session (injected)

Returns:
Expand All @@ -174,14 +176,24 @@ async def list_agents(
# Clamp limit
limit = min(max(1, limit), _MAX_PAGINATION_LIMIT)

# Get total count
count_result = await db.execute(select(func.count()).select_from(Agent))
# Build base filter for name search
name_filter = Agent.name.ilike(f"%{name}%") if name else None

# Get total count (with name filter if provided)
count_query = select(func.count()).select_from(Agent)
if name_filter is not None:
count_query = count_query.where(name_filter)
count_result = await db.execute(count_query)
total = count_result.scalar() or 0

# Build query with cursor-based pagination
# Order by created_at DESC, then by UUID DESC for stable ordering
query = select(Agent).order_by(Agent.created_at.desc(), Agent.agent_uuid.desc())

# Apply name filter if provided
if name_filter is not None:
query = query.where(name_filter)

# If cursor provided, filter to get items after the cursor
if cursor:
try:
Expand Down
29 changes: 28 additions & 1 deletion server/src/agent_control_server/endpoints/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from agent_control_models import ControlDefinition
from agent_control_models.errors import ErrorCode, ValidationErrorItem
from agent_control_models.server import (
AgentRef,
ControlSummary,
CreateControlRequest,
CreateControlResponse,
Expand Down Expand Up @@ -29,7 +30,7 @@
NotFoundError,
)
from ..logging_utils import get_logger
from ..models import Agent, AgentData, Control, policy_controls
from ..models import Agent, AgentData, Control, Policy, policy_controls
from ..services.evaluator_utils import parse_evaluator_ref, validate_config_against_schema

# Pagination constants
Expand Down Expand Up @@ -544,6 +545,31 @@ async def list_controls(
if has_more:
controls = controls[:-1]

# Build mapping of control_id -> agent that uses it
# Traversal: Control -> policy_controls -> Policy -> Agent
control_agent_map: dict[int, AgentRef | None] = {ctrl.id: None for ctrl in controls}
if controls:
control_ids = [ctrl.id for ctrl in controls]
agents_query = (
select(
policy_controls.c.control_id,
Agent.agent_uuid,
Agent.name,
)
.select_from(policy_controls)
.join(Policy, policy_controls.c.policy_id == Policy.id)
.join(Agent, Agent.policy_id == Policy.id)
.where(policy_controls.c.control_id.in_(control_ids))
)
agents_result = await db.execute(agents_query)
for row in agents_result.all():
control_id, agent_uuid, agent_name = row
# Take the first agent found (1 control = 1 agent)
if control_agent_map[control_id] is None:
control_agent_map[control_id] = AgentRef(
agent_id=str(agent_uuid), agent_name=agent_name
)

# Build summaries (filtering already done at DB level)
summaries: list[ControlSummary] = []
for ctrl in controls:
Expand All @@ -560,6 +586,7 @@ async def list_controls(
step_types=scope.get("step_types"),
stages=scope.get("stages"),
tags=data.get("tags", []),
used_by_agent=control_agent_map.get(ctrl.id),
)
)

Expand Down
39 changes: 39 additions & 0 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,45 @@ pnpm fetch-api-types # regenerate API types from server (must be running on :80
- Each folder exports: `form.tsx` (React component), `types.ts` (form types), `index.ts` (re-exports)
- Registry in `evaluators/index.ts` maps evaluator names to form components

### Reusable components (`core/components/`)
- Create reusable components that encapsulate common patterns and logic
- **Best practice**: When creating wrapper components around Mantine components, extend the underlying component's props using `Omit` to exclude overridden props, then spread `...rest` to forward all other props
- This provides full flexibility while maintaining type safety

**Example: SearchInput component pattern**
```typescript
import type { TextInputProps } from "@mantine/core";
import { TextInput } from "@mantine/core";

interface SearchInputProps
extends Omit<TextInputProps, "value" | "onChange" | "leftSection" | "rightSection"> {
queryKey: string; // Required prop specific to this component
}

export function SearchInput({
queryKey,
placeholder = "Search...",
w = 250,
...rest // Forward all other TextInput props
}: SearchInputProps) {
// Component logic...
return (
<TextInput
{...rest} // Spread all forwarded props
// Override specific props
value={searchQuery}
onChange={handleChange}
/>
);
}
```

**Benefits:**
- Full type safety for all underlying component props
- No need to explicitly define every prop in the wrapper interface
- Easy to extend with new props from the underlying component
- Maintains backward compatibility when underlying component adds new props

## Common changes

### Add a new evaluator form
Expand Down
13 changes: 13 additions & 0 deletions ui/src/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export const api = {
list: () => apiClient.GET("/api/v1/evaluators"),
},
controls: {
list: (params?: {
cursor?: number;
limit?: number;
name?: string;
enabled?: boolean;
step_type?: string;
stage?: string;
execution?: string;
tag?: string;
}) =>
apiClient.GET("/api/v1/controls", {
params: params ? { query: params } : undefined,
}),
create: (data: CreateControlRequest) =>
apiClient.PUT("/api/v1/controls", { body: data }),
getData: (controlId: number) =>
Expand Down
12 changes: 12 additions & 0 deletions ui/src/core/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export type ListAgentsResponse = components["schemas"]["ListAgentsResponse"];
// Evaluator types
export type EvaluatorInfo = components["schemas"]["EvaluatorInfo"];
export type EvaluatorsResponse = Record<string, EvaluatorInfo>;
export type EvaluatorConfigItem = components["schemas"]["EvaluatorConfigItem"];
export type ListEvaluatorConfigsResponse =
components["schemas"]["ListEvaluatorConfigsResponse"];

// Request/Response types
export type InitAgentRequest = components["schemas"]["InitAgentRequest"];
Expand Down Expand Up @@ -89,6 +92,15 @@ export type SetControlDataResponse =
components["schemas"]["SetControlDataResponse"];
export type GetControlDataResponse =
components["schemas"]["GetControlDataResponse"];
export type ControlSummary = components["schemas"]["ControlSummary"];
export type ListControlsResponse = components["schemas"]["ListControlsResponse"];

// AgentRef - reference to an agent (for used_by_agents)
// Note: This will be in generated types after running pnpm fetch-api-types
export interface AgentRef {
agent_id: string;
agent_name: string;
}

// Helper type to extract query parameters from operations
type ExtractQueryParams<T> = T extends { parameters: { query?: infer Q } }
Expand Down
58 changes: 58 additions & 0 deletions ui/src/core/components/search-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { TextInputProps } from "@mantine/core";
import { TextInput } from "@mantine/core";
import { IconSearch, IconX } from "@tabler/icons-react";
import { useMemo } from "react";

import { useQueryParam } from "@/core/hooks/use-query-param";

interface SearchInputProps
extends Omit<TextInputProps, "value" | "onChange" | "leftSection" | "rightSection"> {
/** Query parameter key to sync with URL (e.g., "search", "q", "store_q") */
queryKey: string;
}

/**
* Reusable search input component that syncs with URL query parameters.
* Includes a clear button (X icon) when there's text.
* Accepts all TextInput props for full customization.
*
* @example
* <SearchInput queryKey="search" placeholder="Search agents..." w={250} />
*/
export function SearchInput({
queryKey,
placeholder = "Search...",
w = 250,
...rest
}: SearchInputProps) {
const [searchQuery, setSearchQuery] = useQueryParam(queryKey);

const showClearButton = useMemo(() => {
return searchQuery.length > 0;
}, [searchQuery]);

const handleClear = () => {
setSearchQuery("");
};

return (
<TextInput
placeholder={placeholder}
leftSection={<IconSearch size={16} />}
rightSection={
showClearButton ? (
<IconX
size={16}
style={{ cursor: "pointer" }}
onClick={handleClear}
data-testid={`search-clear-${queryKey}`}
/>
) : null
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
w={w}
{...rest}
/>
);
}
15 changes: 12 additions & 3 deletions ui/src/core/hooks/query-hooks/use-agents-infinite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@ import type { ListAgentsResponse } from "@/core/api/types";

const AGENTS_PAGE_SIZE = 10;

export interface UseAgentsInfiniteParams {
name?: string;
enabled?: boolean;
}

/**
* Infinite query hook to fetch agents with cursor-based pagination
* Loads more agents as user scrolls
* Supports server-side filtering by name
*/
export function useAgentsInfinite() {
export function useAgentsInfinite(params?: UseAgentsInfiniteParams) {
const { enabled = true, ...filterParams } = params ?? {};

return useInfiniteQuery({
queryKey: ["agents", "infinite"],
queryKey: ["agents", "infinite", filterParams],
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
const { data, error } = await api.agents.list({
cursor: pageParam,
limit: AGENTS_PAGE_SIZE,
...filterParams,
});
if (error) throw error;
return data;
Expand All @@ -25,5 +33,6 @@ export function useAgentsInfinite() {
return lastPage.pagination.next_cursor ?? undefined;
},
initialPageParam: undefined,
enabled,
});
}
46 changes: 46 additions & 0 deletions ui/src/core/hooks/query-hooks/use-controls-infinite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useInfiniteQuery } from "@tanstack/react-query";

import { api } from "@/core/api/client";
import type { ListControlsResponse } from "@/core/api/types";

const CONTROLS_PAGE_SIZE = 10;

export interface UseControlsInfiniteParams {
name?: string;
enabled?: boolean;
step_type?: string;
stage?: string;
execution?: string;
tag?: string;
}

/**
* Infinite query hook to fetch controls with cursor-based pagination
* Supports server-side filtering by name and other params
*/
export function useControlsInfinite(params?: UseControlsInfiniteParams) {
const { enabled = true, ...filterParams } = params ?? {};

return useInfiniteQuery({
queryKey: ["controls", "infinite", filterParams],
queryFn: async ({ pageParam }: { pageParam: number | undefined }) => {
const { data, error } = await api.controls.list({
cursor: pageParam,
limit: CONTROLS_PAGE_SIZE,
...filterParams,
});
if (error) throw error;
return data;
},
getNextPageParam: (lastPage: ListControlsResponse) => {
// Return undefined if no more pages (stops infinite query)
if (!lastPage.pagination.has_more) return undefined;
// Use the last control's ID as cursor for next page
const controls = lastPage.controls;
if (controls.length === 0) return undefined;
return controls[controls.length - 1].id;
},
initialPageParam: undefined,
enabled,
});
}
Loading
Loading