fix(consultation): expose bounded step budget

This commit is contained in:
Jesse_Chen
2026-08-14 17:32:35 +08:00
parent c6d406e76d
commit c460e59d24
6 changed files with 78 additions and 14 deletions
+3
View File
@@ -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",
});
@@ -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();
+6 -7
View File
@@ -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?.();
+33 -6
View File
@@ -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<ConsultationRuntimeStep, "sequence">) {
if (state.steps.length >= 32) return;
export function appendConsultationRuntimeStep(state: ConsultationRuntimeState, step: Omit<ConsultationRuntimeStep, "sequence">) {
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;
},
@@ -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<typeof createConsultationRuntimeState>) {
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: [] },
};
}
@@ -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(