fix(consult): record a tool call the tool never received
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

staging 手测 run 951a841e 第一次工具调用发出 tool.failed 后重试成功,但回执里
steps 只有 skill 与那次成功的 tool,stepBudget.used 为 2——失败的那次完全不存在。
客户端看见失败过一次,回执说没有,两边都查不到为什么。

工具的 inputSchema 是 strict 的,模型参数不合法时 Mastra 在调用 execute 之前就拒了,
于是工具体内一切都没跑:调用不计数、失败步不记录、连 chart-calculation 活动事件都没
发出(这也是本次定位的证据——失败那次没有任何 activity,重试那次有)。工具无法记录
一次它从未收到的调用。叠加两处:safeToolError 把非超时非取消的错误全塌成
calculation_failed,而即使失败落进工具体的 catch,consultationWorkflowFailureCode
对非 ConsultationWorkflowError 返回 undefined、append 处又写成可选省略,于是最需要
解释的那条记录恰好是唯一没有原因的记录。

改为在流层补记:流是唯一能观测到全部工具失败的位置,无论失败在 schema 这侧还是
execute 那侧,且它持有 startedAt 因而能给出时长。tool-error 分支比对「流已见的错误数」
与「state 里已有的失败 tool 步数」,只在前者更多时补一条,工具仍记录它能看见的失败,
两者不重复计。另新增 consultationToolFailureCode,令每个错误都解析出一个码。

failureCode 刻意仍不进公开回执:白名单与「the public receipt never carries the
internal failure classification」是刻意约束,workflow_rate_limited 这类后端内情不该
上线到客户端。原因走可观测日志的 toolCalls[].failureCode。客户端能看到「有一步失败」,
运维能在日志里看到为什么。

另记入 BUG-268 的线上实测值:单领域 referenceReads 两次均为 2,多领域为 0——不是
从不读方法,而是最需要方法的多领域路径一份都没打开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 15:24:37 +08:00
parent 5caf47a413
commit b5bcbaed73
6 changed files with 175 additions and 26 deletions
+48 -3
View File
@@ -49,6 +49,7 @@ type EventOptions = {
requestId: string;
toolStatus: () => Status;
receipt: () => AgentExecutionReceipt;
state?: ConsultationRuntimeState;
};
function activity(value: unknown): ConsultationAgentPublicEvent | null {
@@ -84,11 +85,43 @@ function safeToolError(error: unknown) {
return "calculation_failed" as const;
}
/**
* Record a tool failure the tool itself could not record.
*
* A call rejected against the tool's input schema never enters `execute`, so
* nothing in the tool runs: the run reported a `tool.failed` event to the client
* while its receipt showed no failed step and its budget counted no call. The
* stream is the only place that observes every failure, whichever side of the
* schema it came from.
*
* The tool still records the failures it can see, with the duration and cause it
* alone knows, so this only fills the gap: it appends when the stream has seen
* more tool errors than the state has failed tool steps. The error object here
* is provider-shaped and cannot be classified further, but the call not reaching
* `execute` is itself the diagnosis.
*/
function recordUnrecordedToolFailure(
state: ConsultationRuntimeState,
toolErrorsSeen: number,
durationMs: number,
) {
const recorded = state.steps.filter((step) => step.kind === "tool" && step.status === "failed").length;
if (recorded >= toolErrorsSeen) return;
appendConsultationRuntimeStep(state, {
kind: "tool",
name: "run-jyotish-consultation",
status: "failed",
durationMs,
failureCode: "tool_call_rejected",
});
}
function mapChunk(
chunk: Chunk,
options: EventOptions,
startedAt: Map<string, number>,
jyotishSkillCallIds: Set<string>,
toolErrors: { seen: number },
): ConsultationAgentPublicEvent[] {
const payload = chunk.payload ?? {};
if (chunk.type === "data-jyotish-activity") {
@@ -123,9 +156,18 @@ function mapChunk(
if (chunk.type === "tool-error") {
const toolName = payload.toolName;
if (toolName === "run-jyotish-consultation") {
const callId = typeof payload.toolCallId === "string" ? payload.toolCallId : "tool";
toolErrors.seen += 1;
if (options.state) {
recordUnrecordedToolFailure(
options.state,
toolErrors.seen,
Math.max(0, Date.now() - (startedAt.get(callId) ?? Date.now())),
);
}
return [{
type: "tool.failed",
callId: typeof payload.toolCallId === "string" ? payload.toolCallId : "tool",
callId,
tool: "run-jyotish-consultation",
code: safeToolError(payload.error),
}];
@@ -138,8 +180,9 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
const events: ConsultationAgentPublicEvent[] = [{ type: "run.started", runId: options.runId, requestId: options.requestId }];
const startedAt = new Map<string, number>();
const jyotishSkillCallIds = new Set<string>();
const toolErrors = { seen: 0 };
for await (const chunk of stream instanceof ReadableStream || Symbol.asyncIterator in stream ? readChunks(stream as ChunkStream) : stream) {
events.push(...mapChunk(chunk, options, startedAt, jyotishSkillCallIds));
events.push(...mapChunk(chunk, options, startedAt, jyotishSkillCallIds, toolErrors));
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
events.push({ type: "answer.delta", text: chunk.payload.text });
}
@@ -183,6 +226,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
let fullOutput = "";
const startedAt = new Map<string, number>();
const jyotishSkillCallIds = new Set<string>();
// A retry reuses these counters so a failure in either attempt is recorded once.
const toolErrors = { seen: 0 };
const send = (controller: ReadableStreamDefaultController<Uint8Array> | undefined, event: ConsultationAgentPublicEvent) => {
if (!firstActivity && (event.type === "skill.started" || event.type === "tool.started" || event.type === "activity")) {
firstActivity = true;
@@ -217,7 +262,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
// total accumulates while the finish reason describes the latest attempt.
const stepCountBeforeAttempt = options.state.modelStepCount;
for await (const chunk of readChunks(stream)) {
for (const event of mapChunk(chunk, options, startedAt, jyotishSkillCallIds)) send(controller, event);
for (const event of mapChunk(chunk, options, startedAt, jyotishSkillCallIds, toolErrors)) send(controller, event);
if (chunk.type === "step-finish") options.state.modelStepCount += 1;
if (chunk.type === "finish") {
const finish = finishTelemetry(chunk);
+19 -2
View File
@@ -143,6 +143,24 @@ export function publicConsultationRuntimeSteps(state: ConsultationRuntimeState)
}));
}
/**
* Why a consultation tool call failed, as a closed machine code. The workflow
* classifier only recognises its own transport faults and returns undefined for
* everything else, so the failures raised by this module — a rejected domain
* plan above all — reached the observability log with no code at all. The one
* record that exists to explain a failure must never be the one without a
* reason, so every error now resolves to a code.
*/
export function consultationToolFailureCode(error: unknown): string {
const workflowCode = consultationWorkflowFailureCode(error);
if (workflowCode) return workflowCode;
const message = error instanceof Error ? error.message : "";
if (message === "invalid_consultation_domain_plan" || message === "unsupported_consultation_domain") {
return "invalid_domain_plan";
}
return "unexpected_error";
}
export function appendConsultationRuntimeStep(state: ConsultationRuntimeState, step: Omit<ConsultationRuntimeStep, "sequence">) {
if (state.steps.length >= state.stepBudget.total) {
state.stepsTruncated = true;
@@ -522,13 +540,12 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
return toModelDomainPlanContext(executions, omittedDomains);
} catch (error) {
ctx.state.consultationToolDurationMs = now() - startedAt;
const failureCode = consultationWorkflowFailureCode(error);
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
name: "run-jyotish-consultation",
status: "failed",
durationMs: ctx.state.consultationToolDurationMs,
...(failureCode ? { failureCode } : {}),
failureCode: consultationToolFailureCode(error),
});
throw error;
}