@bongos/core 1.19.443 → 1.19.445
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/.bongos-core.json +120 -20
- package/docs/adr/0054-session-token-reward-cost-plus.md +31 -0
- package/docs/design/hall-direction-v2.md +295 -0
- package/docs/design/mocks/hall/_tokens.css +370 -0
- package/docs/design/mocks/hall/board/v1.html +846 -0
- package/docs/design/mocks/hall/board/v2.html +858 -0
- package/docs/design/mocks/hall/board/v3.html +884 -0
- package/docs/design/mocks/hall/goals/v1.html +1212 -0
- package/docs/design/mocks/hall/goals/v2.html +1192 -0
- package/docs/design/mocks/hall/goals/v3.html +1205 -0
- package/docs/design/mocks/hall/hall-sheet.html +244 -0
- package/docs/design/mocks/hall/reading/v1.html +1125 -0
- package/docs/design/mocks/hall/reading/v2.html +1177 -0
- package/docs/design/mocks/hall/reading/v3.html +1150 -0
- package/docs/design/mocks/hall/settings/v1.html +1238 -0
- package/docs/design/mocks/hall/settings/v2.html +1235 -0
- package/docs/design/mocks/hall/settings/v3.html +1299 -0
- package/docs/design/mocks/hall/task/v1.html +637 -0
- package/docs/design/mocks/hall/task/v2.html +646 -0
- package/docs/design/mocks/hall/task/v3.html +651 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +46 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-subagent-rewards.js +485 -0
- package/scripts/gds/session-digest-build.js +49 -17
- package/scripts/gds/session-digest.js +11 -1
- package/scripts/gds/ship-session-upload.js +102 -12
- package/scripts/gds/ship.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/backfill_subagent_rewards.mjs +308 -0
- package/tests/credit_grant.mjs +236 -0
package/tests/credit_grant.mjs
CHANGED
|
@@ -588,6 +588,242 @@ await test('cost-plus-only cascade: a 0 per-task credit → idea-promotion slice
|
|
|
588
588
|
assert.equal(client.queries.length, 0, 'no lineage lookup, no bonus INSERT when the base credit is 0');
|
|
589
589
|
});
|
|
590
590
|
|
|
591
|
+
// =========================================================================
|
|
592
|
+
// (7) task 1003435 — a PARALLEL session (one claim, several subagents).
|
|
593
|
+
//
|
|
594
|
+
// The cost ledger has always billed a session's subagents subtree in its own
|
|
595
|
+
// `session:subagent` cost_log row, but model_usage — the reward's only input —
|
|
596
|
+
// was built from ONE file, the interactive transcript. So a builder who followed
|
|
597
|
+
// CLAUDE.md's "delegate freely" moved most of a session's real spend into a
|
|
598
|
+
// bucket the reward could not see, and the two ledgers described different
|
|
599
|
+
// sessions.
|
|
600
|
+
//
|
|
601
|
+
// The acceptance test is that RECONCILIATION: what the reward prices must equal
|
|
602
|
+
// what the ledger bills, within independent-rounding epsilon. It runs both real
|
|
603
|
+
// pricing paths over the same on-disk fixture rather than asserting a hand-typed
|
|
604
|
+
// number on either side, so a change to one that is not made to the other reds.
|
|
605
|
+
// =========================================================================
|
|
606
|
+
|
|
607
|
+
const fs = require('node:fs');
|
|
608
|
+
const os = require('node:os');
|
|
609
|
+
const nodePath = require('node:path');
|
|
610
|
+
const { priceTranscript, priceSubagents, buildCostRows } = require('../scripts/gds/session-cost.js');
|
|
611
|
+
const { buildSessionDigest } = require('../scripts/gds/session-digest-build.js');
|
|
612
|
+
const { foldSubagentUsage } = require('../scripts/gds/ship-session-upload.js');
|
|
613
|
+
const { priceModelUsage } = require('../src/bongos/llm-pricing.js');
|
|
614
|
+
|
|
615
|
+
const PARALLEL_SID = 'sess-parallel';
|
|
616
|
+
|
|
617
|
+
// One assistant transcript line. `id` is the API call's identity — two lines
|
|
618
|
+
// sharing one are ONE call, which is what makes the dedup assertion below real.
|
|
619
|
+
function asstLine(id, model, usage) {
|
|
620
|
+
return JSON.stringify({
|
|
621
|
+
type: 'assistant',
|
|
622
|
+
timestamp: '2026-08-30T00:00:00.000Z',
|
|
623
|
+
message: { id, model, usage, content: [{ type: 'text', text: 'work' }] },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
function userLine(text) {
|
|
627
|
+
return JSON.stringify({
|
|
628
|
+
type: 'user',
|
|
629
|
+
timestamp: '2026-08-30T00:00:00.000Z',
|
|
630
|
+
message: { role: 'user', content: text },
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
function parseJsonlText(raw) {
|
|
634
|
+
return raw.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A session that spawned three subagents, one of them a grader. Mirrors the real
|
|
638
|
+
// on-disk layout: the interactive transcript is a FILE, and the subagents live in
|
|
639
|
+
// a sibling DIRECTORY named for the session id.
|
|
640
|
+
function makeParallelFixture() {
|
|
641
|
+
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'otb-parallel-'));
|
|
642
|
+
const mainPath = nodePath.join(dir, `${PARALLEL_SID}.jsonl`);
|
|
643
|
+
// opus: 150k in / 30k out / 1.5M read / 40k write = $0.75+$0.75+$0.75+$0.25 = $2.50.
|
|
644
|
+
// m1 is written twice (one API call, two content blocks) — priced ONCE.
|
|
645
|
+
const dup = asstLine('m1', 'claude-opus-4-8', {
|
|
646
|
+
input_tokens: 100_000, output_tokens: 20_000,
|
|
647
|
+
cache_read_input_tokens: 1_000_000, cache_creation_input_tokens: 40_000,
|
|
648
|
+
});
|
|
649
|
+
fs.writeFileSync(mainPath, [
|
|
650
|
+
userLine('ship task 1003435'),
|
|
651
|
+
dup,
|
|
652
|
+
dup,
|
|
653
|
+
asstLine('m2', 'claude-opus-4-8', {
|
|
654
|
+
input_tokens: 50_000, output_tokens: 10_000,
|
|
655
|
+
cache_read_input_tokens: 500_000, cache_creation_input_tokens: 0,
|
|
656
|
+
}),
|
|
657
|
+
].join('\n'));
|
|
658
|
+
|
|
659
|
+
const subagents = nodePath.join(dir, PARALLEL_SID, 'subagents');
|
|
660
|
+
const nested = nodePath.join(subagents, 'nested');
|
|
661
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
662
|
+
// opus subagent: 200k in / 40k out / 2M read = $1.00+$1.00+$1.00 = $3.00
|
|
663
|
+
fs.writeFileSync(nodePath.join(subagents, 'agent-a.jsonl'), [
|
|
664
|
+
userLine('audit the pricing path'),
|
|
665
|
+
asstLine('a1', 'claude-opus-4-8', {
|
|
666
|
+
input_tokens: 200_000, output_tokens: 40_000, cache_read_input_tokens: 2_000_000,
|
|
667
|
+
}),
|
|
668
|
+
].join('\n'));
|
|
669
|
+
// sonnet subagent, one level deeper — the walk is recursive:
|
|
670
|
+
// 100k in / 20k out / 1M read = $0.20+$0.20+$0.20 = $0.60
|
|
671
|
+
fs.writeFileSync(nodePath.join(nested, 'agent-b.jsonl'), [
|
|
672
|
+
userLine('read the ledger rows'),
|
|
673
|
+
asstLine('b1', 'claude-sonnet-5', {
|
|
674
|
+
input_tokens: 100_000, output_tokens: 20_000, cache_read_input_tokens: 1_000_000,
|
|
675
|
+
}),
|
|
676
|
+
].join('\n'));
|
|
677
|
+
// A GRADER worker nested in the subagents dir. Its $5.00 is already captured via
|
|
678
|
+
// the worker envelope, so pricing it here would double-count (ADR 0028).
|
|
679
|
+
fs.writeFileSync(nodePath.join(nested, 'grader-worker.jsonl'), [
|
|
680
|
+
userLine('You are the **Hacker** — a specialist adversarial subagent. Find the holes.'),
|
|
681
|
+
asstLine('g1', 'claude-opus-4-8', { input_tokens: 1_000_000 }),
|
|
682
|
+
].join('\n'));
|
|
683
|
+
|
|
684
|
+
return { dir, mainPath, sessionDir: nodePath.join(dir, PARALLEL_SID) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const fixture = makeParallelFixture();
|
|
688
|
+
const INTERACTIVE_USD = 2.50;
|
|
689
|
+
const SUBAGENT_USD = 3.60;
|
|
690
|
+
const SESSION_USD = INTERACTIVE_USD + SUBAGENT_USD; // $6.10
|
|
691
|
+
// Each cost_log row is rounded to 4dp independently and priceModelUsage rounds
|
|
692
|
+
// its own total, so the two sides may disagree by a rounding step per row.
|
|
693
|
+
const EPSILON = 0.0002;
|
|
694
|
+
|
|
695
|
+
// The digest the ship uploads for this session, subagents folded in.
|
|
696
|
+
function parallelDigest() {
|
|
697
|
+
const digest = buildSessionDigest(parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8')));
|
|
698
|
+
foldSubagentUsage(digest, fixture.mainPath, PARALLEL_SID);
|
|
699
|
+
return digest;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
await test('1003435 the cost ledger bills interactive + subagent, grader excluded', () => {
|
|
703
|
+
const inter = priceTranscript(fixture.mainPath);
|
|
704
|
+
const sub = priceSubagents(fixture.sessionDir);
|
|
705
|
+
assert.equal(Math.round(inter.total * 100) / 100, INTERACTIVE_USD, 'm1 priced once, not twice');
|
|
706
|
+
assert.equal(Math.round(sub.subagentTotal * 100) / 100, SUBAGENT_USD);
|
|
707
|
+
assert.equal(sub.subagentFiles, 2);
|
|
708
|
+
assert.equal(sub.graderSkipped, 1, 'the grader worker is skipped by signature');
|
|
709
|
+
const built = buildCostRows({ sessionId: PARALLEL_SID, inter, sub });
|
|
710
|
+
assert.equal(built.rows.length, 2, 'one :interactive row + one :subagent row');
|
|
711
|
+
assert.equal(Math.round(built.rowsTotal * 100) / 100, SESSION_USD);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
await test('1003435 model_usage now carries the subagents subtree, grader excluded', () => {
|
|
715
|
+
const digest = parallelDigest();
|
|
716
|
+
const models = Object.keys(digest.metrics.model_usage).sort();
|
|
717
|
+
assert.deepEqual(models, ['claude-opus-4-8', 'claude-sonnet-5'],
|
|
718
|
+
'the sonnet subagent reached model_usage, which only the fold can do');
|
|
719
|
+
assert.equal(digest.metrics.subagent_files, 2);
|
|
720
|
+
assert.equal(digest.metrics.subagent_grader_skipped, 1);
|
|
721
|
+
// The grader's 1M opus input tokens must be in NEITHER ledger.
|
|
722
|
+
assert.equal(digest.metrics.model_usage['claude-opus-4-8'].input, 350_000,
|
|
723
|
+
'150k interactive + 200k subagent — the grader\'s 1M is not counted');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
await test('1003435 detail + total_tokens stay the INTERACTIVE thread\'s record', () => {
|
|
727
|
+
const interactiveOnly = buildSessionDigest(
|
|
728
|
+
parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'))
|
|
729
|
+
);
|
|
730
|
+
const digest = parallelDigest();
|
|
731
|
+
// The fold is model_usage ONLY: detail and the turn/token metrics feed the BFG
|
|
732
|
+
// corpus + inefficiency evaluator, which reason about ONE agent's turn-by-turn
|
|
733
|
+
// behaviour. Splicing N subagent transcripts into that would distort the very
|
|
734
|
+
// signal they measure, so the subagent_* counters carry the difference instead.
|
|
735
|
+
assert.equal(digest.detail.length, interactiveOnly.detail.length);
|
|
736
|
+
assert.equal(digest.totalTokens, interactiveOnly.totalTokens);
|
|
737
|
+
assert.equal(digest.metrics.assistant_turns, interactiveOnly.metrics.assistant_turns);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await test('1003435 ACCEPTANCE: the reward reconciles with the session\'s cost_log rows', () => {
|
|
741
|
+
const inter = priceTranscript(fixture.mainPath);
|
|
742
|
+
const sub = priceSubagents(fixture.sessionDir);
|
|
743
|
+
const ledgerUsd = buildCostRows({ sessionId: PARALLEL_SID, inter, sub }).rowsTotal;
|
|
744
|
+
const rewardUsd = priceModelUsage(parallelDigest().metrics.model_usage).true_cost_usd;
|
|
745
|
+
assert.ok(Math.abs(rewardUsd - ledgerUsd) <= EPSILON,
|
|
746
|
+
`reward priced $${rewardUsd} but the ledger billed $${ledgerUsd}`);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
await test('1003435 a parallel session is PAID for its subagents', async () => {
|
|
750
|
+
const client = fakeClient();
|
|
751
|
+
client.enqueue(NO_PRIOR);
|
|
752
|
+
client.enqueue([{ id: 99 }]);
|
|
753
|
+
const digest = parallelDigest();
|
|
754
|
+
const res = await awardSessionTokenReward(
|
|
755
|
+
client, 7, PARALLEL_SID, digest.metrics.model_usage, digest.totalTokens
|
|
756
|
+
);
|
|
757
|
+
assert.equal(res.awarded, true);
|
|
758
|
+
assert.equal(res.trueCostUsd, SESSION_USD);
|
|
759
|
+
assert.equal(res.drachmae, Math.round(SESSION_USD * 1.20)); // $6.10 × 1.20 = 7.32 → 7
|
|
760
|
+
// The bug this closes: interactive-only priced the same session at $2.50 → 3.
|
|
761
|
+
const before = computeSessionTokenReward(INTERACTIVE_USD).drachmae;
|
|
762
|
+
assert.ok(res.drachmae > before, `subagent-blind reward was ${before}, now ${res.drachmae}`);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
await test('1003435 a re-ship of the same session is still a no-op (one award per session)', async () => {
|
|
766
|
+
const digest = parallelDigest();
|
|
767
|
+
const client = fakeClient();
|
|
768
|
+
// The watermark already holds everything this session is worth.
|
|
769
|
+
client.enqueue(ledger(Math.round(SESSION_USD * 1.20), 0));
|
|
770
|
+
const res = await awardSessionTokenReward(
|
|
771
|
+
client, 7, PARALLEL_SID, digest.metrics.model_usage, digest.totalTokens
|
|
772
|
+
);
|
|
773
|
+
assert.equal(res.awarded, false);
|
|
774
|
+
assert.equal(res.drachmae, 0);
|
|
775
|
+
assert.equal(client.queries.length, 1, 'SELECT only — a second upload must not pay again');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await test('1003435 a traversing session id folds NOTHING (the walk feeds a payout)', () => {
|
|
779
|
+
// The id is interpolated into a filesystem path whose contents get priced, so
|
|
780
|
+
// a `../` value could aim the walk at another directory and fold fabricated
|
|
781
|
+
// tokens into the reward. The on-ship caller screens the id before resolving
|
|
782
|
+
// the transcript; the SessionEnd hook takes it straight off stdin — so the
|
|
783
|
+
// guard lives in foldSubagentUsage, where every caller gets it.
|
|
784
|
+
const entries = parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'));
|
|
785
|
+
const clean = buildSessionDigest(entries).metrics.model_usage;
|
|
786
|
+
for (const hostile of ['../' + PARALLEL_SID, `${PARALLEL_SID}/..`, 'a/../../etc', '']) {
|
|
787
|
+
const digest = foldSubagentUsage(buildSessionDigest(entries), fixture.mainPath, hostile);
|
|
788
|
+
assert.deepEqual(digest.metrics.model_usage, clean, `folded for id ${JSON.stringify(hostile)}`);
|
|
789
|
+
assert.equal(digest.metrics.subagent_files, undefined);
|
|
790
|
+
}
|
|
791
|
+
// …and the legitimate id still folds, so the guard is not just refusing everything.
|
|
792
|
+
const ok = foldSubagentUsage(buildSessionDigest(entries), fixture.mainPath, PARALLEL_SID);
|
|
793
|
+
assert.equal(ok.metrics.subagent_files, 2);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
await test('1003435 usageOnly changes what is BUILT, never what is COUNTED', () => {
|
|
797
|
+
// The fold reuses buildSessionDigest so the token semantics — dedup by
|
|
798
|
+
// message.id, skip <synthetic>, fold unlabeled turns — have ONE home. usageOnly
|
|
799
|
+
// only drops the per-turn rows it would discard (and their snippet scrubbing,
|
|
800
|
+
// which the SessionEnd watchdog gives a hard time budget). If it ever changed a
|
|
801
|
+
// count, the reward and the ledger would quietly diverge again.
|
|
802
|
+
const entries = parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'));
|
|
803
|
+
const full = buildSessionDigest(entries);
|
|
804
|
+
const lean = buildSessionDigest(entries, { usageOnly: true });
|
|
805
|
+
assert.deepEqual(lean.metrics.model_usage, full.metrics.model_usage);
|
|
806
|
+
assert.equal(lean.totalTokens, full.totalTokens);
|
|
807
|
+
assert.equal(lean.metrics.assistant_turns, full.metrics.assistant_turns);
|
|
808
|
+
assert.equal(lean.metrics.api_calls, full.metrics.api_calls);
|
|
809
|
+
assert.deepEqual(lean.detail, [], 'the detail rows are what usageOnly skips');
|
|
810
|
+
assert.ok(full.detail.length > 0);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await test('1003435 a session with no subagents is untouched (no regression)', () => {
|
|
814
|
+
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'otb-solo-'));
|
|
815
|
+
const mainPath = nodePath.join(dir, 'sess-solo.jsonl');
|
|
816
|
+
fs.writeFileSync(mainPath, asstLine('s1', 'claude-opus-4-8', { input_tokens: 110_000 }));
|
|
817
|
+
const entries = parseJsonlText(fs.readFileSync(mainPath, 'utf8'));
|
|
818
|
+
const before = buildSessionDigest(entries);
|
|
819
|
+
const after = foldSubagentUsage(buildSessionDigest(entries), mainPath, 'sess-solo');
|
|
820
|
+
assert.deepEqual(after.metrics.model_usage, before.metrics.model_usage);
|
|
821
|
+
assert.equal(after.metrics.subagent_files, undefined, 'no counters when there is nothing to count');
|
|
822
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
|
826
|
+
|
|
591
827
|
// -------------------------------------------------------------------------
|
|
592
828
|
// runner summary
|
|
593
829
|
// -------------------------------------------------------------------------
|