Skip to content

Commit 72ca10c

Browse files
authored
Add tool output consumption rate trajectory grader (#57252)
1 parent 71926ba commit 72ca10c

3 files changed

Lines changed: 203 additions & 1 deletion

File tree

.github/workflows/shared/graders/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ to `Implemented` in the same PR that adds `shared/graders/<id>.md`.
4545
| 2 | `skill-constraint-coverage` | Precompiled constraints | Implemented |
4646
| 3 | `exploration-error` | State/task model | Implemented |
4747
| 4 | `exploitation-error` | State/task model | Implemented |
48-
| 12 | `tool-output-consumption-rate` | Provenance/reference IDs | Not started |
48+
| 12 | `tool-output-consumption-rate` | Provenance/reference IDs | Implemented |
4949
| 13 | `end-to-end-lineage-completeness` | Provenance graph | Not started |
5050
| 14 | `action-provenance-coverage` | Provenance graph | Not started |
5151
| 15 | `premature-termination-gap` | Completion predicates | Not started |
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
graders:
3+
# Measures the fraction of tool-originated observations that were actually
4+
# referenced by a later action, per observations[].consumedByActionIds in
5+
# the canonical Trajectory IR. A low rate indicates the agent frequently
6+
# called tools and then ignored their outputs (wasted or speculative tool
7+
# calls); a high rate indicates most tool outputs fed into subsequent
8+
# decisions. Higher is better.
9+
tool-output-consumption-rate:
10+
name: Tool Output Consumption Rate
11+
unit: ratio
12+
direction: higher_is_better
13+
min: 0.0
14+
max: 1.0
15+
script: |
16+
const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
17+
const candidates = [
18+
trace,
19+
trace.trajectoryIR,
20+
trace.trajectoryIr,
21+
trace.ir,
22+
isRecord(trace.agentOutput) ? trace.agentOutput.trajectoryIR : null,
23+
isRecord(trace.agentOutput) ? trace.agentOutput.trajectoryIr : null,
24+
isRecord(trace.agentOutput) ? trace.agentOutput.trajectory : null,
25+
isRecord(trace.agentOutput) ? trace.agentOutput : null,
26+
].filter(isRecord);
27+
28+
const candidate =
29+
candidates.find(value =>
30+
Array.isArray(value.observations) &&
31+
value.observations.some(isRecord) &&
32+
Array.isArray(value.toolCalls) &&
33+
value.toolCalls.some(isRecord)
34+
) ??
35+
// Preserve observations-only traces so missing toolCalls is reported
36+
// as no tool-originated observations rather than no observations.
37+
candidates.find(value => Array.isArray(value.observations) && value.observations.some(isRecord)) ??
38+
candidates.find(value => Array.isArray(value.observations)) ??
39+
null;
40+
const observations = (candidate && Array.isArray(candidate.observations) ? candidate.observations : []).filter(isRecord);
41+
42+
if (observations.length === 0) {
43+
return { value: null, unit: "ratio", passed: null, message: "not applicable: no observations in the trace" };
44+
}
45+
46+
const toolCalls = (candidate && Array.isArray(candidate.toolCalls) ? candidate.toolCalls : []).filter(isRecord);
47+
const toolCallIds = new Set(
48+
toolCalls
49+
.map(toolCall => toolCall.id)
50+
.filter(id => typeof id === "string" && id !== "")
51+
);
52+
const toolObservations = observations.filter(
53+
observation =>
54+
typeof observation.sourceToolCallId === "string" &&
55+
observation.sourceToolCallId !== "" &&
56+
toolCallIds.has(observation.sourceToolCallId)
57+
);
58+
59+
if (toolObservations.length === 0) {
60+
return { value: null, unit: "ratio", passed: null, message: "not applicable: no tool-originated observations in the trace" };
61+
}
62+
63+
const isConsumed = observation =>
64+
Array.isArray(observation.consumedByActionIds) &&
65+
observation.consumedByActionIds.some(actionId => typeof actionId === "string" && actionId !== "");
66+
67+
const consumed = toolObservations.filter(isConsumed);
68+
const unconsumedIds = toolObservations
69+
.filter(observation => !isConsumed(observation))
70+
.slice(0, 5)
71+
.map(observation =>
72+
typeof observation.id === "string" && observation.id !== "" ? observation.id : observation.sourceToolCallId
73+
);
74+
75+
return {
76+
value: helpers.ratio(consumed.length, toolObservations.length),
77+
unit: "ratio",
78+
details: `toolObservations=${toolObservations.length} consumed=${consumed.length}${unconsumedIds.length === 0 ? "" : `; unconsumed: ${unconsumedIds.join(", ")}`}`,
79+
};
80+
---
81+
82+
<!--
83+
tool-output-consumption-rate computes the fraction of tool-originated
84+
observations whose consumedByActionIds array is non-empty in the canonical
85+
Trajectory IR, i.e. the fraction of tool outputs that a later action
86+
actually referenced. Depends on observations[].sourceToolCallId,
87+
observations[].consumedByActionIds, and toolCalls[].id from the IR. Reports
88+
not-applicable (passed: null) when the trace has no observations, or no
89+
observations that originate from a matching tool call, rather than fabricating
90+
a value. Complements the built-in tool-success-rate grader (which measures
91+
whether tool calls succeeded, not whether their outputs were used).
92+
-->

actions/setup/js/trace_graders.test.cjs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,24 @@ function runSkillConstraintCoverage(trace, config) {
141141
});
142142
}
143143

144+
const toolOutputConsumptionRateScriptMatch = fs.readFileSync(path.join(__dirname, "../../../.github/workflows/shared/graders/tool-output-consumption-rate.md"), "utf8").match(/script: \|\n([\s\S]*?)\n^---\s*$/m);
145+
if (!toolOutputConsumptionRateScriptMatch?.[1]) {
146+
throw new Error("unable to extract tool-output-consumption-rate grader script");
147+
}
148+
const toolOutputConsumptionRateScript = toolOutputConsumptionRateScriptMatch[1]
149+
.split("\n")
150+
.map(line => line.slice(6))
151+
.join("\n");
152+
153+
function runToolOutputConsumptionRate(trace) {
154+
return runCustomGrader("tool-output-consumption-rate", toolOutputConsumptionRateScript, makeTrace(trace), {
155+
name: "Tool Output Consumption Rate",
156+
unit: "ratio",
157+
direction: "higher_is_better",
158+
source: "inline",
159+
});
160+
}
161+
144162
describe("trace_graders", () => {
145163
describe("buildGradersSummaryBody", () => {
146164
it("renders all computed grader values without emojis", () => {
@@ -1008,6 +1026,98 @@ describe("trace_graders", () => {
10081026
});
10091027
});
10101028

1029+
describe("tool-output-consumption-rate custom grader", () => {
1030+
it("scores the fraction of matching tool observations that were consumed", () => {
1031+
const result = runToolOutputConsumptionRate({
1032+
trajectoryIR: {
1033+
toolCalls: [{ id: "tc-1" }, { id: "tc-2" }],
1034+
observations: [
1035+
{ id: "obs-1", sourceToolCallId: "tc-1", consumedByActionIds: ["act-1"] },
1036+
{ id: "obs-2", sourceToolCallId: "tc-2", consumedByActionIds: [] },
1037+
{ id: "obs-3", sourceToolCallId: null, consumedByActionIds: ["act-2"] },
1038+
{ id: "obs-4", sourceToolCallId: "unknown", consumedByActionIds: ["act-3"] },
1039+
],
1040+
},
1041+
});
1042+
1043+
expect(result.value).toBeCloseTo(0.5);
1044+
expect(result.details).toContain("toolObservations=2 consumed=1");
1045+
expect(result.details).toContain("unconsumed: obs-2");
1046+
});
1047+
1048+
it("treats malformed consumption metadata as unconsumed", () => {
1049+
const result = runToolOutputConsumptionRate({
1050+
trajectoryIR: {
1051+
toolCalls: [{ id: "tc-1" }],
1052+
observations: [{ id: "obs-1", sourceToolCallId: "tc-1", consumedByActionIds: "act-1" }],
1053+
},
1054+
});
1055+
1056+
expect(result.value).toBe(0);
1057+
expect(result.details).toContain("toolObservations=1 consumed=0");
1058+
});
1059+
1060+
it("treats consumedByActionIds arrays containing only non-string/empty entries as unconsumed", () => {
1061+
const result = runToolOutputConsumptionRate({
1062+
trajectoryIR: {
1063+
toolCalls: [{ id: "tc-1" }, { id: "tc-2" }],
1064+
observations: [
1065+
{ id: "obs-1", sourceToolCallId: "tc-1", consumedByActionIds: [null] },
1066+
{ id: "obs-2", sourceToolCallId: "tc-2", consumedByActionIds: [42, ""] },
1067+
],
1068+
},
1069+
});
1070+
1071+
expect(result.value).toBe(0);
1072+
expect(result.details).toContain("toolObservations=2 consumed=0");
1073+
expect(result.details).toContain("unconsumed: obs-1, obs-2");
1074+
});
1075+
1076+
it("reads observations/toolCalls from the root trace object", () => {
1077+
const result = runToolOutputConsumptionRate({
1078+
toolCalls: [{ id: "tc-1" }],
1079+
observations: [{ id: "obs-1", sourceToolCallId: "tc-1", consumedByActionIds: ["act-1"] }],
1080+
});
1081+
1082+
expect(result.value).toBe(1);
1083+
});
1084+
1085+
it("reads a complete IR nested in agentOutput", () => {
1086+
const result = runToolOutputConsumptionRate({
1087+
agentOutput: {
1088+
trajectoryIR: {
1089+
toolCalls: [{ id: "tc-1" }],
1090+
observations: [{ id: "obs-1", sourceToolCallId: "tc-1", consumedByActionIds: ["act-1"] }],
1091+
},
1092+
},
1093+
});
1094+
1095+
expect(result.value).toBe(1);
1096+
});
1097+
1098+
it.each([
1099+
["no observations", { trajectoryIR: { toolCalls: [{ id: "tc-1" }] } }, "no observations"],
1100+
["no tool calls", { trajectoryIR: { observations: [{ id: "obs-1", sourceToolCallId: "tc-1", consumedByActionIds: ["act-1"] }] } }, "no tool-originated observations"],
1101+
[
1102+
"no matching tool call",
1103+
{
1104+
trajectoryIR: {
1105+
toolCalls: [{ id: "tc-1" }],
1106+
observations: [{ id: "obs-1", sourceToolCallId: "unknown", consumedByActionIds: ["act-1"] }],
1107+
},
1108+
},
1109+
"no tool-originated observations",
1110+
],
1111+
])("normalizes %s as unavailable", (_name, trace, message) => {
1112+
const result = runToolOutputConsumptionRate(trace);
1113+
1114+
expect(result.value).toBeNull();
1115+
expect(result.passed).toBeNull();
1116+
expect(result.status).toBe("unavailable");
1117+
expect(result.message).toContain(message);
1118+
});
1119+
});
1120+
10111121
// --- Hostile data ---
10121122
describe("hostile data handling", () => {
10131123
it("handles hostile strings in tool call names", () => {

0 commit comments

Comments
 (0)