llm: restore request logging lost in the agent-loop migration
Nightly Build / build (push) Successful in 6m50s

The LLM-requests page has been empty since 24ee5b8: deleting
`session/handler/llm_call.rs` dropped both halves of the request log.

The kernel builds the `ModelRequest` itself and sets `log: None`, so the
`LoggingModel` decorator — wrapped once per model by `LlmManager` — wrote
every metadata row with a NULL `user_id`, while the page (and the detail
endpoint) filter on it. Nothing wrote `llm_request_payloads` at all any
more, so the payload viewer had nothing to show either.

Correlation cannot come from `LlmManager`: it builds one shared client
per model and does not know whose traffic it serves. It now comes from
the `ModelSelector`, the one component that knows both the model and the
owner: `SkaldSelector::with_log(RequestLogTarget)` wraps the model it
hands out, so metadata lands in `llm_requests` attributed to the user and
the payload lands in that user's own encrypted DB, keyed by `request_id`.
Session and frame are read off the request's `conversation`/`frame`,
which makes kernel rounds, sub-agent frames and compaction summaries all
attributed with no extra plumbing (`ModelRequest::log` stays unused).

The compactor's summariser call is attributed too, which it never was:
`try_compact`/`force_compact` now take the owner (its selector is built
per compaction, so it can carry the target).

Three tests in `llm::logging` lock this down — the owner/session/frame
columns, the error row with the provider's rejected body, and the
metadata-only path when no owner pool is available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:31:49 +01:00
co-authored by Claude Opus 5
parent a3e1b0add0
commit 73c720e9ef
9 changed files with 342 additions and 60 deletions
+17 -6
View File
@@ -30,6 +30,7 @@ use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::config::DatetimeConfig;
use crate::llm::LlmManager;
use crate::llm::logging::RequestLogTarget;
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
use crate::loop_adapters::async_task::CronExecutor;
use crate::loop_adapters::builtins::{
@@ -132,9 +133,12 @@ impl UserLoopRuntime {
}));
// The default selector has no strength requirement; every turn overrides
// it with the agent's own (D14).
let default_selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(llm_manager.clone(), None));
// it with the agent's own (D14). It carries the owner's log target, so a
// call served by it (recovery, compaction) is still attributed.
let default_selector: Arc<dyn ModelSelector> = Arc::new(
SkaldSelector::new(llm_manager.clone(), None)
.with_log(RequestLogTarget::user(user_id.clone(), pool.clone())),
);
let manager = Arc::new(
LoopManager::builder()
@@ -208,6 +212,12 @@ impl UserLoopRuntime {
&self.store
}
/// Where this user's LLM traffic is logged: metadata in the registry
/// (attributed to them), payloads in their own encrypted pool.
pub fn log_target(&self) -> RequestLogTarget {
RequestLogTarget::user(self.user_id.clone(), self.pool.clone())
}
/// The conversation id of a session — the store's encoding.
pub fn conversation(session_id: i64) -> ConversationId {
SqliteHistory::conversation(session_id)
@@ -259,10 +269,11 @@ impl UserLoopRuntime {
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
extensions.insert(scope.clone());
// ── Selector: this agent's strength (D14) ──
// ── Selector: this agent's strength (D14) + the owner's request log ──
let strength = crate::agents::load_meta(&frame_agent).ok().and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
let selector: Arc<dyn ModelSelector> = Arc::new(
SkaldSelector::new(self.llm_manager.clone(), strength).with_log(self.log_target()),
);
// The session's root frame; the store reuses the provisioned row.
let frame = self