fix(consult): stop the model spending its step budget on invalid tool params
Independent Staging Quality Gate / validate (push) Successful in 13m24s
Independent Staging Quality Gate / publish (push) Successful in 10m13s

A staging consultation calculated the chart and then returned nothing but the
ensureFinalResponseText fallback. The model had made four calls to
run-jyotish-consultation, and two of them never reached a calculation: they set
both domains and theme, which canonicalDomainPlan rejects at execution. The
schema declared those two fields as independent optionals, the description never
mentioned the constraint, and the instructions actively told the model to use
theme for a single-domain retry. Each attempt therefore bought a rule the
contract never stated, and because the throw happens before the step-recording
try/catch, it left no trace in the receipt either.

Make the constraint unrepresentable instead of enforced. The model-facing schema
keeps only question and domains, so Mastra refuses the pair before the tool body
runs; the description states the single-array contract, and the instruction that
advertised theme is gone. canonicalDomainPlan still resolves the single-value
form for callers that build a plan without that schema, and is now exported so
that path has its own tests.

maxSteps and the abort timeout bound the same run but were hard-coded apart. One
calculation takes about 20s against a 110s budget, so time is the binding
constraint and three failed calculations exhaust it whatever the step count. The
budget only has to cover the longest useful shape, so it moves to 8 beside the
timeout with that reasoning recorded, and the recorded step list is sized to
match so an exhausted run cannot truncate its own evidence.

Step exhaustion was only ever inferable by counting events, since finishReason
was recorded nowhere and progressive-disclosure reads never reach the public
stream. Capture it as a closed enum plus a step count, normalizing anything
unrecognized, and log both as controlled fields. Neither may enter the client
receipt, whose step schema is strict and would fail a successful run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 15:14:36 +08:00
parent fb9eec54cc
commit c8d9ec64c3
10 changed files with 355 additions and 25 deletions
+18 -3
View File
@@ -36,6 +36,7 @@ import { streamAgentResponse } from "@/lib/stream-agent-response";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
import {
createConsultationAgentContext,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
createConsultationRuntimeHooks,
createConsultationRuntimeState,
@@ -62,6 +63,16 @@ 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;
const chatRequestMetadataSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
@@ -513,7 +524,10 @@ export async function POST(request: Request) {
name: string,
generalDailyContext: GeneralDailyPanchangaContext | null,
) {
const state = createConsultationRuntimeState();
// The recorded step list has to be able to hold everything the model loop
// can produce, otherwise a run that exhausts its steps also truncates the
// evidence of having done so.
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
const agentStartedAt = Date.now();
@@ -581,6 +595,7 @@ export async function POST(request: Request) {
},
],
retryCount: Math.max(0, usages.length - 1),
...consultationModelStepTelemetry(state),
...(errorCode === undefined ? {} : { errorCode }),
inputTokens,
outputTokens,
@@ -621,10 +636,10 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
];
const agentAbortSignal = AbortSignal.timeout(110_000);
const agentAbortSignal = AbortSignal.timeout(AGENT_TIMEOUT_MS);
const streamOptions = {
runId: requestId,
maxSteps: 6,
maxSteps: AGENT_MAX_STEPS,
abortSignal: agentAbortSignal,
hooks,
};