fix(consult): record a tool call the tool never received
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user