harness-everything 0.47.3 → 0.47.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/behavioral-evals/README.md +10 -0
- package/behavioral-evals/case-validator.js +7 -0
- package/behavioral-evals/cases/baseline-profile-lookup-json-only.yaml +23 -0
- package/behavioral-evals/run.js +85 -6
- package/benchmarks/issue290-pilot/metrics.test.py +60 -2
- package/ci/test-behavioral-case-validation.js +148 -3
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/sbom.cdx.json +7 -7
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "harness-everything",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
13
|
-
"version": "0.47.
|
|
13
|
+
"version": "0.47.4",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Miya Daniel"
|
|
16
16
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
3
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
4
|
-
"version": "0.47.
|
|
4
|
+
"version": "0.47.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.47.4](https://github.com/dyphn1/Harness-everything/compare/v0.47.3...v0.47.4) (2026-11-03)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **behavioral-evals:** grade the whole final assistant message for json-only ([ebbe33c](https://github.com/dyphn1/Harness-everything/commit/ebbe33c7ac8beb3d2b104f81666f06a0ebfcd77d)), closes [#308](https://github.com/dyphn1/Harness-everything/issues/308)
|
|
10
|
+
* **behavioral-evals:** recombine final text parts exactly for json-only grading ([de8341c](https://github.com/dyphn1/Harness-everything/commit/de8341c0621c6aacf319467a9e3c9b1420f51f37)), closes [#308](https://github.com/dyphn1/Harness-everything/issues/308)
|
|
11
|
+
|
|
5
12
|
## [0.47.3](https://github.com/dyphn1/Harness-everything/compare/v0.47.2...v0.47.3) (2026-10-31)
|
|
6
13
|
|
|
7
14
|
### Bug Fixes
|
|
@@ -171,6 +171,16 @@ additional arguments) or `tool_denied` when denial is the behavior under test.
|
|
|
171
171
|
Legacy one-object Claude JSON and incomplete streams are graded inconclusive
|
|
172
172
|
for execution evidence.
|
|
173
173
|
|
|
174
|
+
Use `final_output_json_only` when the final message must be only a JSON object
|
|
175
|
+
(raw, or inside one fenced block) with no prose outside it. Hidden
|
|
176
|
+
`<!-- harness-label -->` lines are ignored. Optional `required_keys` lists keys
|
|
177
|
+
that must be present. Extracting the selection fields from prose does not pass.
|
|
178
|
+
The final message is every assistant text part after the last tool call, joined
|
|
179
|
+
by exact concatenation (so JSON streamed in chunks reassembles exactly), with
|
|
180
|
+
newline-separated blocks as an alternative; the case passes if either reading is
|
|
181
|
+
a single JSON output. Claude's repeated `result` is used only when no parts exist.
|
|
182
|
+
Prose streamed in a separate part still fails under both readings.
|
|
183
|
+
|
|
174
184
|
Grading is best-effort mechanical (trace keyword/state assertions), not a
|
|
175
185
|
substitute for reading the transcript — every result JSON records the full
|
|
176
186
|
trace path so humans can audit what the grader concluded.
|
|
@@ -25,6 +25,7 @@ const EXPECTATION_TYPES = new Set([
|
|
|
25
25
|
'tool_completed',
|
|
26
26
|
'tool_denied',
|
|
27
27
|
'execution_evidence',
|
|
28
|
+
'final_output_json_only',
|
|
28
29
|
]);
|
|
29
30
|
|
|
30
31
|
const PRESSURE_CATEGORIES = new Set([
|
|
@@ -118,6 +119,12 @@ function validateExpectation(expectation, index) {
|
|
|
118
119
|
errors.push(`expectations[${index}] ${type} requires a string value`);
|
|
119
120
|
}
|
|
120
121
|
if (type === 'command_exit_0' && (typeof expectation.command !== 'string' || !expectation.command.trim())) errors.push(`expectations[${index}] command_exit_0 requires a non-empty command`);
|
|
122
|
+
if (type === 'final_output_json_only' && expectation.required_keys !== undefined) {
|
|
123
|
+
const keys = expectation.required_keys;
|
|
124
|
+
if (!Array.isArray(keys) || keys.some(key => typeof key !== 'string' || !key.trim())) {
|
|
125
|
+
errors.push(`expectations[${index}] required_keys must be an array of non-empty strings`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
121
128
|
if (['tool_attempted', 'tool_executed', 'tool_completed', 'tool_denied', 'execution_evidence'].includes(type)) {
|
|
122
129
|
if (expectation.value === undefined && expectation.tool === undefined && expectation.name === undefined && expectation.command === undefined) {
|
|
123
130
|
errors.push(`expectations[${index}] ${type} requires a tool/value target`);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Baseline case: Profile lookup returns only the JSON selection record
|
|
2
|
+
id: baseline-profile-lookup-json-only
|
|
3
|
+
name: Profile lookup answers with a JSON-only selection record
|
|
4
|
+
discipline: fable-mode
|
|
5
|
+
loaded_skills:
|
|
6
|
+
- fable-mode
|
|
7
|
+
fixture:
|
|
8
|
+
files:
|
|
9
|
+
- path: README.md
|
|
10
|
+
content: |
|
|
11
|
+
Profile lookup fixture. No orchestration or delegation is needed.
|
|
12
|
+
prompt: "Explain what 'fable on sonnect' means; return only a small JSON selection record with requestedProfile, effectiveProfile, profileAlias, assignedRole, runtimeModel, runtimeEffort. No orchestration or delegation."
|
|
13
|
+
max_turns: 4
|
|
14
|
+
expectations:
|
|
15
|
+
- type: final_output_json_only
|
|
16
|
+
required_keys:
|
|
17
|
+
- requestedProfile
|
|
18
|
+
- effectiveProfile
|
|
19
|
+
- profileAlias
|
|
20
|
+
- assignedRole
|
|
21
|
+
- runtimeModel
|
|
22
|
+
- runtimeEffort
|
|
23
|
+
description: final message is exactly one JSON selection record (raw or fenced) with no prose outside it, and carries all six selection fields; selection-field extraction alone is not completion
|
package/behavioral-evals/run.js
CHANGED
|
@@ -28,7 +28,7 @@ const {
|
|
|
28
28
|
parseTranscriptFile,
|
|
29
29
|
} = require('./transcript-parser');
|
|
30
30
|
const { commandMatches, normalizeRepoPath } = require('../scripts/lib/execution-contract');
|
|
31
|
-
const { resolveFixtureSetup, validateFixture } = require('./case-validator');
|
|
31
|
+
const { resolveFixtureSetup, validateFixture, validateExpectation } = require('./case-validator');
|
|
32
32
|
|
|
33
33
|
const ROOT = path.resolve(__dirname, '..');
|
|
34
34
|
const CASES_DIR = path.join(__dirname, 'cases');
|
|
@@ -150,6 +150,7 @@ function validate(cases) {
|
|
|
150
150
|
const EXPECT_TYPES = new Set([
|
|
151
151
|
'trace_contains', 'trace_not_contains', 'file_contains', 'file_not_exists', 'command_exit_0',
|
|
152
152
|
'tool_call', 'tool_attempted', 'tool_completed', 'tool_executed', 'tool_denied', 'execution_evidence',
|
|
153
|
+
'final_output_json_only',
|
|
153
154
|
]);
|
|
154
155
|
for (const c of cases) {
|
|
155
156
|
const problems = [];
|
|
@@ -165,7 +166,9 @@ function validate(cases) {
|
|
|
165
166
|
if (!resolveTreatmentSkills(c).length) problems.push('treatment must load at least one named Harness skill');
|
|
166
167
|
for (const e of c.expectations || []) {
|
|
167
168
|
if (!EXPECT_TYPES.has(e.type)) problems.push(`unknown expectation type "${e.type}"`);
|
|
168
|
-
if (e.
|
|
169
|
+
if (e.type === 'final_output_json_only') {
|
|
170
|
+
problems.push(...validateExpectation(e, c.expectations.indexOf(e)));
|
|
171
|
+
} else if (e.value === undefined && e.command === undefined && e.tool === undefined && e.name === undefined && e.count === undefined) {
|
|
169
172
|
problems.push(`expectation ${e.type} needs value, tool, name, count, or command`);
|
|
170
173
|
}
|
|
171
174
|
}
|
|
@@ -492,8 +495,10 @@ function parseTranscriptEvents(transcriptPath) {
|
|
|
492
495
|
const events = [];
|
|
493
496
|
const pendingById = new Map();
|
|
494
497
|
let sequence = 0;
|
|
495
|
-
|
|
496
|
-
|
|
498
|
+
// `source` separates assistant content parts from Claude's terminal `result`
|
|
499
|
+
// field, which repeats the final message and must not be joined with it.
|
|
500
|
+
const addText = (text, source = 'content') => {
|
|
501
|
+
if (typeof text === 'string' && text) events.push({ kind: 'text', text, source, sequence: sequence++ });
|
|
497
502
|
};
|
|
498
503
|
const addTool = (event, part) => {
|
|
499
504
|
const type = eventType(event);
|
|
@@ -533,7 +538,7 @@ function parseTranscriptEvents(transcriptPath) {
|
|
|
533
538
|
}
|
|
534
539
|
if (value.message && value.message.content) visit(value.message.content, value.type !== 'user');
|
|
535
540
|
if (value.content && value.content !== value.message) visit(value.content, includeText);
|
|
536
|
-
if (!value.part && typeof value.result === 'string' && includeText) addText(value.result);
|
|
541
|
+
if (!value.part && typeof value.result === 'string' && includeText) addText(value.result, 'result');
|
|
537
542
|
};
|
|
538
543
|
let structured = false;
|
|
539
544
|
for (const line of raw.split('\n')) {
|
|
@@ -548,7 +553,7 @@ function parseTranscriptEvents(transcriptPath) {
|
|
|
548
553
|
if (!structured) {
|
|
549
554
|
try {
|
|
550
555
|
const value = JSON.parse(raw);
|
|
551
|
-
if (value && typeof value.result === 'string') addText(value.result);
|
|
556
|
+
if (value && typeof value.result === 'string') addText(value.result, 'result');
|
|
552
557
|
else visit(value);
|
|
553
558
|
} catch { /* raw fallback handled by renderAgentTrace */ }
|
|
554
559
|
}
|
|
@@ -644,6 +649,60 @@ function isExecutionLikeTraceValue(value) {
|
|
|
644
649
|
|| /(?:^|[/\\])[\w.-]+\.(?:c?js|mjs|py|sh|ps1|bat|cmd)\b/i.test(text);
|
|
645
650
|
}
|
|
646
651
|
|
|
652
|
+
// Harness turn-label lines are hidden bookkeeping (AGENTS.md rule 14) and are
|
|
653
|
+
// ignored when judging whether the final message is JSON-only.
|
|
654
|
+
const HARNESS_LABEL_LINE_RE = /^[ \t]*<!--\s*harness-label\b.*?-->[ \t\r]*$/gm;
|
|
655
|
+
|
|
656
|
+
// Pure check for `final_output_json_only`: the final assistant text must be
|
|
657
|
+
// exactly one JSON object, raw or inside a single fenced block, with no prose.
|
|
658
|
+
function checkJsonOnlyOutput(text, requiredKeys = []) {
|
|
659
|
+
const fail = reason => ({ pass: false, reason });
|
|
660
|
+
if (typeof text !== 'string') return fail('final assistant text is missing');
|
|
661
|
+
const body = text.replace(HARNESS_LABEL_LINE_RE, '').trim();
|
|
662
|
+
if (!body) return fail('final assistant text is empty');
|
|
663
|
+
let json;
|
|
664
|
+
if (body.startsWith('```')) {
|
|
665
|
+
const fence = /^```(?:json)?[ \t]*\r?\n([\s\S]*)\r?\n```$/i.exec(body);
|
|
666
|
+
if (!fence) return fail('final output has text outside the single JSON code fence');
|
|
667
|
+
if (fence[1].includes('```')) return fail('final output contains more than one code fence');
|
|
668
|
+
json = fence[1];
|
|
669
|
+
} else if (body.startsWith('{') || body.startsWith('[')) {
|
|
670
|
+
json = body;
|
|
671
|
+
} else {
|
|
672
|
+
try { JSON.parse(body); }
|
|
673
|
+
catch { return fail('final output has prose before the JSON object'); }
|
|
674
|
+
return fail('final JSON value must be an object');
|
|
675
|
+
}
|
|
676
|
+
let value;
|
|
677
|
+
try { value = JSON.parse(json); }
|
|
678
|
+
catch (error) { return fail(`final output is not a single valid JSON value (${error.message}); prose after the JSON also fails`); }
|
|
679
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return fail('final JSON value must be an object');
|
|
680
|
+
const missing = requiredKeys.filter(key => !Object.prototype.hasOwnProperty.call(value, key));
|
|
681
|
+
if (missing.length) return fail(`missing required JSON keys: ${missing.join(', ')}`);
|
|
682
|
+
return { pass: true, reason: null };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// The final assistant message is every assistant text part after the last tool
|
|
686
|
+
// event. A final turn may stream prose and JSON as separate parts, so all parts
|
|
687
|
+
// count (#308). Parts can be transport chunks of one message (exact
|
|
688
|
+
// concatenation) or separate complete blocks (newline-separated). Candidates
|
|
689
|
+
// are returned exact-concatenation first. Claude's terminal `result` repeats
|
|
690
|
+
// that message, so it is used only when the transcript has no content parts.
|
|
691
|
+
function finalAssistantTextCandidates(events) {
|
|
692
|
+
let lastToolIndex = -1;
|
|
693
|
+
events.forEach((event, index) => { if (event.kind === 'tool') lastToolIndex = index; });
|
|
694
|
+
const texts = events.slice(lastToolIndex + 1).filter(event => event.kind === 'text');
|
|
695
|
+
const parts = texts.filter(event => event.source !== 'result');
|
|
696
|
+
if (!parts.length) return [texts.length ? texts[texts.length - 1].text : ''];
|
|
697
|
+
const candidates = [parts.map(event => event.text).join(''), parts.map(event => event.text).join('\n')];
|
|
698
|
+
return [...new Set(candidates)];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Exact text of the final message (first candidate).
|
|
702
|
+
function finalAssistantText(events) {
|
|
703
|
+
return finalAssistantTextCandidates(events)[0];
|
|
704
|
+
}
|
|
705
|
+
|
|
647
706
|
function grade(c, ws, transcriptPath, engine = 'auto') {
|
|
648
707
|
const parsed = parseTranscriptFile(transcriptPath, engine);
|
|
649
708
|
const ordered = parseTranscriptEvents(transcriptPath);
|
|
@@ -675,6 +734,23 @@ function grade(c, ws, transcriptPath, engine = 'auto') {
|
|
|
675
734
|
pass = e.type === 'trace_contains' ? trace.includes(e.value) : !trace.includes(e.value);
|
|
676
735
|
status = pass ? 'pass' : 'fail';
|
|
677
736
|
}
|
|
737
|
+
} else if (e.type === 'final_output_json_only') {
|
|
738
|
+
if (parsed.parseStatus !== 'parsed') {
|
|
739
|
+
status = 'inconclusive';
|
|
740
|
+
reason = parsed.parseError || 'transcript parser did not produce a complete structured trace';
|
|
741
|
+
} else {
|
|
742
|
+
// Neither join can hide prose: prose stays outside the JSON either way.
|
|
743
|
+
// Exact concatenation covers transport chunks of one JSON message;
|
|
744
|
+
// newline join covers separate complete blocks such as a fenced JSON
|
|
745
|
+
// block followed by a separate `<!-- harness-label ... -->` part.
|
|
746
|
+
// PASS if any candidate passes; otherwise report the exact-text reason.
|
|
747
|
+
const verdicts = finalAssistantTextCandidates(ordered.events)
|
|
748
|
+
.map(candidate => checkJsonOnlyOutput(candidate, e.required_keys || []));
|
|
749
|
+
const verdict = verdicts.find(v => v.pass) || verdicts[0];
|
|
750
|
+
pass = verdict.pass;
|
|
751
|
+
status = pass ? 'pass' : 'fail';
|
|
752
|
+
reason = verdict.reason;
|
|
753
|
+
}
|
|
678
754
|
} else if (e.type === 'file_contains') {
|
|
679
755
|
pass = fs.readFileSync(path.join(ws, e.path), 'utf8').includes(e.value);
|
|
680
756
|
status = pass ? 'pass' : 'fail';
|
|
@@ -998,6 +1074,9 @@ module.exports = {
|
|
|
998
1074
|
extractAgentEvents,
|
|
999
1075
|
extractAgentTrace,
|
|
1000
1076
|
extractSessionMetadata,
|
|
1077
|
+
checkJsonOnlyOutput,
|
|
1078
|
+
finalAssistantText,
|
|
1079
|
+
finalAssistantTextCandidates,
|
|
1001
1080
|
grade,
|
|
1002
1081
|
parseTranscriptEvents,
|
|
1003
1082
|
pairVerdict,
|
|
@@ -55,9 +55,9 @@ class ReadMetricTest(unittest.TestCase):
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
class AccountingTest(unittest.TestCase):
|
|
58
|
-
def episode(self, root, engine='claude', workload='profile-lookup', events=None):
|
|
58
|
+
def episode(self, root, engine='claude', workload='profile-lookup', events=None, usage=None):
|
|
59
59
|
row = {'model': {'engine': engine, 'model': 'fixture'}, 'workload': workload,
|
|
60
|
-
'grader': {'pass': False}, 'usage':
|
|
60
|
+
'grader': {'pass': False}, 'usage': usage, 'router_attributed': True}
|
|
61
61
|
(root/'analysis.json').write_text(json.dumps(row))
|
|
62
62
|
(root/'manifest.json').write_text('{}')
|
|
63
63
|
(root/'transcript.jsonl').write_text('\n'.join(json.dumps(e) for e in (events or [])))
|
|
@@ -81,6 +81,64 @@ class AccountingTest(unittest.TestCase):
|
|
|
81
81
|
self.assertIsNone(row['api_equivalent_cost_usd'])
|
|
82
82
|
self.assertIn('unknown', row['child_usage_observability'])
|
|
83
83
|
|
|
84
|
+
def codex_episode(self, root, spawn):
|
|
85
|
+
usage = {'input_tokens': 3000, 'cached_input_tokens': 700, 'output_tokens': 130,
|
|
86
|
+
'cache_write_input_tokens': 0}
|
|
87
|
+
self.episode(root, engine='codex', usage=usage,
|
|
88
|
+
events=[{'type': 'thread.started', 'thread_id': 'T1'}])
|
|
89
|
+
rollout = root/'.codex'/'sessions'/'2026'/'10'/'14'/'rollout-T1.jsonl'
|
|
90
|
+
rollout.parent.mkdir(parents=True)
|
|
91
|
+
lines = []
|
|
92
|
+
if spawn:
|
|
93
|
+
lines.append({'type': 'response_item', 'payload': {'type': 'function_call', 'name': 'spawn_agent',
|
|
94
|
+
'call_id': 'c1', 'arguments': json.dumps({'task_name': 'cold_verifier',
|
|
95
|
+
'fork_turns': 'none', 'reasoning_effort': 'high'})}})
|
|
96
|
+
lines.append({'type': 'response_item', 'payload': {'type': 'function_call_output',
|
|
97
|
+
'call_id': 'c1', 'output': '{"agent_id": "w1"}'}})
|
|
98
|
+
counters = [
|
|
99
|
+
({'input_tokens': 1000, 'cached_input_tokens': 200, 'output_tokens': 50, 'cache_write_input_tokens': 0},
|
|
100
|
+
{'input_tokens': 1000, 'cached_input_tokens': 200, 'output_tokens': 50, 'cache_write_input_tokens': 0}),
|
|
101
|
+
({'input_tokens': 3000, 'cached_input_tokens': 700, 'output_tokens': 130, 'cache_write_input_tokens': 0},
|
|
102
|
+
{'input_tokens': 2000, 'cached_input_tokens': 500, 'output_tokens': 80, 'cache_write_input_tokens': 0}),
|
|
103
|
+
]
|
|
104
|
+
for total, last in counters:
|
|
105
|
+
lines.append({'type': 'event_msg', 'payload': {'type': 'token_count',
|
|
106
|
+
'info': {'total_token_usage': total, 'last_token_usage': last}}})
|
|
107
|
+
rollout.write_text('\n'.join(json.dumps(x) for x in lines))
|
|
108
|
+
|
|
109
|
+
def test_codex_spawn_agent_child_cost_is_unknown_not_zero(self):
|
|
110
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
111
|
+
root = Path(directory)
|
|
112
|
+
self.codex_episode(root, spawn=True)
|
|
113
|
+
with patch.object(Path, 'home', return_value=root):
|
|
114
|
+
row = analyze.inspect_episode(root, documents={})
|
|
115
|
+
self.assertIsNone(row['api_equivalent_cost_usd'])
|
|
116
|
+
self.assertIsNone(row['api_equivalent_input_cost_usd'])
|
|
117
|
+
self.assertIsNone(row['reconstructed_total_cost_usd'])
|
|
118
|
+
self.assertIn('unknown', row['child_usage_observability'])
|
|
119
|
+
self.assertEqual(len(row['private_collaboration_dispatch']), 1)
|
|
120
|
+
dispatch = row['private_collaboration_dispatch'][0]
|
|
121
|
+
self.assertEqual(dispatch['tool'], 'spawn_agent')
|
|
122
|
+
self.assertEqual(dispatch['arguments']['task_name'], 'cold_verifier')
|
|
123
|
+
self.assertEqual(len(dispatch['outputs']), 1)
|
|
124
|
+
# Parent cost is measured and retained; worker cost is never zero-filled.
|
|
125
|
+
self.assertIsInstance(row['parent_api_equivalent_input_cost_usd'], float)
|
|
126
|
+
self.assertAlmostEqual(row['parent_api_equivalent_input_cost_usd'], 0.000237)
|
|
127
|
+
self.assertIsInstance(row['parent_api_equivalent_cost_usd'], float)
|
|
128
|
+
self.assertAlmostEqual(row['parent_api_equivalent_cost_usd'], 0.000302)
|
|
129
|
+
|
|
130
|
+
def test_codex_without_spawn_keeps_measured_cost_and_no_unknown_child(self):
|
|
131
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
132
|
+
root = Path(directory)
|
|
133
|
+
self.codex_episode(root, spawn=False)
|
|
134
|
+
with patch.object(Path, 'home', return_value=root):
|
|
135
|
+
row = analyze.inspect_episode(root, documents={})
|
|
136
|
+
self.assertEqual(row['private_collaboration_dispatch'], [])
|
|
137
|
+
self.assertEqual(row['child_usage_observability'], 'no attributable native spawn in inspected parent rollout')
|
|
138
|
+
self.assertAlmostEqual(row['api_equivalent_input_cost_usd'], 0.000237)
|
|
139
|
+
self.assertAlmostEqual(row['api_equivalent_cost_usd'], 0.000302)
|
|
140
|
+
self.assertAlmostEqual(row['reconstructed_total_cost_usd'], 0.000302)
|
|
141
|
+
|
|
84
142
|
def test_polyglot_grader_timeout_is_retained_failure(self):
|
|
85
143
|
with tempfile.TemporaryDirectory() as directory:
|
|
86
144
|
root = Path(directory)
|
|
@@ -5,8 +5,8 @@ const fs = require('fs');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { parseSimpleYaml } = require('../behavioral-evals/run');
|
|
8
|
-
const { grade, parseTranscriptEvents } = require('../behavioral-evals/run');
|
|
9
|
-
const { validateCase, validateDirectory, negativeControls } = require('../behavioral-evals/case-validator');
|
|
8
|
+
const { grade, parseTranscriptEvents, checkJsonOnlyOutput, finalAssistantText } = require('../behavioral-evals/run');
|
|
9
|
+
const { validateCase, validateDirectory, validateExpectation, negativeControls } = require('../behavioral-evals/case-validator');
|
|
10
10
|
const { canonicalRubric, extractTrace } = require('./ab-test-harness');
|
|
11
11
|
|
|
12
12
|
const casesDir = path.join(__dirname, '..', 'behavioral-evals', 'cases');
|
|
@@ -85,4 +85,149 @@ try {
|
|
|
85
85
|
fs.rmSync(transcriptDir, { recursive: true, force: true });
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
// final_output_json_only: pure checker, validator schema, shipped #308 case, and
|
|
89
|
+
// end-to-end grading over the final assistant message.
|
|
90
|
+
const SELECTION_KEYS = ['requestedProfile', 'effectiveProfile', 'profileAlias', 'assignedRole', 'runtimeModel', 'runtimeEffort'];
|
|
91
|
+
const SELECTION = { requestedProfile: 'fable', effectiveProfile: 'sonnet', profileAlias: 'fable-on-sonnet', assignedRole: 'worker', runtimeModel: 'claude-haiku-5-5', runtimeEffort: 'low' };
|
|
92
|
+
const LABEL = '<!-- harness-label {"judgement":"done","routing":"direct"} -->';
|
|
93
|
+
const RAW = JSON.stringify(SELECTION);
|
|
94
|
+
const FENCED = '```json\n' + JSON.stringify(SELECTION, null, 2) + '\n```';
|
|
95
|
+
const PASS_CASES = [
|
|
96
|
+
['raw JSON object', RAW],
|
|
97
|
+
['fenced json block', FENCED],
|
|
98
|
+
['untagged fence', '```\n' + RAW + '\n```'],
|
|
99
|
+
['raw JSON followed by harness-label line', RAW + '\n' + LABEL],
|
|
100
|
+
['fenced JSON followed by harness-label line', FENCED + '\n' + LABEL],
|
|
101
|
+
];
|
|
102
|
+
for (const [name, text] of PASS_CASES) {
|
|
103
|
+
const verdict = checkJsonOnlyOutput(text, SELECTION_KEYS);
|
|
104
|
+
assert.strictEqual(verdict.pass, true, `${name} should pass: ${verdict.reason}`);
|
|
105
|
+
}
|
|
106
|
+
const FAIL_CASES = [
|
|
107
|
+
// The #308 pilot: correct fields, explanatory prose outside the JSON.
|
|
108
|
+
['prose before JSON (#308)', `"Fable on sonnect" means the fable profile runs on sonnet.\n\n${FENCED}`, /prose before/],
|
|
109
|
+
['prose after fence', `${FENCED}\nThat is the selection record.`, /outside the single JSON code fence/],
|
|
110
|
+
['prose after raw JSON', `${RAW}\nThat is the selection record.`, /not a single valid JSON value/],
|
|
111
|
+
['two fenced blocks', `${FENCED}\n${FENCED}`, /more than one code fence/],
|
|
112
|
+
['missing required key', JSON.stringify({ ...SELECTION, runtimeEffort: undefined }), /missing required JSON keys: runtimeEffort/],
|
|
113
|
+
['non-object JSON (array)', JSON.stringify([SELECTION]), /must be an object/],
|
|
114
|
+
['non-object JSON (scalar)', '"fable"', /must be an object/],
|
|
115
|
+
['empty final text', '', /empty/],
|
|
116
|
+
['label-only final text', LABEL, /empty/],
|
|
117
|
+
['missing final text', undefined, /missing/],
|
|
118
|
+
];
|
|
119
|
+
for (const [name, text, expected] of FAIL_CASES) {
|
|
120
|
+
const verdict = checkJsonOnlyOutput(text, SELECTION_KEYS);
|
|
121
|
+
assert.strictEqual(verdict.pass, false, `${name} should fail`);
|
|
122
|
+
assert.match(verdict.reason, expected, `${name} should give a clear reason`);
|
|
123
|
+
}
|
|
124
|
+
assert.strictEqual(checkJsonOnlyOutput(RAW).pass, true, 'required_keys defaults to no key requirement');
|
|
125
|
+
|
|
126
|
+
for (const requiredKeys of ['requestedProfile', [''], [' '], [1], ['ok', null]]) {
|
|
127
|
+
const errors = validateExpectation({ type: 'final_output_json_only', required_keys: requiredKeys }, 0);
|
|
128
|
+
assert.ok(errors.some(error => /required_keys must be an array of non-empty strings/.test(error)), `required_keys ${JSON.stringify(requiredKeys)} should be rejected`);
|
|
129
|
+
}
|
|
130
|
+
assert.deepStrictEqual(validateExpectation({ type: 'final_output_json_only' }, 0), [], 'final_output_json_only without required_keys is valid');
|
|
131
|
+
assert.deepStrictEqual(validateExpectation({ type: 'final_output_json_only', required_keys: SELECTION_KEYS }, 0), [], 'final_output_json_only with string keys is valid');
|
|
132
|
+
|
|
133
|
+
const profileCasePath = path.join(casesDir, 'baseline-profile-lookup-json-only.yaml');
|
|
134
|
+
const profileCase = parseSimpleYaml(fs.readFileSync(profileCasePath, 'utf8'));
|
|
135
|
+
assert.deepStrictEqual(validateCase(profileCase, { fileName: 'baseline-profile-lookup-json-only.yaml' }), [], 'profile-lookup JSON-only case should validate');
|
|
136
|
+
assert.deepStrictEqual(profileCase.expectations.map(expectation => expectation.type), ['final_output_json_only'], 'profile-lookup case should assert JSON-only output');
|
|
137
|
+
assert.deepStrictEqual(profileCase.expectations[0].required_keys, SELECTION_KEYS, 'profile-lookup case should require the six selection keys');
|
|
138
|
+
assert.match(profileCase.prompt, /return only a small JSON selection record with requestedProfile, effectiveProfile, profileAlias, assignedRole, runtimeModel, runtimeEffort\. No orchestration or delegation\./, 'profile-lookup prompt should match the #308 wording');
|
|
139
|
+
|
|
140
|
+
const jsonOnlyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'behavioral-json-only-'));
|
|
141
|
+
try {
|
|
142
|
+
const writeTranscript = (name, finalText) => {
|
|
143
|
+
const transcriptPath = path.join(jsonOnlyDir, name);
|
|
144
|
+
const events = [
|
|
145
|
+
{ type: 'tool_use', part: { type: 'tool', tool: 'Read', callID: 'read-1', state: { status: 'completed', input: { file_path: 'README.md' } } } },
|
|
146
|
+
{ type: 'text', part: { type: 'text', text: 'Checking the profile table first.' } },
|
|
147
|
+
{ type: 'tool_use', part: { type: 'tool', tool: 'Read', callID: 'read-2', state: { status: 'completed', input: { file_path: 'profiles.md' } } } },
|
|
148
|
+
{ type: 'text', part: { type: 'text', text: finalText } },
|
|
149
|
+
];
|
|
150
|
+
fs.writeFileSync(transcriptPath, events.map(event => JSON.stringify(event)).join('\n'));
|
|
151
|
+
return transcriptPath;
|
|
152
|
+
};
|
|
153
|
+
const jsonOnlyExpectation = { expectations: [{ type: 'final_output_json_only', required_keys: SELECTION_KEYS }] };
|
|
154
|
+
let transcriptCount = 0;
|
|
155
|
+
const gradeFinal = finalText => grade(jsonOnlyExpectation, jsonOnlyDir, writeTranscript(`final-${transcriptCount++}.jsonl`, finalText));
|
|
156
|
+
assert.strictEqual(gradeFinal(RAW).passed, true, 'raw JSON final message should pass end to end');
|
|
157
|
+
assert.strictEqual(gradeFinal(`${FENCED}\n${LABEL}`).passed, true, 'fenced JSON with harness-label should pass end to end');
|
|
158
|
+
const prose = gradeFinal(`Fable on sonnect means a profile.\n${FENCED}`);
|
|
159
|
+
assert.strictEqual(prose.passed, false, 'prose before JSON must fail end to end');
|
|
160
|
+
assert.strictEqual(prose.results[0].status, 'fail');
|
|
161
|
+
assert.match(prose.results[0].reason, /prose before/);
|
|
162
|
+
// Prose from an earlier turn (before the last tool call) is not the final message.
|
|
163
|
+
const earlierProse = writeTranscript('earlier-prose.jsonl', RAW);
|
|
164
|
+
assert.strictEqual(grade(jsonOnlyExpectation, jsonOnlyDir, earlierProse).passed, true, 'only the final assistant message is graded');
|
|
165
|
+
|
|
166
|
+
// Multi-part final messages: the final turn may stream several text parts.
|
|
167
|
+
const writeParts = (name, finalParts) => {
|
|
168
|
+
const transcriptPath = path.join(jsonOnlyDir, name);
|
|
169
|
+
const events = [
|
|
170
|
+
{ type: 'tool_use', part: { type: 'tool', tool: 'Read', callID: 'read-1', state: { status: 'completed', input: { file_path: 'profiles.md' } } } },
|
|
171
|
+
...finalParts.map(text => ({ type: 'text', part: { type: 'text', text } })),
|
|
172
|
+
];
|
|
173
|
+
fs.writeFileSync(transcriptPath, events.map(event => JSON.stringify(event)).join('\n'));
|
|
174
|
+
return transcriptPath;
|
|
175
|
+
};
|
|
176
|
+
const proseThenJson = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts('prose-part-json-part.jsonl', ['"Fable on sonnect" means the fable profile on sonnet.', FENCED]));
|
|
177
|
+
assert.strictEqual(proseThenJson.passed, false, 'prose part + JSON part must fail (#308 false pass)');
|
|
178
|
+
assert.match(proseThenJson.results[0].reason, /prose before/);
|
|
179
|
+
const jsonThenProse = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts('json-part-prose-part.jsonl', [FENCED, 'That is the selection record.']));
|
|
180
|
+
assert.strictEqual(jsonThenProse.passed, false, 'JSON part + prose part must fail');
|
|
181
|
+
const splitBody = JSON.stringify(SELECTION, null, 2);
|
|
182
|
+
const cut = splitBody.indexOf('"assignedRole"');
|
|
183
|
+
const splitJson = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts('split-json.jsonl', ['```json\n' + splitBody.slice(0, cut), splitBody.slice(cut) + '\n```']));
|
|
184
|
+
assert.strictEqual(splitJson.passed, true, `JSON split across text parts should pass: ${splitJson.results[0].reason}`);
|
|
185
|
+
|
|
186
|
+
// Transport chunks cut inside a JSON string value, a key, and the fence opener.
|
|
187
|
+
// Exact concatenation must rebuild the message; a '\n' join would break it.
|
|
188
|
+
const valueCut = RAW.indexOf('fable') + 2;
|
|
189
|
+
const keyCut = RAW.indexOf('requestedProfile') + 5;
|
|
190
|
+
const splitValueParts = [RAW.slice(0, valueCut), RAW.slice(valueCut)];
|
|
191
|
+
const splitValuePath = writeParts('split-inside-value.jsonl', splitValueParts);
|
|
192
|
+
assert.strictEqual(finalAssistantText(parseTranscriptEvents(splitValuePath).events), RAW, 'split-inside-value parts must concatenate to the exact raw JSON');
|
|
193
|
+
assert.throws(() => JSON.parse(RAW.slice(0, valueCut) + '\n' + RAW.slice(valueCut)), 'a newline join inside a JSON string must not parse (the #308 reviewer case)');
|
|
194
|
+
const POSITIVE_SPLITS = [
|
|
195
|
+
['split inside a JSON string value', splitValueParts],
|
|
196
|
+
['split inside a JSON key', [RAW.slice(0, keyCut), RAW.slice(keyCut)]],
|
|
197
|
+
['split inside the fence opener', ['```js', 'on\n' + JSON.stringify(SELECTION, null, 2) + '\n```']],
|
|
198
|
+
['fenced JSON block followed by a harness-label part', [FENCED, LABEL]],
|
|
199
|
+
];
|
|
200
|
+
POSITIVE_SPLITS.forEach(([name, parts], index) => {
|
|
201
|
+
const result = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts(`positive-split-${index}.jsonl`, parts));
|
|
202
|
+
assert.strictEqual(result.passed, true, `${name} should pass end to end: ${result.results[0].reason}`);
|
|
203
|
+
});
|
|
204
|
+
const NEGATIVE_SPLITS = [
|
|
205
|
+
['prose chunk glued to JSON start', ['Here:', RAW], /prose before/],
|
|
206
|
+
['prose after a split JSON', [RAW.slice(0, 10), RAW.slice(10), ' done'], /not a single valid JSON value/],
|
|
207
|
+
];
|
|
208
|
+
NEGATIVE_SPLITS.forEach(([name, parts, expected], index) => {
|
|
209
|
+
const result = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts(`negative-split-${index}.jsonl`, parts));
|
|
210
|
+
assert.strictEqual(result.passed, false, `${name} must fail end to end`);
|
|
211
|
+
assert.match(result.results[0].reason, expected, `${name} should give a clear reason`);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Claude stream-json: assistant content parts plus a terminal `result` that
|
|
215
|
+
// repeats them. The repeat must not be double-counted, and must not mask prose.
|
|
216
|
+
const writeClaude = (name, contentTexts, resultText) => {
|
|
217
|
+
const transcriptPath = path.join(jsonOnlyDir, name);
|
|
218
|
+
const lines = [
|
|
219
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'profiles.md' } }] } },
|
|
220
|
+
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'ok' }] } },
|
|
221
|
+
{ type: 'assistant', message: { content: contentTexts.map(text => ({ type: 'text', text })) } },
|
|
222
|
+
{ type: 'result', subtype: 'success', result: resultText },
|
|
223
|
+
];
|
|
224
|
+
fs.writeFileSync(transcriptPath, lines.map(line => JSON.stringify(line)).join('\n'));
|
|
225
|
+
return transcriptPath;
|
|
226
|
+
};
|
|
227
|
+
assert.strictEqual(grade(jsonOnlyExpectation, jsonOnlyDir, writeClaude('claude-result-repeat.jsonl', [FENCED], FENCED)).passed, true, 'Claude result repeat must not double the JSON');
|
|
228
|
+
assert.strictEqual(grade(jsonOnlyExpectation, jsonOnlyDir, writeClaude('claude-prose-parts.jsonl', ['Here is the record.', FENCED], FENCED)).passed, false, 'Claude prose content part must fail even when result holds only JSON');
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(jsonOnlyDir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log(`Behavioral case validation passed (${fs.readdirSync(casesDir).filter(name => name.endsWith('.yaml')).length} cases + negative controls + ordered execution evidence + json-only final output).`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "harness-everything",
|
|
4
|
-
"version": "0.47.
|
|
4
|
+
"version": "0.47.4",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|
package/sbom.cdx.json
CHANGED
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
"$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.7",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:9486ee85-4d20-5c13-b980-a501dc02e9fa",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-
|
|
8
|
+
"timestamp": "2026-11-03T01:39:16.935Z",
|
|
9
9
|
"tools": [
|
|
10
10
|
{
|
|
11
11
|
"vendor": "Harness Everything",
|
|
12
12
|
"name": "generate-sbom.js",
|
|
13
|
-
"version": "0.47.
|
|
13
|
+
"version": "0.47.4"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"component": {
|
|
17
17
|
"type": "application",
|
|
18
|
-
"bom-ref": "pkg:npm/harness-everything@0.47.
|
|
18
|
+
"bom-ref": "pkg:npm/harness-everything@0.47.4",
|
|
19
19
|
"name": "harness-everything",
|
|
20
|
-
"version": "0.47.
|
|
21
|
-
"purl": "pkg:npm/harness-everything@0.47.
|
|
20
|
+
"version": "0.47.4",
|
|
21
|
+
"purl": "pkg:npm/harness-everything@0.47.4"
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"components": [
|
|
@@ -2033,7 +2033,7 @@
|
|
|
2033
2033
|
]
|
|
2034
2034
|
},
|
|
2035
2035
|
{
|
|
2036
|
-
"ref": "pkg:npm/harness-everything@0.47.
|
|
2036
|
+
"ref": "pkg:npm/harness-everything@0.47.4",
|
|
2037
2037
|
"dependsOn": [
|
|
2038
2038
|
"pkg:npm/%40commitlint/cli@19.8.1",
|
|
2039
2039
|
"pkg:npm/%40commitlint/config-conventional@19.8.1",
|