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
@@ -24,6 +24,7 @@ use sqlx::SqlitePool;
use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::llm::LlmManager;
use crate::llm::logging::RequestLogTarget;
use crate::loop_adapters::activation::SkaldToolActivator;
use crate::loop_adapters::builtins::{SkaldAskUserTool, SkaldHumanChannel};
use crate::loop_adapters::history::SqliteHistory;
@@ -109,8 +110,12 @@ impl AgentCatalog for SkaldAgentCatalog {
let meta = crate::agents::load_task_meta(id).map_err(|e| anyhow::anyhow!("{e}"))?;
// The child's own strength drives its selector (D14) — never the
// parent's resolved client.
let selector = Arc::new(SkaldSelector::new(self.llm_manager.clone(), meta.strength));
// parent's resolved client. Its traffic is logged under the same owner
// (the child's frame id already distinguishes it in the log).
let selector = Arc::new(
SkaldSelector::new(self.llm_manager.clone(), meta.strength)
.with_log(RequestLogTarget::user(self.user_id.clone(), self.pool.clone())),
);
let model = meta.client.as_deref().map(ModelHint::name);
// The child's system context: its own prompt, no per-turn extras.
+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
@@ -8,10 +8,11 @@ use std::sync::Arc;
use agent_loop::activation::ToolRendering;
use agent_loop::async_trait;
use agent_loop::model::{ModelHandle, ModelHint, ModelInfo, ModelSelector};
use agent_loop::model::{Model, ModelHandle, ModelHint, ModelInfo, ModelSelector};
use agent_loop::ids::ModelId;
use serde_json::Value;
use crate::llm::logging::{LoggingModel, RequestLogTarget};
use crate::llm::{DtlMode, LlmEntry, LlmManager, LlmStrength};
/// Maps Skald's per-model DTL mode to the crate's wire protocol (D15).
@@ -37,14 +38,42 @@ pub fn model_info_of(entry: &LlmEntry) -> ModelInfo {
/// The selector handed to the loop manager for one turn: the manager's
/// strength tiering + health + priority, behind the crate's seam.
///
/// It is also where **request logging** is attached: the selector is the only
/// component that knows both the model and the owner of the call, so it wraps
/// the model it hands out in a [`LoggingModel`] bound to that owner (see
/// [`Self::with_log`]). Without it the metadata row would land with a NULL
/// `user_id` and no payload — invisible in the LLM-requests page.
pub struct SkaldSelector {
manager: Arc<LlmManager>,
strength: Option<LlmStrength>,
log: Option<RequestLogTarget>,
}
impl SkaldSelector {
pub fn new(manager: Arc<LlmManager>, strength: Option<LlmStrength>) -> Self {
Self { manager, strength }
Self { manager, strength, log: None }
}
/// Attributes every call served by this selector to `target`. Honoured only
/// when instance-wide request logging is on (`llm.requests_log.enabled`).
pub fn with_log(mut self, target: RequestLogTarget) -> Self {
self.log = Some(target);
self
}
/// Wraps a resolved client in the logging decorator when both a target and
/// the registry pool are available; otherwise hands the bare client over.
fn instrument(&self, name: &str, entry: &LlmEntry) -> Arc<dyn Model> {
match (&self.log, self.manager.log_pool()) {
(Some(target), Some(registry)) => Arc::new(LoggingModel::new(
entry.client.clone(),
registry,
name,
target.clone(),
)),
_ => entry.client.clone(),
}
}
}
@@ -60,9 +89,10 @@ impl ModelSelector for SkaldSelector {
let excluded: Vec<&str> = exclude.iter().map(String::as_str).collect();
self.manager.select_excluding(&excluded, self.strength).await?
};
let model = self.instrument(&name, &entry);
Ok(ModelHandle {
id: name,
model: entry.client.clone(),
model,
info: model_info_of(&entry),
})
}