Skip to content

Commit ed0a7b3

Browse files
committed
fix(dashboard-agent): stop a retrieved run's own error counting as a failed tool call in evals
1 parent 643ea39 commit ed0a7b3

4 files changed

Lines changed: 68 additions & 8 deletions

File tree

internal-packages/dashboard-agent/src/eval-error-category.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import { extractToolActivity, unfoldEvalToolOutput } from "./dashboard-agent";
33
import { classifyEvalError, redactEvalToolValue } from "./eval-policy";
44
import { toolResultErrored } from "./eval-turn";
5+
import { curateRun } from "./tool-curation";
56

67
type Messages = Parameters<typeof extractToolActivity>[0];
78

@@ -135,6 +136,32 @@ describe("the derived error category", () => {
135136
expect(redacted).not.toHaveProperty("errorCategory");
136137
});
137138

139+
it("does not label a run that simply carries an error field", () => {
140+
// `curateRun` always emits the key, undefined when the run succeeded, and a run that
141+
// failed is still a tool call that worked.
142+
const succeeded = redactEvalToolValue(curateRun({ id: "run_1", status: "COMPLETED" })) as Record<
143+
string,
144+
unknown
145+
>;
146+
expect(succeeded).not.toHaveProperty("errorCategory");
147+
expect(toolResultErrored(succeeded)).toBe(false);
148+
149+
const failed = redactEvalToolValue(
150+
curateRun({
151+
id: "run_2",
152+
status: "FAILED",
153+
error: { name: "TimeoutError", message: "the task timed out" },
154+
})
155+
) as Record<string, unknown>;
156+
expect(failed).not.toHaveProperty("errorCategory");
157+
expect(toolResultErrored(failed)).toBe(false);
158+
});
159+
160+
it("still reports a returned tool failure after redaction", () => {
161+
const redacted = redactEvalToolValue({ error: "Couldn't get run run_1 (status 500)." });
162+
expect(toolResultErrored(redacted)).toBe(true);
163+
});
164+
138165
it("ignores a tool's own errorCategory field", () => {
139166
const redacted = redactEvalToolValue({
140167
isError: true,

internal-packages/dashboard-agent/src/eval-policy.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,34 @@ export const EVAL_ERROR_CATEGORIES = [
249249

250250
export type EvalErrorCategory = (typeof EVAL_ERROR_CATEGORIES)[number];
251251

252-
/** True when an output is a tool failure: unfolded (`isError`) or a plain `error` field. */
252+
/**
253+
* True when an output is the tool's own failure: unfolded (`isError`), or the `error`
254+
* message string every tool returns when it gives up.
255+
*
256+
* The key alone is not the signal. `curateRun` and `curateDeployment` always emit
257+
* `error`, holding the subject's own failure or nothing at all, and a run that failed is
258+
* still a tool call that worked.
259+
*/
253260
export function evalOutputErrored(output: unknown): boolean {
254261
if (output === null || typeof output !== "object" || Array.isArray(output)) return false;
255-
return (output as { isError?: unknown }).isError === true || "error" in output;
262+
const fields = output as { isError?: unknown; error?: unknown };
263+
return fields.isError === true || typeof fields.error === "string";
264+
}
265+
266+
/**
267+
* The same question asked of an output that has already been through redaction, where the
268+
* message is gone and {@link annotateEvalErrorCategory} has left the derived label behind.
269+
* A tool's own `errorCategory` cannot be mistaken for it: redaction turns any field we
270+
* don't write ourselves into a shape descriptor.
271+
*/
272+
export function redactedEvalOutputErrored(output: unknown): boolean {
273+
if (output === null || typeof output !== "object" || Array.isArray(output)) return false;
274+
const fields = output as { isError?: unknown; errorCategory?: unknown };
275+
if (fields.isError === true) return true;
276+
return (
277+
typeof fields.errorCategory === "string" &&
278+
(EVAL_ERROR_CATEGORIES as readonly string[]).includes(fields.errorCategory)
279+
);
256280
}
257281

258282
/**

internal-packages/dashboard-agent/src/eval-redaction.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
truncateEvalToolValue,
88
unfoldEvalToolOutput,
99
} from "./dashboard-agent";
10-
import { allowedEvalKeys, redactEvalToolValue } from "./eval-policy";
10+
import { allowedEvalKeys, evalOutputErrored, redactEvalToolValue } from "./eval-policy";
1111
import { toolResultErrored } from "./eval-turn";
1212

1313
type Messages = Parameters<typeof extractToolActivity>[0];
@@ -185,8 +185,17 @@ describe("an errored tool result", () => {
185185
expect(toolResultErrored(activity[0]!.output)).toBe(true);
186186
});
187187

188-
it("reads a plain error field as an error too", () => {
189-
expect(toolResultErrored({ error: { name: "TimeoutError" } })).toBe(true);
188+
it("reads a tool's own error message as an error too", () => {
189+
expect(evalOutputErrored({ error: "Couldn't get run run_1 (status 500)." })).toBe(true);
190+
// The subject's failure, not the call's: a run that failed was still retrieved.
191+
expect(evalOutputErrored({ error: { name: "TimeoutError" } })).toBe(false);
192+
expect(evalOutputErrored({ id: "run_1" })).toBe(false);
193+
expect(evalOutputErrored("boom")).toBe(false);
194+
});
195+
196+
it("reads the derived category once the message is gone", () => {
197+
expect(toolResultErrored({ errorCategory: "timeout", error: { redacted: "error" } })).toBe(true);
198+
expect(toolResultErrored({ error: { name: "TimeoutError" } })).toBe(false);
190199
expect(toolResultErrored({ id: "run_1" })).toBe(false);
191200
expect(toolResultErrored("boom")).toBe(false);
192201
});

internal-packages/dashboard-agent/src/eval-turn.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type DashboardAgentDbClient,
66
} from "@internal/dashboard-agent-db";
77
import { logger, task } from "@trigger.dev/sdk";
8-
import { EVAL_ERROR_CATEGORIES, evalOutputErrored } from "./eval-policy";
8+
import { EVAL_ERROR_CATEGORIES, redactedEvalOutputErrored } from "./eval-policy";
99
import { generateObject } from "ai";
1010
import { z } from "zod";
1111

@@ -131,9 +131,9 @@ const TurnEval = z.object({
131131
summary: z.string().describe("One line: what the user asked and how it went."),
132132
});
133133

134-
/** An errored result reaches here unfolded (`isError`) or as a plain `error` field. */
134+
/** A payload's outputs are redacted, so the derived category is what marks a failure. */
135135
export function toolResultErrored(output: unknown): boolean {
136-
return evalOutputErrored(output);
136+
return redactedEvalOutputErrored(output);
137137
}
138138

139139
const JUDGE_SYSTEM = [

0 commit comments

Comments
 (0)