mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +02:00
Quand la façade tient les serveurs MCP (LOKI_MCP=rust), un appel mcp__* d'un de ses tours partait au Go (/internal/v1/tool/exec), qui le renvoyait aussitôt à la façade (/internal/facade/v1/mcp/call) : deux allers-retours pour revenir au même pool. Le Go n'ajoute rien au résultat d'un outil MCP. ChatShared porte désormais le pool (le même que celui des routes, créé une fois) ; GoTurnHost::exec_tool l'appelle directement — même résultat que la route interne, reconnexion comprise. Une reconnexion annonce la version du Go (/internal/v1/auth), lue une fois et retenue. Sans pool tenu, rien ne change. Le Go garde la route interne pour les tours qu'il exécute encore. Test : un tour de la façade contre un faux serveur MCP stdio — rien par le Go, résultats tels que le modèle les reçoit, erreur après reconnexion, version du Go annoncée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
1311 lines
47 KiB
Rust
1311 lines
47 KiB
Rust
//! Un tour de discussion de bout en bout — port de `EnqueueOrStart`,
|
|
//! `StartTurn`, `generate`, `drainQueued`, `startQueuedIfAny`, `dropQueued`
|
|
//! et `compactAndPublish` (`chat_conversation.go`).
|
|
//!
|
|
//! Envoi : doublon d'un réessai reconnu (`cid`), tour préparé par le Go
|
|
//! (`/internal/v1/turn/start` : pièces jointes, capacités, rôle du mode Code,
|
|
//! puce), mis en file si un tour tourne déjà, sinon lancé : moteur prêt
|
|
//! (`/health`), verrou du tour en Rust puis bail du moteur du Go (partagé
|
|
//! avec tâches, bench, optimiseur), bulle utilisateur journalisée et
|
|
//! enregistrée tout de suite.
|
|
//!
|
|
//! Le tour (`generate`) : compaction de début de tour, vue d'envoi
|
|
//! (`/internal/v1/turn/view` + historique), boucle ([`run_turn`]) dont chaque
|
|
//! événement devient un delta du journal, historique rangé (outils, réponse,
|
|
//! raisonnement renvoyé, rappels en porte-à-faux retirés), contexte estimé
|
|
//! quand le moteur ne le compte pas, compaction de fin de tour, puis fin :
|
|
//! `turn_done`, journal coalescé, verrou et bail rendus, discussion
|
|
//! enregistrée, et la file — abandonnée après un stop, sinon le message
|
|
//! suivant part (ou la notification Web Push s'il n'y en a pas).
|
|
//!
|
|
//! Mode Code : la boucle du contrat (vérification isolée, relances et
|
|
//! corrections du builder) suit le tour, avant la compaction de fin
|
|
//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé.
|
|
//!
|
|
//! Le bloc projet figé (PROJ_SNAPSHOT, lot 34) : le Go décide avec la vue du
|
|
//! tour, la façade range l'instantané et pose les mises à jour
|
|
//! ([`ChatService::assemble_turn`]). Le préchauffage (PREWARM, lot 33) part en
|
|
//! fin de tour ([`super::prewarm`]).
|
|
//! Les images gardées (KEEP_TURN_IMAGES, lot 32)
|
|
//! partent en début et en fin de tour quand il le faut
|
|
//! ([`ChatService::kept_images_turn_start`]). La télémétrie des complétions
|
|
//! (tours, vérifications, résumés) rejoint l'anneau du Go
|
|
//! (`/internal/v1/turn/perf`) ; les motifs `retry_patterns` personnalisés
|
|
//! sont lus au démarrage ([`retry_patterns`]). Chaque requête au moteur local
|
|
//! part sur le slot que rend le Go (SIDE_SLOT, rechargement SLOT_PERSIST,
|
|
//! lot 31).
|
|
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::{Map, Value, json};
|
|
use tokio::sync::watch;
|
|
|
|
use super::compact::{
|
|
CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for,
|
|
ctx_pending, drop_kept_images, estimate_tokens, log_compact, log_ctx_estimate,
|
|
log_kept_images_dropped, prepend_ctx_update, strip_context_updates,
|
|
};
|
|
use super::engine::EngineHttp;
|
|
use super::history::{
|
|
drop_stray_nudges, history_from_view, inject_prefix, remind_read_mem_pages, tool_used_delta,
|
|
};
|
|
use super::host::{
|
|
ChatShared, EngineSummarizer, GoTurnHost, SnapTurn, StoreRecall, TurnCompact, keep_images_state,
|
|
};
|
|
use super::journal::Delta;
|
|
use super::keepimg::{has_kept_images, keep_turn_images_on};
|
|
use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop};
|
|
use super::persist::ProjSnapshot;
|
|
use super::sessions::{GoSessionHooks, Sessions};
|
|
use super::turn::{Cancel, TurnConfig, run_turn};
|
|
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
|
use super::wire::{echo_policy_for, resolve_chat_endpoint, turn_reasoning, with_echo};
|
|
use crate::engstatus::llm_port;
|
|
use crate::store::BK_STATE;
|
|
|
|
/// `errModelLoading` : une attente, pas un défaut.
|
|
pub const ERR_MODEL_LOADING: &str =
|
|
"⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.";
|
|
/// `ErrBusy`.
|
|
pub const ERR_BUSY: &str = "génération en cours";
|
|
/// Identifiants d'envoi retenus (`recentCIDs`).
|
|
pub const RECENT_CIDS: usize = 64;
|
|
|
|
/// Un tour préparé par le Go (`/internal/v1/turn/start`).
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct Prepared {
|
|
#[serde(default)]
|
|
pub files: Value,
|
|
/// Capacités demandées.
|
|
#[serde(default)]
|
|
pub caps: Value,
|
|
/// Capacités du tour (rôle du mode Code figé).
|
|
#[serde(default)]
|
|
pub turn_caps: Value,
|
|
/// Le message utilisateur vu par le modèle.
|
|
#[serde(default)]
|
|
pub content: Value,
|
|
#[serde(default)]
|
|
pub model: String,
|
|
#[serde(default)]
|
|
pub code_hint: bool,
|
|
/// Texte tapé (bulle du journal).
|
|
#[serde(skip)]
|
|
pub text: String,
|
|
#[serde(skip)]
|
|
pub temperature: f64,
|
|
}
|
|
|
|
impl Prepared {
|
|
fn cap(&self, k: &str) -> bool {
|
|
self.turn_caps
|
|
.get(k)
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// La bulle utilisateur du journal : texte, modèle qui répond, fichiers.
|
|
fn user_delta(&self) -> Delta {
|
|
let mut d = Map::new();
|
|
d.insert("user".into(), self.text.clone().into());
|
|
if !self.model.is_empty() {
|
|
d.insert("model".into(), self.model.clone().into());
|
|
}
|
|
if self.files.as_array().is_some_and(|f| !f.is_empty()) {
|
|
d.insert("files".into(), self.files.clone());
|
|
}
|
|
d
|
|
}
|
|
|
|
fn user_message(&self) -> Message {
|
|
Message {
|
|
role: "user".into(),
|
|
content: Some(self.content.clone()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Envoi refusé : statut HTTP de `/api/chat/send` et message.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Refused {
|
|
pub status: u16,
|
|
pub error: String,
|
|
}
|
|
|
|
/// Envoi accepté.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub struct Sent {
|
|
/// Mis en file derrière le tour en cours.
|
|
pub queued: bool,
|
|
/// Réessai d'un envoi déjà accepté.
|
|
pub dup: bool,
|
|
}
|
|
|
|
enum StartError {
|
|
/// Le tour de la discussion tient déjà le verrou.
|
|
ConvBusy,
|
|
/// Le moteur est tenu par le Go (tâche, bench, optimiseur) : 409.
|
|
Busy(String),
|
|
/// Moteur pas prêt : 503.
|
|
NotReady(String),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Queue {
|
|
items: VecDeque<Prepared>,
|
|
recent: VecDeque<String>,
|
|
}
|
|
|
|
pub(super) fn obj(k: &str, v: impl Into<Value>) -> Delta {
|
|
let mut d = Map::new();
|
|
d.insert(k.into(), v.into());
|
|
d
|
|
}
|
|
|
|
type BoxFut = Pin<Box<dyn Future<Output = ()> + Send>>;
|
|
|
|
/// Ce qu'un tour lit une fois au départ.
|
|
pub(super) struct TurnEnv {
|
|
pub(super) cfg: std::collections::HashMap<String, String>,
|
|
pub(super) ep: super::wire::ChatEndpoint,
|
|
pub(super) echo: super::wire::EchoPolicy,
|
|
pub(super) ccfg: CompactConfig,
|
|
pub(super) port: u16,
|
|
pub(super) summarizer: EngineSummarizer,
|
|
}
|
|
|
|
/// La décision PROJ_SNAPSHOT rendue avec la vue du tour (`projsnap`).
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub(super) struct SnapDecision {
|
|
/// Instantané neuf : blocs `<context_update>` retirés de l'historique.
|
|
#[serde(default)]
|
|
pub(super) refresh: bool,
|
|
/// Mise à jour à poser en tête du dernier message utilisateur.
|
|
#[serde(default)]
|
|
pub(super) delta: String,
|
|
/// L'instantané à ranger (absent en préchauffage).
|
|
#[serde(default)]
|
|
pub(super) snap: Option<ProjSnapshot>,
|
|
#[serde(default)]
|
|
pub(super) key: String,
|
|
#[serde(default)]
|
|
pub(super) sys_hash: String,
|
|
}
|
|
|
|
/// Les pièces de la vue d'envoi.
|
|
pub(super) struct ViewParts {
|
|
pub(super) head: Vec<Message>,
|
|
pub(super) prefix: String,
|
|
pub(super) tools: Vec<Tool>,
|
|
pub(super) snap: Option<SnapDecision>,
|
|
}
|
|
|
|
/// Une étape assemblée : ce qui part, les outils, l'historique d'où part la
|
|
/// vue (`historyFromView`), et de quoi rafraîchir le bloc projet en plein
|
|
/// tour (PROJ_SNAPSHOT).
|
|
pub(super) struct Assembled {
|
|
pub(super) sent: Vec<Message>,
|
|
pub(super) tools: Vec<Tool>,
|
|
pub(super) hist: Vec<Message>,
|
|
pub(super) snap_turn: Option<SnapTurn>,
|
|
}
|
|
|
|
/// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file.
|
|
pub struct ChatService {
|
|
pub sessions: Arc<Sessions<GoSessionHooks>>,
|
|
pub shared: Arc<ChatShared>,
|
|
q: Mutex<Queue>,
|
|
lease_ttl: Duration,
|
|
}
|
|
|
|
impl ChatService {
|
|
pub fn new(sessions: Arc<Sessions<GoSessionHooks>>, shared: Arc<ChatShared>) -> Arc<Self> {
|
|
Arc::new(ChatService {
|
|
sessions,
|
|
shared,
|
|
q: Mutex::new(Queue::default()),
|
|
lease_ttl: LEASE_TTL,
|
|
})
|
|
}
|
|
|
|
fn queue(&self) -> std::sync::MutexGuard<'_, Queue> {
|
|
self.q.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// `seenCIDLocked` : retient `cid` et dit s'il avait déjà été vu.
|
|
fn seen_cid(&self, cid: &str) -> bool {
|
|
if cid.is_empty() {
|
|
return false;
|
|
}
|
|
let mut q = self.queue();
|
|
if q.recent.iter().any(|c| c == cid) {
|
|
return true;
|
|
}
|
|
q.recent.push_back(cid.to_string());
|
|
while q.recent.len() > RECENT_CIDS {
|
|
q.recent.pop_front();
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Un envoi refusé peut être renvoyé : son cid ne reste pas marqué.
|
|
fn forget_cid(&self, cid: &str) {
|
|
self.queue().recent.retain(|c| c != cid);
|
|
}
|
|
|
|
/// `/api/chat/send` (`EnqueueOrStart`) : corps tel que l'UI l'envoie.
|
|
pub async fn send(self: &Arc<Self>, body: Value) -> Result<Sent, Refused> {
|
|
let cid = body
|
|
.get("cid")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if self.seen_cid(&cid) {
|
|
return Ok(Sent {
|
|
queued: false,
|
|
dup: true,
|
|
});
|
|
}
|
|
let refuse = |me: &Self, status: u16, error: String| {
|
|
me.forget_cid(&cid);
|
|
Err(Refused { status, error })
|
|
};
|
|
let prep = match self
|
|
.shared
|
|
.store
|
|
.internal_post("/internal/v1/turn/start", &body)
|
|
.await
|
|
{
|
|
Ok((s, v)) if s.as_u16() == 200 => {
|
|
serde_json::from_value::<Prepared>(v).unwrap_or_default()
|
|
}
|
|
Ok((s, v)) => {
|
|
let e = v
|
|
.get("error")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
return refuse(self, if s.as_u16() == 400 { 400 } else { 503 }, e);
|
|
}
|
|
Err(e) => return refuse(self, 503, e),
|
|
};
|
|
let prep = Prepared {
|
|
text: body
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
temperature: body
|
|
.get("temperature")
|
|
.and_then(Value::as_f64)
|
|
.unwrap_or(0.0),
|
|
..prep
|
|
};
|
|
let conv = self.sessions.conv();
|
|
if conv.is_generating() {
|
|
self.queue().items.push_back(prep);
|
|
return Ok(Sent {
|
|
queued: true,
|
|
dup: false,
|
|
});
|
|
}
|
|
match self.start(prep.clone()).await {
|
|
Ok(()) => Ok(Sent::default()),
|
|
// Un autre tour a démarré entre-temps (deux appareils) : en file.
|
|
Err(StartError::ConvBusy) => {
|
|
self.queue().items.push_back(prep);
|
|
self.start_queued_if_any().await;
|
|
Ok(Sent {
|
|
queued: true,
|
|
dup: false,
|
|
})
|
|
}
|
|
Err(StartError::Busy(e)) => refuse(self, 409, e),
|
|
Err(StartError::NotReady(e)) => refuse(self, 503, e),
|
|
}
|
|
}
|
|
|
|
/// `healthCheck` : preset externe, toujours prêt ; sinon `/health`.
|
|
async fn engine_ready(&self) -> bool {
|
|
let cfg = self.shared.store.config().await;
|
|
if crate::engstatus::is_external(&cfg) {
|
|
return true;
|
|
}
|
|
let url = format!("http://localhost:{}/health", llm_port(&cfg));
|
|
self.shared.http.health(&url).await
|
|
}
|
|
|
|
/// `StartTurn`. Futur en boîte : la fin d'un tour relance la file, donc
|
|
/// `start` → `generate` → `start`, un cycle que le type doit couper.
|
|
fn start(
|
|
self: &Arc<Self>,
|
|
prep: Prepared,
|
|
) -> Pin<Box<dyn Future<Output = Result<(), StartError>> + Send + '_>> {
|
|
Box::pin(self.start_inner(prep))
|
|
}
|
|
|
|
async fn start_inner(self: &Arc<Self>, prep: Prepared) -> Result<(), StartError> {
|
|
if !self.engine_ready().await {
|
|
return Err(StartError::NotReady(ERR_MODEL_LOADING.into()));
|
|
}
|
|
let conv = self.sessions.conv().clone();
|
|
let (tx, cancel) = Cancel::new();
|
|
let tx = Arc::new(tx);
|
|
let t = tx.clone();
|
|
let epoch = conv
|
|
.begin_turn(Box::new(move || {
|
|
let _ = t.send(true);
|
|
}))
|
|
.map_err(|_| StartError::ConvBusy)?;
|
|
let lease = match EngineLease::acquire(self.shared.store.clone(), self.lease_ttl).await {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
conv.release_turn(epoch);
|
|
return Err(match e {
|
|
LeaseError::Busy { error, .. } => StartError::Busy(error),
|
|
LeaseError::Other(e) => StartError::NotReady(e),
|
|
});
|
|
}
|
|
};
|
|
conv.with_state_if(epoch, |st| st.messages.push(prep.user_message()));
|
|
conv.append_delta(epoch, prep.user_delta());
|
|
if prep.code_hint && !prep.cap("Code") {
|
|
conv.append_delta(epoch, obj("code_hint", true));
|
|
}
|
|
// Instantané tout de suite, écriture par l'écrivain : un crash en
|
|
// pleine génération ne perd pas le message de l'utilisateur.
|
|
self.sessions.persist_async().await;
|
|
let me = self.clone();
|
|
let fut: BoxFut =
|
|
Box::pin(async move { me.generate(prep, epoch, cancel, tx, lease).await });
|
|
tokio::spawn(fut);
|
|
Ok(())
|
|
}
|
|
|
|
/// `drainQueued` : messages en file injectés dans le tour en cours,
|
|
/// journalisés pour tous les appareils.
|
|
fn drain_queued(&self, epoch: i64) -> Vec<Message> {
|
|
let conv = self.sessions.conv();
|
|
if conv.epoch() != epoch {
|
|
return Vec::new();
|
|
}
|
|
let items: Vec<Prepared> = self.queue().items.drain(..).collect();
|
|
items
|
|
.into_iter()
|
|
.map(|p| {
|
|
conv.append_delta(epoch, p.user_delta());
|
|
p.user_message()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// `startQueuedIfAny` : le prochain message de la file part, s'il en reste
|
|
/// et que rien ne tourne. Rend vrai si un tour a démarré.
|
|
async fn start_queued_if_any(self: &Arc<Self>) -> bool {
|
|
loop {
|
|
let conv = self.sessions.conv();
|
|
if conv.is_generating() {
|
|
return false;
|
|
}
|
|
let Some(next) = self.queue().items.pop_front() else {
|
|
return false;
|
|
};
|
|
let epoch = conv.epoch();
|
|
match self.start(next.clone()).await {
|
|
Ok(()) => return true,
|
|
// Un tour vient de partir : le message garde sa place, ce tour
|
|
// le reprendra (injection ou fin de tour).
|
|
Err(StartError::ConvBusy) => {
|
|
self.queue().items.push_front(next);
|
|
return false;
|
|
}
|
|
Err(StartError::Busy(e) | StartError::NotReady(e)) => {
|
|
conv.append_delta(
|
|
epoch,
|
|
obj("error", format!("message en attente non envoyé : {e}")),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `dropQueued` : bouton stop, l'utilisateur reprend la main.
|
|
fn drop_queued(&self, epoch: i64) {
|
|
let n = std::mem::take(&mut self.queue().items).len();
|
|
if n > 0 {
|
|
self.sessions
|
|
.conv()
|
|
.append_delta(epoch, obj("queue_dropped", n));
|
|
}
|
|
}
|
|
|
|
/// `/api/chat/stop` : le tour en cours, ou le détenteur Go du moteur
|
|
/// (tâche planifiée, bench, optimiseur).
|
|
pub async fn stop(&self) {
|
|
let conv = self.sessions.conv();
|
|
if conv.is_generating() {
|
|
conv.stop();
|
|
} else if let Err(e) = engine_stop(&self.shared.store).await {
|
|
eprintln!("[chat] arrêt du détenteur du moteur impossible : {e}");
|
|
}
|
|
}
|
|
|
|
/// `keptImagesTurnStart` : début (ou fin) d'un tour. Les images gardées
|
|
/// (KEEP_TURN_IMAGES) sont retirées d'abord — avant toute compaction, qui
|
|
/// ne résume alors que si c'est encore nécessaire — quand le contexte la
|
|
/// réclame, ou quand elles ne doivent plus partir : clé retirée, vision
|
|
/// absente (un moteur sans projecteur refuserait la requête). Rend
|
|
/// l'historique et le contexte à jour.
|
|
async fn kept_images_turn_start(
|
|
&self,
|
|
epoch: i64,
|
|
cfg: &HashMap<String, String>,
|
|
ccfg: &CompactConfig,
|
|
msgs: Vec<Message>,
|
|
ctx_used: i64,
|
|
peak: i64,
|
|
) -> (Vec<Message>, i64) {
|
|
if !has_kept_images(&msgs) {
|
|
return (msgs, ctx_used);
|
|
}
|
|
if keep_turn_images_on(cfg)
|
|
&& matches!(keep_images_state(&self.shared.store).await, Some((true, _)))
|
|
&& !compact_needed(ccfg, &msgs, ctx_used, peak)
|
|
{
|
|
return (msgs, ctx_used);
|
|
}
|
|
let Some((out, freed)) = drop_kept_images(&msgs) else {
|
|
return (msgs, ctx_used);
|
|
};
|
|
log_kept_images_dropped("début-tour", freed);
|
|
let ctx_used = if ctx_used > 0 {
|
|
(ctx_used - freed).max(1)
|
|
} else {
|
|
ctx_used
|
|
};
|
|
let conv = self.sessions.conv();
|
|
let cur = conv
|
|
.with_state_if(epoch, |st| {
|
|
st.messages = out.clone();
|
|
if st.ctx_used > 0 {
|
|
st.ctx_used = (st.ctx_used - freed).max(1);
|
|
}
|
|
st.ctx_used
|
|
})
|
|
.unwrap_or(0);
|
|
if cur > 0 {
|
|
conv.append_delta(epoch, obj("ctx_used", cur));
|
|
}
|
|
(out, ctx_used)
|
|
}
|
|
|
|
/// `compactAndPublish` : bannière, journal, installation du résultat,
|
|
/// jauge de contexte. Rend l'historique compacté, `None` s'il n'a pas
|
|
/// changé.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn compact_and_publish(
|
|
&self,
|
|
summarizer: &EngineSummarizer,
|
|
ccfg: &CompactConfig,
|
|
caps: CompactCaps,
|
|
mem_off: bool,
|
|
epoch: i64,
|
|
phase: &str,
|
|
msgs: &[Message],
|
|
ctx_used: i64,
|
|
opt: &CompactOpts,
|
|
) -> Option<Vec<Message>> {
|
|
let conv = self.sessions.conv();
|
|
conv.append_delta(epoch, obj("compacting", true));
|
|
let recall = StoreRecall(self.shared.store.clone());
|
|
let criteria = if caps.code {
|
|
super::host::live_criteria(&self.shared.store, &conv.id()).await
|
|
} else {
|
|
String::new()
|
|
};
|
|
let c = Compactor {
|
|
cfg: ccfg,
|
|
summarizer,
|
|
recall: &recall,
|
|
memory: &self.shared.compact_memory,
|
|
criteria: &criteria,
|
|
};
|
|
let (compacted, changed) = c.compact_messages_noted(msgs, caps, opt).await;
|
|
conv.append_delta(epoch, obj("compacting", false));
|
|
log_compact(ccfg, phase, ctx_used, msgs, &compacted, changed);
|
|
if !changed {
|
|
conv.append_delta(epoch, obj("compact_noop", true));
|
|
return None;
|
|
}
|
|
let compacted = if caps.agent {
|
|
remind_read_mem_pages(compacted, msgs, mem_off)
|
|
} else {
|
|
compacted
|
|
};
|
|
let compacted = strip_context_updates(compacted);
|
|
let overhead = (ctx_used - estimate_tokens(msgs, &ccfg.echo)).max(0);
|
|
let est = estimate_tokens(&compacted, &ccfg.echo) + overhead;
|
|
conv.with_state_if(epoch, |st| {
|
|
st.messages = compacted.clone();
|
|
st.proj_snap = None;
|
|
st.ctx_used = est;
|
|
st.ctx_used_len = compacted.len();
|
|
});
|
|
conv.append_delta(epoch, obj("compacted", true));
|
|
conv.append_delta(epoch, obj("ctx_used", est));
|
|
Some(compacted)
|
|
}
|
|
|
|
/// Les pièces de la vue d'envoi (`/internal/v1/turn/view`) : tête du Go
|
|
/// (prompt du preset, consignes, projet), préambule, outils ; avec
|
|
/// PROJ_SNAPSHOT et l'agent, la décision sur le bloc figé (`snap` :
|
|
/// l'instantané de la discussion ; `dry` : préchauffage, rien à ranger).
|
|
pub(super) async fn turn_view(
|
|
&self,
|
|
caps: &Value,
|
|
snap: Option<&ProjSnapshot>,
|
|
dry: bool,
|
|
) -> Result<ViewParts, String> {
|
|
match self
|
|
.shared
|
|
.store
|
|
.internal_post(
|
|
"/internal/v1/turn/view",
|
|
&json!({ "caps": caps, "snap": snap, "dry": dry }),
|
|
)
|
|
.await
|
|
{
|
|
Ok((s, v)) if s.as_u16() == 200 => Ok(ViewParts {
|
|
head: serde_json::from_value::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
|
|
prefix: v["prefix"].as_str().unwrap_or("").to_string(),
|
|
tools: serde_json::from_value::<Vec<Tool>>(v["tools"].clone()).unwrap_or_default(),
|
|
snap: v
|
|
.get("projsnap")
|
|
.filter(|p| p.is_object())
|
|
.and_then(|p| serde_json::from_value(p.clone()).ok()),
|
|
}),
|
|
Ok((_, v)) => Err(v["error"]
|
|
.as_str()
|
|
.unwrap_or("vue du tour refusée")
|
|
.to_string()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// `turnView` : la séquence envoyée pour une étape de la discussion (tour,
|
|
/// relance du builder) — tête du Go, historique, préambule. Avec
|
|
/// PROJ_SNAPSHOT : l'instantané de la discussion rangé, rafraîchi (blocs
|
|
/// `<context_update>` retirés de l'historique), ou complété d'une mise à
|
|
/// jour posée en tête du dernier message utilisateur, dans la discussion
|
|
/// et dans `msgs`. Sans la clé, une discussion qui porte encore des blocs
|
|
/// d'une époque où elle servait en est nettoyée.
|
|
pub(super) async fn assemble_turn(
|
|
&self,
|
|
epoch: i64,
|
|
caps: &Value,
|
|
mut msgs: Vec<Message>,
|
|
) -> Result<Assembled, String> {
|
|
let conv = self.sessions.conv();
|
|
let snap = conv.with_state(|st| st.proj_snap.clone());
|
|
let v = self.turn_view(caps, snap.as_ref(), false).await?;
|
|
let agent = caps.get("Agent").and_then(Value::as_bool).unwrap_or(false);
|
|
let mut snap_turn = None;
|
|
let hist = match v.snap {
|
|
Some(d) => {
|
|
let applied = conv.with_state_if(epoch, |st| {
|
|
if d.refresh {
|
|
st.messages = strip_context_updates(std::mem::take(&mut st.messages));
|
|
st.proj_snap = d.snap.clone();
|
|
return (true, false);
|
|
}
|
|
if d.delta.is_empty() {
|
|
return (false, false);
|
|
}
|
|
// Pas de message utilisateur où la poser : l'état annoncé
|
|
// ne bouge pas, elle partira au tour suivant.
|
|
let Some(m) = st
|
|
.messages
|
|
.last()
|
|
.and_then(|m| prepend_ctx_update(m, &d.delta))
|
|
else {
|
|
return (false, false);
|
|
};
|
|
if let Some(last) = st.messages.last_mut() {
|
|
*last = m;
|
|
}
|
|
st.proj_snap = d.snap.clone();
|
|
(false, true)
|
|
});
|
|
if let Some((refresh, updated)) = applied {
|
|
if refresh {
|
|
msgs = strip_context_updates(msgs);
|
|
}
|
|
if updated
|
|
&& let Some(m) = msgs.last().and_then(|m| prepend_ctx_update(m, &d.delta))
|
|
&& let Some(last) = msgs.last_mut()
|
|
{
|
|
*last = m;
|
|
}
|
|
}
|
|
snap_turn = Some(SnapTurn {
|
|
caps: caps.clone(),
|
|
key: d.key,
|
|
sys_hash: d.sys_hash,
|
|
});
|
|
msgs
|
|
}
|
|
None => {
|
|
if agent {
|
|
conv.with_state_if(epoch, |st| {
|
|
if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) {
|
|
st.proj_snap = None;
|
|
st.messages = strip_context_updates(std::mem::take(&mut st.messages));
|
|
}
|
|
});
|
|
}
|
|
strip_context_updates(msgs)
|
|
}
|
|
};
|
|
let mut sent = v.head;
|
|
sent.extend(hist.iter().cloned());
|
|
Ok(Assembled {
|
|
sent: inject_prefix(sent, &v.prefix),
|
|
tools: v.tools,
|
|
hist,
|
|
snap_turn,
|
|
})
|
|
}
|
|
|
|
/// Configuration, point de chat et résumeur d'un tour (lus une fois).
|
|
pub(super) async fn turn_env(&self) -> TurnEnv {
|
|
let store = &self.shared.store;
|
|
let cfg = store.config().await;
|
|
let local_key = store.get_str(BK_STATE, "api_key").await;
|
|
let ep = resolve_chat_endpoint(&cfg, &local_key);
|
|
let verdict = self
|
|
.shared
|
|
.tpl
|
|
.caps_current(ep.external)
|
|
.map(|r| r.verdict());
|
|
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, verdict.as_ref());
|
|
let ccfg = CompactConfig::from_config(&cfg, echo.clone());
|
|
let port = llm_port(&cfg);
|
|
let summarizer = EngineSummarizer {
|
|
http: self.shared.http.clone(),
|
|
ep: ep.clone(),
|
|
cfg: cfg.clone(),
|
|
patterns: self.shared.patterns.clone(),
|
|
port,
|
|
go: Some((store.clone(), self.sessions.conv().id())),
|
|
};
|
|
TurnEnv {
|
|
cfg,
|
|
ep,
|
|
echo,
|
|
ccfg,
|
|
port,
|
|
summarizer,
|
|
}
|
|
}
|
|
|
|
/// `/api/chat/reset` : fil vide pour tous les appareils, tour arrêté et
|
|
/// verrou rendu, file vidée, fichiers de la discussion effacés.
|
|
pub async fn reset(&self) {
|
|
let conv = self.sessions.conv();
|
|
conv.reset();
|
|
self.queue().items.clear();
|
|
let body = json!({ "id": conv.id(), "files_only": true });
|
|
if let Err(e) = self
|
|
.shared
|
|
.store
|
|
.internal_json(
|
|
hyper::Method::POST,
|
|
"/internal/v1/conv/dropped",
|
|
Some(&body),
|
|
)
|
|
.await
|
|
{
|
|
eprintln!("[chat] fichiers de la discussion non effacés : {e}");
|
|
}
|
|
self.sessions.persist().await;
|
|
}
|
|
|
|
/// `/api/chat/compact` (`CompactNow`) : compaction manuelle, en tâche de
|
|
/// fond ; la progression passe par le journal.
|
|
pub async fn compact_now(self: &Arc<Self>) -> Result<(), Refused> {
|
|
if !self.engine_ready().await {
|
|
return Err(Refused {
|
|
status: 503,
|
|
error: ERR_MODEL_LOADING.into(),
|
|
});
|
|
}
|
|
let conv = self.sessions.conv().clone();
|
|
let epoch = conv.begin_turn(Box::new(|| {})).map_err(|_| Refused {
|
|
status: 409,
|
|
error: ERR_BUSY.into(),
|
|
})?;
|
|
let lease = match EngineLease::acquire(self.shared.store.clone(), self.lease_ttl).await {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
conv.release_turn(epoch);
|
|
return Err(match e {
|
|
LeaseError::Busy { error, .. } => Refused { status: 409, error },
|
|
LeaseError::Other(error) => Refused { status: 503, error },
|
|
});
|
|
}
|
|
};
|
|
let me = self.clone();
|
|
tokio::spawn(async move {
|
|
let env = me.turn_env().await;
|
|
let local = !env.ep.external;
|
|
let snap = conv.with_state_if(epoch, |st| {
|
|
let msgs = st.messages.clone();
|
|
let used = if local {
|
|
ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo)
|
|
} else {
|
|
st.ctx_used
|
|
};
|
|
(msgs, used)
|
|
});
|
|
if let Some((msgs, used)) = snap {
|
|
let changed = me
|
|
.compact_and_publish(
|
|
&env.summarizer,
|
|
&env.ccfg,
|
|
CompactCaps::default(),
|
|
true,
|
|
epoch,
|
|
"manuel",
|
|
&msgs,
|
|
used,
|
|
&CompactOpts::default(),
|
|
)
|
|
.await
|
|
.is_some();
|
|
if changed {
|
|
me.sessions.persist().await;
|
|
}
|
|
}
|
|
conv.release_turn(epoch);
|
|
lease.release().await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// `/api/chat/tool-result` (`loadToolResult`) : le résultat complet d'un
|
|
/// outil — en mémoire tant qu'il n'est pas écrit, sinon en base.
|
|
pub async fn tool_result(&self, id: &str) -> Option<String> {
|
|
if id.contains(['/', '\\']) {
|
|
return None;
|
|
}
|
|
if let Some(s) = self.shared.persister.tool_res_pending(id) {
|
|
return Some(s.to_string());
|
|
}
|
|
let b = self
|
|
.shared
|
|
.store
|
|
.get_open(super::persist::BK_TOOL_RES, id)
|
|
.await
|
|
.ok()
|
|
.flatten()?;
|
|
Some(String::from_utf8_lossy(&b).into_owned())
|
|
}
|
|
|
|
/// Changement de projet (`/api/projects`, action switch), remis par le
|
|
/// Go : refusé pendant un tour.
|
|
pub async fn project_switch(self: &Arc<Self>, slug: &str) -> Result<(), Refused> {
|
|
if self.sessions.conv().is_generating() {
|
|
return Err(Refused {
|
|
status: 409,
|
|
error: "une génération est en cours".into(),
|
|
});
|
|
}
|
|
self.sessions
|
|
.project_switch(slug)
|
|
.await
|
|
.map_err(|e| Refused {
|
|
status: 400,
|
|
error: e.to_string(),
|
|
})?;
|
|
self.prewarm_after_switch().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Suppression d'un projet (`deleteProject`) : ses discussions d'abord,
|
|
/// refusé pendant un tour.
|
|
pub async fn drop_project(&self, slug: &str) -> Result<usize, Refused> {
|
|
if self.sessions.conv().is_generating() {
|
|
return Err(Refused {
|
|
status: 409,
|
|
error: "une génération est en cours".into(),
|
|
});
|
|
}
|
|
self.sessions.drop_project(slug).await.map_err(|e| Refused {
|
|
status: 400,
|
|
error: e.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Un événement d'affichage né hors d'un tour (bascule du mode Code,
|
|
/// critères édités), sur le fil courant.
|
|
pub fn push_delta(&self, d: Delta) {
|
|
let conv = self.sessions.conv();
|
|
conv.append_delta(conv.epoch(), d);
|
|
}
|
|
|
|
/// `generate` : un tour complet, détaché — seul l'arrêt l'interrompt.
|
|
async fn generate(
|
|
self: Arc<Self>,
|
|
prep: Prepared,
|
|
epoch: i64,
|
|
cancel: Cancel,
|
|
cancel_tx: Arc<watch::Sender<bool>>,
|
|
mut lease: EngineLease,
|
|
) {
|
|
let turn_start = Instant::now();
|
|
let conv = self.sessions.conv().clone();
|
|
let env = self.turn_env().await;
|
|
let TurnEnv {
|
|
cfg,
|
|
ep,
|
|
echo,
|
|
ccfg,
|
|
port,
|
|
summarizer,
|
|
} = &env;
|
|
let (cfg, ep, echo, ccfg, port, summarizer) = (
|
|
cfg.clone(),
|
|
ep.clone(),
|
|
echo.clone(),
|
|
ccfg.clone(),
|
|
*port,
|
|
summarizer.clone(),
|
|
);
|
|
let local = !ep.external;
|
|
let conv_id = conv.id();
|
|
// Capacités demandées pour ce tour, retenues en mémoire (jamais
|
|
// persistées) pour préchauffer le suivant (PREWARM).
|
|
self.shared.prewarm.note_caps(&conv_id, prep.caps.clone());
|
|
let agent = prep.cap("Agent");
|
|
let mem_off = prep.turn_caps.get("Mem").and_then(Value::as_str) == Some("off");
|
|
let ccaps = CompactCaps {
|
|
agent,
|
|
code: prep.cap("Code"),
|
|
};
|
|
let ctx_now = |st: &super::persist::ConvState, msgs: &[Message]| {
|
|
if local {
|
|
ctx_pending(st.ctx_used, st.ctx_used_len, msgs, &echo)
|
|
} else {
|
|
st.ctx_used
|
|
}
|
|
};
|
|
|
|
// Instantané de la vue modèle et du contexte.
|
|
let snap = conv.with_state_if(epoch, |st| {
|
|
let msgs = st.messages.clone();
|
|
let used = ctx_now(st, &msgs);
|
|
let peak = std::mem::take(&mut st.gen_peak);
|
|
(msgs, used, peak)
|
|
});
|
|
let Some((msgs, ctx_used, peak)) = snap else {
|
|
lease.release().await;
|
|
return;
|
|
};
|
|
// KEEP_TURN_IMAGES : images gardées retirées d'abord si le contexte le
|
|
// demande ou si elles ne doivent plus partir.
|
|
let (mut msgs, ctx_used) = self
|
|
.kept_images_turn_start(epoch, &cfg, &ccfg, msgs, ctx_used, peak)
|
|
.await;
|
|
|
|
// Compaction de début de tour.
|
|
let mut start_est = ctx_used;
|
|
if compact_needed(&ccfg, &msgs, ctx_used, peak)
|
|
&& !self
|
|
.shared
|
|
.compact_memory
|
|
.refused_skip(&ccfg, &conv_id, ctx_used, &msgs)
|
|
{
|
|
let opt = compact_opts_for(
|
|
&ccfg,
|
|
&self.shared.compact_memory,
|
|
false,
|
|
&msgs,
|
|
ctx_used,
|
|
&conv_id,
|
|
|_| TurnView::default(),
|
|
);
|
|
if let Some(out) = self
|
|
.compact_and_publish(
|
|
&summarizer,
|
|
&ccfg,
|
|
ccaps,
|
|
mem_off,
|
|
epoch,
|
|
"début-tour",
|
|
&msgs,
|
|
ctx_used,
|
|
&opt,
|
|
)
|
|
.await
|
|
{
|
|
msgs = out;
|
|
start_est = conv
|
|
.with_state_if(epoch, |st| st.ctx_used)
|
|
.unwrap_or(start_est);
|
|
}
|
|
}
|
|
|
|
// Vue d'envoi : tête du Go (prompt du preset, consignes, projet ou
|
|
// bloc figé), l'historique, le préambule.
|
|
let Assembled {
|
|
sent,
|
|
tools,
|
|
hist,
|
|
snap_turn,
|
|
} = match self.assemble_turn(epoch, &prep.turn_caps, msgs).await {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
conv.append_delta(epoch, obj("error", e));
|
|
self.finish(epoch, &cancel, lease, turn_start).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let temperature = if prep.temperature == 0.0 {
|
|
0.7
|
|
} else {
|
|
prep.temperature
|
|
};
|
|
let reasoning = turn_reasoning(&cfg, &self.shared.effort);
|
|
let tc = TurnConfig {
|
|
cfg: cfg.clone(),
|
|
ep: ep.clone(),
|
|
tools: tools.clone(),
|
|
temperature,
|
|
code: prep.cap("Code"),
|
|
reasoning: reasoning.clone(),
|
|
echo: echo.clone(),
|
|
compact: ccfg.clone(),
|
|
patterns: self.shared.patterns.clone(),
|
|
};
|
|
let me = self.clone();
|
|
let host = GoTurnHost {
|
|
shared: self.shared.clone(),
|
|
conv_id: conv_id.clone(),
|
|
caps: prep.turn_caps.clone(),
|
|
cfg: cfg.clone(),
|
|
port,
|
|
summarizer: summarizer.clone(),
|
|
compact: TurnCompact {
|
|
cfg: ccfg.clone(),
|
|
caps: ccaps,
|
|
criteria: String::new(),
|
|
main_conv: conv_id.clone(),
|
|
tools,
|
|
effort: reasoning.effort.clone(),
|
|
kwargs: reasoning.kwargs.clone().unwrap_or_default(),
|
|
},
|
|
queued: Box::new(move || me.drain_queued(epoch)),
|
|
perf_kind: super::perf::KIND_MAIN,
|
|
side_slot: Default::default(),
|
|
projsnap: snap_turn,
|
|
snap_refreshed: Default::default(),
|
|
};
|
|
|
|
// Chaque événement devient un delta du journal.
|
|
let mut content = String::new();
|
|
let mut new_base: Option<Vec<Message>> = None;
|
|
let mut final_echo: Option<ReasoningEcho> = None;
|
|
let mut saw_usage = false;
|
|
let mut turn_peak = 0i64;
|
|
let mut asked = false;
|
|
let mut cb = |ev: StreamEvent| -> bool {
|
|
match ev {
|
|
StreamEvent::Err(e) => {
|
|
conv.append_delta(epoch, obj("error", e));
|
|
}
|
|
StreamEvent::ToolUsed(t) => {
|
|
// Le texte écrit avant l'appel est déjà dans le message
|
|
// assistant qui porte l'appel : pas deux fois.
|
|
content.clear();
|
|
conv.append_delta(epoch, tool_used_delta(&t));
|
|
}
|
|
StreamEvent::NewHistory(h) => new_base = Some(history_from_view(&h, &hist)),
|
|
StreamEvent::Echo(e) => final_echo = Some(e),
|
|
StreamEvent::Compacting(b) => {
|
|
conv.append_delta(epoch, obj("compacting", b));
|
|
}
|
|
StreamEvent::Stats(s) => {
|
|
if s.prompt_tokens_total > 0 {
|
|
if !saw_usage && local && start_est > 0 {
|
|
log_ctx_estimate("début-tour", start_est, s.prompt_tokens_total);
|
|
}
|
|
saw_usage = true;
|
|
conv.with_state_if(epoch, |st| st.ctx_used = s.ctx_after());
|
|
}
|
|
if local {
|
|
turn_peak = turn_peak.max(s.gen_tokens);
|
|
}
|
|
conv.append_delta(
|
|
epoch,
|
|
obj("stats", serde_json::to_value(&s).unwrap_or_default()),
|
|
);
|
|
}
|
|
StreamEvent::Ask(a) => {
|
|
// Question structurée (outil ask) : carte à boutons.
|
|
asked = true;
|
|
conv.append_delta(
|
|
epoch,
|
|
obj("ask", serde_json::to_value(&a).unwrap_or_default()),
|
|
);
|
|
}
|
|
StreamEvent::DropReasoning => {
|
|
conv.append_delta(epoch, obj("drop_reasoning", true));
|
|
}
|
|
StreamEvent::Reasoning(r) if !r.is_empty() => {
|
|
conv.append_delta(epoch, obj("reasoning_content", r));
|
|
}
|
|
StreamEvent::Content(c) if !c.is_empty() => {
|
|
content.push_str(&c);
|
|
conv.append_delta(epoch, obj("content", c));
|
|
}
|
|
_ => {}
|
|
}
|
|
true // génération détachée : jamais interrompue par un abonné
|
|
};
|
|
|
|
let res = {
|
|
let turn = run_turn(&host, &tc, sent, &cancel, &mut cb);
|
|
tokio::pin!(turn);
|
|
tokio::select! {
|
|
r = &mut turn => r,
|
|
sig = lease.interrupted() => {
|
|
if sig == LeaseSignal::Lost {
|
|
eprintln!("[chat] bail du moteur perdu en plein tour : arrêt");
|
|
}
|
|
let _ = cancel_tx.send(true);
|
|
turn.await
|
|
}
|
|
}
|
|
};
|
|
// Bloc projet rafraîchi par une compaction en cours de tour
|
|
// (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie.
|
|
let refreshed = host.take_refreshed_snap();
|
|
drop(host);
|
|
|
|
// Vue modèle rangée : outils puis réponse finale — sauf si un reset
|
|
// ou une bascule est passé entre-temps.
|
|
let after = conv.with_state_if(epoch, |st| {
|
|
if let Some(base) = new_base.take() {
|
|
st.messages = base;
|
|
}
|
|
if let Some(s) = refreshed {
|
|
st.proj_snap = Some(s);
|
|
}
|
|
st.messages.extend(res.extra.iter().cloned());
|
|
if !content.trim().is_empty() {
|
|
let a = Message {
|
|
role: "assistant".into(),
|
|
content: Some(Value::String(content.clone())),
|
|
..Default::default()
|
|
};
|
|
st.messages.push(with_echo(a, final_echo.as_ref()));
|
|
}
|
|
st.messages = drop_stray_nudges(std::mem::take(&mut st.messages));
|
|
if saw_usage {
|
|
st.ctx_used_len = st.messages.len();
|
|
}
|
|
st.gen_peak = st.gen_peak.max(turn_peak);
|
|
let msgs = st.messages.clone();
|
|
(ctx_now(st, &msgs), st.gen_peak, msgs)
|
|
});
|
|
let Some((mut ctx_used, mut peak, mut msgs)) = after else {
|
|
lease.release().await;
|
|
return; // reset pendant le tour : il a déjà enregistré le fil vide
|
|
};
|
|
|
|
// Moteur muet sur l'usage : l'estimation, sinon la jauge reste à zéro.
|
|
if !saw_usage {
|
|
let est = estimate_tokens(&msgs, &echo);
|
|
conv.with_state_if(epoch, |st| {
|
|
st.ctx_used = est;
|
|
st.ctx_used_len = msgs.len();
|
|
});
|
|
conv.append_delta(epoch, obj("ctx_used", est));
|
|
ctx_used = est;
|
|
}
|
|
|
|
// Boucle du contrat (mode Code) : vérification indépendante des
|
|
// critères, corrections, re-vérification — AVANT de rendre la main
|
|
// (verify.rs). Rien hors mode Code ou sans critères.
|
|
if !cancel.is_cancelled() {
|
|
let pe = super::verify::PassEnv {
|
|
env: &env,
|
|
turn_caps: &prep.turn_caps,
|
|
temperature,
|
|
reasoning: &reasoning,
|
|
ccaps,
|
|
conv_id: &conv_id,
|
|
};
|
|
let mut run = super::verify::PassRun {
|
|
lease: &mut lease,
|
|
cancel: &cancel,
|
|
cancel_tx: &cancel_tx,
|
|
};
|
|
self.code_verify_loop(&pe, epoch, asked, &mut run).await;
|
|
let after = conv.with_state_if(epoch, |st| {
|
|
let m = st.messages.clone();
|
|
(ctx_now(st, &m), st.gen_peak, m)
|
|
});
|
|
let Some(a) = after else {
|
|
lease.release().await;
|
|
return; // reset pendant la vérification
|
|
};
|
|
(ctx_used, peak, msgs) = a;
|
|
}
|
|
|
|
// Compaction de fin de tour : la jauge retombe tout de suite, le tour
|
|
// suivant démarre avec de la marge. Pas après un stop. Les images
|
|
// gardées partent d'abord, comme en début de tour.
|
|
if !cancel.is_cancelled() {
|
|
(msgs, ctx_used) = self
|
|
.kept_images_turn_start(epoch, &cfg, &ccfg, msgs, ctx_used, peak)
|
|
.await;
|
|
}
|
|
if !cancel.is_cancelled()
|
|
&& compact_needed(&ccfg, &msgs, ctx_used, peak)
|
|
&& !self
|
|
.shared
|
|
.compact_memory
|
|
.refused_skip(&ccfg, &conv_id, ctx_used, &msgs)
|
|
{
|
|
let opt = compact_opts_for(
|
|
&ccfg,
|
|
&self.shared.compact_memory,
|
|
false,
|
|
&msgs,
|
|
ctx_used,
|
|
&conv_id,
|
|
|_| TurnView::default(),
|
|
);
|
|
self.compact_and_publish(
|
|
&summarizer,
|
|
&ccfg,
|
|
ccaps,
|
|
mem_off,
|
|
epoch,
|
|
"fin-tour",
|
|
&msgs,
|
|
ctx_used,
|
|
&opt,
|
|
)
|
|
.await;
|
|
}
|
|
self.finish(epoch, &cancel, lease, turn_start).await;
|
|
}
|
|
|
|
/// Fin d'un tour (le `defer` de `generate`).
|
|
async fn finish(
|
|
self: &Arc<Self>,
|
|
epoch: i64,
|
|
cancel: &Cancel,
|
|
lease: EngineLease,
|
|
turn_start: Instant,
|
|
) {
|
|
let conv = self.sessions.conv();
|
|
conv.append_delta(epoch, obj("turn_done", true));
|
|
let current = conv.end_turn(epoch);
|
|
lease.release().await;
|
|
if !current {
|
|
return; // reset pendant le tour : il a déjà enregistré le fil vide
|
|
}
|
|
self.sessions.persist().await;
|
|
if cancel.is_cancelled() {
|
|
self.drop_queued(epoch);
|
|
return;
|
|
}
|
|
if !self.start_queued_if_any().await {
|
|
let ms = turn_start.elapsed().as_millis() as u64;
|
|
if let Err(e) = self
|
|
.shared
|
|
.store
|
|
.internal_json(
|
|
hyper::Method::POST,
|
|
"/internal/v1/turn/done",
|
|
Some(&json!({ "ms": ms })),
|
|
)
|
|
.await
|
|
{
|
|
eprintln!("[chat] fin de tour non signalée : {e}");
|
|
}
|
|
// Plus rien ne suit : le prochain tour peut être préparé pendant
|
|
// que l'utilisateur lit (PREWARM, off par défaut).
|
|
self.prewarm_kick(super::prewarm::TURN_END).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Qui tient la conversation, d'après le Go (`/internal/v1/chat/holder`) :
|
|
/// `None` : la façade ; `Some(raison)` : le Go la garde (réglage que la façade
|
|
/// ne sait pas honorer). Décidé une fois par process, côté Go.
|
|
pub async fn go_keeps_chat(store: &crate::store::Store) -> Result<Option<String>, String> {
|
|
let v = store
|
|
.internal_json(hyper::Method::GET, "/internal/v1/chat/holder", None)
|
|
.await?;
|
|
match v["holder"].as_str() {
|
|
Some("rust") => Ok(None),
|
|
Some("go") => Ok(Some(v["reason"].as_str().unwrap_or("").to_string())),
|
|
_ => Err(format!("détenteur de la conversation illisible : {v}")),
|
|
}
|
|
}
|
|
|
|
/// `retryPatterns()` : la surcharge `retry_patterns` de la base si elle se
|
|
/// lit, sinon les motifs embarqués. Lue une fois, au démarrage, comme le Go
|
|
/// la compile une fois par processus.
|
|
pub async fn retry_patterns(
|
|
store: &crate::store::Store,
|
|
) -> std::sync::Arc<super::stream::RetryPatterns> {
|
|
let raw = store.get_str(BK_STATE, "retry_patterns").await;
|
|
match super::stream::RetryPatterns::from_override(&raw) {
|
|
Some(p) => {
|
|
eprintln!(
|
|
"[chat] motifs de relance personnalisés (retry_patterns) : {} motif(s)",
|
|
p.len()
|
|
);
|
|
std::sync::Arc::new(p)
|
|
}
|
|
None => super::stream::RetryPatterns::builtin(),
|
|
}
|
|
}
|
|
|
|
impl ChatShared {
|
|
/// L'état partagé des tours, autour de l'écrivain des discussions.
|
|
/// `img_dir` : `LOKI_HOME/chatimg`.
|
|
pub fn for_sessions(
|
|
sessions: &Sessions<GoSessionHooks>,
|
|
store: crate::store::Store,
|
|
img_dir: std::path::PathBuf,
|
|
) -> Self {
|
|
ChatShared {
|
|
http: EngineHttp::new(),
|
|
store,
|
|
persister: sessions.persister().clone(),
|
|
effort: super::wire::EffortFallbacks::new(),
|
|
echo_refusals: super::wire::EchoRefusals::new(),
|
|
compact_memory: super::compact::CompactMemory::new(),
|
|
images: super::host::ImageCache::new(img_dir),
|
|
patterns: super::stream::RetryPatterns::builtin(),
|
|
tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()),
|
|
prewarm: Default::default(),
|
|
mcp: None,
|
|
go_version: Default::default(),
|
|
}
|
|
}
|
|
}
|