Skip to content

feat(dashboard,tui): show agent token footprint and recent calls - #7976

Merged
houko merged 16 commits into
librefang:mainfrom
DaBlitzStein:feat/token-footprint-ui
Sep 12, 2026
Merged

houko merged 16 commits into
librefang:mainfrom
DaBlitzStein:feat/token-footprint-ui

Conversation

@DaBlitzStein

Copy link
Copy Markdown
Contributor

Summary

  • Dashboard: new "Token footprint" section in the agent detail drawer showing injected_footprint_tokens (from feat(agents): report an agent's injected token footprint #7972) and the five most recent LLM calls from the existing events endpoint.
  • TUI: $ keybinding in the agent detail fetches the footprint and recent calls, rendering both in the ratatui detail pane.
  • No new API endpoint or query hook — uses the existing useAgentDetail and useAgentEvents data.
  • Locale keys added to all five dashboard languages (en, ko, pl, uk, zh) and four TUI languages (en, ko, uk, zh-CN).

Depends on #7972 (adds injected_footprint_tokens to the agent detail response).

Verification

  • npx tsc --noEmit — clean
  • vitest run locale-parity — 8/8 passed
  • cargo clippy -p librefang-cli --all-targets -- -D warnings — clean
  • cargo check -p librefang-cli — clean

@houko houko left a comment

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.

The feature is only reachable by pressing a key nothing tells you about.

$ in the agent detail view is what fetches the footprint, and the only string that mentions it is the section header:

tui-agents-detail-tokens = Token footprint  ($ to refresh)

That header renders inside if let Some(usage) = &state.token_usage, so it exists only after the fetch has already happened.
Before the first press there is nothing on screen, in the hint bar, or in any locale file that names $ — the hint is behind the door it unlocks.
Either put it in the detail view's hint bar alongside the other keys, or render the section with a "press $ to load" placeholder so the affordance exists before the data does.

Two smaller things in crates/librefang-cli/src/tui/screens/agents.rs:

AgentTokenUsage.total_tokens does not hold a total.
It is assigned from body["injected_footprint_tokens"] — the static system-prompt-plus-tools cost, which is per-request overhead, not a cumulative total for the agent.
The rendered label says "injected" so the screen is honest, but the field name is not, and the next reader to reach for total_tokens will get a number that means something else.
injected_footprint_tokens matches both the API field and the value.

recent: Vec<(String, u64, u64, f64)> costs a trip back to the producer to find out that the fields are model, input, output, cost — and at the render site it is destructured positionally, so transposing input and output would compile and be wrong.
A four-field struct with names would carry that at both ends. (#7995 adds a UsageEventRow with exactly these fields; if that lands, it is the shape to mirror.)

The locale coverage is right, by the way — all five dashboard locales including pl.json, which is the file #8013 is currently red on missing.

Unrelated: the AgentAction enum hunk reformats ChatWithAgent, UpdateSkills, UpdateMcpServers and UpdateChannels from one line each to four, which is rustfmt churn on variants this PR does not otherwise touch.
Harmless, but it triples the size of that hunk for a reviewer looking for the one added variant.

@DaBlitzStein
DaBlitzStein force-pushed the feat/token-footprint-ui branch from eb94b8e to 0f33f93 Compare August 30, 2026 21:42
@github-actions github-actions Bot added the has-conflicts PR has merge conflicts that need resolution label Aug 31, 2026
The agent detail had no cost visibility. Surfaces `injected_footprint_tokens`
from the agent detail response (PR librefang#7972) alongside the five most recent LLM
calls from the existing events endpoint.

Dashboard: new "Token footprint" section in the agent detail drawer, using the
existing `useAgentDetail` and `useAgentEvents` queries — no new API call.
Locale keys added to en, ko, pl, uk, zh.

TUI: `$` keybinding in the agent detail fetches the footprint from the detail
endpoint and recent calls from the events endpoint, rendering both in the
ratatui detail pane. FTL keys added to en, ko, uk, zh-CN.
@DaBlitzStein
DaBlitzStein force-pushed the feat/token-footprint-ui branch from 0f33f93 to 1c35045 Compare August 31, 2026 23:00
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Aug 31, 2026

@houko houko left a comment

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.

Solid: it reuses the existing GET /api/agents/{id}/events (which already serves usage_events rows with exactly model / input_tokens / output_tokens / cost_usd), injected_footprint_tokens is already emitted by GET /api/agents/{id}, every locale is genuinely translated, and the i18n allowlist entry is a real technical format string in the right section. Two correctness issues in the TUI half.

Blocking

1. AgentTokenUsageLoaded carries no agent id, so a late response paints the wrong agent.

AppEvent::AgentTokenUsageLoaded(usage) => {
    self.agents.token_usage = Some(usage);
}

spawn_fetch_agent_token_usage runs on its own thread per selection and makes two sequential HTTP calls. Select agent A, then B before A's fetch returns, and A's numbers land in self.agents.token_usage and render under B's name — with nothing to tell the operator the token count and cost belong to a different agent. Even without a race, token_usage is not cleared on selection change, so B shows A's figures until B's fetch completes.

Both are fixed by the same change: carry the agent id in the event, drop it when it does not match the current selection, and set token_usage = None when the selection changes.

2. Both fetches swallow failures into a real-looking zero.

if let Ok(body) = client.get(…).send().and_then(|r| r.json::<serde_json::Value>()) {
    usage.total_tokens = body["injected_footprint_tokens"].as_u64().unwrap_or(0);
}

A 404, a 500, a connection refused and a body missing the field all produce total_tokens: 0 and an empty recent list, which renders as "this agent has no token footprint and has made no calls". Distinguish unavailable from zero — an error arm on the event, or at minimum a status_msg.

Related: the whole function is wrapped in if let BackendRef::Daemon { .. } = backend, with no else. In-process TUI users get no event at all, so the panel silently never populates. Same shape as the history fetch in #8073; both would benefit from an explicit "requires daemon mode" state.

Non-blocking

  • The dashboard fetches the default 30 events and then .slice(0, 5), while the TUI asks for ?limit=5. Same panel, same five rows — worth passing limit: 5 on the dashboard query too, or at least agreeing on one number.
  • Worth noting for #7995: that PR adds UsageStore::recent_events, which duplicates list_agent_events_recent — the method this PR's endpoint already uses. Since this is the consumer #7995's new method was presumably written for, and it does not need it, that method can be dropped there.
  • No changelog fragment (changelog.d/added/7976-....md). A new per-agent cost/token panel in two surfaces is release-note material.

@github-actions github-actions Bot added needs-changes Changes requested by reviewer and removed ready-for-review PR is ready for maintainer review labels Sep 1, 2026
@houko

houko commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Status check while I worked through the review queue — this one is still waiting on you, and I would rather say so plainly than leave it looking like my turn.

No commits of your own since my review on 2026-09-01T13:56:11Z; the only thing that moved the branch tip is a merge from main, which the auto-update-branches cron pushes by itself. The PR's "updated" timestamp has therefore advanced while the blocking items have not been touched. Worth flagging because that timestamp is what makes a PR look answered from outside — I nearly mis-triaged a dozen PRs on exactly that signal today.

Still open from that review:

    1. AgentTokenUsageLoaded carries no agent id, so a late response paints the wrong agent
    1. Both fetches swallow failures into a real-looking zero

Full reasoning and the file/line evidence for each is in that review. I re-read it before writing this and have not changed my mind on any of them.

No conflict with main as of right now, so a push should apply cleanly — though I merged 20 PRs today, so re-check before you start.

Not asking you to prioritise this over anything else.

houko and others added 3 commits September 2, 2026 03:07
Both requests behind the agent detail's $ key were wrapped in `if let Ok(...)`, so a 404, a 500 or a refused connection rendered as a footprint of 0 tokens with no recent calls — the same view a genuinely idle agent gets.
They now go through daemon_response like the rest of the TUI fetch helpers (librefang#8141) and surface the daemon's reason, and the in-process backend says so rather than dropping the keypress.
Also restores the source_template doc comment the branch had dropped, and adds the missing changelog fragment.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Pushed bd8803109.

Merging main reported "Already up to date" — the previous head was itself a merge of 59ca61045, so this branch was not stale. That confirms the red gate was only the cancelled run, with no test failure behind it.

Three things fixed here because they were in this PR's own code:

  1. The token-footprint fetch swallowed every error. Both requests in spawn_fetch_agent_token_usage were wrapped in if let Ok(...), so a 404, a 500 and a refused connection all rendered as a footprint of zero tokens with no recent calls — indistinguishable from an idle agent. On the in-process backend the $ key did nothing at all. Both now go through daemon_response (the helper main introduced in TUI: two fetch helpers blank their screen on error and in in-process mode with no signal #8141) and emit AppEvent::FetchError with the reason, with new keys in all four locales.
  2. The diff had removed the source_template doc comment from feat: show custom agent type in agent description (dashboard/TUI/API) #8018 in dashboard/src/api.ts — restored, and the new field documented alongside it.
  3. Added the missing changelog fragment, changelog.d/added/7976-agent-token-footprint.md.

The one missing test was added too, covering $ with and without the detail pane open. It was confirmed failing first — with the expected id changed to agent-8 it fails with left: "agent-7" — so it discriminates rather than passing by construction.

Verification on the pushed tip: cargo nextest run -p librefang-cli — 495/495, with dollar_key_requests_the_footprint_for_the_open_agent_only, test_no_untranslated_strings, test_no_dead_locale_keys and test_locales_cover_used_i18n_keys green by name. cargo check / cargo clippy -- -D warnings / cargo fmt --check all clean. npx tsc --noEmit clean; npx vitest run src/pages/AgentsPage.test.tsx src/lib/queries/agents.test.tsx — 2 files, 14 tests. i18n-parity failing set unchanged from main. Changelog attribution check passes.

Note on the first item: this is the fourth place in the TUI with the same shape — a spawn_* fetch that discards the error and emits its success event anyway, so a failure renders as empty data. #8144 fixed it for the memory and goals panes, #8059 for the Auxiliary tab, #8073 for version history, and this one for the token footprint. Filing that separately as a pattern rather than continuing to fix instances.

@houko houko left a comment

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.

Re-reviewed at the current head. Blocking #2 is fixed properly. Blocking #1 is untouched, so I am leaving this one open for it.

#2 — done, and the daemon-only case with it

daemon_response(...) wraps both fetches, and each failure arm now sends AppEvent::FetchError(message) and returns instead of falling through to AgentTokenUsageLoaded with a zeroed struct. A 404, a 500 and a connection refused are no longer indistinguishable from "this agent has made no calls".

The let BackendRef::Daemon { .. } = backend else with a tui-agents-token-usage-daemon-only message is the explicit state I asked for on the in-process path — previously that branch produced no event at all and the panel just never populated.

#1 — the stale-response race is unchanged

The event still carries no agent id:

AgentTokenUsageLoaded(crate::tui::screens::agents::AgentTokenUsage),

and the handler still applies it unconditionally:

AppEvent::AgentTokenUsageLoaded(usage) => {
    self.agents.token_usage = Some(usage);
}

spawn_fetch_agent_token_usage takes agent_id: String, uses it to build both URLs, and then drops it on the floor at the send. So the two halves of what I reported are both still live:

  • Late response paints the wrong agent. Select A, select B before A's two sequential HTTP calls return, and A's token count and cost render under B's name with nothing indicating whose numbers they are.
  • No reset on selection change. token_usage is initialised to None once at construction (screens/agents.rs) and never cleared when the selection moves, so B shows A's figures until B's own fetch completes — which happens on every selection change, race or no race.

Both close with the same edit: put the id in the event, ignore a payload whose id is not the current selection, and set token_usage = None when the selection changes. The action variant already carries the id (AgentAction::FetchAgentTokenUsage(String)), so it is a matter of threading it through rather than plumbing anything new.

Worth pinning with a test, since this is invisible in manual use unless you happen to click fast on a slow daemon: dispatch AgentTokenUsageLoaded for agent A while the selection is B, and assert token_usage stays None.

Still non-blocking

The dashboard fetches the default page and does .slice(0, 5) while the TUI asks for ?limit=5. Same panel, same five rows — worth agreeing on one number, but not a reason to hold this.

@github-actions github-actions Bot added the has-conflicts PR has merge conflicts that need resolution label Sep 3, 2026
)

# Conflicts:
#	crates/librefang-cli/src/tui/event.rs
#	crates/librefang-cli/src/tui/mod.rs
#	crates/librefang-cli/src/tui/screens/agents.rs
@github-actions github-actions Bot removed the has-conflicts PR has merge conflicts that need resolution label Sep 3, 2026
`AgentTokenUsageLoaded` carried no agent id and the handler applied it unconditionally, so selecting A and then B before A's two sequential HTTP calls returned painted A's token count and cost under B's name, with nothing on the panel saying whose numbers they were.

`token_usage` also survived a selection change, so B showed A's figures until B's own fetch completed — on every selection change, race or no race.

The event now carries the id `spawn_fetch_agent_token_usage` was given, `apply_token_usage` drops a payload that is not the open agent's, and opening a different agent (or resetting the screen) clears the panel first.
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Sep 5, 2026
Address the review by removing scope rather than adding it.

Dropped, because nothing in the diff called either:

- `UsageStore::recent_events` and `UsageEventRow` — the backend half of librefang#7976, whose consumer and test belong with it.
  When they move, pair `ORDER BY timestamp DESC` with `, id DESC`: the column is text and without a tiebreaker calls recorded inside the same second come back in arbitrary order, which is how every other store in this repo handles it.
- The `pub(crate)` widening of `LibreFangKernel::lookup_provider_url`.
  A visibility bump is an API decision and should arrive with the code that needs it.

Kept and finished:

- The system-channel list is now `channel_registry::is_system_channel`, called from both `build_sender_prefix` and `build_channel_section`, replacing the byte-identical `matches!` copy the fix had introduced one file from the original.
  Adding a fourth sentinel is a one-line edit instead of a two-file edit a reviewer has to catch.
- Two tests over the one behaviour a user can observe: a `webui` / `cron` / `autonomous` channel with `channel_send` granted is told not to use it and is never handed a recipient, while `telegram` keeps the recipient instruction.
  Verified red first — with the branch disabled the `webui` prompt reads `recipient="127.0.0.1"` for an adapter that does not exist.
- The terminal-failure path of `call_with_retry` and `stream_with_retry` folded into `classify_terminal_llm_error`, so the two cannot drift apart on which failures count against the circuit breaker.
@github-actions github-actions Bot added size/L 250-999 lines changed and removed size/M 50-249 lines changed labels Sep 5, 2026
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Addressed at 0d382d26e.

#1 is closed both ways. AgentTokenUsageLoaded is a struct variant carrying agent_id now, and apply_token_usage only folds the payload in when it matches the current selection; token_usage is set to None when the selection changes, so B no longer shows A's figures while its own fetch is in flight. The id was already on AgentAction::FetchAgentTokenUsage, so it was threading rather than new plumbing.

Both tests, and the red separates the two halves cleanly. Removing the id guard fails the race test and leaves the reset test green, because the reset lives elsewhere:

a_footprint_for_another_agent_is_ignored ... FAILED
opening_another_agent_drops_the_previous_footprint ... ok

The first is the one you asked for — AgentTokenUsageLoaded dispatched for A while the selection is B, asserting token_usage stays None. Both green with the fix.

On the non-blocking point: the dashboard's .slice(0, 5) and the TUI's ?limit=5 still disagree in mechanism. Left as-is here since you did not want it holding the PR, but flagging that I read it and did not silently skip it.

Ready for another look — and the new P2 on extraction_model in #7985 is being handled there.

)}
</div>

{detailAgent.injected_footprint_tokens != null && detailAgent.injected_footprint_tokens > 0 && (

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.

The > 0 half of this guard treats a genuine zero as "no data", and it also gates the Recent calls list, which does not depend on the footprint at all.

available_tools returns an empty Vec for an agent with tools_disabled = true (crates/librefang-kernel/src/kernel/tools_and_skills.rs:177), and the footprint is estimate_token_count(&[], Some(&system_prompt), Some(&[])) (crates/librefang-api/src/routes/agents/lifecycle.rs:1078). So for a tools-disabled agent whose manifest carries no system_prompt (identity files supply the persona instead), the daemon truthfully answers injected_footprint_tokens: 0 — and the drawer then renders nothing at all: not the "0" that would confirm the agent is cheap, and not the five recent LLM calls, which are sourced from /api/agents/{id}/events and are perfectly available.

Suggest gating the section on presence only, and gating the recent-call rows on their own data:

Suggested change
{detailAgent.injected_footprint_tokens != null && detailAgent.injected_footprint_tokens > 0 && (
{detailAgent.injected_footprint_tokens != null && (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — gated the panel on injected_footprint_tokens != null only, extracted as a named type-predicate hasTokenFootprintData (matching the existing cloneResultNotice pattern in this file since the drawer has no render harness). The recent-calls list already had its own independent .length > 0 gate and is now reached regardless of the footprint value. Added hasTokenFootprintData unit tests in AgentsPage.test.tsx (zero, non-zero, null/undefined) — verified red against the old > 0 guard, green with the fix.

Comment thread crates/librefang-cli/src/tui/event.rs Outdated
.and_then(|r| r.json::<serde_json::Value>().map_err(|e| e.to_string()))
{
Ok(body) => {
usage.total_tokens = body["injected_footprint_tokens"].as_u64().unwrap_or(0);

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.

unwrap_or(0) turns a missing field into a confident "injected 0".

bd88031 already established the rule for this panel — a fetch that did not produce a number must say so rather than paint a zero — but that only covers the transport/status failure. A 200 whose body has no injected_footprint_tokens (a TUI talking to a daemon older than #7972, or a future rename of the key) lands here and reports an agent with zero static footprint, which is indistinguishable from the real zero case and is the one number the operator pressed $ to get.

Prefer as_u64() returning None to go down the same FetchError path as a non-200:

Suggested change
usage.total_tokens = body["injected_footprint_tokens"].as_u64().unwrap_or(0);
usage.total_tokens = match body["injected_footprint_tokens"].as_u64() {
Some(n) => n,
None => {
let _ = tx.send(AppEvent::FetchError(crate::i18n::t(
"tui-agents-token-usage-failed",
)));
return;
}
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — .as_u64() now matches on Some/None, and None routes down the same FetchError path as a non-200 response instead of unwrap_or(0). Added a raw-socket one-shot mock server in event.rs's test module (no existing HTTP mock helper covered response-body shape, only transport failure) and a regression test asserting a 200 missing injected_footprint_tokens produces FetchError, not a fake zero — verified it panics/fails against the pre-fix unwrap_or(0), passes with the fix.

]));
}

if let Some(usage) = &state.token_usage {

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.

This block is appended to a Paragraph that is rendered without .scroll(...), so anything past the bottom of chunks[0] is silently clipped.

Budget on an 80x24 terminal: 1 row tab bar (tui/mod.rs:3005) + 2 rows screen border + 1 row hint bar leaves the detail pane 20 rows. A typical daemon agent already draws 14–16 lines (blank, ID, Name, State, Provider, Model, Created, Active, optional Tags/Caps/Parent/Children, blank, Skills, MCP, Channels), and this block adds 2 + up to 5 more. So pressing $ on an agent that has tags and capabilities either shows only part of the recent-call list or, on a shorter terminal, produces no visible change whatsoever — no error, no scroll, nothing to distinguish it from an unbound key.

Either render this block first (right under the header, where it cannot be pushed off), or give the paragraph a scroll offset.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — moved the token-footprint block from the end of the lines vec to right after the fixed header (Model line), before the variable-length optional sections (Created/Active/Tags/Caps/Parent/Children/Skills/MCP/Channels), so it can no longer be pushed past the bottom of the pane. Went with relocation over a scroll offset since the screen has no scroll state today and this is the smaller change.

tui-agents-title-custom-name = Custom — Name
tui-agents-title-custom-desc = Custom — Description
tui-agents-title-custom-prompt = Custom — System Prompt
tui-agents-detail-tokens = Token footprint ($ to refresh)

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.

tui-agents-hints-detail is not updated, in any of the four locales, so $ never appears in the detail screen's hint bar — it still reads [s] Edit skills [m] Edit MCP [n] Edit channels [p] Model params [c] Chat [k] Kill [Esc] Back (crates/librefang-cli/locales/en/main.ftl:2127 and the ko / uk / zh-CN equivalents).

The only in-app mention of the key is ($ to refresh) inside tui-agents-detail-tokens, which is rendered by the panel that only exists after the operator has already pressed $. As shipped, the feature is reachable only by reading the source or the changelog. Adding [$] Tokens to the four hint strings costs four lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — added [$] Tokens (and localized equivalents) to tui-agents-hints-detail in all four locales (en/ko/uk/zh-CN).

Comment thread crates/librefang-cli/src/tui/event.rs Outdated
tx: mpsc::Sender<AppEvent>,
) {
std::thread::spawn(move || {
let BackendRef::Daemon { base_url, api_key } = backend else {

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.

Every other per-agent fetch on this screen serves both backends — spawn_fetch_agent_skills (event.rs:1802), spawn_fetch_agent_mcp_servers and spawn_fetch_agent_channels all have a BackendRef::InProcess(kernel) arm — so in in-process TUI mode $ is the one key on the detail screen that can only ever produce an error line.

Both halves are computable locally: the footprint is compactor::estimate_token_count over kernel.available_tools(aid) plus manifest.model.system_prompt (exactly what routes/agents/lifecycle.rs:1078 does), and the recent calls are memory_substrate().usage().list_agent_events_recent(aid, 5), the same call the route makes. That is an InProcess arm of roughly a dozen lines rather than a capability gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — added a BackendRef::InProcess arm to spawn_fetch_agent_token_usage mirroring routes/agents/lifecycle.rs:1078: librefang_kernel::compactor::estimate_token_count over kernel.available_tools(aid) + manifest.model.system_prompt for the footprint, and memory_substrate().usage().list_agent_events_recent(aid, 5) for the recent calls (the KernelApi trait method needed a fully-qualified call to avoid an ambiguity with the AgentSubsystemApi/McpSubsystemApi/SkillsSubsystemApi traits already imported in this file). The now-unreachable tui-agents-token-usage-daemon-only locale key was removed from all four locales.

Fixes review findings on librefang#7976: the footprint guard hid a genuine zero
and the independently-sourced recent-calls list along with it, the TUI
fetch read a missing field as a confident zero, the panel had no
in-process-kernel arm, the block could render off-screen with no
scroll offset, and the `$` key was undiscoverable outside the source.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Every one of the 5 review threads on this PR now has an inline reply, and the branch is at 9c27f301c.

Each reply states what changed and where, the commit it landed in, and the literal failure from reverting the production block and running the test against it — a regression test that stays green with the fix removed guards nothing, so that cycle was run rather than assumed. Where a finding was not acted on, the reply says so and gives the reason instead of leaving the thread unanswered.

Flagging it here because the review is still recorded as requesting changes, and a push does not retract that on its own. Ready for another look whenever it suits you.

@houko

houko commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Structural note before the code review: this PR is part of a group of six that restructure the same agent-manifest editing surface without being stacked on each other — #7835, #7749, #7976, #8028, #8231 and #8041, plus #8013 which has now merged. git merge-base --is-ancestor is false in both directions for every pair, so they are diverged copies rather than a stack, and tui/screens/agents.rs / AgentsPage.tsx / agentManifest.ts are being rewritten by several of them at once against bases that predate the others.

Full write-up, with the shared-file counts per pair, is on #7835. I am holding the code review here until a merge order is picked and the rest are rebased — reviewing now means reviewing the same restructuring several times and having most of those reviews invalidated by the first merge.

Any findings already open on this PR still stand on their own.

@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

@houko — the block from 2026-09-03 is asking for an edit that is already at the head, so this is a request for re-review rather than a new push. Nothing changed here; I checked because the PR is still CHANGES_REQUESTED.

Blocking #1 — all three parts are in, at cf4f3d843:

  • The event carries the id. crates/librefang-cli/src/tui/event.rs:356 is AgentTokenUsageLoaded { agent_id, usage }, and both send sites (:2295, :2353) pass the agent_id that spawn_fetch_agent_token_usage already had.
  • A payload for another agent is ignored. screens/agents.rs:277 — apply_token_usage(&mut self, agent_id: &str, usage) folds the payload in only if self.detail.as_ref().is_some_and(|d| d.id == agent_id). tui/mod.rs:515 destructures both fields and routes through it rather than assigning unconditionally.
  • The selection change clears it. screens/agents.rs:527 sets token_usage = None on the path that opens a detail, and :269 does the same on reset — so B no longer shows A's figures while B's own fetch is in flight, race or no race.

And the test you asked for is there: a_footprint_for_another_agent_is_ignored (screens/agents.rs:1922) puts agent-2 in detail, dispatches a payload for agent-1, asserts token_usage stays None, then dispatches agent-2's own and asserts it lands.

The non-blocking one does not reproduce, though. The dashboard is not fetching a different number for the same panel — useAgentEvents(id, 30) backs two surfaces: the Logs tab at AgentsPage.tsx:2317, which renders the full list as a terminal-style turn feed, and the footprint panel at :3163, which slices 5. Dropping the request to ?limit=5 to match the TUI would truncate the Logs tab from 30 rows to 5. Both surfaces already render the same five rows in the footprint panel; the difference is one query serving two panels on the web side and only one on the TUI side. Happy to change it if you would rather the footprint panel had its own query, but as it stands there is no number to agree on.

Your five inline findings from 2026-09-07 were each answered in their own thread on 2026-09-09; none is outstanding.

@houko houko left a comment

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.

Re-reviewed at the current head. Blocking #1 is fixed.

0d382d26 gives the event the agent id it was missing — AgentTokenUsageLoaded { agent_id, usage } — and the handler routes it through apply_token_usage(&agent_id, usage), which applies the payload only when detail.id == agent_id. A response that lands after the operator has moved to another agent is dropped instead of being painted onto whoever is on screen.

With #2 already done in the previous round, that closes both.

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels Sep 12, 2026
houko added a commit that referenced this pull request Sep 12, 2026
…and model sentinel tests (#7995)

* fix(runtime,kernel): improve error classification, system channel detection, and model sentinel resolution

- Unsupported-parameter rejections no longer count against the circuit breaker, preventing false provider lockouts after reasoning_effort mismatches.
- System channels (webui, cron, autonomous) get a channel_send suppression instruction so agents stop trying to route media through nonexistent adapters.
- lookup_provider_url visibility widened to pub(crate) for reuse.
- Regression tests for model="default" sentinel resolution that caused "Invalid model name" failures.

* feat(memory): add per-agent recent LLM call history query

Adds UsageEventRow and UsageStore::recent_events() to surface individual
LLM calls (model, tokens, cost) for the agent token footprint UI. Backed
by the existing idx_usage_agent_time index.

* refactor(runtime): narrow the PR to the channel_send prompt fix

Address the review by removing scope rather than adding it.

Dropped, because nothing in the diff called either:

- `UsageStore::recent_events` and `UsageEventRow` — the backend half of #7976, whose consumer and test belong with it.
  When they move, pair `ORDER BY timestamp DESC` with `, id DESC`: the column is text and without a tiebreaker calls recorded inside the same second come back in arbitrary order, which is how every other store in this repo handles it.
- The `pub(crate)` widening of `LibreFangKernel::lookup_provider_url`.
  A visibility bump is an API decision and should arrive with the code that needs it.

Kept and finished:

- The system-channel list is now `channel_registry::is_system_channel`, called from both `build_sender_prefix` and `build_channel_section`, replacing the byte-identical `matches!` copy the fix had introduced one file from the original.
  Adding a fourth sentinel is a one-line edit instead of a two-file edit a reviewer has to catch.
- Two tests over the one behaviour a user can observe: a `webui` / `cron` / `autonomous` channel with `channel_send` granted is told not to use it and is never handed a recipient, while `telegram` keeps the recipient instruction.
  Verified red first — with the branch disabled the `webui` prompt reads `recipient="127.0.0.1"` for an adapter that does not exist.
- The terminal-failure path of `call_with_retry` and `stream_with_retry` folded into `classify_terminal_llm_error`, so the two cannot drift apart on which failures count against the circuit breaker.

* fix(runtime): scope channel_send suppression to webui, delegate sentinel list

Fixes review findings on #7995: the system-channel channel_send
suppression covered cron and autonomous turns too, which regressed a
capability those turns already had (channel_send with an explicit
real channel and recipient), and its wording claimed media is
attached automatically even for webui, which nothing in the runtime
does. channel_registry::is_system_channel also kept a third
hand-copied sentinel list behind a circular-dependency excuse that no
longer held; it now delegates to librefang-channels, which is
drift-guarded against the kernel constants.

* fix(runtime): give the prompt its own non-interactive-turn predicate

Review follow-up on #7995.

The webui arm compared exactly while the system-channel arm three lines below
it trimmed and lowercased, so a "WebUI" fell past the first into the second and
told a live browser session its turn had no default channel or recipient. The
kernel only ever stamps the lowercase constant today, so this was latent, but
two comparisons that close disagreeing about normalisation do not survive the
next edit.

The second arm also asked the wrong question. is_reserved_system_channel
answers which names would collide with a kernel SessionId, and webui is in that
set precisely because it derives one — yet a live user is waiting on a webui
reply. Deriving the prompt's question from that list means a future interactive
surface added for SessionId reasons silently starts telling a real user their
turn is a background one. The prompt builder now has its own
is_non_interactive_turn over named constants, so the strings are still declared
once in librefang-channels while the two questions stay separate.

Deletes changelog.d/fixed/7995-webui-channel-send-instruction.md, which still
described the cron/autonomous suppression this PR removed and closed with the
'shown to the user automatically' claim the same PR retracted. Both fragments
carried (#7995), so collect-fragments would have folded the retracted statement
into the release body next to its own retraction.

Verified the new test fails against the exact comparison ("WebUI" must take the
webui arm) and passes with it.

* fix(runtime): agree with #8149 on what a background turn is told

Both PRs rewrite the same branch of build_channel_section, which origin/main
has neither of, and they had landed on opposite instructions: this one offered
channel_send with an explicit target, #8149 forbade it and then explained how
to use it in the next sentence.

The offer is the true one. channel_send reads channel and recipient from its
own arguments and dispatches to that adapter, so the sentinel channel the turn
runs under never enters into it. What is impossible is replying into cron or
autonomous, which have no adapter, and that is what the prompt says now.

The sentence is byte-identical to the one #8149 writes, and a test on each
side asserts it by name, so whichever merges second the resolution is textual
rather than a choice between two instructions.

The webui arm also names the /api/uploads URL the generating tool returns,
which is the only thing that puts a file in front of the browser.

---------

Co-authored-by: Evan <suzukaze.haduki@gmail.com>
@houko
houko merged commit 57d8a46 into librefang:main Sep 12, 2026
45 of 46 checks passed
@houko houko mentioned this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants