Files
Jyotisha/frontend/src/lib/agent-observability.ts
T
jesse-ux 137ed6f408 fix(consult): stop the consult-gate VedAstro wait and record step timing
Consult turns reuse a same-day official snapshot inside the rectification gate, or record deferred_in_consultation instead of starting another 4-second snapshot. Classification, step 0, step 1, and the time to the first answer character go into the existing usage metadata and a new admin usage column.

BUG-1231, BUG-1232
2026-10-05 02:33:36 +08:00

260 lines
9.3 KiB
TypeScript

import { z } from "zod";
import { consultationDomainSchema } from "./consultation-domain-registry.ts";
/**
* Closed, non-PII observability contract for Agent runs.
*
* There is intentionally no free-form metadata bag. Raw input/output text,
* prompts, messages, birth data, names, email addresses, secrets, API keys,
* provider payloads, stack traces and internal filesystem paths are not fields
* in this schema. Every object is strict, so unknown fields fail closed.
*/
const opaqueIdSchema = z.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/, "invalid opaque identifier");
const machineValueSchema = z.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9][A-Za-z0-9._:+-]*$/, "invalid machine value");
const machineCodeSchema = z.string()
.min(1)
.max(80)
.regex(/^[a-z][a-z0-9._-]*$/, "invalid machine code");
const durationMsSchema = z.number().int().min(0).max(7 * 24 * 60 * 60 * 1000);
const countSchema = z.number().int().min(0).max(1_000_000);
const tokenCountSchema = z.number().int().min(0).max(1_000_000_000);
export const agentObservabilityStepStatuses = [
"completed",
"failed",
"cancelled",
"skipped",
] as const;
export const billingSettlementResults = [
"completed",
"cancelled",
"failed",
"not_applicable",
] as const;
export type AgentBillingSettlementResult = (typeof billingSettlementResults)[number];
export type AgentSettlementResult = Exclude<AgentBillingSettlementResult, "not_applicable">;
/**
* Closed vocabulary for why the model stopped stepping. These are the provider
* finish reasons plus the two Mastra adds; anything else normalizes to
* `unknown` so an unrecognized provider string can never become a log field.
*
* `tool-calls` on a run that produced no answer means the step budget ran out
* while the model still wanted to call a tool, which is otherwise only
* inferable from the recorded step list.
*/
export const agentModelFinishReasons = [
"stop",
"length",
"content-filter",
"tool-calls",
"error",
"other",
"tripwire",
"retry",
"unknown",
] as const;
export type AgentModelFinishReason = (typeof agentModelFinishReasons)[number];
const knownModelFinishReasons = new Set<string>(agentModelFinishReasons);
export function toAgentModelFinishReason(value: unknown): AgentModelFinishReason {
return typeof value === "string" && knownModelFinishReasons.has(value)
? value as AgentModelFinishReason
: "unknown";
}
export type AgentSettlementTelemetryOutcome = Readonly<{
billingSettlementResult: AgentSettlementResult;
errorCode?: string;
}>;
export function settlementTelemetryOutcome(
settlementResult: AgentSettlementResult,
errorCode?: string,
): AgentSettlementTelemetryOutcome {
if (settlementResult === "failed") {
return {
billingSettlementResult: "failed",
errorCode: "settlement_failed",
};
}
return {
billingSettlementResult: settlementResult,
...(errorCode === undefined ? {} : { errorCode }),
};
}
const nullableDurationSchema = durationMsSchema.nullable();
const nullableTokenSchema = tokenCountSchema.nullable();
/** Step 0 decides the chart tool. Step 1 writes the answer. Tokens stay null when the provider omits them. */
export const consultationModelStepTimingSchema = z.object({
index: z.union([z.literal(0), z.literal(1)]),
durationMs: nullableDurationSchema,
reasoningTokens: nullableTokenSchema,
outputTokens: nullableTokenSchema,
inputTokens: nullableTokenSchema,
cachedInputTokens: nullableTokenSchema,
}).strict().readonly();
export const consultationAnswerTimingSchema = z.object({
reasoning_ms: nullableDurationSchema,
}).strict().readonly();
export const consultationClassificationTimingSchema = z.object({
durationMs: nullableDurationSchema,
}).strict().readonly();
export const agentObservabilityToolCallSchema = z.object({
name: machineCodeSchema,
durationMs: durationMsSchema,
status: z.enum(agentObservabilityStepStatuses),
// Closed classification of why the call failed. Never upstream error text.
failureCode: machineCodeSchema.optional(),
}).strict().readonly();
export const agentObservabilityContractPhaseSchema = z.object({
phase: machineCodeSchema,
durationMs: durationMsSchema.optional(),
status: z.enum(agentObservabilityStepStatuses),
}).strict().readonly();
/**
* The evidence-card record (TASK-consult-evidence-card-20260927 D7): exactly
* six fields, numbers / enums / card field names only. No question, answer,
* birth data, name, email, user or session id, and never the matched text.
*/
export const EVIDENCE_CARD_TELEMETRY_FIELDS = [
"domains",
"cardVersion",
"cardChars",
"cardTokenEstimate",
"citedFieldIds",
"feedback",
] as const;
export const evidenceCardTelemetrySchema = z.object({
domains: z.array(consultationDomainSchema).min(1).max(2),
cardVersion: z.literal("evidence-card-v2"),
cardChars: countSchema,
cardTokenEstimate: countSchema,
citedFieldIds: z.array(z.string().min(1).max(120).regex(/^[A-Za-z][A-Za-z0-9._-]*$/, "invalid card field id")).max(64),
// Thumbs are client state only today (not stored), so a turn is logged
// with `none`; storing thumbs per turn needs a table and is a separate task.
feedback: z.enum(["up", "down", "none"]),
}).strict().readonly();
export type EvidenceCardTelemetry = z.infer<typeof evidenceCardTelemetrySchema>;
export const agentObservabilityEventSchema = z.object({
runId: opaqueIdSchema.optional(),
requestId: opaqueIdSchema.optional(),
sessionId: opaqueIdSchema.optional(),
caseId: opaqueIdSchema.optional(),
agentVersion: machineValueSchema.optional(),
skillVersion: machineValueSchema.optional(),
modelVersion: machineValueSchema.optional(),
policyVersion: machineValueSchema.optional(),
toolCalls: z.array(agentObservabilityToolCallSchema).max(64).optional(),
contractPhases: z.array(agentObservabilityContractPhaseSchema).max(64).optional(),
classification: consultationClassificationTimingSchema.optional(),
modelSteps: z.array(consultationModelStepTimingSchema).max(2).optional(),
answer: consultationAnswerTimingSchema.optional(),
retryCount: z.number().int().min(0).max(100).optional(),
errorCode: machineCodeSchema.optional(),
// Why the model stopped, and how many model steps the run consumed across
// every attempt. Both are enum-like machine values, never provider text.
modelFinishReason: z.enum(agentModelFinishReasons).optional(),
modelStepCount: countSchema.optional(),
// How the stream that wrote the answer ended, whether Mastra's abort chunk
// arrived in it, and how many visible characters the answer had. Enum,
// boolean and count only: never answer text (BUG-1051).
composeFinishReason: z.enum([...agentModelFinishReasons, "missing"]).optional(),
composeAborted: z.boolean().optional(),
answerVisibleChars: countSchema.optional(),
// How many reference documents the model opened after loading the skill, and how many strict-method
// sections the server delivered with the evidence. Both are needed to read the other: zero reads is
// only a gap in the answer's method if nothing was delivered either.
skillReferenceReads: countSchema.optional(),
methodologySections: countSchema.optional(),
inputTokens: tokenCountSchema.optional(),
outputTokens: tokenCountSchema.optional(),
costMicrousd: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
evidenceCount: countSchema.optional(),
claimCount: countSchema.optional(),
sectionCount: countSchema.optional(),
themeCoverage: z.array(consultationDomainSchema).max(10).optional(),
reportJobDurationMs: durationMsSchema.optional(),
reportJobPeakMemoryBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
billingSettlementResult: z.enum(billingSettlementResults).optional(),
evidenceCard: evidenceCardTelemetrySchema.optional(),
}).strict().refine(
(event) => Boolean(event.runId || event.requestId || event.sessionId || event.caseId),
{ message: "at least one controlled identifier is required" },
).readonly();
export type AgentObservabilityEvent = z.infer<typeof agentObservabilityEventSchema>;
export type AgentObservabilitySink = (event: AgentObservabilityEvent) => void;
export type AgentObservabilityLogger = (event: unknown) => AgentObservabilityEvent;
function consoleSink(event: AgentObservabilityEvent): void {
console.info("[agent-observability]", JSON.stringify(event));
}
export function createAgentObservabilityLogger(
sink: AgentObservabilitySink = consoleSink,
): AgentObservabilityLogger {
return (event) => {
const parsed = agentObservabilityEventSchema.parse(event);
try {
sink(parsed);
} catch {
// Observability transport failure must not change the business response.
}
return parsed;
};
}
export const logAgentObservability = createAgentObservabilityLogger();
const knownErrorCodes = new Set([
"runtime_contract_incomplete",
"skill_binding_failed",
"empty_answer",
"answer_truncated",
"calculation_failed",
"timeout",
"cancelled",
"settlement_failed",
"thinking_tool_choice_unsupported",
"provider_error",
]);
export function toAgentObservabilityErrorCode(error: unknown): string {
if (error instanceof DOMException) {
if (error.name === "TimeoutError") return "timeout";
if (error.name === "AbortError") return "cancelled";
}
if (error instanceof Error && knownErrorCodes.has(error.message)) return error.message;
return "calculation_failed";
}