Skip to content

feat(observability): turns per human message — the other half of the #70 ledger - #124

Merged
yetone merged 1 commit into
yetone:mainfrom
wg2038:feat/turns-per-message
Sep 1, 2026
Merged

feat(observability): turns per human message — the other half of the #70 ledger#124
yetone merged 1 commit into
yetone:mainfrom
wg2038:feat/turns-per-message

Conversation

@wg2038

@wg2038 wg2038 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Step ② of the ladder agreed in the #70 review: make the waste measurable before acting on it. Turns per human message, over agent_runs — the number any routing change (#92's me, a future one-of-us) claims to shrink, so its effect can be read off a panel instead of argued from vibes.

What it measures

  • Numerator: for each human message, the token-bearing runs whose drained inbox (agent_runs.input_message_ids) included it. A run draining a burst of k messages counts toward each of them — deliberate: the message genuinely participated in that turn. This is also why the panel reports counts, not dollars: dollars per message would need fractional run attribution, a different and denser metric (the silent-rate panel next to it already owns the dollars for the runs that said nothing).
  • Denominator: human-authored, non-system messages in group/direct rooms with at least one agent member — so a message nobody's inbox ever reached shows up as 0 turns instead of vanishing from the stats.
  • Shape: per conversation kind (group/direct, never averaged, same rule as the silent-rate panel): messages, turns, avg, median (percentile_cont), and a 0 / 1 / 2 / 3–5 / 6+ histogram bar. A group width near 1 is the state feat(agent-routing): avoid waking every agent with the same group context #70's routing work is trying to reach.

Where it lives

Rides on the existing GET /agents/observability/wakes response (turnsPerMessage field) so the panel keeps one fetch and one window selector. Room-wide by design — the agentId filter does not apply, because fan-out width is a property of the room; the panel says so explicitly when a filter is active rather than silently mixing scopes. Implemented in observability.ts next to getWakeEconomics (one aggregate query, grouped in SQL, no per-row JS); rendered as a second card in the wake-economics panel; zh-CN included.

Not in this PR (on purpose)

  • CUMORA_MODEL_PRICES_JSON: the review also noted every cost row is cost_estimated today. That affects the dollar columns, which this PR doesn't add — but the reminder stands for the silent-spend column that already exists; costEstimated on this response already surfaces it.
  • Any routing change. This is the measuring tape; feat(agent-routing): elect one agent for unaddressed human group messages #123 (parked draft) is what it would be used to judge.

Tests & gates

agents-observability-turns.test.ts: bucket mapping, fixed histogram order (the bar renderer depends on it), pg numeric-string conversion, rides-along on the wake-economics response, sinceHours clamp parity. Gates: typecheck, server:typecheck, lint (pre-existing single info), three guard:* scripts, routing + observability test files green.

…etone#70 ledger

The wake-economics panel answers "of the turns that fired, how many said
nothing". This adds the figure the yetone#70 review asked for before any further
routing change: how many turns a human message fires in the first place.
The scheduler picks recipients purely by membership, so fan-out width is
the number a routing change claims to shrink — measured here rather than
argued.

Attribution: agent_runs.input_message_ids is the inbox a run actually
drained, so a message's turn count is its token-bearing runs. A run
draining a burst counts toward each message it read — deliberate (the
message genuinely participated in that turn), and why the panel reports
counts, not dollars. Denominator: human non-system messages in
group/direct rooms with at least one agent member, so a message nobody's
inbox reached shows up as 0 turns instead of vanishing.

Room-wide by design: the agentId filter does not apply, because width is
a property of the room. The panel says so when a filter is active.
Rides on the existing /agents/observability/wakes response so the panel
keeps one fetch; buckets are group/direct, never averaged, with avg,
median, and a 0/1/2/3-5/6+ distribution bar.
@yetone

yetone commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Thanks — the framing is right, and step ② is exactly the order we agreed in the #70 review: measure the width before changing the routing. The attribution choice (a run counts toward each message it drained, counts not dollars) is well argued and I agree with it. One blocking issue before this can land.

avgTurns and medianTurns drop the zero-turn messages

The denominator prose says it plainly:

a message nobody's inbox ever reached shows up as 0 turns instead of vanishing from the stats

The histogram honours that — it counts through COALESCE(t.turns, 0). But avg and percentile_cont read t.turns raw, and after the LEFT JOIN that column is NULL for exactly the messages that got no run. SQL aggregates skip NULLs, so those messages vanish from both numbers.

Reproduced on Postgres with 4 messages, 2 of which got no run (4 turns + 2 turns + nothing + nothing):

messages | turns | avg_as_written | avg_intended | median_as_written | median_intended
---------+-------+----------------+--------------+-------------------+----------------
       4 |     6 |          3.000 |        1.500 |             3.000 |           1.000

The panel reports 3.0 where the real fan-out width is 1.5 — a 2× overstatement, and it grows with the share of messages nobody woke on. That is the one number this feature exists to produce, and the number a routing change would be judged by, so an over-count here would make a real improvement look like noise (or manufacture one that isn't there).

It is also visibly self-contradictory in the card: turns and messages are both rendered, so 6 / 4 = 1.5 sits next to a printed average of 3.0.

The fix is two COALESCEs:

COALESCE(avg(COALESCE(t.turns, 0)), 0) AS avg_turns,
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY COALESCE(t.turns, 0)), 0) AS median_turns,

Why CI didn't catch it

agents-observability-turns.test.ts mocks pool.query and returns canned rows, so it covers the JS mapping (pg numeric-string → number, histogram order) but never executes the aggregate. Worth adding one case that runs the real SQL against a seeded room where some human messages drew no run — that is the assertion that would have failed here, and it is the invariant the panel's whole claim rests on.

Everything else reads well: the room-wide scoping with the explicit note when an agent filter is active, the sinceHours clamp parity, and keeping dollars out of this panel. Push the fix and I'll merge.

@yetone yetone mentioned this pull request Aug 31, 2026
@yetone
yetone merged commit 9060df7 into yetone:main Sep 1, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 1, 2026
@yetone

yetone commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Merged — with the aggregate blocker fixed in place rather than held any longer.

The framing and the attribution argument were right, and the panel is worth having. The two COALESCEs moved inside the aggregates:

COALESCE(avg(COALESCE(t.turns, 0)), 0) AS avg_turns,
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY COALESCE(t.turns, 0)), 0) AS median_turns,

Verified against Postgres — 4 messages, 6 turns, 2 of them never woken: avg 1.50, median 1.00, which now agrees with the turns/messages pair rendered beside it instead of printing 3.0 next to a ratio of 1.5.

I also added one test that runs the aggregate shape against a real Postgres and pins both the correct value and the wrong one. Your other cases mock pool.query, which is exactly why they could not catch this: a mock returns whatever rows it was told to, so the SQL never executed. That is worth carrying into the next observability PR — anything whose value comes out of an aggregate needs at least one case that actually runs it.

Thanks for the panel. #123 is the thing it was built to judge, whenever you pick it back up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants