Files
Loki/rust/loki-rs/src/chat/host.rs
T
Claude 76d9dce74a Lot 38 : l'outil criteria du mode Code exécuté par la façade
L'outil par lequel le modèle tient les critères d'acceptation de sa tâche
(poser, marquer faits, lister, effacer) partait au Go, qui renvoyait la
liste à la façade comme delta. Il s'exécute maintenant dans le tour de la
façade (chat/crittool.rs, port de toolCriteria) : mêmes messages, ids en
nombre ou en chaîne, vingt critères au plus, `passed` réservé à la passe de
vérification, rangement scellé (chat/crit:<id>), liste poussée à l'écran à
chaque changement par un nouveau champ `deltas` de l'hôte du tour.

Les critères sont rangés sous la discussion du tour (le Go prenait la
discussion active : elles ne diffèrent que si l'utilisateur en change
pendant la génération). see_image reste au Go (préparation de l'image).

Parité : journal de 22 appels tiré du Go (parity_criteria_test.go →
tests/parity_tools/criteria.json), rejoué par tests/tools.rs ; le contrat
du mode Code de tests/lot6.rs est désormais vérifié par la façade seule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:01:03 +00:00

1039 lines
37 KiB
Rust

//! Le tour branché sur le vrai monde : le [`TurnHost`] de la façade.
//!
//! - envoi au moteur par [`EngineHttp`] (clé de CET endpoint), pannes
//! classées et formulées comme `friendlyLLMError` ;
//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé
//! tenu par le Go), outils MCP par le pool de la façade quand elle le tient
//! (lot 36), outils task_* quand elle tient les tâches (lot 37), critères
//! du mode Code (lot 38) ; les autres
//! par le Go (`/internal/v1/tool/exec`), avec
//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur,
//! image relayée) ;
//! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ;
//! - compaction par [`Compactor`] : résumé demandé au moteur
//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go
//! ([`StoreRecall`]) ;
//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go
//! (déchiffrées), gardées en cache comme `chatImageDataURL` ;
//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go
//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état
//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol,
//! sur ce slot, jusqu'à la fin de sa lecture.
//!
//! Écarts assumés : le résumé en continuation (COMPACT_CONTINUATION) demande
//! l'état du slot du moteur, tenu par le Go — `slot_holds` rend faux et la
//! compaction résume la transcription. Ni conseil de cache ni garde
//! d'écriture du mode Code (état du Go) : `cache_hint` et `preflight` absents.
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::StreamExt;
use serde_json::{Map, Value, json};
use super::compact::{
CompactCaps, CompactConfig, CompactMemory, CompactOpts, Compactor, ContinuationRequest,
RecallArchive, Summarizer, SummaryChoice, SummaryError, TranscriptRequest, compact_opts_mid,
parse_summary_response,
};
use super::engine::{EngineHttp, classify, friendly_error};
use super::persist::{Persister, ProjSnapshot, StoreSink, ToolResJob};
use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
use super::types::{AskEvent, DiffLine, Message, Tool};
use super::wire::{
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, wire_messages,
};
use crate::store::{BK_STATE, Store};
/// Taille au-delà de laquelle le cache d'images oublie les plus anciennes
/// (`imgCacheMax`).
pub const IMG_CACHE_MAX: usize = 64 << 20;
/// Corps d'un refus de résumé gardé au plus (`postSummary`).
const SUMMARY_ERR_MAX: usize = 500;
/// Base64 standard (avec remplissage), comme `base64.StdEncoding`.
pub fn b64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| *c.get(2).unwrap_or(&0) as u32;
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 {
T[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if c.len() > 2 {
T[n as usize & 63] as char
} else {
'='
});
}
out
}
/// Type MIME d'une image rangée, d'après son extension (`imgExtByMime`).
fn image_mime(name: &str) -> &'static str {
for (ext, mime) in [
(".png", "image/png"),
(".gif", "image/gif"),
(".webp", "image/webp"),
] {
if name.ends_with(ext) {
return mime;
}
}
"image/jpeg"
}
/// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`).
fn image_name(r: &str) -> Option<&str> {
let name = r.strip_prefix(IMG_REF_SCHEME)?;
(!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name)
}
#[derive(Default)]
struct ImgCacheInner {
map: HashMap<String, String>,
order: VecDeque<String>,
bytes: usize,
}
/// Images jointes en data-URL, bornées à [`IMG_CACHE_MAX`] (la plus ancienne
/// part en premier, jamais la dernière).
pub struct ImageCache {
dir: PathBuf,
inner: Mutex<ImgCacheInner>,
}
impl ImageCache {
/// `dir` : `LOKI_HOME/chatimg`.
pub fn new(dir: PathBuf) -> Self {
ImageCache {
dir,
inner: Mutex::new(ImgCacheInner::default()),
}
}
pub fn get(&self, r: &str) -> Option<String> {
let name = image_name(r)?;
self.inner.lock().ok()?.map.get(name).cloned()
}
fn put(&self, name: &str, url: String) {
let Ok(mut g) = self.inner.lock() else { return };
if g.map.contains_key(name) {
return;
}
g.bytes += url.len();
g.map.insert(name.to_string(), url);
g.order.push_back(name.to_string());
while g.bytes > IMG_CACHE_MAX && g.order.len() > 1 {
if let Some(old) = g.order.pop_front()
&& let Some(u) = g.map.remove(&old)
{
g.bytes -= u.len();
}
}
}
/// Lit et ouvre (par le Go) les images référencées pas encore en cache.
/// Une image illisible reste absente : la requête dira « indisponible ».
pub async fn prefetch(&self, store: &Store, msgs: &[Message]) {
for m in msgs {
for p in content_parts(m.content.as_ref()) {
let Some(name) = image_name(part_image_url(p)) else {
continue;
};
if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) {
continue;
}
let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else {
continue;
};
let Ok(b) = store.open(raw).await else {
continue;
};
self.put(
name,
format!("data:{};base64,{}", image_mime(name), b64(&b)),
);
}
}
}
}
/// Le moteur vu de la compaction (`postSummary`).
#[derive(Clone)]
pub struct EngineSummarizer {
pub http: EngineHttp,
pub ep: ChatEndpoint,
pub cfg: HashMap<String, String>,
pub patterns: Arc<RetryPatterns>,
pub port: u16,
/// Le Go et la discussion des résumés : télémétrie (kind=compact),
/// requêtes en vol et slot du moteur. `None` sans base (tests).
pub go: Option<(Store, String)>,
}
impl EngineSummarizer {
/// `postSummary`. `main` : le résumé prolonge le prompt du slot de la
/// discussion (continuation) ; sinon c'est un prompt neuf, sur le second
/// slot quand il est en service (SIDE_SLOT).
async fn post(&self, mut payload: Value, main: bool) -> Result<SummaryChoice, SummaryError> {
let mut _gate = None;
if !self.ep.external
&& let Some((store, _)) = &self.go
{
let slot = engine_slot_for(store, "", main).await;
if let Some(n) = slot
&& let Some(o) = payload.as_object_mut()
{
o.insert("id_slot".into(), Value::from(n));
}
_gate = engine_begin(store, "", slot == Some(1), false).await;
}
let body = serde_json::to_vec(&payload).unwrap_or_default();
let resp = self
.http
.post(&self.ep.url, &self.ep.key, body)
.await
.map_err(|e| {
SummaryError::Other(friendly_error(classify(&e), &e.to_string(), self.port, ""))
})?;
let status = resp.status().as_u16();
let raw = resp
.bytes()
.await
.map_err(|e| SummaryError::Other(e.to_string()))?;
if status != 200 {
let b = &raw[..raw.len().min(SUMMARY_ERR_MAX)];
return Err(SummaryError::Http {
who: if self.ep.external {
"API externe"
} else {
"llama-server"
}
.into(),
status,
body: String::from_utf8_lossy(b).trim().to_string(),
});
}
let out = parse_summary_response(&raw);
// Télémétrie de la compaction : réponse non streamée, timings et usage
// à la racine, lus sans échec possible — dès que la réponse se lit.
let decoded = match &out {
Ok(_) => true,
Err(SummaryError::Other(e)) => e == super::compact::ERR_EMPTY_SUMMARY,
Err(_) => false,
};
if decoded && let Some((store, conv)) = &self.go {
let (complete, stats) = super::perf::wire_stats(&raw);
let rec = PerfRec {
complete,
external: self.ep.external,
stats,
..Default::default()
};
super::perf::record(store, super::perf::KIND_COMPACT, conv, &rec).await;
}
out
}
}
impl Summarizer for EngineSummarizer {
async fn summarize_transcript(
&self,
req: TranscriptRequest,
) -> Result<SummaryChoice, SummaryError> {
let mut p = json!({
"model": self.ep.model,
"messages": [
{"role": "system", "content": req.system},
{"role": "user", "content": req.transcript},
],
"stream": false,
"temperature": 0.2,
"max_tokens": req.max_tokens,
"chat_template_kwargs": {"enable_thinking": false},
});
// Propre à llama.cpp : une API distante stricte refuse l'argument.
if self.ep.external
&& let Some(o) = p.as_object_mut()
{
o.remove("chat_template_kwargs");
}
self.post(p, false).await
}
async fn summarize_continuation(
&self,
req: ContinuationRequest,
) -> Result<SummaryChoice, SummaryError> {
let mut p = build_chat_payload(
&self.ep,
&req.messages,
0.2,
&ChatPayloadOpts {
tools: &req.tools,
disable_tools: false,
tool_choice_none: true,
effort: &req.effort,
kwargs: Some(&req.kwargs),
no_sampling: true,
},
&self.cfg,
&|_| None,
);
if let Some(o) = p.as_object_mut() {
o.insert("stream".into(), Value::Bool(false));
o.remove("stream_options");
o.insert("max_tokens".into(), json!(req.max_tokens));
}
self.post(p, true).await
}
fn slot_holds(&self, _conv: &str) -> bool {
false
}
fn textual_tool_call(&self, content: &str) -> bool {
self.patterns.textual_tool_call(content)
}
}
/// Archive recall tenue par le Go.
pub struct StoreRecall(pub Store);
impl RecallArchive for StoreRecall {
async fn archive(&self, label: &str, role: &str, content: &str) -> Result<String, String> {
self.0.recall_archive(label, role, content).await
}
}
/// Ce qui survit d'un tour à l'autre (un exemplaire par processus).
pub struct ChatShared {
pub http: EngineHttp,
pub store: Store,
pub persister: Persister<StoreSink>,
pub effort: EffortFallbacks,
pub echo_refusals: EchoRefusals,
pub compact_memory: CompactMemory,
pub images: ImageCache,
pub patterns: Arc<RetryPatterns>,
/// Sonde de gabarit (`chat_tplprobe.go`) : derniers verdicts et cache.
pub tpl: Arc<super::tplprobe::TplProbe>,
/// Préchauffage (PREWARM) : celui en vol, capacités des discussions.
pub prewarm: super::prewarm::Prewarm,
/// Le pool MCP, quand la façade le tient (LOKI_MCP=rust) : les appels
/// `mcp__*` du tour y vont directement (lot 36).
pub mcp: Option<Arc<crate::mcp::Mcp>>,
/// Les tâches, quand la façade les tient (LOKI_TASKS=rust) : les outils
/// `task_*` du tour s'exécutent ici (lot 37).
pub tasks: Option<Arc<crate::tasks::Tasks>>,
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
/// une fois.
pub go_version: tokio::sync::OnceCell<String>,
}
impl ChatShared {
/// [`Self::go_version`] : `/internal/v1/auth`, retenue une fois lue.
pub async fn go_version(&self) -> String {
if let Some(v) = self.go_version.get() {
return v.clone();
}
let v = self
.store
.internal_json(hyper::Method::GET, "/internal/v1/auth", None)
.await
.ok()
.and_then(|v| v.get("version").and_then(Value::as_str).map(str::to_string))
.unwrap_or_default();
if !v.is_empty() {
let _ = self.go_version.set(v.clone());
}
v
}
}
/// Ce que la compaction sait du tour.
pub struct TurnCompact {
pub cfg: CompactConfig,
pub caps: CompactCaps,
/// Mode Code : critères rendus de la discussion.
pub criteria: String,
/// Discussion du tour (vide : tâche ou sous-agent).
pub main_conv: String,
/// Forme de la requête du tour, pour les options de la compaction en
/// cours de tour.
pub tools: Vec<Tool>,
pub effort: String,
pub kwargs: Map<String, Value>,
}
/// Le [`TurnHost`] de la façade.
pub struct GoTurnHost {
pub shared: Arc<ChatShared>,
/// Discussion dont relèvent les résultats complets.
pub conv_id: String,
/// Capacités du tour, telles que l'API d'outils les attend (champs Go).
pub caps: Value,
pub cfg: HashMap<String, String>,
pub port: u16,
pub summarizer: EngineSummarizer,
pub compact: TurnCompact,
/// Messages mis en file par l'utilisateur pendant le tour.
pub queued: Box<dyn Fn() -> Vec<Message> + Send + Sync>,
/// Un événement pour l'écran, hors du flux du tour (la liste des
/// critères après l'outil `criteria`).
pub deltas: Box<dyn Fn(super::journal::Delta) + Send + Sync>,
/// Nature des complétions pour la télémétrie : `main` (tour de la
/// discussion), `verify` (passe de vérification du mode Code).
pub perf_kind: &'static str,
/// La requête qui part va sur le second slot (posé par `engine_slot`,
/// lu par `send` pour le ticket « en vol »).
pub side_slot: AtomicBool,
/// PROJ_SNAPSHOT : la clé et le système du tour (`None` sans la clé), et
/// l'instantané posé par un rafraîchissement en cours de tour.
pub projsnap: Option<SnapTurn>,
pub snap_refreshed: Mutex<Option<ProjSnapshot>>,
}
/// PROJ_SNAPSHOT (lot 34) : ce qu'un tour avec la clé garde pour rafraîchir
/// le bloc projet quand une compaction réécrit l'historique en plein tour
/// (`projSnapTurn`).
#[derive(Debug, Clone)]
pub struct SnapTurn {
pub caps: Value,
pub key: String,
pub sys_hash: String,
}
impl GoTurnHost {
/// L'instantané posé en cours de tour (`takeRefreshed`), s'il y en a un.
pub fn take_refreshed_snap(&self) -> Option<ProjSnapshot> {
self.snap_refreshed
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
}
}
/// Délai d'une demande de slot : le rechargement SLOT_PERSIST relit jusqu'à
/// 8 Gio (`slotPersistRestoreTimeout`, deux minutes).
const SLOT_ASK_TIMEOUT: Duration = Duration::from_secs(180);
/// `engineSlotFor` demandé au Go (`/internal/v1/turn/slot`). `main` : fil de
/// la discussion ; `conv` (étape d'un tour seulement) : le Go recharge
/// d'abord l'état SLOT_PERSIST de cette discussion. Go injoignable : aucun
/// `id_slot`, la requête d'avant la clé.
pub async fn engine_slot_for(store: &Store, conv: &str, main: bool) -> Option<u8> {
match store
.internal_post_within(
"/internal/v1/turn/slot",
&json!({ "conv": conv, "main": main }),
SLOT_ASK_TIMEOUT,
)
.await
{
Ok((code, v)) if code.is_success() => v["slot"].as_i64().and_then(|n| u8::try_from(n).ok()),
Ok((code, v)) => {
eprintln!("[loki-rs] moteur : slot non demandé ({code} {v})");
None
}
Err(e) => {
eprintln!("[loki-rs] moteur : slot non demandé ({e})");
None
}
}
}
/// Ouvre un ticket « en vol » auprès du Go (`engineRequestBeginSide`) : le
/// slot de la discussion n'est pas effacé pendant la requête, et un
/// préchauffage est annulé — sauf `keep_prewarm`, une étape de tour qui le
/// prolonge. `main_conv` : étape d'un tour de cette discussion ; `side` :
/// requête sur le second slot. `None` si le Go ne répond pas — la requête
/// part quand même.
pub async fn engine_begin(
store: &Store,
main_conv: &str,
side: bool,
keep_prewarm: bool,
) -> Option<GateTicket> {
match store
.internal_json(
hyper::Method::POST,
"/internal/v1/oai/begin",
Some(&json!({ "main_conv": main_conv, "side": side, "prewarm_keep": keep_prewarm })),
)
.await
{
Ok(v) => v["ticket"].as_u64().map(|ticket| GateTicket {
store: store.clone(),
ticket,
}),
Err(e) => {
eprintln!("[loki-rs] moteur : requête non comptée en vol ({e})");
None
}
}
}
/// KEEP_TURN_IMAGES (lot 32) : la vision est-elle réellement active
/// (projecteur déclaré et vu par le moteur) et le budget des images gardées.
/// Go injoignable : `None` (images éphémères).
pub async fn keep_images_state(store: &Store) -> Option<(bool, i64)> {
match store
.internal_post("/internal/v1/turn/keepimg", &json!({}))
.await
{
Ok((code, v)) if code.is_success() => Some((
v["vision"].as_bool().unwrap_or(false),
v["budget"].as_i64().unwrap_or(0),
)),
_ => None,
}
}
/// Rend le ticket « en vol » au Go quand la réponse du moteur est lue ou
/// abandonnée (`engineRequestBeginSide` … `end`).
pub struct GateTicket {
store: Store,
ticket: u64,
}
impl Drop for GateTicket {
fn drop(&mut self) {
let (store, ticket) = (self.store.clone(), self.ticket);
if let Ok(rt) = tokio::runtime::Handle::try_current() {
rt.spawn(async move {
let _ = store
.internal_json(
hyper::Method::POST,
"/internal/v1/oai/end",
Some(&json!({ "ticket": ticket })),
)
.await;
});
}
}
}
impl GoTurnHost {
async fn gate_begin(&self, keep_prewarm: bool) -> Option<GateTicket> {
let main_conv = if self.perf_kind == super::perf::KIND_MAIN {
self.conv_id.as_str()
} else {
""
};
let side = self.side_slot.load(Ordering::SeqCst);
engine_begin(&self.shared.store, main_conv, side, keep_prewarm).await
}
}
/// `toolResID` : « <discussion>.<24 hex> ».
fn tool_res_id(conv: &str) -> String {
let mut b = [0u8; 12];
let _ = getrandom::fill(&mut b);
let conv = match conv.trim() {
"" => "nosession",
c => c,
};
format!("{conv}.{}", hex::encode(b))
}
/// Réponse de `/internal/v1/tool/exec` → [`ToolOutcome`].
pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome {
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let n = |k: &str| v.get(k).and_then(Value::as_i64).unwrap_or(0);
let mut relay: Option<Message> = v
.get("relay")
.and_then(|r| serde_json::from_value(r.clone()).ok());
if relay.is_none()
&& let Some(img) = v.get("vision_img").filter(|i| i.is_object())
{
relay = Some(see_image_message(label, img.clone()));
}
ToolOutcome {
result: s("result"),
diff: v
.get("diff")
.and_then(|d| serde_json::from_value::<Vec<DiffLine>>(d.clone()).ok())
.unwrap_or_default(),
added: n("added"),
removed: n("removed"),
ask: v
.get("ask")
.and_then(|a| serde_json::from_value::<AskEvent>(a.clone()).ok()),
image: s("image"),
live_shot: s("shot"),
relay,
}
}
/// `seeImageMessage` : l'image relayée au modèle.
pub fn see_image_message(label: &str, img: Value) -> Message {
let text = if label.is_empty() {
"Image :".to_string()
} else {
format!("Image demandée ({label}) :")
};
Message {
role: "user".into(),
content: Some(json!([{"type": "text", "text": text}, img])),
..Default::default()
}
}
impl TurnHost for GoTurnHost {
async fn send(
&self,
ep: &ChatEndpoint,
body: Vec<u8>,
keep_prewarm: bool,
) -> Result<Reply, SendError> {
// Moteur local : requête comptée en vol jusqu'à la fin de lecture du
// flux (le ticket voyage avec le corps). Rien pour une API externe.
// Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le
// Go l'annulerait aussi, un aller-retour plus tard).
let gate = if ep.external {
None
} else {
if !keep_prewarm {
self.shared.prewarm.stop();
}
self.gate_begin(keep_prewarm).await
};
match self.shared.http.post(&ep.url, &ep.key, body).await {
Ok(resp) => {
let status = resp.status().as_u16();
if status == 200
&& let Some(g) = &gate
{
// Le slot porte désormais cette requête (engineServed,
// engineMarkMain pour une étape de la discussion).
let _ = self
.shared
.store
.internal_json(
hyper::Method::POST,
"/internal/v1/oai/served",
Some(&json!({ "ticket": g.ticket })),
)
.await;
}
let body = resp.bytes_stream().map(move |r| {
let _keep = &gate;
r.map_err(|e| e.to_string())
});
Ok(Reply {
status,
body: Box::pin(body),
})
}
Err(e) => {
let kind = classify(&e);
Err(SendError {
kind,
message: friendly_error(kind, &e.to_string(), self.port, ""),
})
}
}
}
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
// Outils MCP, quand la façade tient le pool : ce que ferait le Go
// (`mcpCall` → /internal/facade/v1/mcp/call), sans ses deux
// allers-retours. Le Go n'en tire rien d'autre que le texte.
// Critères d'acceptation du mode Code (lot 38) : ceux de la
// discussion du tour ; seule la vérification marque `passed`.
if name == "criteria" {
let verifier = self.caps.get("Role").and_then(Value::as_str) == Some("verifier");
let out =
super::crittool::exec(&self.shared.store, &self.compact.main_conv, args, verifier)
.await;
if let Some(list) = &out.notify {
(self.deltas)(super::crittool::notify_delta(
list,
super::crittool::now_ms(),
));
}
return ToolOutcome {
result: out.result,
..Default::default()
};
}
// Outils task_*, quand la façade tient les tâches (lot 37).
if crate::tasks::tools::is_tool(name)
&& let Some(tasks) = &self.shared.tasks
{
let dir = tasks.scripts_dir();
let env = crate::tasks::tools::ToolEnv {
store: &self.shared.store,
scripts_dir: &dir,
now_ns: tasks.now(),
};
if let Some(result) = crate::tasks::tools::exec(&env, name, args).await {
return ToolOutcome {
result,
..Default::default()
};
}
}
if crate::mcp::config::is_mcp_tool(name)
&& let Some(mcp) = &self.shared.mcp
{
let version = self.shared.go_version().await;
return ToolOutcome {
result: mcp.call(&self.shared.store, name, args, &version).await,
..Default::default()
};
}
// Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go.
if crate::tools::is_local(name) {
let st = crate::tools::GoToolState {
store: self.shared.store.clone(),
caps: self.caps.clone(),
};
let code = self
.caps
.get("Code")
.and_then(Value::as_bool)
.unwrap_or(false);
if let Some(out) = crate::tools::exec_local(&st, code, name, args, label).await {
return out;
}
}
match self
.shared
.store
.tool_exec(name, &Value::Object(args.clone()), &self.caps)
.await
{
Ok(v) => tool_outcome(&v, label),
Err(e) => ToolOutcome {
result: format!("[erreur] exécution de l'outil impossible : {e}"),
..Default::default()
},
}
}
fn save_tool_result(&self, result: &str) -> String {
let id = tool_res_id(&self.conv_id);
self.shared.persister.enqueue_tool_res(ToolResJob {
key: id.clone(),
plain: Arc::from(result),
});
id
}
async fn compact(&self, phase: CompactPhase, msgs: &[Message]) -> Option<Vec<Message>> {
let recall = StoreRecall(self.shared.store.clone());
// Mode Code : l'état des critères au moment du résumé (le Go les relit
// aussi à chaque compaction — l'outil criteria a pu les changer).
let criteria = if self.compact.caps.code {
live_criteria(&self.shared.store, &self.compact.main_conv).await
} else {
self.compact.criteria.clone()
};
let c = Compactor {
cfg: &self.compact.cfg,
summarizer: &self.summarizer,
recall: &recall,
memory: &self.shared.compact_memory,
criteria: &criteria,
};
let (out, changed) = match phase {
CompactPhase::Reactive | CompactPhase::FullWindow => {
c.compact_messages(msgs, self.compact.caps).await
}
CompactPhase::Mid { used, rescue } => {
let conv = self.compact.main_conv.as_str();
let opt: CompactOpts = compact_opts_mid(
&self.compact.cfg,
&self.shared.compact_memory,
self.summarizer.slot_holds(conv),
self.summarizer.ep.external,
conv,
msgs,
used,
self.compact.tools.clone(),
&self.compact.effort,
self.compact.kwargs.clone(),
rescue,
|m| {
wire_messages(m, false, &self.compact.cfg.echo)
.0
.into_owned()
},
);
c.compact_messages_noted(msgs, self.compact.caps, &opt)
.await
}
};
changed.then_some(out)
}
fn compact_refused(&self, used: i64, msgs: &[Message]) -> bool {
self.shared.compact_memory.refused_skip(
&self.compact.cfg,
&self.compact.main_conv,
used,
msgs,
)
}
fn drain_queued(&self) -> Vec<Message> {
(self.queued)()
}
fn prefix_unstable(
&self,
tools: &[Tool],
kwargs: Option<&Map<String, Value>>,
effort: &str,
) -> bool {
let shape = super::tplprobe::Shape::new(tools, kwargs, effort);
self.shared.tpl.caps_for(false, &shape).is_some_and(|r| {
r.prefix_stable == super::wire::TplTri::No
&& !r.model.is_empty()
&& r.model == super::wire::reasoning_echo_model(&self.cfg)
})
}
/// Fil principal seulement (`perfMain`) : une tâche ou un sous-agent ne
/// déclenche pas la sonde.
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
if self.compact.main_conv.is_empty() {
return;
}
let shape = super::tplprobe::Shape::new(tools, kwargs, effort);
let (shared, port) = (self.shared.clone(), self.port);
tokio::spawn(async move {
let key = shared.store.get_str(BK_STATE, "api_key").await;
let env = super::tplprobe::Env {
external: false,
port: port.into(),
key,
};
shared.tpl.kick(env, shape);
});
}
async fn prepare_images(&self, msgs: &[Message]) {
self.shared.images.prefetch(&self.shared.store, msgs).await;
}
fn image_data_url(&self, r: &str) -> Option<String> {
self.shared.images.get(r)
}
fn effort_remember(&self, want: &str, got: &str) {
self.shared.effort.remember(&self.cfg, want, got);
}
fn echo_refuse(&self, model: &str) {
self.shared.echo_refusals.refuse(model);
}
async fn project_refresh(&self, msgs: Vec<Message>) -> Vec<Message> {
let Some(t) = &self.projsnap else {
return msgs;
};
let got = self
.shared
.store
.internal_post(
"/internal/v1/turn/projsnap/refresh",
&json!({ "caps": t.caps, "key": t.key, "sys_hash": t.sys_hash }),
)
.await;
let (block, snap) = match got {
Ok((code, v)) if code.is_success() => (
serde_json::from_value::<Vec<Message>>(v["block"].clone()).ok(),
serde_json::from_value::<ProjSnapshot>(v["snap"].clone()).ok(),
),
_ => (None, None),
};
// Go injoignable : le bloc figé reste, sans les mises à jour (le début
// du prompt change de toute façon) ; l'instantané n'est pas remplacé.
let msgs = super::compact::strip_context_updates(msgs);
match (block, snap) {
(Some(block), Some(snap)) => {
*self
.snap_refreshed
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(snap);
super::compact::replace_project_head(msgs, &block)
}
_ => msgs,
}
}
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
self.perf_kind == super::perf::KIND_MAIN
&& self.shared.prewarm.live()
&& self
.shared
.prewarm
.keeps(prefix, super::prewarm::opts_hash(payload))
}
async fn keep_images(&self) -> Option<i64> {
// Fil principal d'une discussion seulement : la vérification ne
// persiste pas son historique.
if !super::keepimg::keep_turn_images_on(&self.cfg)
|| self.perf_kind != super::perf::KIND_MAIN
|| self.conv_id.is_empty()
{
return None;
}
match keep_images_state(&self.shared.store).await {
Some((true, budget)) => Some(budget),
_ => None,
}
}
async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> {
let (code, v) = self
.shared
.store
.internal_post("/internal/v1/turn/keepimg/store", &json!({ "msg": m }))
.await
.ok()?;
if !code.is_success() || v["ok"] != true {
return None;
}
let msg: Message = serde_json::from_value(v["msg"].clone()).ok()?;
Some((msg, v["weight"].as_i64().unwrap_or(0)))
}
async fn engine_slot(&self, ep: &ChatEndpoint) -> Option<u8> {
if ep.external {
self.side_slot.store(false, Ordering::SeqCst);
return None;
}
// Étape d'un tour de la discussion : le slot 0, après rechargement de
// l'état gardé (SLOT_PERSIST). Vérification : le second slot.
let main = self.perf_kind == super::perf::KIND_MAIN;
let conv = if main { self.conv_id.as_str() } else { "" };
let slot = engine_slot_for(&self.shared.store, conv, main).await;
self.side_slot.store(slot == Some(1), Ordering::SeqCst);
slot
}
fn cache_hint(&self) -> Option<CacheHintFn> {
None
}
async fn perf_record(&self, rec: PerfRec) -> PerfMarks {
super::perf::record(&self.shared.store, self.perf_kind, &self.conv_id, &rec).await
}
fn preflight(&self) -> Option<PreflightFn> {
None
}
}
/// Les critères du mode Code rendus pour le résumé ("" : aucun).
pub(crate) async fn live_criteria(store: &crate::store::Store, conv_id: &str) -> String {
super::verify::crit_render(&super::verify::crit_list(store, conv_id).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_standard() {
assert_eq!(b64(b""), "");
assert_eq!(b64(b"f"), "Zg==");
assert_eq!(b64(b"fo"), "Zm8=");
assert_eq!(b64(b"foo"), "Zm9v");
assert_eq!(b64(b"foobar"), "Zm9vYmFy");
assert_eq!(b64(&[0xff, 0xfe, 0x00]), "//4A");
}
#[test]
fn references_d_images() {
assert_eq!(image_name("loki-img:ab.png"), Some("ab.png"));
for bad in [
"loki-img:",
"loki-img:../x",
"loki-img:a/b",
"loki-img:a\\b",
"data:x",
"ab.png",
] {
assert_eq!(image_name(bad), None, "{bad}");
}
assert_eq!(image_mime("a.png"), "image/png");
assert_eq!(image_mime("a.webp"), "image/webp");
assert_eq!(image_mime("a.jpg"), "image/jpeg");
assert_eq!(image_mime("a.img"), "image/jpeg");
}
#[test]
fn cache_d_images_borne() {
let c = ImageCache::new(PathBuf::from("/nulle-part"));
let big = "x".repeat(IMG_CACHE_MAX / 2 + 1);
c.put("a.png", big.clone());
c.put("b.png", big.clone());
assert_eq!(c.get("loki-img:a.png"), None, "la plus ancienne part");
assert_eq!(c.get("loki-img:b.png").map(|u| u.len()), Some(big.len()));
// Une seule image, même trop grosse, reste.
let c = ImageCache::new(PathBuf::from("/nulle-part"));
c.put("g.png", "y".repeat(IMG_CACHE_MAX + 1));
assert!(c.get("loki-img:g.png").is_some());
}
#[test]
fn reponse_de_l_api_d_outils() {
let v = json!({
"label": "ls", "result": "a\nb",
"diff": [{"op": "+", "text": "x"}], "added": 1, "removed": 2,
"ask": {"question": "On continue ?", "options": ["oui"]},
"image": "captures/a.png", "shot": "s1",
"relay": {"role": "user", "content": [{"type": "text", "text": "Voici la capture demandée."}]}
});
let o = tool_outcome(&v, "ls");
assert_eq!(o.result, "a\nb");
assert_eq!((o.added, o.removed, o.diff.len()), (1, 2, 1));
assert_eq!(o.ask.unwrap().question, "On continue ?");
assert_eq!(
(o.image.as_str(), o.live_shot.as_str()),
("captures/a.png", "s1")
);
assert_eq!(o.relay.unwrap().role, "user");
// vision_img seul : le message relayé est bâti ici (seeImageMessage).
let img = json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}});
let o = tool_outcome(&json!({"result": "ok", "vision_img": img}), "photo.png");
assert_eq!(
o.relay.unwrap().content,
Some(json!([{"type": "text", "text": "Image demandée (photo.png) :"}, img]))
);
assert_eq!(
see_image_message("", json!({})).content.unwrap()[0]["text"],
"Image :"
);
assert_eq!(tool_outcome(&json!({}), "").result, "");
}
#[test]
fn identifiant_d_un_resultat_complet() {
let id = tool_res_id("c42");
let (conv, rand) = id.split_once('.').unwrap();
assert_eq!((conv, rand.len()), ("c42", 24));
assert!(tool_res_id(" ").starts_with("nosession."));
assert_ne!(tool_res_id("c"), tool_res_id("c"));
}
}