Skip to content

fix(runtime): stop suggesting channel_send to kernel-internal system channels - #8149

Merged
houko merged 8 commits into
librefang:mainfrom
DaBlitzStein:fix/channelsend-system-guard
Sep 14, 2026
Merged

houko merged 8 commits into
librefang:mainfrom
DaBlitzStein:fix/channelsend-system-guard

Conversation

@DaBlitzStein

Copy link
Copy Markdown
Contributor

Summary

  • The channel_send media hint in build_channel_section was emitted unconditionally whenever the tool was granted — including on the kernel-internal system channels (webui, cron, autonomous) where no messaging adapter exists, so the suggestion only pushed the agent to attempt a send that cannot reach anyone and then improvise a fallback channel on its own.
  • The hint is now suppressed there: webui is told that files, images, and media flow to the user through the normal response stream, and background runs (cron, autonomous) are told to target a real messaging channel/recipient explicitly, or use notify_owner if available.
  • The suppression reuses the existing is_reserved_system_channel helper; non-system channels keep the existing media hint unchanged.
  • Adds test_channel_send_hint_suppressed_for_webui and test_channel_send_hint_suppressed_for_cron_and_autonomous in prompt_builder/tests.rs: each asserts the new guidance text is present and the media-hint marker (image_url) is absent, so reverting the guard fails the tests.
  • Changelog fragment under changelog.d/fixed/.

The #6504 review called this branch "a real fix for a real problem (agent trying to channel_send to a client IP with no adapter)" but unrelated to that PR's stated workflow-UX scope, and noted that the existing hint tests only exercise non-system channels so nothing pinned the new branch — this PR is that part recovered as an atomic change, with the pinning tests included.

Refs #6504.

Verification

@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox size/M 50-249 lines changed labels Sep 2, 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.

The diagnosis is right and the two tests pin it properly — reverting the guard fails them by name. One change requested, plus two nits.

The webui guidance is broader than the problem it fixes

"You are on the LibreFang web interface. Files, images, and media you generate are shown to the user automatically in your response — do NOT use `channel_send`."

channel_send is not scoped to the current conversation. Its schema takes an explicit channel argument (crates/librefang-runtime/src/tool_runner/definitions.rs:1030) and its description is "Send a message or media to a user on a configured channel (email, telegram, slack, etc)". A user sitting in the dashboard asking the agent to email a report, or to post a result to a Slack channel, is an ordinary supported action — and this line tells the agent not to do it.

The cron / autonomous branch in the same commit gets this exactly right: it says channel_send cannot reach this channel and then points at the alternative ("target a real messaging channel/recipient explicitly"). The webui branch should be scoped the same way — something like "do NOT use channel_send to reply here; use it only to reach someone on a different channel (email, telegram, …)".

test_channel_send_hint_suppressed_for_webui still passes under that wording: it asserts on "web interface" and on the absence of image_url, and neither moves.

Nit: the outer guard is case-insensitive, the inner branch is not

is_reserved_system_channel trims and lowercases before matching (crates/librefang-channels/src/types.rs:22-25), so "WebUI" enters the new block — and then channel == "webui" is false, so a live web session falls into the background-run branch and is told "no live user watching this response", which is the opposite of true. channel.trim().eq_ignore_ascii_case("webui") closes it.

Not reachable today (the kernel passes SYSTEM_CHANNEL_WEBUI, and the match channel above at prompt_builder.rs:1256 is exact-case throughout), so this is consistency rather than a live bug.

Nit: changelog fragment has no trailing newline

All 43 existing fragments under changelog.d/ end with one; 8149-channel-send-system-channel-guard.md does not. render_fragment_bullet handles the missing newline fine, so this is hygiene only.

@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Pushed ff25f2552: merged current main.

No hand-written merge resolution to review — git merge-tree --write-tree against the committed tree reports zero difference from git's own automerge.

Verification on the pushed tip:

  • cargo check -p librefang-runtime --all-targets — clean, no warnings.
  • cargo nextest run -p librefang-runtime -E 'test(prompt_builder)' — 105/105, exit 0.
  • The four tests this PR adds ran and passed by name: test_channel_send_hint_suppressed_for_cron_and_autonomous, test_channel_send_hint_suppressed_for_webui, test_channel_send_hint_with_tool, test_channel_send_hint_without_tool.
  • More to the point, the two tests on main's side that assert against the same prompt text also pass: silent_response::tests::thematic_and_scaffold_headers_match_prompt_builder_output and …structural_and_envelope_markers_absent_from_prompt_builder. Those are what demonstrate the merge did not silently drop a header — a prompt-builder change that broke them would still compile and still pass this PR's own tests.

DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Sep 5, 2026
Review of librefang#8149 pointed out the webui branch was broader than the bug it fixes.
`channel_send` takes an explicit `channel` argument and reaches email, telegram and slack, so a blanket "do NOT use `channel_send`" told an agent on the dashboard not to email a report or post to a Slack channel — both ordinary, supported actions.
The guidance now forbids only replying through it, and points at what it is still for, matching the shape the cron/autonomous branch already had.

Also match the inner `webui` test to the outer guard's case sensitivity.
`is_reserved_system_channel` trims and lowercases, so `"WebUI"` entered the block and then failed `channel == "webui"`, dropping a live web session into the background-run branch and telling it there is no live user watching.
Not reachable today because the kernel passes `SYSTEM_CHANNEL_WEBUI`, but the two comparisons should not disagree; `test_channel_send_hint_webui_match_is_case_insensitive` pins it.

And add the trailing newline the changelog fragment was missing.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Addressed at bb94c9194.

The webui guidance is scoped now. It says not to use channel_send to reply here, and points at the alternative — reach someone on a different channel, naming that channel and recipient explicitly — mirroring how the cron / autonomous branch already reads. A user in the dashboard asking the agent to email a report is an ordinary supported action and the old wording told the agent not to do it.

test_channel_send_hint_suppressed_for_webui still passes under the new wording, as you predicted: it asserts on "web interface" and on the absence of image_url, neither of which moved.

Both nits done. The inner branch is channel.trim().eq_ignore_ascii_case("webui") now, and there is a test for it rather than leaving it as consistency-only — reverting to the exact comparison fails test_channel_send_hint_webui_match_is_case_insensitive, which asserts a mixed-case WebUI still gets the web-interface text and is not told there is no live user watching. Trailing newline added to the fragment.

5 tests green.

Ready for another look.

} else {
section.push_str(
"\n\nThis is a background run (no interactive chat is attached, so there is no \
live user watching this response). `channel_send` cannot reach this system \

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.

no live user watching this response is true for autonomous but not for cron.

The cron tick delivers the turn's response text to a real person: cron_tick.rs:374 passes &result.response into deliver_cron_output, and cron_deliver_response (cron_bridge.rs:194-250) pushes it through send_channel_message for CronDelivery::Channel { .. } and LastChannel; the fan-out path substitutes CRON_EMPTY_OUTPUT_HEARTBEAT — "(cron heartbeat: empty output)" — when the agent produced nothing.

Scenario: a job configured with a delivery target now runs with a system prompt asserting nobody reads its response. The model writes a terse internal note (or nothing) instead of the user-facing summary, and the owner's chat receives that note, or the empty-output heartbeat sentinel, in place of the report the job exists to send. build_channel_section has no view of CronDelivery, so this fires on every cron run with channel_send granted, delivery-configured or not.

The channel_send suppression itself is right; it is the claim about the response's fate that is wrong. Saying only that there is no interactive chat to reply into keeps the fix and drops the false premise.

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.

Confirmed and fixed. deliver_cron_output (kernel/cron_bridge.rs) reads &result.response and pushes it through cron_deliver_response / cron_fan_out_targets whenever a delivery target is configured, so "no live user watching this response" is false for cron. The background-run branch no longer makes that claim; it now says only that there is no interactive chat attached for channel_send to reply into, which is true for both cron and autonomous regardless of delivery configuration. Added test_channel_send_hint_cron_does_not_claim_nobody_reads_the_response to pin this.

"\n\nThis is a background run (no interactive chat is attached, so there is no \
live user watching this response). `channel_send` cannot reach this system \
channel — do NOT use it here. To reach a person, target a real messaging \
channel/recipient explicitly, or use `notify_owner` if available.",

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.

or use notify_owner if available points at a path that delivers nothing on exactly these two channels.

tool_notify_owner only sets ToolResult.owner_notice (tool_runner/notify.rs:52), and that field is consumed solely by the interactive API surfaces: routes/agents/messaging.rs:370 (reply body), routes/agents/messaging.rs:700 (SSE event) and routes/agents/sessions.rs:1018. The cron path reads only result.response (cron_tick.rs:361-379) and the autonomous tick discards the result entirely (background_lifecycle.rs:1268-1271), so owner_notice is dropped on the floor.

This is the #7086 dead end the cross-chat guard was rewritten to stop recommending — see the comment at tool_runner/channel.rs:445-447 and the guard's own message at :470: "notify_owner is not a delivery path on a channel, it only surfaces a notice to the operator out of band."

Scenario: a cron job with no delivery configured asks the agent to tell the owner something. The agent calls notify_owner, gets back "Notice queued for the owner. Do not repeat the summary in your public reply.", reports success, and the owner is never told. Recommend dropping the notify_owner clause and keeping only "target a real messaging channel/recipient explicitly".

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.

Confirmed and fixed. tool_notify_owner only sets ToolResult.owner_notice, consumed solely by the interactive API surfaces (routes/agents/messaging.rs, routes/agents/sessions.rs); cron_tick reads only result.response and background_lifecycle discards the whole result for autonomous ticks, so owner_notice is dropped on the floor on both paths. Dropped the notify_owner clause from the background-run hint entirely, keeping only the real messaging channel/recipient guidance. Added test_channel_send_hint_background_run_does_not_recommend_notify_owner to pin this.

if channel.trim().eq_ignore_ascii_case("webui") {
section.push_str(
"\n\nYou are on the LibreFang web interface. Files, images, and media you \
generate are shown to the user automatically in your response — do NOT use \

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.

shown to the user automatically in your response describes a mechanism that does not exist.

Nothing attaches generated artefacts to a webui turn. tool_image_generate returns saved_to (workspace paths) and image_urls (/api/uploads/<id>, minted by routes/media.rs:148) inside the tool result; the only way either reaches the browser is the agent putting the URL into its reply text. A file merely written to the workspace has no URL at all.

Scenario: a dashboard user asks for a generated chart. The agent, told delivery is automatic, answers "here's the chart" with no link — and the user sees nothing. The old hint named a delivery mechanism that could not work on webui; this one asserts a mechanism that does not exist, which fails the same way and is harder to notice.

Worth stating the actual contract instead: media reaches the browser only when the reply embeds the /api/uploads/... URL the generating tool returned.

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.

Confirmed and fixed. Nothing attaches a generated artefact to a webui turn; tool_image_generate returns image_urls (/api/uploads/, minted by routes/media.rs) inside the tool result, and it only reaches the browser if the reply text embeds that URL. The webui hint now states that contract instead of claiming automatic delivery. Added test_channel_send_hint_webui_does_not_claim_automatic_media_delivery to pin this.

`channel_send` to reply here. Use it only to reach someone on a different \
channel (email, telegram, …), naming that channel and recipient explicitly.",
);
} 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.

The else arm assumes every non-webui member of RESERVED_SYSTEM_CHANNEL_NAMES is a non-interactive background run, but that list is owned by an unrelated concern: it exists so sanitize_channel_name can stop externally-supplied channel names from colliding with kernel-derived session ids (crates/librefang-channels/src/types.rs:15-25). A name added there later — an interactive surface reserving its own session namespace, say — would silently start telling a live user's turn that "there is no live user watching this response", and no test would fail.

The two existing sites that need this same distinction spell the sentinels out rather than reusing the reserved-name predicate: agent_loop/mod.rs:393 (matches!(channel, "webui" | "cron" | "autonomous")) and handles/approval_gate.rs:85 (c == SYSTEM_CHANNEL_CRON || c == SYSTEM_CHANNEL_AUTONOMOUS). Matching cron / autonomous explicitly here, and falling through to the normal hint for anything else, keeps a newly reserved name from inheriting background-run guidance by accident.

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.

Confirmed and fixed. The background-run branch now matches the literal cron / autonomous channel names, mirroring the existing pattern in agent_loop::mod::build_sender_prefix, instead of deriving the decision from is_reserved_system_channel / RESERVED_SYSTEM_CHANNEL_NAMES, which exists only to keep externally-supplied channel names from colliding with a kernel-derived SessionId. A future addition to that list now falls through to the normal per-user channel_send hint instead of silently inheriting background-run guidance.

@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

All three points are addressed on the current head.

The webui guidance is now scoped the way the cron / autonomous branch already was — it says not to use channel_send to reply here, and then points at what it is for (prompt_builder.rs:1360-1363):

do NOT use channel_send to reply here. Use it only to reach someone on a different channel (email, telegram, …), naming that channel and recipient explicitly.

Emailing a report or posting to Slack from a dashboard session is no longer discouraged. test_channel_send_hint_suppressed_for_webui still passes unchanged, since it asserts on "web interface" and on the absence of image_url.

The case mismatch is closed with channel.trim().eq_ignore_ascii_case("webui") (prompt_builder.rs:1358), so "WebUI" can no longer pass the outer guard and then fall into the background-run branch that tells a live web session nobody is watching.

The fragment ends with a newline.

One more thing in that fragment, found while checking the newline and fixed at b29022df0: it closed with (#6504) alone, which is the issue rather than this PR. Per changelog.d/README.md, only the last (#N) group decides which generated line a curated bullet replaces, so #8149 would have kept its own generated line and appeared twice in the release body, with cargo xtask release warning about it. It now reads (#8149, #6504), and scripts/check-changelog-attribution.py passes.

Ready for another look.

@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

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

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 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 four wording findings are all resolved, and the replacement text is accurate — I re-checked each claim against the code rather than against the previous round's comments:

  • /api/uploads/<id> is really what the generating tool hands back (routes/media.rs:148), so the webui branch now points at the actual contract instead of an imagined auto-attach.
  • notify_owner is gone from the background-run branch; owner_notice is still only set at tool_runner/notify.rs:52 and still read by nobody on the cron or autonomous path.
  • The "no live user watching" claim is gone for cron, which is correct — cron_tick.rs:371 and :470 both hand result.response to deliver_cron_output.
  • The webui / cron / autonomous literals replace the RESERVED_SYSTEM_CHANNEL_NAMES-derived predicate, which is the right call: that list is owned by sanitize_channel_name's SessionId-collision concern (librefang-channels/src/types.rs:15-25), not by "is this interactive".

Two things left.

1. Two changelog fragments for one PR, where the second describes correcting the first. changelog.d/fixed/8149-channel-send-system-channel-guard.md and changelog.d/fixed/8149-channel-send-system-channel-guard-wording.md both carry (#8149) and both get folded into ### Fixed verbatim by cargo xtask collect-fragments. A release reader gets one bullet saying the guard was added and a second saying three assertions in that guard's wording were wrong — but the first wording never shipped, so the second bullet describes fixing something no user ever saw. This should be one fragment describing the shipped behaviour: channel_send is no longer suggested on webui / cron / autonomous, and each of those channels now gets guidance that matches what the system actually delivers.

2. "Keep these literals in sync with the kernel-side sentinels" is an unenforced comment. SYSTEM_CHANNEL_CRON / _AUTONOMOUS / _WEBUI are "cron" / "autonomous" / "webui" at librefang-kernel/src/kernel/mod.rs:177-191; the runtime now hardcodes the same three strings. The circular-dep reasoning for not importing them is right, but nothing fails if the kernel side is renamed — the prompt would quietly go back to telling a live web session it is a background run, which is exactly the class of bug this PR is fixing.

librefang-kernel depends on librefang-runtime, so the guard belongs on the kernel side. build_channel_section is private, so a call-through test is not available; a three-line assertion pinning the constants' values, with a comment naming prompt_builder.rs as the reason they cannot move, is enough and costs nothing. The existing resolve_scope_channel_* tests (kernel/tests.rs:15252-15296) already iterate all three constants and are the natural home.

The alternative — lifting the three constants into librefang-types, which both crates already depend on, and having both sides import them — is the version with no invariant left to state. That is a different crate from the one this PR is in, so it is your call rather than mine; say the word and I will not hold the PR for it.

@houko

houko commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Cross-PR collision, since both are open and both are yours: #7995 rewrites the same block of prompt_builder.rs this PR does, with a different predicate (channel_registry::is_system_channel → librefang_channels::types::is_reserved_system_channel) and a different cron/autonomous message ("channel_send still works, name a real channel and recipient" vs this PR's "do NOT use it here").

The circular-dependency reasoning in this PR's comment is right about the kernel constants and wrong about the list: librefang-runtime already depends on librefang-channels (crates/librefang-runtime/Cargo.toml), which publishes the same three names. So point 2 of my earlier comment has a cheaper answer than a kernel-side pin — details and the suggested resolution are in the table on #7995, which is where I would rather settle it than have the same argument twice.

Nothing to change here until that is decided; the wording fixes in this PR stand on their own either way.

DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Sep 11, 2026
Two fragments both carried (librefang#8149) and both would have been folded into
### Fixed verbatim, so a release reader got one bullet saying the guard was
added and a second saying three assertions in its wording were wrong.
The first wording never shipped, so the second bullet described fixing
something no user ever saw.
One fragment now describes the shipped behaviour: which channels stop being
offered channel_send, and what each of them is told instead.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

@houko El punto 1 está arreglado en 136d07986; el punto 2 lo dejo donde tú mismo lo mandaste.

1. Los dos fragmentos de changelog eran uno. Confirmado: 8149-channel-send-system-channel-guard.md y …-wording.md llevaban ambos (#8149) y cargo xtask collect-fragments los habría plegado los dos en ### Fixed, de modo que el lector de la release veía un bullet diciendo que se añadió la guarda y otro diciendo que tres afirmaciones de su redacción eran falsas — cuando esa primera redacción no llegó a publicarse nunca.

Ahora hay un solo fragmento que describe el comportamiento que sí sale: qué canales dejan de recibir la sugerencia de channel_send, y qué se le dice a cada uno en su lugar — que en webui el navegador solo ve lo que el texto de la respuesta incrusta como URL /api/uploads/..., que una run de fondo no tiene chat interactivo al que contestar pero sí tiene quien lea su respuesta cuando el cron configura destino, y que notify_owner no se ofrece porque su aviso no lo lee nadie en esas dos rutas. scripts/check-changelog-attribution.py pasa.

2. La sincronía de los literales con los centinelas del kernel: la dejo para #7995, como pediste. Tu comentario posterior dice que librefang-runtime ya depende de librefang-channels, que publica los mismos tres nombres, y que la respuesta barata está en la tabla de #7995 antes que en un pin kernel-side. Estoy de acuerdo y no me adelanto: tocar aquí el predicado significaría resolver dos veces la misma colisión con #7995, que reescribe el mismo bloque de prompt_builder.rs. Las correcciones de redacción de este PR se sostienen solas con cualquiera de las dos salidas, que es lo que tú mismo apuntabas.

Este PR no tiene cambios de código en esta ronda — solo el changelog — así que la verificación de la ronda anterior sigue vigente sin repetirla.

Te pido re-revisión cuando puedas.

DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Sep 11, 2026
…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, librefang#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 librefang#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.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Añadido en e802149a4. Viene de trabajo que estaba sin commitear en un worktree y se habría perdido al limpiarlo; va emparejado con #7995 y hay un matiz de orden de merge al final.

Los dos PRs se contradecían en el mismo bloque

Este PR y #7995 reescriben cada uno la misma rama de build_channel_section, que origin/main no tiene en ninguna de las dos formas. Y decían lo contrario: aquí se prohibía channel_send —y en la frase siguiente se explicaba cómo usarla, contradiciéndose dentro del mismo párrafo— y allí se ofrecía con destino explícito.

La oferta es la cierta. channel_send lee channel y recipient de sus propios argumentos y despacha a ese adaptador, así que el canal centinela bajo el que corre el turno no entra en la decisión; un turno de fondo alcanza a una persona en un canal real igual que cualquier otro. Lo imposible es responder dentro de cron o autonomous, que no tienen adaptador, y eso es lo que dice ahora.

Comprobado sobre las ramas ya empujadas, no sobre mis árboles locales: extrayendo los literales del bloque de las dos y expandiendo las continuaciones \ (que en Rust se comen el salto y la sangría), diff da cero diferencias — y no solo en la frase compartida, los cuatro textos que emite el bloque salen idénticos. test_channel_send_hint_background_run_wording_matches_7995 la custodia nombrando al PR hermano, y el gemelo background_run_guidance_matches_the_wording_shared_with_8149 hace lo propio del otro lado.

⚠️ Orden de merge: mergear éste antes que #7995

La mitad webui —el match normalizado y el texto de /api/uploads— ya estaba en esta rama y no en la de #7995. Medido antes de tocar nada: grep -c eq_ignore_ascii_case("webui") daba 1 aquí y 0 allí. Ya lo he igualado en #7995, pero si aquél entrara primero lo haría sin esa mitad durante la ventana entre los dos merges. Lo he dejado escrito también en su comentario.

Verificación

  • cargo test -p librefang-runtime --no-fail-fast → 2500/2501.
  • El único fallo es checkpoint_manager::tests::restore_accepts_valid_commit_hash_shorter_than_eight_characters, un InitFailed("timed out after 30s") inicializando git. Demostrado ajeno: aislado con --test-threads=1 pasa en 34 s, no toca prompt_builder, y es un test distinto del que cayó en la corrida de fix(runtime,kernel): error classification, system channel detection, and model sentinel tests #7995 — la suite tardó 1319 s aquí frente a 633 s allí, con la máquina cargada por otras lanes. Dos fallos distintos en dos corridas del mismo código es contención, no defecto.
  • cargo check --workspace --all-targets --exclude librefang-desktop → EXIT=0.
  • cargo clippy --workspace --all-targets --exclude librefang-desktop -- -D warnings → EXIT=0, cero warnings.
  • Rojo primero: revirtiendo solo prompt_builder.rs y dejando el test, test_channel_send_hint_background_run_wording_matches_7995 cae; reponiéndolo, verde.

Esto es independiente del punto 2 de tu última revisión, que sigue donde lo dejaste: la sincronía de los literales con los centinelas del kernel se decide en #7995, y de hecho su 86d1683ff ya la resolvió por ahí, con librefang_channels::types::SYSTEM_CHANNEL_* y un predicado propio is_non_interactive_turn.

(librefang-desktop se excluye porque a esta máquina le faltan las cabeceras GTK.)

@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. Both points are addressed.

The webui guidance is scoped now. It says not to use channel_send to reply here, then points at what it is for — "use it only to reach someone on a different channel (email, telegram, …)" — so an agent asked from the dashboard to email a report is no longer told not to.

The case-insensitivity nit is closed: channel.trim().eq_ignore_ascii_case("webui") at prompt_builder.rs:1367, matching the outer guard.

c3dfbd8e also corrects something neither of us flagged: the old text claimed generated media is "shown to the user automatically", when the browser only renders what the reply embeds. The wording now says so and names the /api/uploads/... URL as the thing to include.

@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>
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Sep 12, 2026
…channels

The channel_send media hint was emitted unconditionally whenever the tool was granted, including on the kernel-internal system channels (webui, cron, autonomous) where no messaging adapter exists.
On those channels the suggestion only pushed the agent to attempt a send that cannot reach anyone and to improvise a fallback channel on its own.
The hint is now suppressed there: webui is told media flows through the normal response stream, and background runs are told to target a real messaging channel explicitly (or notify_owner) instead.
Recovered from the closed workflow-ux-v2 PR (librefang#6504), which the maintainer called 'a real fix for a real problem' outside that PR's scope; the suppression now reuses the existing is_reserved_system_channel helper and adds tests for all three system channels.
Review of librefang#8149 pointed out the webui branch was broader than the bug it fixes.
`channel_send` takes an explicit `channel` argument and reaches email, telegram and slack, so a blanket "do NOT use `channel_send`" told an agent on the dashboard not to email a report or post to a Slack channel — both ordinary, supported actions.
The guidance now forbids only replying through it, and points at what it is still for, matching the shape the cron/autonomous branch already had.

Also match the inner `webui` test to the outer guard's case sensitivity.
`is_reserved_system_channel` trims and lowercases, so `"WebUI"` entered the block and then failed `channel == "webui"`, dropping a live web session into the background-run branch and telling it there is no live user watching.
Not reachable today because the kernel passes `SYSTEM_CHANNEL_WEBUI`, but the two comparisons should not disagree; `test_channel_send_hint_webui_match_is_case_insensitive` pins it.

And add the trailing newline the changelog fragment was missing.
The bullet closed with the issue number alone, so the release flow had no PR
number to match and would have emitted the generated line as well, listing the
change twice in the release body.
…guard

The `channel_send` system-channel guard told a cron run nobody was
watching its response, even though a configured delivery target hands
that response straight to a real person (kernel/cron_bridge.rs). It
recommended `notify_owner` as a fallback there, even though neither the
cron nor the autonomous path ever reads the notice it queues. It told
webui that generated media is shown to the user automatically, even
though the browser only ever sees what the reply text embeds. The
background-run and webui branches now match the literal cron /
autonomous / webui channel names instead of deriving from the shared
reserved-channel-name list, so a future addition to that list no longer
inherits background-run guidance by accident.
Two fragments both carried (librefang#8149) and both would have been folded into
### Fixed verbatim, so a release reader got one bullet saying the guard was
added and a second saying three assertions in its wording were wrong.
The first wording never shipped, so the second bullet described fixing
something no user ever saw.
One fragment now describes the shipped behaviour: which channels stop being
offered channel_send, and what each of them is told instead.
…told

The hint contradicted itself inside its own paragraph: it said do NOT use
channel_send here and then, in the next sentence, explained how to use it.

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, and a background turn reaches a person on a real channel exactly like any
other turn. What is impossible is replying into cron or autonomous, which have
no adapter.

the opposite instruction. Both now emit the same sentence byte for byte, and a
test on each side asserts it by name, so whichever merges second the
resolution is textual.
@DaBlitzStein
DaBlitzStein force-pushed the fix/channelsend-system-guard branch from e802149 to 6c0680f Compare September 13, 2026 19:35
@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 Sep 13, 2026
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Rebasado sobre origin/main 57ad92341 (release v2026.9.14) y republicado con --force-with-lease.
La rama vuelve a estar MERGEABLE, pero el PR ha cambiado de forma y conviene leer por qué antes de revisarlo.

El cambio de producción de este PR ya está en main.
Lo entró c281eeda5 — fix(runtime,kernel): error classification, system channel detection, and model sentinel tests (#7995) — y en una versión mejor que la de esta rama: donde aquí se comparaban los literales "webui" / "cron" / "autonomous", main usa las constantes librefang_channels::types::SYSTEM_CHANNEL_{WEBUI,CRON,AUTONOMOUS} y un predicado propio is_non_interactive_turn, cuyo comentario cita textualmente «(#8149 review)».
Los dos textos que se inyectan en el prompt —el de webui y el de la ejecución en segundo plano— son byte a byte los mismos en las dos ramas; lo único que difería era el mecanismo de emparejado.
Por eso resolví cada hunk de crates/librefang-runtime/src/prompt_builder.rs a favor de main, y el resultado es que ese fichero ya no aparece en el diff del PR.

Conjunto de ficheros: 4 antes, 3 después.
El que sale es exactamente crates/librefang-runtime/src/prompt_builder.rs, y sale porque está absorbido, no perdido: git diff origin/main..HEAD -- crates/librefang-runtime/src/prompt_builder.rs es vacío.
Los otros tres siguen: los dos fragmentos de changelog y crates/librefang-runtime/src/prompt_builder/tests.rs.

Lo que queda de este PR, y no es poco: los seis tests.
#7995 entró el comportamiento sin ninguna prueba — main solo tiene test_channel_send_hint_with_tool y test_channel_send_hint_without_tool, los dos anteriores a todo esto.
Las seis que aporta esta rama son las únicas que custodian el comportamiento nuevo, y siguen apuntando a lo correcto contra la implementación de main:

  • test_channel_send_hint_suppressed_for_webui y test_channel_send_hint_webui_match_is_case_insensitive — SYSTEM_CHANNEL_WEBUI vale "webui" y main compara con trim() + eq_ignore_ascii_case, así que el caso "WebUI" sigue siendo el discriminante que era.
  • test_channel_send_hint_suppressed_for_cron_and_autonomous.
  • test_channel_send_hint_cron_does_not_claim_nobody_reads_the_response — un cron con destino configurado sí entrega la respuesta a una persona, y el prompt no puede afirmar lo contrario.
  • test_channel_send_hint_background_run_does_not_recommend_notify_owner — owner_notice se descarta en la ruta de cron y en la autónoma, así que recomendarlo es un callejón sin salida.
  • test_channel_send_hint_background_run_wording_matches_7995 — pinta exactamente la frase que main emite hoy, así que a partir de ahora cualquier reescritura de ese bloque tiene que pasar por aquí.
  • test_channel_send_hint_webui_does_not_claim_automatic_media_delivery.

Los dos fragmentos de changelog también siguen siendo necesarios: main no tiene ninguno bajo changelog.d/fixed/ para #8149, y el comportamiento entró sin entrada de changelog propia.

Verificado

  • rustfmt --edition 2021 --check limpio en prompt_builder/tests.rs.
  • La firma de build_channel_section en main sigue siendo la de seis argumentos que usan los tests, y coincide con la que ya usaban los dos tests preexistentes del mismo fichero.
  • No hay colisión de nombres de test: ninguno de los seis existe ya en main.

NO verificado por mi parte: no he compilado ni ejecutado nada de Rust — ni cargo check, ni clippy, ni cargo test.
El CARGO_TARGET_DIR de esta máquina está compartido con otra compilación en curso, así que la parte Rust queda enteramente para CI.

Sobre ver los tests en rojo: aquí ya no se puede hacer quitando «el cambio de producción de este PR», porque ese cambio es de main.
Para comprobar que discriminan hay que revertir el bloque if channel.trim().eq_ignore_ascii_case(SYSTEM_CHANNEL_WEBUI) { … } else if is_non_interactive_turn(channel) { … } de prompt_builder.rs en main; sin él los seis caen.

Revisiones sin atender: quedan 4 hilos de revisión sin resolver, todos de @houko y todos sobre crates/librefang-runtime/src/prompt_builder.rs, marcados como outdated.
Los dejo explícitamente señalados en vez de darlos por cerrados: apuntan a un fichero que este PR ya no toca, así que muy probablemente los cierra la entrada de #7995, pero esa lectura la debería confirmar quien los escribió.

The rebase onto main dropped this branch's `prompt_builder.rs` changes, because librefang#7995 had already landed the same block — and landed it better, matching `SYSTEM_CHANNEL_WEBUI` and `is_non_interactive_turn()` rather than the three string literals written here. What is left is the seven regression tests and the changelog, and the two fragments described work that is no longer in the diff.

One of them claimed the three channels are "matched by name rather than through the shared reserved-channel-name list", which is not how main does it. The other claimed the wording "is now byte-identical to the one librefang#7995 writes into the same block", a sentence that cannot be true of a branch that no longer writes into that block at all.

Fragments reach the GitHub release body verbatim, so both are replaced by one that says what actually merges: the behaviour shipped with librefang#7995, and these tests are what stop it drifting back.
@houko
houko merged commit f108391 into librefang:main Sep 14, 2026
42 checks passed
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Sep 14, 2026
@houko houko mentioned this pull request Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Agent loop, LLM drivers, WASM sandbox ready-for-review PR is ready for maintainer review size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants