mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
Le builder délègue à un rôle en lecture seule (explorer, code-reviewer, planner) sans passer par /tool/exec : src/chat/subagent.rs porte toolSubagent. Mêmes refus avant tout appel au moteur (rôle inconnu, verifier compris, tâche vide, récursion), contexte isolé servi par le Go (POST /internal/v1/turn/subagent : prompt du rôle + briefing machine, outils, capacités ; subagentView et subagentFraming partagés), tour imbriqué run_turn avec l'endpoint, le raisonnement, l'écho et l'arrêt du tour qui délègue, température 0,6, travail annexe (second slot, effacé à la fin), télémétrie à part. Seul le texte écrit après le dernier outil revient, rogné, préfixé du rôle, tronqué à 6000 runes. Le tour du rôle est porté par SubHost, qui délègue tout à son GoTurnHost sauf un nouvel appel à subagent ; les outils de la façade passent dans GoTurnHost::exec_tool_base, que seul le tour principal complète de subagent (le type du tour imbriqué ne se contient pas lui-même). Parité : journal tiré du Go contre un faux moteur scripté (parity_subagent_test.go → tests/parity_subagent/subagent.json : refus, réponse directe, rapport après deux outils, texte vide relancé, rapport tronqué, cadrage du planner, moteur qui refuse ; requêtes du rôle), rejoué par un tour complet de la façade (tests/lot6.rs) ; TestSubagentVueInterne côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
209 lines
7.9 KiB
Rust
209 lines
7.9 KiB
Rust
//! Télémétrie des complétions de la façade (lots 29 et 39). Toutes
|
|
//! rejoignent l'anneau tenu ici ([`crate::perfring`]) : il les range,
|
|
//! calcule la perte de cache contre la précédente de la même discussion et
|
|
//! l'alerte au seuil du preset actif ; il nourrit `/api/perf/summary`.
|
|
//!
|
|
//! Ce module porte ce que la façade relève elle-même : la réponse entière
|
|
//! d'un résumé de compaction (`perfRecFromWire`), et le passage des stats
|
|
//! d'une complétion à l'anneau (`perfRecFromStats`). Des identifiants et
|
|
//! des compteurs, jamais de texte. Rien ne retourne au moteur.
|
|
|
|
use serde_json::Value;
|
|
|
|
use super::turn::{PerfMarks, PerfRec};
|
|
use super::types::StatsEvent;
|
|
use crate::perfring::{PerfRing, perf_alert};
|
|
use crate::store::{BK_STATE, Store};
|
|
|
|
/// Nature d'une complétion.
|
|
pub const KIND_MAIN: &str = "main";
|
|
pub const KIND_VERIFY: &str = "verify";
|
|
/// Sous-agent du mode Code (`perfSubagent`).
|
|
pub const KIND_SUBAGENT: &str = "subagent";
|
|
pub const KIND_COMPACT: &str = "compact";
|
|
pub const KIND_PREWARM: &str = "prewarm";
|
|
|
|
/// `perfRecFromWire` : une réponse NON streamée (compaction), lue d'un
|
|
/// bloc, au format des stats d'une complétion streamée. Jamais d'échec : un
|
|
/// compteur illisible reste inconnu. Rend aussi si la complétion est
|
|
/// complète (un usage, ou des timings).
|
|
pub fn wire_stats(raw: &[u8]) -> (bool, StatsEvent) {
|
|
let mut s = StatsEvent::default();
|
|
// Seule la première valeur JSON compte (`json.Decoder`).
|
|
let v = serde_json::Deserializer::from_slice(raw)
|
|
.into_iter::<Value>()
|
|
.next()
|
|
.and_then(Result::ok)
|
|
.unwrap_or(Value::Null);
|
|
// Une valeur non nulle d'un autre type (chaîne, nombre) alloue quand
|
|
// même la structure côté Go avant l'erreur de type : présente, vide.
|
|
let has_timings = v.get("timings").is_some_and(|t| !t.is_null());
|
|
if let Some(t) = v.get("timings").filter(|t| t.is_object()) {
|
|
let n = |k: &str| perf_num(t.get(k));
|
|
s.prompt_tokens = n("prompt_n").unwrap_or(0.0) as i64;
|
|
s.prompt_ms = n("prompt_ms").unwrap_or(0.0);
|
|
s.prompt_per_second = n("prompt_per_second").unwrap_or(0.0);
|
|
s.gen_tokens = n("predicted_n").unwrap_or(0.0) as i64;
|
|
s.gen_per_second = n("predicted_per_second").unwrap_or(0.0);
|
|
s.cache_tokens = n("cache_n").map(|f| f as i64);
|
|
s.draft_n = n("draft_n").map(|f| f as i64);
|
|
s.draft_accepted = n("draft_n_accepted").map(|f| f as i64);
|
|
}
|
|
let usage = v.get("usage").filter(|u| u.is_object());
|
|
if let Some(u) = usage {
|
|
s.prompt_tokens_total = perf_num(u.get("prompt_tokens")).unwrap_or(0.0) as i64;
|
|
if s.gen_tokens == 0 {
|
|
s.gen_tokens = perf_num(u.get("completion_tokens")).unwrap_or(0.0) as i64;
|
|
}
|
|
}
|
|
if s.cache_tokens.is_none() {
|
|
s.cache_tokens = usage
|
|
.and_then(|u| u.get("prompt_tokens_details"))
|
|
.filter(|d| d.is_object())
|
|
.and_then(|d| perf_num(d.get("cached_tokens")))
|
|
.map(|f| f as i64);
|
|
}
|
|
(s.prompt_tokens_total > 0 || has_timings, s)
|
|
}
|
|
|
|
/// `perfNum` : un nombre (ou une chaîne qui en contient un), fini, positif
|
|
/// et < 1e12 ; sinon inconnu.
|
|
fn perf_num(v: Option<&Value>) -> Option<f64> {
|
|
let f = match v? {
|
|
Value::Number(n) => n.as_f64()?,
|
|
Value::String(s) => s.parse::<f64>().ok()?,
|
|
_ => return None,
|
|
};
|
|
(f.is_finite() && (0.0..1e12).contains(&f)).then_some(f)
|
|
}
|
|
|
|
/// Range la complétion dans l'anneau et rend ce qu'il en tire : la perte
|
|
/// de cache, ce qui s'est intercalé, et l'alerte au seuil du preset actif
|
|
/// (jamais sur une API externe). La télémétrie ne fait jamais échouer un
|
|
/// tour.
|
|
pub async fn record(
|
|
store: &Store,
|
|
ring: &PerfRing,
|
|
kind: &str,
|
|
conv: &str,
|
|
rec: &PerfRec,
|
|
) -> PerfMarks {
|
|
let kind = if kind.is_empty() { KIND_MAIN } else { kind };
|
|
let s = &rec.stats;
|
|
let r = crate::perfring::PerfRec {
|
|
kind: kind.to_string(),
|
|
conv: conv.to_string(),
|
|
iter: rec.iter,
|
|
complete: rec.complete,
|
|
total: s.prompt_tokens_total,
|
|
cached: s.cache_tokens,
|
|
new: s.prompt_tokens,
|
|
pp_ms: s.prompt_ms,
|
|
pp_tps: s.prompt_per_second,
|
|
gen_tokens: s.gen_tokens,
|
|
tg_tps: s.gen_per_second,
|
|
draft_n: s.draft_n,
|
|
draft_accepted: s.draft_accepted,
|
|
ttft_ms: s.ttft_ms,
|
|
..Default::default()
|
|
};
|
|
let prefix: &[u64] = if rec.complete { &rec.prefix } else { &[] };
|
|
let got = ring.record(r, prefix);
|
|
let mut alert = false;
|
|
if rec.complete && !rec.external && (got.lost.is_some_and(|l| l > 0) || got.cached == Some(0)) {
|
|
let cfg = store.config().await;
|
|
let stored = crate::gguf::parse_stored_model_dirs(
|
|
store
|
|
.get(BK_STATE, crate::gguf::STATE_MODEL_DIRS_KEY)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.as_deref(),
|
|
);
|
|
alert = perf_alert(&got, ring.lost_alert_at(&cfg, &stored));
|
|
}
|
|
PerfMarks {
|
|
kind: got.kind,
|
|
lost: got.lost,
|
|
lost_after: got.lost_after,
|
|
lost_alert: alert,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::turn::perf_prefix;
|
|
use super::super::types::Message;
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn releve_d_une_reponse_entiere() {
|
|
let (complete, s) = wire_stats(
|
|
br#"{"timings":{"prompt_n":"120","prompt_ms":50.5,"prompt_per_second":2376,"predicted_n":30,"predicted_per_second":40,"cache_n":900,"draft_n":-1},"usage":{"prompt_tokens":1020,"completion_tokens":31}} parasite"#,
|
|
);
|
|
assert!(complete);
|
|
assert_eq!(
|
|
s,
|
|
StatsEvent {
|
|
prompt_tokens_total: 1020,
|
|
cache_tokens: Some(900),
|
|
prompt_tokens: 120,
|
|
prompt_ms: 50.5,
|
|
prompt_per_second: 2376.0,
|
|
gen_tokens: 30,
|
|
gen_per_second: 40.0,
|
|
..Default::default()
|
|
}
|
|
);
|
|
// Sans timings : le cache d'OpenAI, les jetons générés de l'usage.
|
|
let (complete, s) = wire_stats(
|
|
br#"{"usage":{"prompt_tokens":10,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}"#,
|
|
);
|
|
assert_eq!(
|
|
(
|
|
complete,
|
|
s.prompt_tokens_total,
|
|
s.gen_tokens,
|
|
s.cache_tokens
|
|
),
|
|
(true, 10, 3, Some(4))
|
|
);
|
|
// Rien de lisible : incomplète, tout inconnu.
|
|
let (complete, s) = wire_stats(b"pas du json");
|
|
assert!(!complete);
|
|
assert_eq!(s.cache_tokens, None);
|
|
let (complete, s) = wire_stats(br#"{"timings":null,"usage":{"prompt_tokens":1e13}}"#);
|
|
assert_eq!((complete, s.prompt_tokens_total), (false, 0));
|
|
// Des timings d'un autre type : présents mais vides, comme le Go.
|
|
let (complete, s) = wire_stats(br#"{"timings":"x"}"#);
|
|
assert_eq!((complete, s.prompt_tokens), (true, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn empreintes_cumulees() {
|
|
let m = |c: &str| Message {
|
|
role: "user".into(),
|
|
content: Some(json!(c)),
|
|
..Default::default()
|
|
};
|
|
let a = perf_prefix(&[m("a"), m("b")]);
|
|
let b = perf_prefix(&[m("a"), m("b"), m("c")]);
|
|
assert_eq!(a.len(), 2);
|
|
assert_eq!(b[..2], a[..]);
|
|
assert_ne!(perf_prefix(&[m("x"), m("b")])[1], a[1]);
|
|
assert!(perf_prefix(&[]).is_empty());
|
|
// FNV-1a de référence : le JSON du message, puis l'octet nul.
|
|
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
|
for c in serde_json::to_vec(&m("a")).unwrap().into_iter().chain([0]) {
|
|
h = (h ^ u64::from(c)).wrapping_mul(0x0000_0100_0000_01b3);
|
|
}
|
|
assert_eq!(perf_prefix(&[m("a")]), vec![h]);
|
|
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
|
for c in *b"a" {
|
|
h = (h ^ u64::from(c)).wrapping_mul(0x0000_0100_0000_01b3);
|
|
}
|
|
assert_eq!(h, 0xaf63_dc4c_8601_ec8c);
|
|
}
|
|
}
|