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
11 changes: 11 additions & 0 deletions evaluators/contrib/defenseclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ 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, while the **Events** tab provides 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 event
payloads as **Full content**, **Redacted content**, or **Metadata only** when
DefenseClaw explicitly reports their disclosure state. Events from other
evaluators receive no privacy label unless they report that state. Treat access
to full-content 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 }),
},
};
2 changes: 1 addition & 1 deletion ui/src/core/constants/agent-routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AgentDetailTab = 'controls' | 'monitor';
export type AgentDetailTab = 'controls' | 'monitor' | 'events';

type RouteQueryValue =
| string
Expand Down
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,
});
}
49 changes: 36 additions & 13 deletions ui/src/core/page-components/agent-detail/agent-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ import {
Title,
} from '@mantine/core';
import { Button, TimeRangeSwitch } from '@rungalileo/jupiter-ds';
import { IconAlertCircle, IconChartBar, IconShield } from '@tabler/icons-react';
import {
IconAlertCircle,
IconChartBar,
IconListDetails,
IconShield,
} from '@tabler/icons-react';
import { useRouter } from 'next/router';
import React, { useMemo, useRef, useState } from 'react';

import { ErrorBoundary } from '@/components/error-boundary';
import type { Control } from '@/core/api/types';
import { SearchInput } from '@/core/components/search-input';
import { getAgentRoute } from '@/core/constants/agent-routes';
import {
type AgentDetailTab,
getAgentRoute,
} from '@/core/constants/agent-routes';
import { MODAL_NAMES } from '@/core/constants/modal-routes';
import { useAgent } from '@/core/hooks/query-hooks/use-agent';
import { useAgentControls } from '@/core/hooks/query-hooks/use-agent-controls';
Expand All @@ -32,13 +40,14 @@ import { useTimeRangePreference } from '@/core/hooks/use-time-range-preference';
import { ControlsTab } from './controls/controls-tab';
import { useControlsTableColumns } from './controls/table-columns';
import { useDeleteControlFlow } from './controls/use-delete-control-flow';
import { AgentEvents } from './events';
import { ControlStoreModal } from './modals/control-store';
import { EditControlContent } from './modals/edit-control/edit-control-content';
import { AgentsMonitor, TIME_RANGE_SEGMENTS } from './monitor';

type AgentDetailPageProps = {
agentId: string;
defaultTab?: 'controls' | 'monitor';
defaultTab?: AgentDetailTab;
};

const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => {
Expand Down Expand Up @@ -90,6 +99,7 @@ const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => {

const [activeTab, setActiveTab] = useState<string | null>(() => {
if (defaultTab === 'monitor') return 'monitor';
if (defaultTab === 'events') return 'events';
if (defaultTab === 'controls') return 'controls';
return 'controls';
});
Expand Down Expand Up @@ -275,18 +285,14 @@ const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => {
value={activeTab}
onChange={(value) => {
setActiveTab(value);
if (value === 'monitor') {
router.push(
getAgentRoute(agentId, { tab: 'monitor', query: router.query }),
undefined,
{
shallow: true,
}
);
} else if (value === 'controls') {
if (
value === 'controls' ||
value === 'monitor' ||
value === 'events'
) {
router.push(
getAgentRoute(agentId, {
tab: 'controls',
tab: value,
query: router.query,
}),
undefined,
Expand All @@ -312,6 +318,12 @@ const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => {
>
Monitor
</Tabs.Tab>
<Tabs.Tab
value="events"
leftSection={<IconListDetails size={16} />}
>
Events
</Tabs.Tab>
</Tabs.List>

<Group gap="md" pos="absolute" right={0} top="-8px">
Expand Down Expand Up @@ -366,6 +378,17 @@ const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => {
) : null}
</ErrorBoundary>
</Tabs.Panel>

<Tabs.Panel value="events" pt="lg">
<ErrorBoundary variant="page">
{agent?.agent.agent_name && activeTab === 'events' ? (
<AgentEvents
agentName={agent.agent.agent_name}
timeRangeValue={timeRangeValue}
/>
) : null}
</ErrorBoundary>
</Tabs.Panel>
</Tabs>
</Stack>

Expand Down
59 changes: 59 additions & 0 deletions ui/src/core/page-components/agent-detail/events/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { Box, Loader, Stack, Text } from '@mantine/core';
import type { TimeRangeValue } from '@rungalileo/jupiter-ds';
import { useMemo } from 'react';

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

import { RecentExecutions } from '../monitor/recent-executions';
import { mapTimeRangeTypeToTimeRange } from '../monitor/utils';

type AgentEventsProps = {
agentName: string;
timeRangeValue: TimeRangeValue;
};

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 AgentEvents({ agentName, timeRangeValue }: AgentEventsProps) {
const apiTimeRange = useMemo(
() => mapTimeRangeTypeToTimeRange(timeRangeValue.type),
[timeRangeValue.type]
);
const eventWindowMs =
TIME_RANGE_MILLISECONDS[apiTimeRange] ?? TIME_RANGE_MILLISECONDS['1h'];
const {
data: recentEvents,
error,
isLoading,
} = useAgentEvents(agentName, eventWindowMs, { refetchInterval: 2000 });

if (isLoading && !recentEvents) {
return (
<Box py="xl">
<Stack align="center" gap="md">
<Loader size="md" />
<Text c="dimmed">Loading recent executions...</Text>
</Stack>
</Box>
);
}

return (
<RecentExecutions
events={recentEvents?.events ?? []}
hasError={error !== null}
/>
);
}
Loading
Loading