1955ba8cef
A staging consultation submitted a three-domain plan, calculated all three successfully in 62.9s, and returned nothing but the ensureFinalResponseText fallback. The step budget was barely touched, so this is not the exhaustionc8d9ec64fixed. toModelDomainPlanContext returns two different shapes: a single domain flattens the evidence packet to the top level, several domains return only success, domains and consultations. Every hard output rule in jyotishInstructions is written against those top-level paths — evidence_contract.answer_policy, hard_blockers, rectification.boundary, status. None of them resolve in the multi-domain shape, and under a policy that forbids stating anything the server evidence does not support, silence is what the instructions ask for. Merge the packets into one top-level contract shaped exactly like the single domain one. Merging may only restrict: status takes the worst of ready > degraded > blocked, hard_blockers and missing_route_layers take the union, permission booleans need every domain to agree while limitation booleans need only one, and a field the domains genuinely disagree on is reported as unresolved rather than decided. available_layers is the one permission-shaped union, because a layer really was computed for some domain and denying it would deny real evidence. The natal projection is the same chart for every domain, so it is hoisted to one copy when the domains agree and left per-domain when they do not. The domain cap was six, advertised as six, and could never be paid for. Domains run sequentially at ~21s each against a cumulative 110s abort signal, so six is ~126s and four leaves nothing to write the answer with. Concurrency is not available: the Python API is a single GIL-bound ThreadingHTTPServer whose async work already sits behind a two-worker bounded queue that answers 503 when full. Derive the cap from the clock instead of choosing it — 110s minus a 45s answer reserve, divided by 21s, is three — and let the model-facing schema carry that bound so an unpayable plan is unrepresentable. A caller that builds a plan without that schema is truncated rather than refused, the loop stops early when the measured pace says the next domain will not fit, and either way the dropped domains are disclosed through omitted_domains and the receipt while status degrades, so a partial answer cannot be presented as complete. run.failed carried a code and nothing else, so the step durations, step budget and workflow route recorded byc8d9ec64were unavailable exactly when a run needed explaining. Send the same allowlisted receipt run.completed sends, built through publicConsultationRuntimeSteps so the internal failure code and model loop diagnostics stay server-side, and never let building it replace the failure event with a silent close. An agentic run that fails before streamAgentResponse exists never reached the settle-and-log path either, so the request-level catch now goes through the same entry point. Refs BUG-256, BUG-257, BUG-258. Co-authored-by: Cursor <cursoragent@cursor.com>
245 lines
7.5 KiB
TypeScript
245 lines
7.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { ZodError } from "zod";
|
|
|
|
import {
|
|
agentModelFinishReasons,
|
|
agentObservabilityEventSchema,
|
|
createAgentObservabilityLogger,
|
|
settlementTelemetryOutcome,
|
|
toAgentModelFinishReason,
|
|
toAgentObservabilityErrorCode,
|
|
} from "../src/lib/agent-observability.ts";
|
|
|
|
const baseEvent = {
|
|
runId: "8d14b4f7-9d0b-4b4e-a48d-365e5d2a1d4b",
|
|
requestId: "f2ff3466-913b-4a79-8527-b90f28a6c95d",
|
|
sessionId: "2cf930ab-d59d-4f36-b92f-aa4ea035305a",
|
|
caseId: "case-42",
|
|
agentVersion: "consultation-agentic-v1",
|
|
skillVersion: "6.9.14",
|
|
modelVersion: "3",
|
|
policyVersion: "consultation-runtime-contract-v1",
|
|
toolCalls: [
|
|
{ name: "run-jyotish-consultation", durationMs: 321, status: "completed" },
|
|
],
|
|
contractPhases: [
|
|
{ phase: "skill.loaded", durationMs: 12, status: "completed" },
|
|
{ phase: "answer.first_output", durationMs: 456, status: "completed" },
|
|
{ phase: "billing.settled", status: "completed" },
|
|
],
|
|
retryCount: 1,
|
|
errorCode: "runtime_contract_incomplete",
|
|
modelFinishReason: "tool-calls",
|
|
modelStepCount: 8,
|
|
inputTokens: 1200,
|
|
outputTokens: 345,
|
|
evidenceCount: 4,
|
|
claimCount: 7,
|
|
sectionCount: 5,
|
|
themeCoverage: ["career", "wealth"],
|
|
reportJobDurationMs: 2500,
|
|
reportJobPeakMemoryBytes: 64 * 1024 * 1024,
|
|
billingSettlementResult: "completed",
|
|
} as const;
|
|
|
|
test("strict schema accepts only bounded non-PII Agent run metrics", () => {
|
|
assert.deepEqual(agentObservabilityEventSchema.parse(baseEvent), baseEvent);
|
|
});
|
|
|
|
test("logger validates before emitting and returns the closed payload", () => {
|
|
const emitted: unknown[] = [];
|
|
const logger = createAgentObservabilityLogger((event) => emitted.push(event));
|
|
|
|
const parsed = logger(baseEvent);
|
|
|
|
assert.deepEqual(parsed, baseEvent);
|
|
assert.deepEqual(emitted, [baseEvent]);
|
|
});
|
|
|
|
test("unknown or PII-bearing fields fail closed instead of being stripped", () => {
|
|
const logger = createAgentObservabilityLogger(() => {
|
|
assert.fail("invalid observability payload must not reach the sink");
|
|
});
|
|
const forbiddenFields = [
|
|
"text",
|
|
"content",
|
|
"input",
|
|
"output",
|
|
"question",
|
|
"answer",
|
|
"prompt",
|
|
"messages",
|
|
"birthDate",
|
|
"birthTime",
|
|
"birthPlace",
|
|
"birthData",
|
|
"name",
|
|
"email",
|
|
"secret",
|
|
"apiKey",
|
|
"providerPayload",
|
|
"stack",
|
|
"absolutePath",
|
|
"metadata",
|
|
];
|
|
|
|
for (const field of forbiddenFields) {
|
|
assert.throws(
|
|
() => logger({ runId: baseEvent.runId, [field]: "sensitive-value" }),
|
|
ZodError,
|
|
`${field} must fail closed`,
|
|
);
|
|
}
|
|
|
|
assert.throws(
|
|
() => logger({
|
|
runId: baseEvent.runId,
|
|
toolCalls: [{
|
|
name: "run-jyotish-consultation",
|
|
durationMs: 1,
|
|
status: "completed",
|
|
prompt: "raw prompt",
|
|
}],
|
|
}),
|
|
ZodError,
|
|
);
|
|
});
|
|
|
|
test("free-form prose, email-like values and internal paths are rejected", () => {
|
|
assert.throws(
|
|
() => agentObservabilityEventSchema.parse({
|
|
runId: baseEvent.runId,
|
|
errorCode: "provider returned user@example.com",
|
|
}),
|
|
ZodError,
|
|
);
|
|
assert.throws(
|
|
() => agentObservabilityEventSchema.parse({
|
|
runId: baseEvent.runId,
|
|
modelVersion: "/Users/jesse/private/model.json",
|
|
}),
|
|
ZodError,
|
|
);
|
|
assert.throws(
|
|
() => agentObservabilityEventSchema.parse({ inputTokens: 1 }),
|
|
ZodError,
|
|
);
|
|
});
|
|
|
|
test("sink failures are isolated after strict validation", () => {
|
|
const logger = createAgentObservabilityLogger(() => {
|
|
throw new Error("transport unavailable");
|
|
});
|
|
|
|
assert.deepEqual(logger({ runId: baseEvent.runId, inputTokens: 1 }), {
|
|
runId: baseEvent.runId,
|
|
inputTokens: 1,
|
|
});
|
|
});
|
|
|
|
test("error normalization never records arbitrary exception messages", () => {
|
|
assert.equal(
|
|
toAgentObservabilityErrorCode(new Error("runtime_contract_incomplete")),
|
|
"runtime_contract_incomplete",
|
|
);
|
|
assert.equal(
|
|
toAgentObservabilityErrorCode(new Error("/opt/internal/users/alice.json")),
|
|
"calculation_failed",
|
|
);
|
|
assert.equal(
|
|
toAgentObservabilityErrorCode(new Error("provider said user@example.com")),
|
|
"calculation_failed",
|
|
);
|
|
});
|
|
|
|
test("the model finish reason is a closed vocabulary, never a provider string", () => {
|
|
for (const reason of agentModelFinishReasons) {
|
|
assert.equal(toAgentModelFinishReason(reason), reason);
|
|
}
|
|
assert.equal(toAgentModelFinishReason("max-steps-exceeded"), "unknown");
|
|
assert.equal(toAgentModelFinishReason("provider said user@example.com"), "unknown");
|
|
assert.equal(toAgentModelFinishReason(undefined), "unknown");
|
|
assert.equal(toAgentModelFinishReason({ reason: "stop" }), "unknown");
|
|
assert.throws(
|
|
() => agentObservabilityEventSchema.parse({
|
|
runId: baseEvent.runId,
|
|
modelFinishReason: "stopped because the provider said so",
|
|
}),
|
|
ZodError,
|
|
);
|
|
});
|
|
|
|
test("step exhaustion is recorded rather than inferred from the step list", () => {
|
|
const emitted: unknown[] = [];
|
|
const logger = createAgentObservabilityLogger((event) => emitted.push(event));
|
|
|
|
logger({
|
|
runId: baseEvent.runId,
|
|
modelFinishReason: toAgentModelFinishReason("tool-calls"),
|
|
modelStepCount: 8,
|
|
});
|
|
|
|
assert.deepEqual(emitted, [{
|
|
runId: baseEvent.runId,
|
|
modelFinishReason: "tool-calls",
|
|
modelStepCount: 8,
|
|
}]);
|
|
});
|
|
|
|
test("settlement telemetry reports successful cancellation as cancelled", () => {
|
|
assert.deepEqual(
|
|
settlementTelemetryOutcome("cancelled", "cancelled"),
|
|
{
|
|
billingSettlementResult: "cancelled",
|
|
errorCode: "cancelled",
|
|
},
|
|
);
|
|
});
|
|
|
|
test("settlement telemetry fail-closes a final cancellation failure", () => {
|
|
const rawFailure = "provider rejected user@example.com at /Users/alice/private.json";
|
|
const outcome = settlementTelemetryOutcome("failed", rawFailure);
|
|
const parsed = agentObservabilityEventSchema.parse({
|
|
runId: baseEvent.runId,
|
|
...outcome,
|
|
});
|
|
|
|
assert.deepEqual(outcome, {
|
|
billingSettlementResult: "failed",
|
|
errorCode: "settlement_failed",
|
|
});
|
|
assert.deepEqual(parsed, {
|
|
runId: baseEvent.runId,
|
|
billingSettlementResult: "failed",
|
|
errorCode: "settlement_failed",
|
|
});
|
|
assert.doesNotMatch(JSON.stringify(parsed), /provider|example\.com|\/Users\//);
|
|
});
|
|
|
|
test("ordinary consultation logRun uses the strict logger and aggregated usage", () => {
|
|
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
|
|
|
assert.match(route, /logAgentObservability\(\{/);
|
|
assert.match(route, /inputTokens/);
|
|
assert.match(route, /outputTokens/);
|
|
assert.match(route, /billingSettlementResult/);
|
|
assert.match(route, /\.\.\.consultationModelStepTelemetry\(state\),/);
|
|
assert.doesNotMatch(route, /\[consult-agentic\]/);
|
|
});
|
|
|
|
test("an agentic run that fails before streaming still emits the observability event", () => {
|
|
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
|
|
|
// streamAgentResponse's onError is the only settle-and-log path for a run
|
|
// that got far enough to stream. Everything earlier—plan assembly, chart
|
|
// truth, tool construction—throws into the request-level catch, which must
|
|
// reach the same entry point instead of a bare cancel().
|
|
assert.match(route, /agenticFailure\.report = async \(error\) => \{/);
|
|
assert.match(route, /settleRun\(cancel, toAgentObservabilityErrorCode\(error\)\)/);
|
|
const requestCatch = route.slice(route.lastIndexOf("} catch (error) {"));
|
|
assert.match(requestCatch, /if \(agenticFailure\.report\) await agenticFailure\.report\(error\);\s*\n\s*else await cancel\(\);/);
|
|
});
|