fix(consultation): expose bounded step budget
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user