Keep skill truth boundaries, but stop asking the model to announce skipped domains or parrot tool JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
A staging run answered "which year?" with nothing but the model explaining that
its own tool calls had failed. Two causes, one upstream of the other.
The domains parameter accepted any string, so it stated no vocabulary at all
while the skill's methodology names strict-workflow checklists the tool has
never accepted. The model followed the skill, the schema took it, and the call
died in the registry two steps later. Enumerating the accepted values puts the
vocabulary where the model reads it. Aliases stay in the enum: they are a
promise the instructions make and a test pins.
Mastra reports an input-schema rejection by resolving with a validation
envelope rather than throwing, so enumerating alone would have turned those
rejections into tool.completed for calls that never ran. The stream now reads
that envelope for what it is.
Text written before the runtime contract is ready was held rather than dropped,
so a later successful call released the model's narration of its own failures as
the entire visible answer. Dropping it means a run that cannot answer says so.
Co-authored-by: Cursor <cursoragent@cursor.com>
Measured use of the three chips above the composer was negligible. They
were also not what they appeared to be: the server looked up a fixed
triplet by session theme and passed it as metadata that overrode
anything the model produced, so the same ten hardcoded sets served every
user regardless of question or chart. That is a plausible reason nobody
pressed them.
Both copies of the per-theme table are gone, reply metadata narrows to
the session title, and the two parse entry points collapse into one now
that they return the same shape. The write schema still tolerates a
suggestions field so a client on the previous bundle does not lose its
message mid-deploy, and stored answers containing the legacy hidden
block are still stripped rather than shown raw.
Co-authored-by: Cursor <cursoragent@cursor.com>
The home screen renders all ten domains from the consultation registry,
but the Agent only ever wrote three of them; the other seven were static
registry prompts dressed up as personalized starting points. The payload
now has to cover every domain in registry order, validated as a set
rather than per item, so a short or misordered answer is rejected whole
instead of silently leaving cards on static copy.
The greeting went the other way. Nothing has rendered it since the hero
note was removed, so it leaves the schema, the prompt, and the client
contract rather than costing tokens for text no one reads.
Ten questions take much longer to generate than three, so the route,
the server generation budget, and the client request deadline all grow
together, and the cache version bump forces existing payloads to be
regenerated once under the new shape.
Co-authored-by: Cursor <cursoragent@cursor.com>
The daily starlanguage card claimed to be personal but was a four-card
rotation picked by hashing the date and birth place, with the same pool
duplicated as a client fallback. It now collects chart, Vimshottari and
Narayana dasha, D9/D10 and today's transits, hands that evidence to a
dedicated Agent, and keeps the result per account per day in process. A
failed generation says so instead of printing generic advice.
The starter heading is drawn from a pool on each visit, and the
rectification card drops its fine print.
Co-authored-by: Cursor <cursoragent@cursor.com>
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 exhaustion
c8d9ec64 fixed. 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 by c8d9ec64 were 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>
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>
Every workflow fault except abort and timeout collapsed into the single
calculation_failed code, and the upstream message was discarded, so a failing
run left no evidence of whether the API rejected the call or returned a payload
that missed the response contract.
Classify failures into a closed vocabulary carried on ConsultationWorkflowError
and record it as the failureCode of the runtime step. The observability tool
call schema gains one controlled optional field; upstream error text stays out
of logs, as that contract requires. Forward request_id to the API so a run can
be aligned with its access log.
Build public receipts from an explicit allowlist. The internal failure code
must not reach the client contract, whose step schema is strict and would
otherwise reject a successful run.
Co-authored-by: Cursor <cursoragent@cursor.com>
The runtime contract gated on total consultation tool attempts, so a single
transient calculation failure raised the counter past one and made the run
unrecoverable even after a later attempt succeeded. The already-computed chart
was discarded and the request ended as runtime_contract_incomplete.
Count only successful workflow executions against the single-calculation
boundary. Failed attempts stay in the attempt counter for observability. The
request-scoped cache still retains the successful promise, so a request can
never run more than one billable calculation.
Also record the staging-first branch delivery convention in AGENTS.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the Direct Agentic textStream relay with a durable V9 agent runtime:
- agentic-rectification.ts: short boundary-only system prompt (no gate->scan
->score->diagnostics copy); pins skills/jyotish-birth-time-rectification;
per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12,
accept/confirm 6) with a hard ceiling and repeated-tool-call detection.
- rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/
revise-evidence, compare-candidates, read-diagnostics, offer-candidates,
accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs
only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and
accepted!=confirmed semantics; confirm requires gate + grounded consent.
- /api/rectification/agent: caseId/sessionId/requestId/action/message; exact
Case<->Session binding verified server-side; client history never overrides
the durable dossier; pending turn -> completed/failed/retryable; consumes
result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider
metadata/tool payloads/birth data/scores never forwarded); first-turn real
skill.started/skill.loaded gate with one controlled retry; billing bound to
rectification:case:{caseId}.
- New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql:
case dossier/compute, turn finalize, fingerprint-cached candidate persist,
case-scoped accept, consent-gated confirm, guarded transitions,
needs_rebaseline profile guard, run_phases receipt table, and the
rectification_runtime_version feature flag (v9 default, legacy read-only).
- Frontend: homepage/sidebar entry routing now uses the server Case open API
(openRectificationFromHomepage/openRectificationSession/startNewRectification)
with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven
by entry-summary; chat restores from persisted turns, candidate cards from
the Candidate Snapshot API, activity from real NDJSON + persisted receipts;
direct durable candidate-accept endpoint for the UI cards.