From c460e59d24884181ed5137df8f9399a987b37667 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 14 Aug 2026 17:32:35 +0800 Subject: [PATCH] fix(consultation): expose bounded step budget --- frontend/src/app/api/consult/route.ts | 3 ++ frontend/src/lib/consultation-agent-events.ts | 8 ++++ frontend/src/lib/stream-agent-response.ts | 13 +++---- frontend/src/mastra/consultation-tools.ts | 39 ++++++++++++++++--- .../consultation-agentic-runtime.test.ts | 20 +++++++++- .../consultation-workflow-contract.test.ts | 9 +++++ 6 files changed, 78 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index d39fa8b0..1a88a82d 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -23,6 +23,7 @@ import { streamAgentResponse } from "@/lib/stream-agent-response"; import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events"; import { createConsultationAgentContext, + consultationStepBudgetReceipt, createConsultationRuntimeHooks, createConsultationRuntimeState, } from "@/mastra/consultation-tools"; @@ -534,6 +535,7 @@ export async function POST(request: Request) { runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded }, steps: state.steps, + stepBudget: consultationStepBudgetReceipt(state), workflow: workflowReceipt, techniqueTruth: "not-applicable", }); @@ -596,6 +598,7 @@ export async function POST(request: Request) { runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded }, steps: state.steps, + stepBudget: consultationStepBudgetReceipt(state), workflow: state.workflowReceipt ?? workflowReceipt, techniqueTruth: state.techniqueTruth ?? "unknown", }); diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index 9dc331c6..453d268d 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -24,6 +24,13 @@ const executionStepSchema = z.object({ durationMs: z.number().int().min(0).optional(), }).strict(); +const stepBudgetSchema = z.object({ + planned: z.number().int().min(1).max(32), + used: z.number().int().min(0).max(32), + remaining: z.number().int().min(0).max(32), + truncated: z.boolean(), +}).strict(); + export const agentExecutionReceiptSchema = z.object({ runId: z.string().min(1).max(120), runtime: z.literal("mastra-agentic"), @@ -33,6 +40,7 @@ export const agentExecutionReceiptSchema = z.object({ version: z.string().max(120).optional(), }).strict(), steps: z.array(executionStepSchema).max(32), + stepBudget: stepBudgetSchema.optional(), workflow: workflowReceiptSchema, techniqueTruth: z.string().max(120).optional(), }).strict(); diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 3206dbc8..5ec69ec1 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -1,4 +1,7 @@ -import type { ConsultationRuntimeState } from "../mastra/consultation-tools.ts"; +import { + appendConsultationRuntimeStep, + type ConsultationRuntimeState, +} from "../mastra/consultation-tools.ts"; import { agentExecutionReceiptSchema, consultationAgentPublicEventSchema, @@ -203,18 +206,14 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { try { const first = await consumeAttempt(controller, options.stream); if (!contractReady(options) && options.retry) { - if (options.state.steps.length < 32) { - options.state.steps.push({ sequence: options.state.steps.length + 1, kind: "validation", name: "runtime-contract-retry", status: "completed" }); - } + appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" }); send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" }); await consumeAttempt(controller, await options.retry()); } if (!contractReady(options)) throw new Error("runtime_contract_incomplete"); const ensuredFinalResponse = ensureFinalResponseText(fullOutput, contractReady(options)); if (ensuredFinalResponse) { - if (options.state.steps.length < 32) { - options.state.steps.push({ sequence: options.state.steps.length + 1, kind: "validation", name: "ensure-final-response", status: "completed" }); - } + appendConsultationRuntimeStep(options.state, { kind: "validation", name: "ensure-final-response", status: "completed" }); if (!firstOutput) { firstOutput = true; await options.onFirstOutput?.(); diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 3674c11d..9f84f6bb 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -15,6 +15,14 @@ const consultationToolInputSchema = z.object({ theme: z.enum(["career", "marriage", "wealth", "timing", "general"]), }).strict(); +const MAX_RECORDED_STEPS = 32; + +export type ConsultationStepBudget = { + planned: number; + reservedValidation: number; + total: number; +}; + export type ConsultationRuntimeStep = { sequence: number; kind: "skill" | "tool" | "validation"; @@ -33,9 +41,13 @@ export type ConsultationRuntimeState = { workflowReceipt?: WorkflowReceipt; techniqueTruth?: string; steps: ConsultationRuntimeStep[]; + stepBudget: ConsultationStepBudget; + stepsTruncated: boolean; }; -export function createConsultationRuntimeState(): ConsultationRuntimeState { +export function createConsultationRuntimeState(options: { plannedSteps?: number; reservedValidationSteps?: number } = {}): ConsultationRuntimeState { + const reservedValidation = Math.max(0, Math.min(MAX_RECORDED_STEPS - 1, Math.floor(options.reservedValidationSteps ?? 2))); + const planned = Math.max(1, Math.min(MAX_RECORDED_STEPS - reservedValidation, Math.floor(options.plannedSteps ?? 6))); return { jyotishSkillLoaded: false, skillReferenceReadCount: 0, @@ -43,12 +55,27 @@ export function createConsultationRuntimeState(): ConsultationRuntimeState { consultationToolCompleted: false, consultationToolCallCount: 0, steps: [], + stepBudget: { planned, reservedValidation, total: planned + reservedValidation }, + stepsTruncated: false, }; } -function appendStep(state: ConsultationRuntimeState, step: Omit) { - if (state.steps.length >= 32) return; +export function appendConsultationRuntimeStep(state: ConsultationRuntimeState, step: Omit) { + if (state.steps.length >= state.stepBudget.total) { + state.stepsTruncated = true; + return false; + } state.steps.push({ sequence: state.steps.length + 1, ...step }); + return true; +} + +export function consultationStepBudgetReceipt(state: ConsultationRuntimeState) { + return { + planned: state.stepBudget.total, + used: state.steps.length, + remaining: Math.max(0, state.stepBudget.total - state.steps.length), + truncated: state.stepsTruncated, + }; } export type ConsultationAgentContext = Readonly<{ @@ -104,7 +131,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { ctx.state.techniqueTruth = receipt.techniqueTruth; ctx.state.consultationToolCompleted = true; ctx.state.consultationToolDurationMs = Date.now() - startedAt; - appendStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs }); + appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs }); await context.writer?.custom({ type: "data-jyotish-activity", data: { phase: "evidence-validation", label: "正在核对可用证据" }, @@ -112,7 +139,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { return toAgentConsultationContext(guarded); } catch (error) { ctx.state.consultationToolDurationMs = Date.now() - startedAt; - appendStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: ctx.state.consultationToolDurationMs }); + appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: ctx.state.consultationToolDurationMs }); throw error; } })(); @@ -138,7 +165,7 @@ export function createConsultationRuntimeHooks(state: ConsultationRuntimeState) if (isJyotishLoad(toolName, input)) { const durationMs = Date.now() - (skillStartedAt || Date.now()); if (!error) state.jyotishSkillLoaded = true; - appendStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: error ? "failed" : "completed", durationMs }); + appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: error ? "failed" : "completed", durationMs }); } if ((toolName === "skill_read" || toolName === "read_file") && !error) state.skillReferenceReadCount += 1; }, diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index ec8567b7..d782fd9c 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createConsultationTools, createConsultationRuntimeState } from "../src/mastra/consultation-tools.ts"; +import { + appendConsultationRuntimeStep, + consultationStepBudgetReceipt, + createConsultationTools, + createConsultationRuntimeState, +} from "../src/mastra/consultation-tools.ts"; import { getJyotishAgent } from "../src/mastra/index.ts"; import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts"; import { collectAgentPublicEvents, ensureFinalResponseText, streamAgentResponse } from "../src/lib/stream-agent-response.ts"; @@ -106,12 +111,25 @@ test("incremental NDJSON parser handles arbitrary chunk boundaries", () => { }); + +test("uses a bounded dynamic step budget and reports truncation", () => { + const state = createConsultationRuntimeState({ plannedSteps: 1, reservedValidationSteps: 1 }); + assert.deepEqual(state.stepBudget, { planned: 1, reservedValidation: 1, total: 2 }); + assert.equal(appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed" }), true); + assert.equal(appendConsultationRuntimeStep(state, { kind: "validation", name: "ensure-final-response", status: "completed" }), true); + assert.equal(appendConsultationRuntimeStep(state, { kind: "tool", name: "unexpected-extra-step", status: "completed" }), false); + assert.equal(state.steps.length, 2); + assert.equal(state.stepsTruncated, true); + assert.deepEqual(consultationStepBudgetReceipt(state), { planned: 2, used: 2, remaining: 0, truncated: true }); +}); + function receipt(state: ReturnType) { return { runId: "run", runtime: "mastra-agentic" as const, skill: { name: "jyotish-vedic-astrology" as const, loaded: state.jyotishSkillLoaded }, steps: state.steps, + stepBudget: consultationStepBudgetReceipt(state), workflow: state.workflowReceipt ?? { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, }; } diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index f97b3267..93b25bc5 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -6,9 +6,18 @@ const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.met const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8"); const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8"); +const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8"); const workflow = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8"); const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging.yml", import.meta.url), "utf8"); + +test("uses one runtime step append entry and no scattered hard-coded step cap", () => { + assert.match(tools, /export function appendConsultationRuntimeStep/); + assert.doesNotMatch(tools, /steps\.length\s*>=\s*32/); + assert.doesNotMatch(stream, /state\.steps\.push/); + assert.doesNotMatch(stream, /steps\.length\s*<\s*32/); +}); + test("personal consultation lets the Agent invoke the server-bound workflow tool", () => { const agenticStart = route.indexOf("async function runAgenticConsultation"); const agenticBranch = route.slice(