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
260 lines
9.3 KiB
TypeScript
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";
|
|
}
|