fix(consult): give a multi-domain plan a top-level answer contract it can obey
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>
This commit is contained in:
@@ -35,6 +35,8 @@ import { streamTextResponse } from "@/lib/stream-text-response";
|
||||
import { streamAgentResponse } from "@/lib/stream-agent-response";
|
||||
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
|
||||
import {
|
||||
AGENT_MAX_STEPS,
|
||||
AGENT_TIMEOUT_MS,
|
||||
createConsultationAgentContext,
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
@@ -63,15 +65,10 @@ import { z } from "zod";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
// These two limits bound the same Agent run and must be changed together. One
|
||||
// chart calculation takes about 20s and maxDuration caps the request near 120s,
|
||||
// so wall-clock time, not steps, is the binding constraint: three failed
|
||||
// calculations exhaust the timeout no matter how many steps remain. The step
|
||||
// budget therefore only has to cover the longest useful shape—skill load, a
|
||||
// couple of progressive-disclosure reference reads, one calculation plus one
|
||||
// retry, and the answer turn—since a larger budget cannot buy more time.
|
||||
const AGENT_MAX_STEPS = 8;
|
||||
const AGENT_TIMEOUT_MS = 110_000;
|
||||
// The step budget, the wall-clock budget and the domain cap all bound this same
|
||||
// run, so they are declared as one group in @/mastra/consultation-tools with the
|
||||
// reasoning that ties them together. maxDuration above is the ceiling they must
|
||||
// stay under; raising it here without raising that is meaningless.
|
||||
|
||||
const chatRequestMetadataSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
@@ -518,6 +515,14 @@ export async function POST(request: Request) {
|
||||
await settleResult(action);
|
||||
}
|
||||
|
||||
// A run that fails before streamAgentResponse exists never reaches its error
|
||||
// path, so the closed observability event—the only place the tool failure
|
||||
// code, per-step durations and the model finish reason are recorded—would be
|
||||
// lost for exactly the runs that failed hardest. The agentic setup publishes
|
||||
// its settle-and-log entry point here so the request-level catch below can
|
||||
// still emit it.
|
||||
const agenticFailure: { report?: (error: unknown) => Promise<void> } = {};
|
||||
|
||||
async function runAgenticConsultation(
|
||||
consultationMode: ConsultationBirthTimeMode,
|
||||
history: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
@@ -617,6 +622,14 @@ export async function POST(request: Request) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
agenticFailure.report = async (error) => {
|
||||
try {
|
||||
await settleRun(cancel, toAgentObservabilityErrorCode(error));
|
||||
} catch {
|
||||
// Settlement already reported itself through logRun; the request-level
|
||||
// failure response must not depend on it succeeding.
|
||||
}
|
||||
};
|
||||
const baseMessages = [
|
||||
...history.map((message) => message.role === "user"
|
||||
? { role: "user" as const, content: message.text }
|
||||
@@ -917,7 +930,8 @@ export async function POST(request: Request) {
|
||||
onCancel: () => settle(cancel),
|
||||
});
|
||||
} catch (error) {
|
||||
await cancel();
|
||||
if (agenticFailure.report) await agenticFailure.report(error);
|
||||
else await cancel();
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
|
||||
|
||||
Reference in New Issue
Block a user