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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ jobs:
run: make sdk-ts-build

- name: Verify generated TypeScript client is current
# GitHub deliberately withholds repository secrets from fork pull requests.
# Keep every auth-free SDK check above running, and run regeneration when
# Speakeasy credentials are available on trusted branches.
if: env.SPEAKEASY_API_KEY != ''
run: make sdk-ts-generate-check

- name: Verify generated method naming conventions
Expand Down
10 changes: 10 additions & 0 deletions evaluators/contrib/defenseclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ pip install "agent-control-evaluators[defenseclaw]"
The configuration contracts and JSON Schemas are implemented and discoverable. Both evaluator
classes intentionally execute as no-ops: they return `matched=False` without contacting or
installing any DefenseClaw runtime or OSS package.

The complementary DefenseClaw watcher can emit post-decision
`ControlExecutionEvent` records through the Agent Control SDK. The agent Monitor
shows aggregate enforcement counts and a **Recent executions** drill-down with
trace/span/request correlation, control and rule identity, action, and duration.
When the DefenseClaw integration is enabled, it includes exact blocked input,
raw request body, and enforcement reason by default. Monitor labels those spans
`UNREDACTED` and renders the content in the execution drill-down. DefenseClaw
operators can explicitly select metadata-only delivery. Treat access to these
events as access to sensitive workload data.
3 changes: 3 additions & 0 deletions ui/src/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,5 +262,8 @@ export const api = {
apiClient.GET('/api/v1/observability/stats', {
params: { query: params },
}),
queryEvents: (
body: paths['/api/v1/observability/events/query']['post']['requestBody']['content']['application/json']
) => apiClient.POST('/api/v1/observability/events/query', { body }),
},
};
39 changes: 39 additions & 0 deletions ui/src/core/hooks/query-hooks/use-agent-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';

import { api } from '@/core/api/client';
import type { components } from '@/core/api/generated/api-types';

export type ControlExecutionEvent =
components['schemas']['ControlExecutionEvent'];
export type EventQueryResponse = components['schemas']['EventQueryResponse'];

export function useAgentEvents(
agentName: string,
eventWindowMs: number,
options?: { enabled?: boolean; refetchInterval?: number }
) {
return useQuery({
queryKey: ['agent-monitor-events', agentName, eventWindowMs],
queryFn: async (): Promise<EventQueryResponse> => {
const { data, error } = await api.observability.queryEvents({
agent_name: agentName,
start_time: new Date(Date.now() - eventWindowMs).toISOString(),
limit: 20,
offset: 0,
});

if (error) {
throw new Error('Failed to fetch recent executions');
}

return data;
},
enabled:
options?.enabled !== false &&
!!agentName &&
Number.isFinite(eventWindowMs) &&
eventWindowMs > 0,
refetchInterval: options?.refetchInterval ?? 5000,
refetchIntervalInBackground: false,
});
}
26 changes: 26 additions & 0 deletions ui/src/core/page-components/agent-detail/monitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ export const TIME_RANGE_SEGMENTS: TimeRangeOption[] = [
{ label: '1Y', value: 'lastYear' },
];

import { useAgentEvents } from '@/core/hooks/query-hooks/use-agent-events';
Comment thread
vineethsai7 marked this conversation as resolved.
import type { StatsResponse } from '@/core/hooks/query-hooks/use-agent-monitor';
import { useAgentMonitor } from '@/core/hooks/query-hooks/use-agent-monitor';

import { ControlStatsTable } from './control-stats-table';
import { RecentExecutions } from './recent-executions';
import { SummaryCard } from './summary-card';
import type { SummaryMetrics } from './types';
import { mapTimeRangeTypeToTimeRange } from './utils';
Expand Down Expand Up @@ -49,6 +51,18 @@ function calculateSummary(
};
}

const TIME_RANGE_MILLISECONDS: Record<string, number> = {
'1m': 60_000,
'5m': 5 * 60_000,
'15m': 15 * 60_000,
'1h': 60 * 60_000,
'24h': 24 * 60 * 60_000,
'7d': 7 * 24 * 60 * 60_000,
'30d': 30 * 24 * 60 * 60_000,
'180d': 180 * 24 * 60 * 60_000,
'365d': 365 * 24 * 60 * 60_000,
};

export function AgentsMonitor({
agentUuid,
timeRangeValue,
Expand All @@ -70,6 +84,13 @@ export function AgentsMonitor({

// Calculate summary metrics
const summary = useMemo(() => calculateSummary(stats), [stats]);
const eventWindowMs =
TIME_RANGE_MILLISECONDS[apiTimeRange] ?? TIME_RANGE_MILLISECONDS['1h'];
const { data: recentEvents, error: recentEventsError } = useAgentEvents(
agentUuid,
eventWindowMs,
{ refetchInterval: 2000 }
);

if (isLoading && !stats) {
return (
Expand Down Expand Up @@ -126,6 +147,11 @@ export function AgentsMonitor({
</Text>
</Box>
)}

<RecentExecutions
events={recentEvents?.events ?? []}
hasError={recentEventsError !== null}
/>
</Stack>
);
}
249 changes: 249 additions & 0 deletions ui/src/core/page-components/agent-detail/monitor/recent-executions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
'use client';

import {
Accordion,
Badge,
Card,
Code,
Group,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
import { useState } from 'react';

import type { ControlExecutionEvent } from '@/core/hooks/query-hooks/use-agent-events';

type RecentExecutionsProps = {
events: ControlExecutionEvent[];
hasError?: boolean;
};

type EventMetadata = Record<string, unknown>;

function asRecord(value: unknown): EventMetadata | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as EventMetadata)
: null;
}

function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}

function executionKey(event: ControlExecutionEvent): string {
return (
event.control_execution_id ||
[
event.trace_id || 'no-trace',
event.span_id || 'no-span',
event.control_id,
event.timestamp || 'no-time',
].join(':')
);
}

function Detail({ label, value }: { label: string; value: string }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Code style={{ overflowWrap: 'anywhere' }}>{value}</Code>
</Stack>
);
}

export function RecentExecutions({
events,
hasError = false,
}: RecentExecutionsProps) {
const latestKey = events.length > 0 ? executionKey(events[0]) : null;
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const openValue = selectedValue ?? latestKey;

if (hasError) {
return (
<Card withBorder p="md">
<Title order={3} size="h5">
Recent executions
</Title>
<Text size="sm" c="red" mt="xs">
Failed to load recent executions.
</Text>
</Card>
);
}

if (events.length === 0) {
return (
<Card withBorder p="md">
<Title order={3} size="h5">
Recent executions
</Title>
<Text size="sm" c="dimmed" mt="xs">
Exact control spans will appear here as they are received.
</Text>
</Card>
);
}

return (
<Card withBorder p="md">
<Group justify="space-between" mb="sm">
<Title order={3} size="h5">
Recent executions
</Title>
<Text size="xs" c="dimmed">
Latest span opens automatically
</Text>
</Group>
<Accordion
value={openValue}
onChange={setSelectedValue}
variant="separated"
>
{events.map((event) => {
const metadata = (event.metadata ?? {}) as EventMetadata;
const blockedInput = asRecord(metadata.blocked_input);
const prompt = asString(blockedInput?.prompt);
const rawRequestBody = asString(blockedInput?.raw_request_body);
const verdictReason = asString(metadata.verdict_reason);
const ruleIds = Array.isArray(metadata.rule_ids)
? metadata.rule_ids.filter(
(value): value is string => typeof value === 'string'
)
: [];
const requestId = asString(metadata.request_id);
const key = executionKey(event);
const contentUnredacted = metadata.content_unredacted === true;

return (
<Accordion.Item key={key} value={key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={1}>
<Text size="sm" fw={600}>
{event.control_name}
</Text>
<Text size="xs" c="dimmed">
{event.timestamp
? new Date(event.timestamp).toLocaleString()
: 'Timestamp unavailable'}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
{contentUnredacted ? (
<Badge color="orange" variant="light">
Unredacted
</Badge>
) : null}
{!contentUnredacted ? (
<Badge color="gray" variant="light">
Metadata only
</Badge>
) : null}
<Badge color={event.action === 'deny' ? 'red' : 'blue'}>
{event.action}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Detail label="Trace ID" value={event.trace_id} />
<Detail label="Span ID" value={event.span_id} />
<Detail
label="Request ID"
value={requestId ?? 'Unavailable'}
/>
<Detail
label="Rule IDs"
value={
ruleIds.length > 0 ? ruleIds.join(', ') : 'Unavailable'
}
/>
<Detail label="Stage" value={event.check_stage} />
<Detail
label="Duration"
value={
event.execution_duration_ms == null
? 'Unavailable'
: `${event.execution_duration_ms} ms`
}
/>
</SimpleGrid>

{prompt !== null ? (
<Stack gap="xs">
<Group gap="xs">
<Text size="sm" fw={600}>
Blocked input
</Text>
<Badge
color={contentUnredacted ? 'orange' : 'gray'}
variant="light"
>
{contentUnredacted
? 'Unredacted content'
: 'Redacted content'}
</Badge>
</Group>
<Code
block
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
}}
>
{prompt}
</Code>
</Stack>
) : null}

{verdictReason !== null ? (
<Stack gap="xs">
<Text size="sm" fw={600}>
Enforcement reason
</Text>
<Code
block
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
}}
>
{verdictReason}
</Code>
</Stack>
) : null}

{rawRequestBody !== null ? (
<Accordion variant="contained">
<Accordion.Item value="raw-request">
<Accordion.Control>Raw request body</Accordion.Control>
<Accordion.Panel>
<Code
block
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
}}
>
{rawRequestBody}
</Code>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</Card>
);
}
Loading
Loading