Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ce1a3ae
Plan deterministic trace graders
Copilot Aug 22, 2026
de6658f
Add initial deterministic trace graders
Copilot Aug 22, 2026
4b4de8a
docs(adr): add draft ADR-54678 for deterministic trace grading framework
github-actions[bot] Aug 22, 2026
9eb6028
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
0d6a86e
Harden trace grader runtime isolation and manifest transport
Copilot Aug 22, 2026
53938a6
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
1a1b44d
Merge remote-tracking branch 'origin/main' into copilot/implement-det…
Copilot Aug 22, 2026
ed01ef4
Refresh branch and fix trace graders JS type casts
Copilot Aug 22, 2026
ef94444
Merge remote-tracking branch 'origin/main' into copilot/implement-det…
Copilot Aug 22, 2026
dce434c
Integrate experiment context into graders and evals
Copilot Aug 22, 2026
7a02562
Allow experiments metrics to reference grader results
Copilot Aug 22, 2026
b0ad3c7
Mark trace graders as experimental
Copilot Aug 22, 2026
673bba9
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
1781737
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
2906c4a
fix trace grader sandbox embedding and script-size contract
Copilot Aug 22, 2026
803ce40
test: add graders integration workflow coverage
Copilot Aug 22, 2026
ba7aae8
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
9baed09
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
f377fa2
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
00ca65f
Harden trace grader log parsing and summary output
Copilot Aug 22, 2026
9fff955
Harden trace grader fallback parsing and summary safety
Copilot Aug 22, 2026
096aa3e
chore: start triage for unresolved review feedback
Copilot Aug 22, 2026
455639f
fix grader sandbox clone fallback and parser edge cases
Copilot Aug 22, 2026
1d3542e
fix: rename grader clone helper to explicit clone-or-undefined
Copilot Aug 22, 2026
4e303c5
Merge branch 'main' into copilot/implement-deterministic-trace-graders
github-actions[bot] Aug 22, 2026
6c61d48
refactor: rename trace graders wording to graders
Copilot Aug 22, 2026
1b3d870
Merge remote-tracking branch 'origin/main' into copilot/implement-det…
Copilot Aug 22, 2026
5131cd9
refactor: rename grader implementation id
Copilot Aug 22, 2026
782f56d
docs: add graders specification
Copilot Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix grader sandbox clone fallback and parser edge cases
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
  • Loading branch information
Copilot and gh-aw-bot authored Aug 22, 2026
commit 455639fcfac213cfa0965a2ede15fad7edcc8a29
12 changes: 10 additions & 2 deletions actions/setup/js/trace_graders.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -320,19 +320,27 @@ const BUILTIN_META = {
"artifact-production": { unit: "count", direction: "higher_is_better" },
};

/**
* @param {any} toolCall
* @returns {boolean}
*/
function isToolFailure(toolCall) {
return toolCall.success === false || toolCall.status === "error" || toolCall.status === "failure" || toolCall.error !== undefined;
}

/**
* @param {PreprocessedTrace} trace
* @returns {number} Success rate of tool calls (0-1), or 1 if no tool calls
*/
function gradeToolSuccessRate(trace) {
if (trace.toolCalls.length === 0) return 1;
const successes = trace.toolCalls.filter(t => t.success === true || (t.success !== false && t.status !== "error" && t.status !== "failure" && t.error === undefined)).length;
const successes = trace.toolCalls.length - trace.toolCalls.filter(isToolFailure).length;
return successes / trace.toolCalls.length;
}

/** @param {PreprocessedTrace} trace @returns {number} */
function gradeToolFailureCount(trace) {
return trace.toolCalls.filter(t => t.success === false || t.status === "error" || t.status === "failure" || t.error !== undefined).length;
return trace.toolCalls.filter(isToolFailure).length;
}

/** @param {PreprocessedTrace} trace @returns {number} */
Expand Down
11 changes: 10 additions & 1 deletion actions/setup/js/trace_graders.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ describe("trace_graders", () => {
});
expect(gradeToolFailureCount(trace)).toBe(2);
});

it("matches success-rate complement for ambiguous calls", () => {
const trace = makeTrace({
toolCalls: [{ name: "a", success: true }, { name: "b", status: "error" }, { name: "c" }],
});
const failures = gradeToolFailureCount(trace);
const successRate = gradeToolSuccessRate(trace);
expect(successRate).toBeCloseTo((trace.toolCalls.length - failures) / trace.toolCalls.length);
});
});

describe("gradeRetries", () => {
Expand Down Expand Up @@ -491,7 +500,7 @@ describe("trace_graders", () => {
});

it("handles nested malicious JSON in JSONL", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The hostile data test for proto-pollution (__proto__) asserts the parsed object exists and Object.prototype is unpolluted, but JSON.parse in modern Node already ignores __proto__ keys — the test passes vacuously and doesn't actually validate that the safeParseJsonl layer adds any additional protection. A more meaningful test would use Object.create(null) or check for the constructor key attack vector.

💡 Suggestion

Either remove the test (it's testing V8's built-in behaviour, not gh-aw code) or replace it with a test for a pattern your code does guard against — e.g. {"constructor":{"prototype":{"polluted":true}}} — and assert Object.prototype.polluted remains undefined.

@copilot please address this.

const hostile = '{"a": 1, "__proto__": {"polluted": true}}';
const hostile = '{"a":1,"constructor":{"prototype":{"polluted":true}}}';
const result = safeParseJsonl(hostile);
expect(result.length).toBe(1);
expect(result[0].a).toBe(1);
Expand Down
26 changes: 9 additions & 17 deletions actions/setup/js/trace_graders_worker.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,17 @@
const vm = require("vm");

/**
* @param {any} obj
* @param {any} value
* @param {string} label
* @returns {any}
*/
function deepClone(obj) {
if (obj === null || obj === undefined) return obj;
function tryStructuredClone(value, label) {
if (value === null || value === undefined) return value;
try {
return structuredClone(obj);
return structuredClone(value);
} catch {
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item));
}
if (obj !== null && typeof obj === "object") {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = deepClone(v);
}
return out;
}
return obj;
process.stderr.write(`grader worker: failed to structuredClone ${label}; value will default to {}\n`);
return undefined;
}
}

Expand Down Expand Up @@ -64,8 +56,8 @@ async function main() {
}

try {
const trace = deepFreeze(deepClone(payload.trace || {}));
const config = deepFreeze(deepClone(payload.config || {}));
const trace = deepFreeze(tryStructuredClone(payload.trace, "trace") ?? {});
const config = deepFreeze(tryStructuredClone(payload.config, "config") ?? {});
const run = deepFreeze({ graderCount: Number(payload.graderCount) || 0 });
const workflow = deepFreeze({});
const script = String(payload.script || "");
Expand Down
11 changes: 10 additions & 1 deletion pkg/workflow/graders_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,14 @@ func (c *Compiler) parseGradersFromFrontmatter(frontmatter map[string]any) (*Gra
}

// Parse explicit entries
seenNormalizedIDs := make(map[string]string, len(m))
for id, entryRaw := range m {
rawID := id
id = strings.TrimSpace(id)
if existingRawID, exists := seenNormalizedIDs[id]; exists {
return nil, fmt.Errorf("graders has duplicate id %q after normalization. Remove whitespace variants (for example %q and %q)", id, existingRawID, rawID)
}
seenNormalizedIDs[id] = rawID
if !graderIDPattern.MatchString(id) {
return nil, fmt.Errorf("graders has invalid id %q: must match %s. Example:\ngraders:\n my-metric:\n script: \"return { value: trace.toolCalls.length }\"", id, graderIDPattern.String())
}
Comment on lines +210 to +219
Expand All @@ -219,7 +225,11 @@ func (c *Compiler) parseGradersFromFrontmatter(frontmatter map[string]any) (*Gra
def = builtinDefFromMeta(meta)
}

_, isBuiltin := builtinSet[id]
if entryRaw == nil {
if !isBuiltin {
return nil, fmt.Errorf("graders.%s is not a built-in grader and requires a 'script' field. Built-in graders: %s", id, strings.Join(BuiltinGraderIDs, ", "))
}
cfg.Graders[id] = def
continue
}
Comment on lines +229 to +235
Expand All @@ -229,7 +239,6 @@ func (c *Compiler) parseGradersFromFrontmatter(frontmatter map[string]any) (*Gra
return nil, fmt.Errorf("graders.%s must be a map or null, got %T. Example:\ngraders:\n %s:\n enabled: true", id, entryRaw, id)
}

_, isBuiltin := builtinSet[id]
if err := parseGraderEntryFields(def, entry, id, isBuiltin); err != nil {
return nil, err
}
Expand Down
33 changes: 33 additions & 0 deletions pkg/workflow/graders_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ func TestParseGradersFromFrontmatter_CustomWithoutScript(t *testing.T) {
}
}

// TestParseGradersFromFrontmatter_CustomNullRejected verifies null custom graders are rejected at parse time.
func TestParseGradersFromFrontmatter_CustomNullRejected(t *testing.T) {
var c Compiler
_, err := c.parseGradersFromFrontmatter(map[string]any{
"graders": map[string]any{
"my-custom": nil,
},
})
if err == nil {
t.Fatal("expected error for null custom grader")
}
if !strings.Contains(err.Error(), "requires a 'script' field") {
t.Fatalf("expected missing-script error, got: %v", err)
}
}

// TestParseGradersFromFrontmatter_ScriptLengthUsesCharacters verifies limits align to character count.
func TestParseGradersFromFrontmatter_ScriptLengthUsesCharacters(t *testing.T) {
var c Compiler
Expand Down Expand Up @@ -199,6 +215,23 @@ func TestParseGradersFromFrontmatter_InvalidID(t *testing.T) {
}
}

// TestParseGradersFromFrontmatter_DuplicateNormalizedID verifies whitespace variants are rejected.
func TestParseGradersFromFrontmatter_DuplicateNormalizedID(t *testing.T) {
var c Compiler
_, err := c.parseGradersFromFrontmatter(map[string]any{
"graders": map[string]any{
"retry-test": map[string]any{"script": "return 1"},
" retry-test": map[string]any{"script": "return 2"},
},
})
if err == nil {
t.Fatal("expected duplicate normalized id error")
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected duplicate id error, got: %v", err)
}
}

// TestParseGradersFromFrontmatter_AllDisabledError verifies error when all disabled.
func TestParseGradersFromFrontmatter_AllDisabledError(t *testing.T) {
var c Compiler
Expand Down
Loading