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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`@chatbot/*` package namespace and infrastructure/database identifiers are unchanged.

### Added
- Graph-agent playground executions now persist a per-node trace in
`AgentExecution.output.trace` (no schema change) and show graph nodes in the console
trace tab. Persisted and streamed trace data is redacted with irreversible placeholders
(`[CPF]`, `[CNPJ]`, `[CARTAO]`, `[EMAIL]`, `[TELEFONE]`, `[IP]`, `[TOKEN]`) and size-capped.
Only structured identifiers are detected; names, addresses and free text are not.
- `SUPPORT.md` describing where to get help
- `.github/CODEOWNERS` for automatic review routing
- README badges (build status, license)
Expand Down
14 changes: 10 additions & 4 deletions apps/web-ui/app/api/agents/[id]/playground/resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
PausedExecutionService,
LlmProviderService,
TenantConfigService,
NodeTraceCollector,
sanitizeExecutionEvent,
} from '@chatbot/shared';
import { createLLMProvider, type TenantLLMConfig } from '@chatbot/ai';
import { GraphExecutor, createNodeExecutors } from '@chatbot/agent-studio/server';
Expand Down Expand Up @@ -125,6 +127,8 @@ export async function POST(

let fullText = '';
let pausedAgain = false;
// Continue the per-node trace persisted before the pause (already redacted).
const traceCollector = NodeTraceCollector.fromOutput(agentExecution.output);

const stream = new ReadableStream({
async start(controller) {
Expand All @@ -140,13 +144,15 @@ export async function POST(
metadata,
{
onEvent: (event) => {
sendEvent(event.type, event);
traceCollector.handleEvent(event);
sendEvent(event.type, sanitizeExecutionEvent(event));
if (event.type === 'text_delta') {
fullText += (event as any).delta;
}
},
onPause: async (pauseInfo) => {
pausedAgain = true;
traceCollector.recordPause();
await pausedExecService.create({
tenantId,
agentId,
Expand All @@ -159,7 +165,7 @@ export async function POST(
});
await db.agentExecution.update({
where: { id: paused.executionId },
data: { status: 'paused' },
data: { status: 'paused', output: { ...traceCollector.snapshot() } },
});
logger.info({ executionId: paused.executionId, resumeToken: pauseInfo.resumeToken }, 'Playground resumed execution paused again');
},
Expand All @@ -171,7 +177,7 @@ export async function POST(
where: { id: paused.executionId },
data: {
status: 'completed',
output: { text: fullText },
output: { text: fullText, ...traceCollector.snapshot() },
completedAt: new Date(),
},
});
Expand All @@ -181,7 +187,7 @@ export async function POST(
sendEvent('execution_error', { error: errorMessage });
await db.agentExecution.update({
where: { id: paused.executionId },
data: { status: 'failed', output: { error: errorMessage }, completedAt: new Date() },
data: { status: 'failed', output: { error: errorMessage, ...traceCollector.snapshot() }, completedAt: new Date() },
});
logger.error({ executionId: paused.executionId, error: errorMessage }, 'Graph resume execution failed');
} finally {
Expand Down
14 changes: 10 additions & 4 deletions apps/web-ui/app/api/agents/[id]/playground/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
LlmProviderService,
PausedExecutionService,
playgroundRequestSchema,
NodeTraceCollector,
sanitizeExecutionEvent,
} from '@chatbot/shared';
import { S3Service } from '@chatbot/shared/server';
import { streamChat, createLLMProvider, type TenantLLMConfig, ContentResolver, type MessageAttachment, buildBuiltInTools } from '@chatbot/ai';
Expand Down Expand Up @@ -352,6 +354,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
let graphTtftMs: number | undefined;
let paused = false;
const pausedExecService = new PausedExecutionService(db);
// Per-node trace, redacted and size-capped before it is persisted or streamed.
const traceCollector = new NodeTraceCollector();

const stream = new ReadableStream({
async start(controller) {
Expand Down Expand Up @@ -379,13 +383,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (event.type === 'text_delta' && graphTtftMs === undefined) {
graphTtftMs = Date.now() - graphStartTime;
}
sendEvent(event.type, event);
traceCollector.handleEvent(event);
sendEvent(event.type, sanitizeExecutionEvent(event));
if (event.type === 'text_delta') {
fullText += (event as any).delta;
}
},
onPause: async (pauseInfo) => {
paused = true;
traceCollector.recordPause();
await pausedExecService.create({
tenantId,
agentId: id,
Expand All @@ -398,7 +404,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
});
await db.agentExecution.update({
where: { id: execution.id },
data: { status: 'paused' },
data: { status: 'paused', output: { ...traceCollector.snapshot() } },
});
sendEvent('execution_paused', {
resumeToken: pauseInfo.resumeToken,
Expand All @@ -422,7 +428,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
where: { id: execution.id },
data: {
status: 'completed',
output: { text: fullText },
output: { text: fullText, ...traceCollector.snapshot() },
completedAt: new Date(),
},
});
Expand All @@ -440,7 +446,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
});
await db.agentExecution.update({
where: { id: execution.id },
data: { status: 'failed', output: { error: errorMessage }, completedAt: new Date() },
data: { status: 'failed', output: { error: errorMessage, ...traceCollector.snapshot() }, completedAt: new Date() },
});
logger.error({ executionId: execution.id, error: errorMessage }, 'Graph execution failed');
} finally {
Expand Down
56 changes: 56 additions & 0 deletions apps/web-ui/components/agents/playground/console-trace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,35 @@ interface TraceItem {
output?: unknown;
}

/** Subset of a graph `NodeTraceEntry` as streamed (already redacted by the server). */
interface NodeTracePayload {
nodeId?: string;
nodeType?: string;
nodeLabel?: string;
status?: string;
durationMs?: number;
input?: unknown;
output?: unknown;
error?: string;
}

function applyNodeTrace(item: TraceItem, trace: NodeTracePayload): void {
item.name = trace.nodeLabel ?? item.name;
item.type = trace.nodeType ?? item.type;
item.status = trace.status === 'failed' ? 'failed' : 'completed';
item.durationMs = trace.durationMs;
item.input = trace.input;
item.output =
trace.error !== undefined
? { ...((trace.output as Record<string, unknown> | undefined) ?? {}), error: trace.error }
: trace.output;
}

function buildTraceItems(events: ConsoleEvent[]): TraceItem[] {
const items: TraceItem[] = [];
const toolCalls = new Map<string, TraceItem>();
// Latest running item per graph node id (a node may run more than once in loops).
const runningNodes = new Map<string, TraceItem>();

for (const event of events) {
if (event.type === 'tool_call') {
Expand Down Expand Up @@ -66,6 +92,36 @@ function buildTraceItems(events: ConsoleEvent[]): TraceItem[] {
execItem.status = event.data.error ? 'failed' : 'completed';
execItem.durationMs = event.data.durationMs as number;
}
} else if (event.type === 'node_start') {
const nodeId = String(event.data.nodeId ?? 'node');
const item: TraceItem = {
id: event.id,
type: String(event.data.nodeType ?? 'node'),
name: nodeId,
status: 'running',
};
runningNodes.set(nodeId, item);
items.push(item);
} else if (event.type === 'node_complete') {
const trace = (event.data.trace ?? {}) as NodeTracePayload;
const nodeId = String(event.data.nodeId ?? trace.nodeId ?? 'node');
let item = runningNodes.get(nodeId);
if (!item) {
item = { id: event.id, type: trace.nodeType ?? 'node', name: nodeId, status: 'running' };
items.push(item);
}
applyNodeTrace(item, trace);
runningNodes.delete(nodeId);
} else if (event.type === 'node_error') {
const nodeId = String(event.data.nodeId ?? 'node');
let item = runningNodes.get(nodeId);
if (!item) {
item = { id: event.id, type: 'node', name: nodeId, status: 'running' };
items.push(item);
}
item.status = 'failed';
item.output = { error: String(event.data.error ?? 'Node failed') };
runningNodes.delete(nodeId);
} else if (event.type === 'error') {
items.push({
id: event.id,
Expand Down
24 changes: 24 additions & 0 deletions libs/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ export { createLogger } from './logging/logger';
// Utils
export { withTimeout, TimeoutError } from './utils/with-timeout';

// PII redaction (irreversible placeholders, for persisted/displayed observability data)
export {
redactText,
redactDeep,
DEFAULT_PII_RULES,
SAUDE_RULES,
REDACTED_PLACEHOLDER,
TRUNCATED_PLACEHOLDER,
NodeTraceCollector,
sanitizeExecutionEvent,
sanitizeTraceEntry,
truncateValue,
} from './pii';
export type {
PiiRule,
PiiCategory,
CustomPiiPattern,
RedactOptions,
NodeTraceLike,
PersistedNodeTrace,
PersistedTraceOutput,
TraceEvent,
} from './pii';

// Database
export { getPrismaClient, disconnectPrisma } from './db/prisma-client';
export { getTenantClient, TENANT_SCOPED_MODELS } from './db/tenant-middleware';
Expand Down
27 changes: 27 additions & 0 deletions libs/shared/src/pii/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export {
redactText,
redactDeep,
isValidCpf,
isValidCnpj,
isValidCardNumber,
isValidIpv4,
isValidBrPhone,
DEFAULT_PII_RULES,
CREDENCIAIS_RULES,
IDENTIFICADORES_RULES,
FINANCEIRO_RULES,
REDE_E_CONTATO_RULES,
SAUDE_RULES,
REDACTED_PLACEHOLDER,
TRUNCATED_PLACEHOLDER,
} from './redact';
export type { PiiRule, PiiCategory, CustomPiiPattern, RedactOptions } from './redact';
export {
NodeTraceCollector,
sanitizeExecutionEvent,
sanitizeTraceEntry,
truncateValue,
MAX_FIELD_CHARS,
MAX_TRACE_ENTRIES,
} from './node-trace';
export type { NodeTraceLike, PersistedNodeTrace, PersistedTraceOutput, TraceEvent } from './node-trace';
Loading