Add agent logs and stats endpoints

This commit is contained in:
Ajax Davis 2026-01-03 17:05:45 +10:00
parent ef95c37984
commit b064a3e481
4 changed files with 868 additions and 1 deletions

View file

@ -0,0 +1,172 @@
/**
* Agent Conversation Logs Endpoint
*
* GET: Retrieve recent conversation activity logs for an agent
*/
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 30;
type RouteContext = {
params: Promise<{ id: string }>;
};
interface LogEntry {
id: string;
timestamp: string;
type: 'message' | 'tool_call' | 'tool_result' | 'conversation_start';
conversationId: string;
conversationSlug: string;
role?: string;
content?: string;
toolName?: string;
toolCallId?: string;
inputTokens?: number;
outputTokens?: number;
}
/**
* GET /api/agents/[id]/logs
* Retrieve recent conversation activity logs (accepts id or uid)
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex query logic needed for flexible log filtering
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { id: idOrUid } = await context.params;
const { searchParams } = new URL(request.url);
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '100', 10), 500);
const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
const conversationSlug = searchParams.get('conversation') || undefined;
const type = searchParams.get('type') || undefined; // 'message' | 'tool_call' | 'all'
try {
// Fetch agent by id or uid
const agent = await prisma.agent.findFirst({
where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: { id: true, name: true, uid: true },
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Build role filter based on type
const roleFilter =
type === 'tool_call'
? { in: ['TOOL' as const] }
: type === 'message'
? { in: ['USER' as const, 'ASSISTANT' as const] }
: undefined;
// Fetch messages with conversation info
const messages = await prisma.message.findMany({
where: {
conversation: {
agentId: agent.id,
...(conversationSlug && { slug: conversationSlug }),
},
...(roleFilter && { role: roleFilter }),
},
orderBy: { createdAt: 'desc' },
take: limit + 1,
skip: offset,
include: {
conversation: {
select: {
id: true,
slug: true,
title: true,
},
},
},
});
const hasMore = messages.length > limit;
const data = hasMore ? messages.slice(0, limit) : messages;
// Transform to log entries
const logs: LogEntry[] = data.map((msg) => {
const base = {
id: msg.id,
timestamp: msg.createdAt.toISOString(),
conversationId: msg.conversation.id,
conversationSlug: msg.conversation.slug,
};
if (msg.role === 'TOOL') {
return {
...base,
type: 'tool_result' as const,
toolName: msg.toolName || undefined,
toolCallId: msg.toolCallId || undefined,
content: msg.content.slice(0, 500), // Truncate for logs view
};
}
return {
...base,
type: 'message' as const,
role: msg.role,
content: msg.content.slice(0, 500), // Truncate for logs view
inputTokens: msg.inputTokens || undefined,
outputTokens: msg.outputTokens || undefined,
};
});
// Get unique tool calls from assistant messages
const assistantMessages = data.filter((m) => m.role === 'ASSISTANT' && m.toolCalls);
const toolCallLogs: LogEntry[] = [];
for (const msg of assistantMessages) {
if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
for (const tc of msg.toolCalls as Array<{
id?: string;
toolCallId?: string;
name?: string;
toolName?: string;
}>) {
toolCallLogs.push({
id: `${msg.id}-${tc.id || tc.toolCallId}`,
timestamp: msg.createdAt.toISOString(),
type: 'tool_call',
conversationId: msg.conversation.id,
conversationSlug: msg.conversation.slug,
toolName: tc.name || tc.toolName,
toolCallId: tc.id || tc.toolCallId,
});
}
}
}
// Merge and sort all logs
const allLogs = [...logs, ...toolCallLogs].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return NextResponse.json({
success: true,
data: {
agent: {
id: agent.id,
uid: agent.uid,
name: agent.name,
},
logs: allLogs.slice(0, limit),
pagination: {
limit,
offset,
hasMore,
},
},
});
} catch (error) {
console.error('Failed to fetch agent logs:', error);
return NextResponse.json({ success: false, error: 'Failed to fetch logs' }, { status: 500 });
}
}

View file

@ -0,0 +1,269 @@
/**
* Agent Statistics Endpoint
*
* GET: Retrieve aggregated statistics for an agent's conversations
*/
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 30;
type RouteContext = {
params: Promise<{ id: string }>;
};
interface AgentStats {
agent: {
id: string;
uid: string;
name: string;
provider: string;
modelId: string;
createdAt: string;
};
conversations: {
total: number;
last24h: number;
last7d: number;
last30d: number;
};
messages: {
total: number;
byRole: {
user: number;
assistant: number;
tool: number;
system: number;
};
last24h: number;
last7d: number;
};
tokens: {
totalInput: number;
totalOutput: number;
total: number;
averagePerConversation: number;
last24h: {
input: number;
output: number;
};
last7d: {
input: number;
output: number;
};
};
tools: {
totalCalls: number;
uniqueToolsUsed: number;
topTools: Array<{
name: string;
count: number;
}>;
last24h: number;
};
activity: {
firstConversation: string | null;
lastConversation: string | null;
averageMessagesPerConversation: number;
longestConversation: {
id: string;
slug: string;
messageCount: number;
} | null;
};
}
/**
* GET /api/agents/[id]/stats
* Retrieve aggregated statistics for an agent (accepts id or uid)
*/
export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { id: idOrUid } = await context.params;
try {
// Fetch agent by id or uid
const agent = await prisma.agent.findFirst({
where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: {
id: true,
uid: true,
name: true,
provider: true,
modelId: true,
createdAt: true,
},
});
if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
const now = new Date();
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Conversation stats
const [totalConversations, convLast24h, convLast7d, convLast30d] = await Promise.all([
prisma.conversation.count({ where: { agentId: agent.id } }),
prisma.conversation.count({ where: { agentId: agent.id, createdAt: { gte: last24h } } }),
prisma.conversation.count({ where: { agentId: agent.id, createdAt: { gte: last7d } } }),
prisma.conversation.count({ where: { agentId: agent.id, createdAt: { gte: last30d } } }),
]);
// Message stats
const [totalMessages, userMessages, assistantMessages, toolMessages, msgLast24h, msgLast7d] =
await Promise.all([
prisma.message.count({ where: { conversation: { agentId: agent.id } } }),
prisma.message.count({ where: { conversation: { agentId: agent.id }, role: 'USER' } }),
prisma.message.count({ where: { conversation: { agentId: agent.id }, role: 'ASSISTANT' } }),
prisma.message.count({ where: { conversation: { agentId: agent.id }, role: 'TOOL' } }),
prisma.message.count({
where: { conversation: { agentId: agent.id }, createdAt: { gte: last24h } },
}),
prisma.message.count({
where: { conversation: { agentId: agent.id }, createdAt: { gte: last7d } },
}),
]);
// Token stats using aggregate
const [tokenTotals, tokensLast24h, tokensLast7d] = await Promise.all([
prisma.message.aggregate({
where: { conversation: { agentId: agent.id } },
_sum: { inputTokens: true, outputTokens: true },
}),
prisma.message.aggregate({
where: { conversation: { agentId: agent.id }, createdAt: { gte: last24h } },
_sum: { inputTokens: true, outputTokens: true },
}),
prisma.message.aggregate({
where: { conversation: { agentId: agent.id }, createdAt: { gte: last7d } },
_sum: { inputTokens: true, outputTokens: true },
}),
]);
const totalInput = tokenTotals._sum.inputTokens || 0;
const totalOutput = tokenTotals._sum.outputTokens || 0;
// Tool usage stats
const toolMessages2 = await prisma.message.findMany({
where: { conversation: { agentId: agent.id }, role: 'TOOL' },
select: { toolName: true, createdAt: true },
});
const toolCounts: Record<string, number> = {};
let toolLast24h = 0;
for (const tm of toolMessages2) {
if (tm.toolName) {
toolCounts[tm.toolName] = (toolCounts[tm.toolName] || 0) + 1;
}
if (tm.createdAt >= last24h) {
toolLast24h++;
}
}
const topTools = Object.entries(toolCounts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// Activity stats
const [firstConv, lastConv, longestConv] = await Promise.all([
prisma.conversation.findFirst({
where: { agentId: agent.id },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
prisma.conversation.findFirst({
where: { agentId: agent.id },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
}),
prisma.conversation.findFirst({
where: { agentId: agent.id },
orderBy: { messages: { _count: 'desc' } },
select: {
id: true,
slug: true,
_count: { select: { messages: true } },
},
}),
]);
const stats: AgentStats = {
agent: {
id: agent.id,
uid: agent.uid,
name: agent.name,
provider: agent.provider,
modelId: agent.modelId,
createdAt: agent.createdAt.toISOString(),
},
conversations: {
total: totalConversations,
last24h: convLast24h,
last7d: convLast7d,
last30d: convLast30d,
},
messages: {
total: totalMessages,
byRole: {
user: userMessages,
assistant: assistantMessages,
tool: toolMessages,
system: 0, // System messages are inline, not stored separately
},
last24h: msgLast24h,
last7d: msgLast7d,
},
tokens: {
totalInput,
totalOutput,
total: totalInput + totalOutput,
averagePerConversation:
totalConversations > 0 ? Math.round((totalInput + totalOutput) / totalConversations) : 0,
last24h: {
input: tokensLast24h._sum.inputTokens || 0,
output: tokensLast24h._sum.outputTokens || 0,
},
last7d: {
input: tokensLast7d._sum.inputTokens || 0,
output: tokensLast7d._sum.outputTokens || 0,
},
},
tools: {
totalCalls: toolMessages,
uniqueToolsUsed: Object.keys(toolCounts).length,
topTools,
last24h: toolLast24h,
},
activity: {
firstConversation: firstConv?.createdAt.toISOString() || null,
lastConversation: lastConv?.createdAt.toISOString() || null,
averageMessagesPerConversation:
totalConversations > 0 ? Math.round(totalMessages / totalConversations) : 0,
longestConversation: longestConv
? {
id: longestConv.id,
slug: longestConv.slug,
messageCount: longestConv._count.messages,
}
: null,
},
};
return NextResponse.json({
success: true,
data: stats,
});
} catch (error) {
console.error('Failed to fetch agent stats:', error);
return NextResponse.json({ success: false, error: 'Failed to fetch stats' }, { status: 500 });
}
}

View file

@ -135,9 +135,12 @@ export async function fetchAgentByIdOrUidWithTools(
/**
* Sanitize npm package name to valid tool name
* OpenAI limits tool names to 64 characters
*/
function sanitizeToolName(name: string): string {
return name.replace(/[@/]/g, '-').replace(/^-+/, '');
const sanitized = name.replace(/[@/]/g, '-').replace(/^-+/, '');
// Truncate to 64 chars (OpenAI limit)
return sanitized.slice(0, 64);
}
/**

View file

@ -0,0 +1,423 @@
/**
* Agent Endpoints Integration Tests
*
* Tests the agent conversation API endpoints including:
* - Sending messages and receiving streaming responses
* - Tool calls and execution
* - Conversation history persistence
* - Database state verification
*/
import { prisma } from '@tpmjs/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
const TEST_AGENT_UID = 'omega'; // The test agent UID
const TEST_CONVERSATION_SLUG = `test-conv-${Date.now()}`;
interface SSEEvent {
event: string;
data: unknown;
}
/**
* Parse SSE response into array of events
*/
function parseSSEResponse(text: string): SSEEvent[] {
const events: SSEEvent[] = [];
const lines = text.split('\n');
let currentEvent = '';
let currentData = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7);
} else if (line.startsWith('data: ')) {
currentData = line.slice(6);
if (currentEvent && currentData) {
try {
events.push({
event: currentEvent,
data: JSON.parse(currentData),
});
} catch {
events.push({
event: currentEvent,
data: currentData,
});
}
currentEvent = '';
currentData = '';
}
}
}
return events;
}
describe('Agent Endpoints', () => {
let testAgent: {
id: string;
uid: string;
name: string;
userId: string;
provider: string;
} | null = null;
let testConversationId: string | null = null;
beforeAll(async () => {
// Find the test agent in the database
testAgent = await prisma.agent.findUnique({
where: { uid: TEST_AGENT_UID },
select: {
id: true,
uid: true,
name: true,
userId: true,
provider: true,
},
});
if (!testAgent) {
console.warn(`⚠️ Test agent "${TEST_AGENT_UID}" not found. Skipping agent tests.`);
}
});
afterAll(async () => {
// Clean up test conversation
if (testConversationId) {
await prisma.conversation.deleteMany({
where: { id: testConversationId },
});
}
await prisma.$disconnect();
});
describe('POST /api/agents/[id]/conversation/[conversationId]', () => {
it('should create a new conversation and receive a response', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hello, what is 2 + 2?' }),
}
);
expect(response.ok).toBe(true);
expect(response.headers.get('content-type')).toContain('text/event-stream');
const text = await response.text();
const events = parseSSEResponse(text);
// Should have at least chunk events and a complete event
expect(events.length).toBeGreaterThan(0);
// Find the complete event
const completeEvent = events.find((e) => e.event === 'complete');
expect(completeEvent).toBeDefined();
if (completeEvent?.data && typeof completeEvent.data === 'object') {
const data = completeEvent.data as { conversationId?: string };
testConversationId = data.conversationId || null;
}
// Find text chunks
const textChunks = events.filter((e) => e.event === 'chunk');
expect(textChunks.length).toBeGreaterThan(0);
// Verify token usage was reported
const tokenEvent = events.find((e) => e.event === 'tokens');
expect(tokenEvent).toBeDefined();
if (tokenEvent?.data && typeof tokenEvent.data === 'object') {
const tokens = tokenEvent.data as { inputTokens?: number; outputTokens?: number };
expect(tokens.inputTokens).toBeGreaterThan(0);
expect(tokens.outputTokens).toBeGreaterThan(0);
}
});
it('should persist conversation in database', async () => {
if (!testAgent || !testConversationId) {
console.log('Skipping: No test conversation available');
return;
}
const conversation = await prisma.conversation.findUnique({
where: { id: testConversationId },
include: {
messages: {
orderBy: { createdAt: 'asc' },
},
},
});
expect(conversation).toBeDefined();
expect(conversation?.agentId).toBe(testAgent.id);
expect(conversation?.slug).toBe(TEST_CONVERSATION_SLUG);
// Should have at least a user message and an assistant message
expect(conversation?.messages.length).toBeGreaterThanOrEqual(2);
const userMessage = conversation?.messages.find((m) => m.role === 'USER');
expect(userMessage?.content).toContain('2 + 2');
const assistantMessage = conversation?.messages.find((m) => m.role === 'ASSISTANT');
expect(assistantMessage).toBeDefined();
expect(assistantMessage?.content.length).toBeGreaterThan(0);
});
it('should maintain conversation history on follow-up messages', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'What was my previous question?' }),
}
);
expect(response.ok).toBe(true);
const text = await response.text();
const events = parseSSEResponse(text);
// Collect all text from chunks
const textContent = events
.filter((e) => e.event === 'chunk')
.map((e) => {
const data = e.data as { text?: string };
return data.text || '';
})
.join('');
// The response should reference the previous question about 2+2
expect(textContent.toLowerCase()).toMatch(/2.*\+.*2|previous|earlier|asked/);
});
it('should execute tool calls when requested', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
// Use a unique conversation for tool test
const toolConvSlug = `tool-test-${Date.now()}`;
const response = await fetch(
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${toolConvSlug}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Please execute this Python code: print("Hello from test")',
}),
}
);
expect(response.ok).toBe(true);
const text = await response.text();
const events = parseSSEResponse(text);
// Should have tool call events if the agent has code execution tools
const toolCallEvents = events.filter((e) => e.event === 'tool_call');
const toolResultEvents = events.filter((e) => e.event === 'tool_result');
if (toolCallEvents.length > 0 && toolCallEvents[0]) {
console.log(`✓ Tool called: ${JSON.stringify(toolCallEvents[0].data)}`);
// Tool results may come in same stream or be processed async
// The important thing is the tool was called
console.log(` Tool results received: ${toolResultEvents.length}`);
// Verify tool call was saved to database
const conversation = await prisma.conversation.findFirst({
where: {
agentId: testAgent.id,
slug: toolConvSlug,
},
include: {
messages: true,
},
});
const toolMessage = conversation?.messages.find((m) => m.role === 'TOOL');
if (toolMessage) {
expect(toolMessage.toolName).toBeDefined();
expect(toolMessage.toolCallId).toBeDefined();
}
// Clean up
await prisma.conversation.deleteMany({
where: { agentId: testAgent.id, slug: toolConvSlug },
});
} else {
console.log('ℹ️ No tool calls made (agent may not have tool access)');
}
});
});
describe('GET /api/agents/[id]/conversation/[conversationId]', () => {
it('should retrieve conversation history', async () => {
if (!testAgent || !testConversationId) {
console.log('Skipping: No test conversation available');
return;
}
const response = await fetch(
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`,
{ method: 'GET' }
);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data.slug).toBe(TEST_CONVERSATION_SLUG);
expect(result.data.messages).toBeInstanceOf(Array);
expect(result.data.messages.length).toBeGreaterThanOrEqual(2);
});
it('should return 404 for non-existent conversation', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/non-existent-conv-12345`,
{ method: 'GET' }
);
expect(response.status).toBe(404);
});
});
describe('GET /api/agents/[id]/conversations', () => {
it('should list all conversations for an agent', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(`${BASE_URL}/api/agents/${testAgent.id}/conversations`, {
method: 'GET',
});
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(result.data).toBeInstanceOf(Array);
expect(result.pagination).toBeDefined();
// Our test conversation should be in the list
const testConv = result.data.find((c: { slug: string }) => c.slug === TEST_CONVERSATION_SLUG);
expect(testConv).toBeDefined();
expect(testConv.messageCount).toBeGreaterThanOrEqual(2);
});
it('should support pagination', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(
`${BASE_URL}/api/agents/${testAgent.id}/conversations?limit=1&offset=0`,
{ method: 'GET' }
);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.pagination.limit).toBe(1);
expect(result.data.length).toBeLessThanOrEqual(1);
});
});
describe('Database State Verification', () => {
it('should have correct message token counts', async () => {
if (!testConversationId) {
console.log('Skipping: No test conversation available');
return;
}
const messages = await prisma.message.findMany({
where: { conversationId: testConversationId },
orderBy: { createdAt: 'asc' },
});
// Assistant messages should have token counts
const assistantMessages = messages.filter((m) => m.role === 'ASSISTANT');
for (const msg of assistantMessages) {
expect(msg.inputTokens).toBeGreaterThan(0);
expect(msg.outputTokens).toBeGreaterThan(0);
}
});
it('should correctly track tool calls in messages', async () => {
if (!testConversationId) {
console.log('Skipping: No test conversation available');
return;
}
const toolMessages = await prisma.message.findMany({
where: {
conversationId: testConversationId,
role: 'TOOL',
},
});
for (const msg of toolMessages) {
expect(msg.toolCallId).toBeDefined();
expect(msg.toolName).toBeDefined();
expect(msg.toolResult).toBeDefined();
}
});
});
describe('Agent with lookup by ID or UID', () => {
it('should accept agent ID in URL', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(`${BASE_URL}/api/agents/${testAgent.id}/conversations`, {
method: 'GET',
});
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
});
it('should accept agent UID in URL', async () => {
if (!testAgent) {
console.log('Skipping: No test agent available');
return;
}
const response = await fetch(`${BASE_URL}/api/agents/${testAgent.uid}/conversations`, {
method: 'GET',
});
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
});
});
});